// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient import SignalServiceKit import SignalRingRTC import SignalUI import UIKit // TODO: Eventually add 1:1 call support to this view // and replace CallViewController class GroupCallViewController: UIViewController { private let call: SignalCall private let groupCall: GroupCall private let ringRtcCall: SignalRingRTC.GroupCall private lazy var callControlsConfirmationToastManager = CallControlsConfirmationToastManager( presentingContainerView: callControlsConfirmationToastContainerView ) private lazy var callControls = CallControls( call: call, callService: callService, confirmationToastManager: callControlsConfirmationToastManager, delegate: self ) private lazy var callControlsConfirmationToastContainerView = UIView() private var callService: CallService { AppEnvironment.shared.callService } private var incomingCallControls: IncomingCallControls? private lazy var callHeader = CallHeader(groupCall: groupCall, delegate: self) private lazy var notificationView = GroupCallNotificationView(groupCall: groupCall) private lazy var videoGrid = GroupCallVideoGrid(call: call, groupCall: groupCall) private lazy var videoOverflow = GroupCallVideoOverflow(call: call, groupCall: groupCall, delegate: self) private let localMemberView: CallMemberView private let speakerView: CallMemberView private var didUserEverSwipeToSpeakerView = true private var didUserEverSwipeToScreenShare = true private let swipeToastView = GroupCallSwipeToastView() private let speakerPage = UIView() private let scrollView = UIScrollView() private let incomingReactionsView = IncomingReactionsView() private var isCallMinimized = false { didSet { speakerView.isCallMinimized = isCallMinimized scheduleControlTimeoutIfNecessary() } } 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) } } } private lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTouchRootView)) private lazy var videoOverflowTopConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .top) private lazy var videoOverflowTrailingConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .trailing) private enum CallControlsDisplayState { /// "Overflow" refers to the "..." menu that shows reactions & "Raise Hand". case callControlsAndOverflow case callControlsOnly case none } private var callControlsDisplayState: CallControlsDisplayState = .callControlsOnly { didSet { updateCallUI() scheduleControlTimeoutIfNecessary() } } private var hasUnresolvedSafetyNumberMismatch = false private var hasDismissed = false private var membersAtJoin: Set? private static let keyValueStore = SDSKeyValueStore(collection: "GroupCallViewController") private static let didUserSwipeToSpeakerViewKey = "didUserSwipeToSpeakerView" private static let didUserSwipeToScreenShareKey = "didUserSwipeToScreenShare" /// When the local member view (which is displayed picture-in-picture) is /// tapped, it expands. If the frame is expanded, its enlarged frame is /// stored here. If the pip is not in the expanded state, this value is nil. private var expandedPipFrame: CGRect? /// Whether the local member view pip has an animation currently in progress. private var isPipAnimationInProgress = false /// Whether a relayout needs to occur after the pip animation completes. /// This is true when we suspended an attempted relayout triggered during /// the pip animation. private var shouldRelayoutAfterPipAnimationCompletes = false private var postAnimationUpdateMemberViewFramesSize: CGSize? private var postAnimationUpdateMemberViewFramesControlsAreHidden: Bool? private lazy var reactionsBurstView: ReactionsBurstView = { ReactionsBurstView(burstAligner: self.incomingReactionsView) }() private lazy var reactionsSink: ReactionsSink = { ReactionsSink(reactionReceivers: [ self.incomingReactionsView, self.reactionsBurstView ]) }() private lazy var callControlsOverflowView: CallControlsOverflowView = { return CallControlsOverflowView( call: self.call, reactionSender: self.ringRtcCall, reactionsSink: self.reactionsSink, raiseHandSender: self.ringRtcCall, emojiPickerSheetPresenter: self, callControlsOverflowPresenter: self ) }() init(call: SignalCall, groupCall: GroupCall) { // TODO: Eventually unify UI for group and individual calls let type = CallMemberView.MemberType.remoteInGroup(.speaker) speakerView = CallMemberView(type: type) localMemberView = CallMemberView(type: CallMemberView.MemberType.local) self.call = call self.groupCall = groupCall self.ringRtcCall = groupCall.ringRtcCall super.init(nibName: nil, bundle: nil) self.localMemberView.animatableLocalMemberViewDelegate = self groupCall.addObserverAndSyncState(self) videoGrid.memberViewErrorPresenter = self videoOverflow.memberViewErrorPresenter = self speakerView.errorPresenter = self localMemberView.errorPresenter = 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() } NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) } static func presentLobby(thread: TSGroupThread, videoMuted: Bool = false) { Task { await self._presentLobby(shouldAskForCameraPermission: !videoMuted) { let callService = AppEnvironment.shared.callService! return callService.buildAndConnectGroupCall(for: thread, isVideoMuted: videoMuted) } await databaseStorage.awaitableWrite { tx in // Dismiss the group call tooltip self.preferences.setWasGroupCallTooltipShown(tx: tx) } } } static func presentLobby(for callLink: CallLink) { Task { do { try await self._presentLobby(shouldAskForCameraPermission: true) { let callService = AppEnvironment.shared.callService! return try await callService.buildAndConnectCallLinkCall(callLink: callLink) } } catch { owsFail("[CallLink] TODO: Couldn't buildAndConnectCallLinkCall \(error)") } } } private static func _presentLobby( shouldAskForCameraPermission: Bool, buildAndStartConnecting: () async throws -> (SignalCall, GroupCall)? ) async rethrows { guard await CallStarter.prepareToStartCall(shouldAskForCameraPermission: shouldAskForCameraPermission) != nil else { return } guard let (call, groupCall) = try await buildAndStartConnecting() else { owsFailDebug("Can't show lobby if the call can't start") return } let vc = GroupCallViewController(call: call, groupCall: groupCall) vc.modalTransitionStyle = .crossDissolve WindowManager.shared.startCall(viewController: vc) } 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) view.addSubview(incomingReactionsView) incomingReactionsView.autoPinEdge(.leading, to: .leading, of: view, withOffset: 22) incomingReactionsView.autoPinEdge(.bottom, to: .top, of: videoOverflow, withOffset: -25) incomingReactionsView.widthAnchor.constraint(equalToConstant: IncomingReactionsView.Constants.viewWidth).isActive = true incomingReactionsView.heightAnchor.constraint(equalToConstant: IncomingReactionsView.viewHeight).isActive = true 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.addSubview(callControlsConfirmationToastContainerView) callControlsConfirmationToastContainerView.autoPinEdge(.bottom, to: .top, of: callControls, withOffset: -30) callControlsConfirmationToastContainerView.autoHCenterInSuperview() view.addSubview(callControlsOverflowView) if UIDevice.current.isIPad { callControlsOverflowView.centerXAnchor.constraint(equalTo: callControls.centerXAnchor).isActive = true } else { callControlsOverflowView.autoPinEdge(.trailing, to: .trailing, of: view, withOffset: -12) } callControlsOverflowView.autoPinEdge(.bottom, to: .top, of: callControls, withOffset: -12) view.addSubview(reactionsBurstView) reactionsBurstView.autoPinEdgesToSuperviewEdges() 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 callService.sendInitialPhoneOrientationNotification() if let splitViewSnapshot = SignalApp.shared.snapshotSplitViewController(afterScreenUpdates: false) { 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() } } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if hasUnresolvedSafetyNumberMismatch && CurrentAppContext().isAppForegroundAndActive() { // If we're not active yet, this will be handled by the `didBecomeActive` callback. resolveSafetyNumberMismatch() } } @objc private func didBecomeActive() { if hasUnresolvedSafetyNumberMismatch { resolveSafetyNumberMismatch() } } private var hasOverflowMembers: Bool { videoGrid.maxItems < ringRtcCall.remoteDeviceStates.count } private func updateScrollViewFrames(size: CGSize? = nil, controlsAreHidden: Bool) { view.layoutIfNeeded() let size = size ?? view.frame.size if ringRtcCall.remoteDeviceStates.count < 2 || ringRtcCall.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 videoGrid.frame = CGRect( x: 0, y: view.safeAreaInsets.top, width: size.width, height: size.height - view.safeAreaInsets.top - (controlsAreHidden ? 16 : callControls.height) - (hasOverflowMembers ? videoOverflow.height + 32 : 0) ) 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.inherentPipSize.aspectRatio + 4) if view.width + trailingConstraintConstant > videoOverflow.contentSize.width { trailingConstraintConstant += 16 } videoOverflowTrailingConstraint.constant = trailingConstraintConstant view.layoutIfNeeded() } private func updateMemberViewFrames(size: CGSize? = nil, controlsAreHidden: Bool) { guard !isPipAnimationInProgress else { // Wait for the pip to reach its new size before re-laying out. // Otherwise the pip snaps back to its size at the start of the // animation, effectively undoing it. When the animation is // complete, we'll call `updateMemberViewFrames`. self.shouldRelayoutAfterPipAnimationCompletes = true self.postAnimationUpdateMemberViewFramesSize = size self.postAnimationUpdateMemberViewFramesControlsAreHidden = controlsAreHidden return } 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.applyChangesToCallMemberViewAndVideoView { view in view.removeFromSuperview() } speakerView.applyChangesToCallMemberViewAndVideoView { view in view.removeFromSuperview() } switch ringRtcCall.localDeviceState.joinState { case .joined: if ringRtcCall.remoteDeviceStates.count > 0 { speakerView.applyChangesToCallMemberViewAndVideoView { view in speakerPage.addSubview(view) view.autoPinEdgesToSuperviewEdges() } localMemberView.applyChangesToCallMemberViewAndVideoView { aView in view.insertSubview(aView, belowSubview: callControlsConfirmationToastContainerView) } let pipSize = CallMemberView.pipSize( expandedPipFrame: self.expandedPipFrame, remoteDeviceCount: ringRtcCall.remoteDeviceStates.count ) if ringRtcCall.remoteDeviceStates.count > 1 { let y: CGFloat if nil != expandedPipFrame { // Necessary because when the pip is expanded, the // pip height will not follow along with that of // the video overflow, which is tiny. y = yMax - pipSize.height } else { y = videoOverflow.frame.origin.y } localMemberView.applyChangesToCallMemberViewAndVideoView { view in view.frame = CGRect( x: size.width - pipSize.width - 16, y: y, width: pipSize.width, height: pipSize.height ) } flipCameraTooltipManager.presentTooltipIfNecessary( fromView: self.view, widthReferenceView: self.view, tailReferenceView: localMemberView, tailDirection: .down, isVideoMuted: call.isOutgoingVideoMuted ) } else { localMemberView.applyChangesToCallMemberViewAndVideoView { view in view.frame = CGRect( x: size.width - pipSize.width - 16, y: yMax - pipSize.height, width: pipSize.width, height: pipSize.height ) } flipCameraTooltipManager.presentTooltipIfNecessary( fromView: self.view, widthReferenceView: self.view, tailReferenceView: localMemberView, tailDirection: .down, isVideoMuted: call.isOutgoingVideoMuted ) } } else { localMemberView.applyChangesToCallMemberViewAndVideoView { view in speakerPage.addSubview(view) view.frame = CGRect(origin: .zero, size: size) } } case .notJoined, .joining, .pending: localMemberView.applyChangesToCallMemberViewAndVideoView { view in speakerPage.addSubview(view) view.frame = CGRect(origin: .zero, size: size) } } } private func updateSwipeToastView() { let isSpeakerViewAvailable = ringRtcCall.remoteDeviceStates.count >= 2 && ringRtcCall.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 ? OWSLocalizedString( "GROUP_CALL_SCREEN_SHARE_TOAST", comment: "Toast view text informing user about swiping to screen share" ) : OWSLocalizedString( "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 SDSDatabaseStorage.shared.asyncWrite { writeTx in Self.keyValueStore.setBool(true, key: Self.didUserSwipeToScreenShareKey, transaction: writeTx) } } } else { didUserEverSwipeToSpeakerView = true 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 } } } private var flipCameraTooltipManager = FlipCameraTooltipManager(db: DependenciesBridge.shared.db) private func updateCallUI(size: CGSize? = nil) { // Force load the view if it hasn't been yet. _ = self.view let localDevice = ringRtcCall.localDeviceState let isFullScreen = localDevice.joinState != .joined || ringRtcCall.remoteDeviceStates.isEmpty localMemberView.configure( call: call, isFullScreen: isFullScreen ) localMemberView.applyChangesToCallMemberViewAndVideoView { view in // In the context of `isCallInPip`, the "pip" refers to when the entire call is in a pip // (ie, minimized in the app). This is not to be confused with the local member view pip // (ie, when the call is full screen and the local user is displayed in a pip). // The following line disallows having a [local member] pip within a [call] pip. view.isHidden = WindowManager.shared.isCallInPip } if let speakerState = ringRtcCall.remoteDeviceStates.sortedBySpeakerTime.first { speakerView.configure( call: call, remoteGroupMemberDeviceState: speakerState ) } else { speakerView.clearConfiguration() } guard !isCallMinimized else { return } if case .groupThread(let groupThreadCall) = groupCall.concreteType, groupThreadCall.groupCallRingState.isIncomingRing { callControls.isHidden = true createIncomingCallControlsIfNeeded().isHidden = false // These views aren't visible at this point, but we need them to be configured anyway. updateMemberViewFrames(size: size, controlsAreHidden: true) updateScrollViewFrames(size: size, controlsAreHidden: true) return } if let incomingCallControls, !incomingCallControls.isHidden { // We were showing the incoming call controls, but now we don't want to. // To make sure all views transition properly, pretend we were showing the regular controls all along. callControls.isHidden = false incomingCallControls.isHidden = true } let callControlsAreHidden = callControls.isHidden && callHeader.isHidden let callControlsOverflowContentIsHidden = self.callControlsOverflowView.isHidden if !callControlsAreHidden && !callControlsOverflowContentIsHidden { self.callControlDisplayStateDidChange( oldState: .callControlsAndOverflow, newState: self.callControlsDisplayState, size: size ) } else if !callControlsAreHidden { self.callControlDisplayStateDidChange( oldState: .callControlsOnly, newState: self.callControlsDisplayState, size: size ) } else if !callControlsOverflowContentIsHidden { owsFailDebug("Call Controls Overflow content should never be visible while Call Controls are hidden. Desired new state: \(self.callControlsDisplayState).") recoverFromOverflowOnlyCallControlsDisplayState( newState: self.callControlsDisplayState, size: size ) } else { self.callControlDisplayStateDidChange( oldState: .none, newState: self.callControlsDisplayState, size: size ) } updateSwipeToastView() } // Theoretically, we should never show the call controls overflow _only_, without call controls // accompanying it. However, this does somehow happen. As a quick fix, we'll bring the UI back // to a sane state via this method. But a deeper investigation as to how this state is reached // in the first place is warranted. private func recoverFromOverflowOnlyCallControlsDisplayState( newState: CallControlsDisplayState, size: CGSize? ) { switch newState { case .callControlsAndOverflow: animateCallControls( hideCallControls: false, size: size ) case .callControlsOnly: animateCallControls( hideCallControls: false, size: size ) self.callControlsOverflowView.animateOut() case .none: self.callControlsOverflowView.animateOut() } } private func callControlDisplayStateDidChange( oldState: CallControlsDisplayState, newState: CallControlsDisplayState, size: CGSize? ) { func updateFrames(controlsAreHidden: Bool) { updateMemberViewFrames(size: size, controlsAreHidden: controlsAreHidden) updateScrollViewFrames(size: size, controlsAreHidden: controlsAreHidden) } switch oldState { case .callControlsAndOverflow: switch newState { case .callControlsAndOverflow: updateFrames(controlsAreHidden: false) case .callControlsOnly: self.callControlsOverflowView.animateOut() case .none: // This can happen if you tap the root view fast enough in succession. animateCallControls( hideCallControls: true, size: size ) self.callControlsOverflowView.animateOut() } case .callControlsOnly: switch newState { case .callControlsAndOverflow: self.callControlsOverflowView.animateIn() case .callControlsOnly: updateFrames(controlsAreHidden: false) case .none: animateCallControls( hideCallControls: true, size: size ) } case .none: switch newState { case .callControlsAndOverflow: owsFailDebug("Impossible callControlsDisplayState transition") // But if you must... animateCallControls( hideCallControls: false, size: size ) self.callControlsOverflowView.animateIn() case .callControlsOnly: animateCallControls( hideCallControls: false, size: size ) case .none: updateFrames(controlsAreHidden: true) } } } private func animateCallControls( hideCallControls: Bool, size: CGSize? ) { callControls.isHidden = false callHeader.isHidden = false UIView.animate(withDuration: 0.15, animations: { self.callControls.alpha = hideCallControls ? 0 : 1 self.callHeader.alpha = hideCallControls ? 0 : 1 self.updateMemberViewFrames(size: size, controlsAreHidden: hideCallControls) self.updateScrollViewFrames(size: size, controlsAreHidden: hideCallControls) self.view.layoutIfNeeded() }) { _ in self.callControls.isHidden = hideCallControls self.callHeader.isHidden = hideCallControls } } private func dismissCall(shouldHangUp: Bool = true) { if shouldHangUp { callService.callUIAdapter.localHangupCall(call) } didHangupCall() } private func didHangupCall() { guard !hasDismissed else { return } hasDismissed = true self.callControlsDisplayState = .callControlsOnly guard let splitViewSnapshot = SignalApp.shared.snapshotSplitViewController(afterScreenUpdates: false), view.superview?.insertSubview(splitViewSnapshot, belowSubview: view) != nil else { // This can happen if we're in the background when the call is dismissed (say, from CallKit). WindowManager.shared.endCall(viewController: self) return } splitViewSnapshot.autoPinEdgesToSuperviewEdges() UIView.animate(withDuration: 0.2, animations: { self.view.alpha = 0 }) { _ in splitViewSnapshot.removeFromSuperview() WindowManager.shared.endCall(viewController: self) } } override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } override var prefersHomeIndicatorAutoHidden: Bool { return true } // MARK: - Call controls timeout @objc private func didTouchRootView(sender: UIGestureRecognizer) { if callControlsMustBeVisible { return } switch self.callControlsDisplayState { case .callControlsAndOverflow, .none: self.callControlsDisplayState = .callControlsOnly case .callControlsOnly: self.callControlsDisplayState = .none } } private var callControlsMustBeVisible: Bool { return ringRtcCall.remoteDeviceStates.isEmpty } private var controlTimeoutTimer: Timer? private func scheduleControlTimeoutIfNecessary() { let shouldAutomaticallyHideCallControls: Bool = { switch self.callControlsDisplayState { case .callControlsAndOverflow, .none: return false case .callControlsOnly: break } if callControlsMustBeVisible { return false } if isCallMinimized { return false } return true }() guard shouldAutomaticallyHideCallControls else { controlTimeoutTimer?.invalidate() controlTimeoutTimer = nil return } guard controlTimeoutTimer == nil else { return } controlTimeoutTimer = .scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in self?.timeoutControls() } } private func timeoutControls() { self.controlTimeoutTimer = nil self.callControlsDisplayState = .none } private func showCallControlsIfTheyMustBeVisible() { if callControlsMustBeVisible { showCallControlsIfHidden() } } private func showCallControlsIfHidden() { switch self.callControlsDisplayState { case .callControlsAndOverflow, .callControlsOnly: break case .none: self.callControlsDisplayState = .callControlsOnly } } // MARK: - Ringing/Incoming Call Controls private func createIncomingCallControlsIfNeeded() -> IncomingCallControls { if let incomingCallControls { return incomingCallControls } let incomingCallControls = IncomingCallControls( isVideoCall: true, didDeclineCall: { [unowned self] in self.dismissCall() }, didAcceptCall: { [unowned self] hasVideo in self.acceptRingingIncomingCall(hasVideo: hasVideo) } ) self.view.addSubview(incomingCallControls) incomingCallControls.autoPinWidthToSuperview() incomingCallControls.autoPinEdge(toSuperviewEdge: .bottom) self.incomingCallControls = incomingCallControls return incomingCallControls } private func acceptRingingIncomingCall(hasVideo: Bool) { // Explicitly unmute video in order to request permissions as needed. // (Audio is unmuted as part of the call UI adapter.) callService.updateIsLocalVideoMuted(isLocalVideoMuted: !hasVideo) // When turning off video, default speakerphone to on. if !hasVideo, !callService.audioService.hasExternalInputs { callService.audioService.requestSpeakerphone(call: call, isEnabled: true) } callService.callUIAdapter.answerCall(call) } } extension GroupCallViewController: CallViewControllerWindowReference { var localVideoViewReference: CallMemberView { localMemberView } var remoteVideoViewReference: CallMemberView { speakerView } var remoteVideoAddress: SignalServiceAddress { guard let firstMember = ringRtcCall.remoteDeviceStates.sortedByAddedTime.first else { return DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!.aciAddress } return firstMember.address } 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 showCallControlsIfHidden() animateReturnFromPip(pipSnapshot: pipSnapshot, pipFrame: pipWindow.frame, splitViewSnapshot: splitViewSnapshot) } func willMoveToPip(pipWindow: UIWindow) { flipCameraTooltipManager.dismissTooltip() localMemberView.applyChangesToCallMemberViewAndVideoView { view in view.isHidden = true } } 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.videoGrid.reloadData() self.scrollView.contentOffset = originalContentOffset self.view.layoutIfNeeded() }) { _ in splitViewSnapshot.removeFromSuperview() pipSnapshot.removeFromSuperview() if self.hasUnresolvedSafetyNumberMismatch { self.resolveSafetyNumberMismatch() } } } private func safetyNumberMismatchAddresses(untrustedThreshold: Date?) -> [SignalServiceAddress] { databaseStorage.read { transaction in let addressesToCheck: [SignalServiceAddress] if case .groupThread(let groupThreadCall) = groupCall.concreteType, ringRtcCall.localDeviceState.joinState == .notJoined { // If we haven't joined the call yet, we want to alert for all members of the group addressesToCheck = groupThreadCall.groupThread.recipientAddresses(with: transaction) } else { // If we are in the call, we only care about safety numbers for the active call participants addressesToCheck = ringRtcCall.remoteDeviceStates.map { $0.value.address } } let identityManager = DependenciesBridge.shared.identityManager return addressesToCheck.filter { memberAddress in identityManager.untrustedIdentityForSending( to: memberAddress, untrustedThreshold: untrustedThreshold, tx: transaction.asV2Read ) != nil } } } private func groupCallThreadForSafetyNumberMismatch() -> GroupThreadCall { switch groupCall.concreteType { case .groupThread(let groupThreadCall): return groupThreadCall case .callLink: owsFail("[CallLink] TODO: Support Safety Number mismatches.") } } fileprivate func resolveSafetyNumberMismatch() { let resendMediaKeysAndResetMismatch = { [unowned self] in self.ringRtcCall.resendMediaKeys() self.hasUnresolvedSafetyNumberMismatch = false } if !isCallMinimized, CurrentAppContext().isAppForegroundAndActive() { presentSafetyNumberChangeSheetIfNecessary { [weak self] success in guard let self = self else { return } if success { resendMediaKeysAndResetMismatch() } else { self.dismissCall() } } } else { let unresolvedAddresses = safetyNumberMismatchAddresses(untrustedThreshold: nil) guard !unresolvedAddresses.isEmpty else { // Spurious warning, maybe from delayed callbacks. resendMediaKeysAndResetMismatch() return } // If a problematic member was present at join, leaves, and then joins again, // we'll still treat them as having been there "since join", but that's okay. // It's not worth trying to track this more precisely. let atLeastOneUnresolvedPresentAtJoin = unresolvedAddresses.contains { membersAtJoin?.contains($0) ?? false } Self.notificationPresenterImpl.notifyForGroupCallSafetyNumberChange( inThread: self.groupCallThreadForSafetyNumberMismatch().groupThread, presentAtJoin: atLeastOneUnresolvedPresentAtJoin ) } } fileprivate func presentSafetyNumberChangeSheetIfNecessary(untrustedThreshold: Date? = nil, completion: @escaping (Bool) -> Void) { let localDeviceHasNotJoined = ringRtcCall.localDeviceState.joinState == .notJoined let newUntrustedThreshold = Date() let addressesToAlert = safetyNumberMismatchAddresses(untrustedThreshold: untrustedThreshold) // There are no unverified addresses that we're currently concerned about. No need to show a sheet guard !addressesToAlert.isEmpty else { return completion(true) } if let existingSheet = presentedViewController as? SafetyNumberConfirmationSheet { // The set of untrusted addresses may have changed. // It's a bit clunky, but we'll just dismiss the existing sheet before putting up a new one. existingSheet.dismiss(animated: false) } let continueCallString = OWSLocalizedString("GROUP_CALL_CONTINUE_BUTTON", comment: "Button to continue an ongoing group call") let leaveCallString = OWSLocalizedString("GROUP_CALL_LEAVE_BUTTON", comment: "Button to leave a group call") let cancelString = CommonStrings.cancelButton let approveText: String let denyText: String if localDeviceHasNotJoined { approveText = CallControls.joinButtonLabel(for: call) denyText = cancelString } else { approveText = continueCallString denyText = leaveCallString } let sheet = SafetyNumberConfirmationSheet( addressesToConfirm: addressesToAlert, confirmationText: approveText, cancelText: denyText, theme: .translucentDark ) { [weak self] didApprove in if let self, didApprove { self.presentSafetyNumberChangeSheetIfNecessary(untrustedThreshold: newUntrustedThreshold, completion: completion) } else { completion(false) } } sheet.allowsDismissal = localDeviceHasNotJoined present(sheet, animated: true, completion: nil) } } extension GroupCallViewController: GroupCallObserver { func groupCallLocalDeviceStateChanged(_ call: GroupCall) { AssertIsOnMainThread() owsAssert(self.groupCall === call) updateCallUI() switch ringRtcCall.localDeviceState.joinState { case .joined: if membersAtJoin == nil { membersAtJoin = Set(ringRtcCall.remoteDeviceStates.lazy.map { $0.value.address }) } case .pending, .joining, .notJoined: membersAtJoin = nil } } func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) { AssertIsOnMainThread() owsAssert(self.groupCall === call) isAnyRemoteDeviceScreenSharing = ringRtcCall.remoteDeviceStates.values.first { $0.sharingScreen == true } != nil showCallControlsIfTheyMustBeVisible() updateCallUI() scheduleControlTimeoutIfNecessary() } func groupCallPeekChanged(_ call: GroupCall) { AssertIsOnMainThread() owsAssert(self.groupCall === call) updateCallUI() } func groupCallEnded(_ call: GroupCall, reason: GroupCallEndReason) { AssertIsOnMainThread() owsAssert(self.groupCall === call) let title: String switch reason { case .deviceExplicitlyDisconnected: dismissCall(shouldHangUp: false) return case .hasMaxDevices: if let maxDevices = ringRtcCall.maxDevices { let formatString = OWSLocalizedString( "GROUP_CALL_HAS_MAX_DEVICES_%d", tableName: "PluralAware", 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.localizedStringWithFormat(formatString, maxDevices) } else { title = OWSLocalizedString( "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." ) } case .removedFromCall: // [CallLink] TODO: . fallthrough case .deniedRequestToJoinCall: // [CallLink] TODO: . fallthrough case .serverExplicitlyDisconnected, .callManagerIsBusy, .sfuClientFailedToJoin, .failedToCreatePeerConnectionFactory, .failedToNegotiateSrtpKeys, .failedToCreatePeerConnection, .failedToStartPeerConnection, .failedToUpdatePeerConnection, .failedToSetMaxSendBitrate, .iceFailedWhileConnecting, .iceFailedAfterConnected, .serverChangedDemuxId: Logger.warn("Group call ended with reason \(reason)") title = OWSLocalizedString( "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) showCallControlsIfTheyMustBeVisible() updateCallUI() } func groupCallReceivedReactions(_ call: GroupCall, reactions: [SignalRingRTC.Reaction]) { owsAssert(self.groupCall === call) let localAci = databaseStorage.read { tx in return DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aci } guard let localAci else { owsFailDebug("Local user is in call but doesn't have ACI!") return } let mappedReactions = databaseStorage.read { tx in return reactions.map { reaction in let name: String let aci: Aci if let remoteDeviceState = ringRtcCall.remoteDeviceStates[reaction.demuxId], remoteDeviceState.aci != localAci { name = contactsManager.displayName(for: remoteDeviceState.address, tx: tx).resolvedValue() aci = remoteDeviceState.aci } else { name = CommonStrings.you aci = localAci } return Reaction( emoji: reaction.value, name: name, aci: aci, timestamp: Date.timeIntervalSinceReferenceDate ) } } self.reactionsSink.addReactions(reactions: mappedReactions) } func groupCallReceivedRaisedHands(_ call: GroupCall, raisedHands: [DemuxId]) { updateCallUI() } func callMessageSendFailedUntrustedIdentity(_ call: GroupCall) { AssertIsOnMainThread() owsAssert(self.groupCall === call) if !hasUnresolvedSafetyNumberMismatch { hasUnresolvedSafetyNumberMismatch = true resolveSafetyNumberMismatch() } } } extension GroupCallViewController: CallHeaderDelegate { func didTapBackButton() { if groupCall.hasJoinedOrIsWaitingForAdminApproval { isCallMinimized = true WindowManager.shared.leaveCallView() // This ensures raised hands are removed updateCallUI() } else { dismissCall() } } func didTapMembersButton() { switch groupCall.concreteType { case .groupThread(let groupThreadCall): let sheet = GroupCallMemberSheet(call: call, groupThreadCall: groupThreadCall) present(sheet, animated: true) case .callLink: owsFail("[CallLink] TODO: Add Info button for Call Link calls") } } } 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: CallControlsDelegate { func didPressRing() { switch groupCall.concreteType { case .groupThread(let groupThreadCall): if groupThreadCall.ringRestrictions.isEmpty { // Refresh the call header. callHeader.groupCallLocalDeviceStateChanged(groupThreadCall) } else if groupThreadCall.ringRestrictions.contains(.groupTooLarge) { let toast = ToastController(text: OWSLocalizedString("GROUP_CALL_TOO_LARGE_TO_RING", comment: "Text displayed when trying to turn on ringing when calling a large group.")) toast.presentToastView(from: .top, of: view, inset: view.safeAreaInsets.top + 8) } case .callLink: owsFail("Can't ring a call link") } } func didPressJoin() { if call.isFull { let text: String if let maxDevices = ringRtcCall.maxDevices { let formatString = OWSLocalizedString( "GROUP_CALL_HAS_MAX_DEVICES_%d", tableName: "PluralAware", comment: "An error displayed to the user when the group call ends because it has exceeded the max devices. Embeds {{max device count}}." ) text = String.localizedStringWithFormat(formatString, maxDevices) } else { text = OWSLocalizedString( "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." ) } let toastController = ToastController(text: text) // Leave the toast up longer than usual because this message is pretty long. toastController.presentToastView( from: .top, of: view, inset: view.safeAreaInsets.top + 8, dismissAfter: .seconds(8) ) return } presentSafetyNumberChangeSheetIfNecessary { [weak self] success in guard let self = self else { return } guard success else { return } self.callService.joinGroupCallIfNecessary(self.call, groupCall: self.groupCall) } } func didPressHangup() { didHangupCall() } func didPressMore() { if self.callControlsOverflowView.isHidden { self.callControlsDisplayState = .callControlsAndOverflow } else { self.callControlsDisplayState = .callControlsOnly } } } extension GroupCallViewController: CallMemberErrorPresenter { func presentErrorSheet(title: String, message: String) { let actionSheet = ActionSheetController(title: title, message: message, theme: .translucentDark) actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton)) presentActionSheet(actionSheet) } } extension GroupCallViewController: AnimatableLocalMemberViewDelegate { var enclosingBounds: CGRect { return self.view.bounds } var remoteDeviceCount: Int { return ringRtcCall.remoteDeviceStates.count } func animatableLocalMemberViewDidCompleteExpandAnimation(_ localMemberView: CallMemberView) { self.expandedPipFrame = localMemberView.frame self.isPipAnimationInProgress = false performRetroactiveUiUpdateIfNecessary() } func animatableLocalMemberViewDidCompleteShrinkAnimation(_ localMemberView: CallMemberView) { self.expandedPipFrame = nil self.isPipAnimationInProgress = false performRetroactiveUiUpdateIfNecessary() } private func performRetroactiveUiUpdateIfNecessary() { if self.shouldRelayoutAfterPipAnimationCompletes { if let postAnimationUpdateMemberViewFramesSize, let postAnimationUpdateMemberViewFramesControlsAreHidden { updateMemberViewFrames( size: postAnimationUpdateMemberViewFramesSize, controlsAreHidden: postAnimationUpdateMemberViewFramesControlsAreHidden ) self.postAnimationUpdateMemberViewFramesSize = nil self.postAnimationUpdateMemberViewFramesControlsAreHidden = nil } self.shouldRelayoutAfterPipAnimationCompletes = false } } func animatableLocalMemberViewWillBeginAnimation(_ localMemberView: CallMemberView) { self.isPipAnimationInProgress = true self.flipCameraTooltipManager.dismissTooltip() } } // MARK: - CallControlsOverflowPresenter extension GroupCallViewController: CallControlsOverflowPresenter { func callControlsOverflowWillAppear() { self.controlTimeoutTimer?.invalidate() self.controlTimeoutTimer = nil } func callControlsOverflowDidDisappear() { self.scheduleControlTimeoutIfNecessary() } func willSendReaction() { self.callControlsDisplayState = .callControlsOnly } func didTapRaiseOrLowerHand() { self.callControlsDisplayState = .callControlsOnly } }