// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Combine import LibSignalClient import SignalRingRTC import SignalServiceKit import SignalUI import SwiftUI import UIKit // MARK: - GroupCallViewController // TODO: Eventually add 1:1 call support to this view // and replace CallViewController final class GroupCallViewController: UIViewController { // MARK: Properties private let call: SignalCall private let groupCall: GroupCall private let ringRtcCall: SignalRingRTC.GroupCall private lazy var callControlsConfirmationToastManager = CallControlsConfirmationToastManager( presentingContainerView: callControlsConfirmationToastContainerView, ) private lazy var bottomSheet: CallDrawerSheet = { let dataSource: any CallDrawerSheetDataSource = switch groupCall.concreteType { case .groupThread(let groupThreadCall): GroupCallSheetDataSource(groupCall: groupThreadCall) case .callLink(let callLinkCall): GroupCallSheetDataSource(groupCall: callLinkCall) } return CallDrawerSheet( call: call, callSheetDataSource: dataSource, callService: callService, confirmationToastManager: callControlsConfirmationToastManager, callControlsDelegate: self, sheetPanDelegate: self, callDrawerDelegate: self, ) }() private lazy var fullscreenLocalMemberAddOnsView = SupplementalCallControlsForFullscreenLocalMember( call: call, groupCall: groupCall, callService: callService, ) 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) /// A UIStackView which allows taps on its subviews, but passes taps outside of those or in explicitly ignored views through to the parent. private class PassthroughStackView: UIStackView { var ignoredViews: WeakArray = [] override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { // super.hitTest will return the deepest view hit, so if it's // just `self`, the highest view, that means a subview wasn't hit. let hitView = super.hitTest(point, with: event) if let hitView, hitView == self || ignoredViews.contains(hitView) { return nil } return hitView } } /// A container view which allows taps in the given height range, but /// passes through taps outside of it. private class ApprovalStackContainerView: UIView { var stackHeight: CGFloat = 0 override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let isPointInStack = (self.height - point.y) <= stackHeight if isPointInStack { return super.hitTest(point, with: event) } return nil } } private let bottomVStack = PassthroughStackView() private let videoOverflowContainer = UIView() private let raisedHandsToastContainer = UIView() private lazy var raisedHandsToast = RaisedHandsToast(call: self.groupCall) private lazy var remoteMuteToast = RemoteMuteToast(call: self.groupCall) private var approvalRequestActionsSubscription: AnyCancellable? private lazy var callLinkApprovalViewModel: CallLinkApprovalViewModel = { let viewModel = CallLinkApprovalViewModel() approvalRequestActionsSubscription = viewModel.performRequestAction .sink { [weak self] action, request in guard let self else { return } switch action { case .approve: self.ringRtcCall.approveUser(request.aci.rawUUID) case .deny: self.ringRtcCall.denyUser(request.aci.rawUUID) case .viewDetails: self.presentApprovalRequestDetails(approvalRequest: request) } } return viewModel }() private let approvalStackContainer = ApprovalStackContainerView() /// The `UIHostingController` with the approval request views in a stack. private lazy var approvalStack = UIHostingController(rootView: VStack { Spacer() ApprovalRequestStack( viewModel: self.callLinkApprovalViewModel, didTapMore: { [weak self] requests in self?.presentBulkApprovalSheet() }, didChangeHeight: { [weak self] height in self?.approvalStackHeightConstraint?.constant = height self?.approvalStackContainer.stackHeight = height self?.updateCallUI(shouldAnimateViewFrames: true) }, ) }) /// A view used in `bottomVStack` that takes the height of the approval stack. Does not actually hold any content. private let approvalStackHeightView = UIView() private lazy var callLinkLobbyToastLabel = UILabel() private lazy var callLinkLobbyToast: UIView = { let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark)) backgroundView.layer.cornerRadius = 10 backgroundView.clipsToBounds = true backgroundView.contentView.addSubview(callLinkLobbyToastLabel) backgroundView.contentView.layoutMargins = .init(margin: 12) callLinkLobbyToastLabel.autoPinEdgesToSuperviewMargins() callLinkLobbyToastLabel.font = .dynamicTypeFootnote callLinkLobbyToastLabel.textColor = .white callLinkLobbyToastLabel.textAlignment = .center callLinkLobbyToastLabel.numberOfLines = 0 return backgroundView }() private lazy var videoGrid: GroupCallVideoGrid = { let result = GroupCallVideoGrid(call: call, groupCall: groupCall) result.memberViewErrorPresenter = self return result }() private lazy var videoOverflow: GroupCallVideoOverflow = { let result = GroupCallVideoOverflow(call: call, groupCall: groupCall, delegate: self) result.memberViewErrorPresenter = self return result }() private lazy var speakerView: CallMemberView = { let result = CallMemberView(type: .remoteInGroup(.speaker)) result.errorPresenter = self return result }() private lazy var localMemberView: CallMemberView = { let result = CallMemberView(type: .local) result.errorPresenter = self result.animatableLocalMemberViewDelegate = self return result }() private var didUserEverSwipeToSpeakerView: Bool private var didUserEverSwipeToScreenShare: Bool private let swipeToastView = GroupCallSwipeToastView() private let speakerPage = UIView() private let scrollView = UIScrollView() private enum Page { case grid case speaker } private var page: Page = .grid { didSet { guard page != oldValue else { return } videoOverflow.reloadData() updateCallUI(shouldAnimateViewFrames: true) ImpactHapticFeedback.impactOccurred(style: .light) } } private let incomingReactionsView = IncomingReactionsView() private var isCallMinimized = false { didSet { speakerView.isCallMinimized = isCallMinimized scheduleBottomSheetTimeoutIfNecessary() } } 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 bottomVStackTopConstraint = self.bottomVStack.autoPinEdge(.bottom, to: .top, of: self.view) private lazy var videoOverflowTrailingConstraint = videoOverflow.autoPinEdge(toSuperviewEdge: .trailing) private var approvalStackHeightConstraint: NSLayoutConstraint? private lazy var bottomSheetStateManager: GroupCallBottomSheetStateManager = { return GroupCallBottomSheetStateManager(delegate: self) }() private var hasUnresolvedSafetyNumberMismatch = false private var hasDismissed = false private var membersAtJoin: Set? private static let keyValueStore = KeyValueStore(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 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.bottomSheet, callControlsOverflowPresenter: self, ) }() private var callControlsOverflowBottomConstraint: NSLayoutConstraint? private var callControlsConfirmationToastContainerViewBottomConstraint: NSLayoutConstraint? static func load(call: SignalCall, groupCall: GroupCall, tx: DBReadTransaction) -> GroupCallViewController { let didUserEverSwipeToSpeakerView = keyValueStore.getBool( didUserSwipeToSpeakerViewKey, defaultValue: false, transaction: tx, ) let didUserEverSwipeToScreenShare = keyValueStore.getBool( didUserSwipeToScreenShareKey, defaultValue: false, transaction: tx, ) let phoneNumberSharingMode = SSKEnvironment.shared.udManagerRef.phoneNumberSharingMode(tx: tx).orDefault return GroupCallViewController( call: call, groupCall: groupCall, didUserEverSwipeToSpeakerView: didUserEverSwipeToSpeakerView, didUserEverSwipeToScreenShare: didUserEverSwipeToScreenShare, phoneNumberSharingMode: phoneNumberSharingMode, ) } init( call: SignalCall, groupCall: GroupCall, didUserEverSwipeToSpeakerView: Bool, didUserEverSwipeToScreenShare: Bool, phoneNumberSharingMode: PhoneNumberSharingMode, ) { // TODO: Eventually unify UI for group and individual calls self.call = call self.groupCall = groupCall self.ringRtcCall = groupCall.ringRtcCall self.didUserEverSwipeToSpeakerView = didUserEverSwipeToSpeakerView self.didUserEverSwipeToScreenShare = didUserEverSwipeToScreenShare super.init(nibName: nil, bundle: nil) groupCall.addObserver(self) groupCall.addObserver(AppEnvironment.shared.callLinkProfileKeySharingManager) NotificationCenter.default.addObserver( self, selector: #selector(didBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(didCompleteAnySpamChallenge), name: SpamChallengeResolver.didCompleteAnyChallenge, object: nil, ) self.callLinkLobbyToastLabel.text = switch phoneNumberSharingMode { case .everybody: OWSLocalizedString( "CALL_LINK_LOBBY_SHARING_INFO_PHONE_NUMBER_SHARING_ON", comment: "Text that appears on a toast in a call lobby before joining a call link informing the user what information will be shared with other call members when they have phone number sharing turned on.", ) case .nobody: OWSLocalizedString( "CALL_LINK_LOBBY_SHARING_INFO_PHONE_NUMBER_SHARING_OFF", comment: "Text that appears on a toast in a call lobby before joining a call link informing the user what information will be shared with other call members when they have phone number sharing turned off.", ) } } static func presentLobby(forGroupId groupId: GroupIdentifier, videoMuted: Bool = false) { self._presentLobby { viewController, modalViewController -> (() -> Void)? in return await self._prepareLobby( from: viewController, modalViewController: modalViewController, shouldAskForCameraPermission: !videoMuted, buildAndStartConnecting: { let callService = AppEnvironment.shared.callService! return callService.buildAndConnectGroupCall(for: groupId, isVideoMuted: videoMuted) }, ) } } static func presentLobby( for callLink: CallLink, callLinkStateRetrievalStrategy: CallService.CallLinkStateRetrievalStrategy = .fetch, ) { self._presentLobby { viewController, modalViewController in do { return try await self._prepareLobby( from: viewController, modalViewController: modalViewController, shouldAskForCameraPermission: true, buildAndStartConnecting: { let callService = AppEnvironment.shared.callService! return try await callService.buildAndConnectCallLinkCall( callLink: callLink, callLinkStateRetrievalStrategy: callLinkStateRetrievalStrategy, ) }, ) } catch { Logger.warn("Call link lobby presentation failed with error \(error)") return { OWSActionSheets.showActionSheet( title: CallStrings.callLinkErrorSheetTitle, message: OWSLocalizedString( "CALL_LINK_JOIN_CALL_FAILURE_SHEET_DESCRIPTION", comment: "Description of sheet presented when joining call from call link sheet fails.", ), ) } } } } private static func _presentLobby( prepareLobby: @escaping @MainActor (_ viewController: UIViewController, _ modalViewController: UIViewController) async -> (() -> Void)?, ) { guard let frontmostViewController = UIApplication.shared.frontmostViewController else { owsFail("Can't start a call if there's no view controller") } ModalActivityIndicatorViewController.present( fromViewController: frontmostViewController, title: CommonStrings.preparingModal, canCancel: false, presentationDelay: 0.25, asyncBlock: { modal in let presentLobbyOrError = await prepareLobby(frontmostViewController, modal) modal.dismissIfNotCanceled(completionIfNotCanceled: presentLobbyOrError ?? {}) }, ) } private static func _prepareLobby( from viewController: UIViewController, modalViewController: UIViewController, shouldAskForCameraPermission: Bool, buildAndStartConnecting: () async throws -> (SignalCall, GroupCall)?, ) async rethrows -> (() -> Void)? { do throws(CallStarter.PrepareToStartCallError) { _ = try await CallStarter.prepareToStartCall( from: modalViewController, shouldAskForCameraPermission: shouldAskForCameraPermission, ) } catch { return { CallStarter.showPrepareToStartCallError(error, from: viewController) } } guard let (call, groupCall) = try await buildAndStartConnecting() else { owsFailDebug("Can't show lobby if the call can't start") return nil } let vc = SSKEnvironment.shared.databaseStorageRef.read { tx in return GroupCallViewController.load(call: call, groupCall: groupCall, tx: tx) } return { vc.modalTransitionStyle = .crossDissolve AppEnvironment.shared.windowManagerRef.startCall(viewController: vc) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: Lifecycle 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(self.bottomVStack) self.bottomVStack.autoPinWidthToSuperview() self.bottomVStack.axis = .vertical self.bottomVStack.spacing = Constants.bottomVStackSpacing self.bottomVStack.preservesSuperviewLayoutMargins = true self.bottomVStack.alignment = .center self.bottomVStack.ignoredViews.append(fullscreenLocalMemberAddOnsView) switch groupCall.concreteType { case .groupThread: break case .callLink: // Lobby text self.bottomVStack.addArrangedSubview(self.callLinkLobbyToast) self.callLinkLobbyToast.autoPinWidthToSuperviewMargins() // Approvals self.addChild(self.approvalStack) approvalStackContainer.addSubview(self.approvalStack.view) self.approvalStack.view.autoPinEdgesToSuperviewEdges() self.view.addSubview(approvalStackContainer) self.approvalStack.view.backgroundColor = .clear self.approvalStack.didMove(toParent: self) // If passthroughView changed height to match the height of its content, // the SwiftUI content would jump around as the UIView's height changes, // so instead, make it taller than it needs, and pin its bottom to a // placeholder view that adjusts height based on the content. self.bottomVStack.addArrangedSubview(self.approvalStackHeightView) self.approvalStackHeightConstraint = self.approvalStackHeightView .autoSetDimension(.height, toSize: 0) self.pinWidthWithBottomSheetMaxWidth(approvalStackContainer) approvalStackContainer.autoHCenterInSuperview() approvalStackContainer.autoSetDimension(.height, toSize: 300) approvalStackContainer.autoPinEdge(.bottom, to: .bottom, of: self.approvalStackHeightView) } videoOverflowContainer.addSubview(self.videoOverflow) self.bottomVStack.addArrangedSubview(videoOverflowContainer) self.bottomVStack.ignoredViews.append(videoOverflowContainer) self.videoOverflowContainer.autoPinWidthToSuperview() self.videoOverflow.autoPinHeightToSuperview() self.videoOverflow.autoPinEdge(toSuperviewEdge: .leading) self.bottomVStack.insertArrangedSubview(raisedHandsToastContainer, at: 0) self.bottomVStack.ignoredViews.append(raisedHandsToastContainer) raisedHandsToastContainer.layoutMargins = .init(margin: 0) raisedHandsToastContainer.preservesSuperviewLayoutMargins = true raisedHandsToastContainer.isHiddenInStackView = true raisedHandsToastContainer.addSubview(raisedHandsToast) self.pinWidthWithBottomSheetMaxWidth(raisedHandsToastContainer) raisedHandsToast.autoPinEdges(toSuperviewMarginsExcludingEdge: .leading) raisedHandsToast.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual) raisedHandsToast.horizontalPinConstraint = raisedHandsToast.autoPinEdge(toSuperviewMargin: .leading) raisedHandsToast.delegate = self view.addSubview(remoteMuteToast) remoteMuteToast.autoPinEdgesToSuperviewEdges() scrollView.addSubview(videoGrid) scrollView.addSubview(speakerPage) view.addSubview(incomingReactionsView) incomingReactionsView.autoPinEdge(.leading, to: .leading, of: view, withOffset: 22) incomingReactionsView.autoPinEdge(.bottom, to: .top, of: self.bottomVStack, withOffset: -16) 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.autoHCenterInSuperview() view.addSubview(callControlsOverflowView) callControlsOverflowView.isHidden = true if UIDevice.current.isIPad { callControlsOverflowView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true } else { callControlsOverflowView.autoPinEdge( .trailing, to: .trailing, of: view, withOffset: -12, ) } self.callControlsConfirmationToastContainerViewBottomConstraint = callControlsConfirmationToastContainerView.autoPinEdge( .bottom, to: .bottom, of: self.view, withOffset: callControlsConfirmationToastContainerViewBottomConstraintConstant, ) self.callControlsOverflowBottomConstraint = self.callControlsOverflowView.autoPinEdge( .bottom, to: .bottom, of: self.view, withOffset: callControlsOverflowBottomConstraintConstant, ) view.addSubview(reactionsBurstView) reactionsBurstView.autoPinEdgesToSuperviewEdges() view.addGestureRecognizer(tapGesture) } override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver( self, selector: #selector(otherUsersProfileChanged(notification:)), name: UserProfileNotifications.otherUsersProfileDidChange, object: nil, ) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) let wasOnSpeakerPage = self.page == .speaker 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() } } } private var isReadyToHandleObserver = false override func viewIsAppearing(_ animated: Bool) { super.viewIsAppearing(animated) self.isReadyToHandleObserver = true updateCallUI() } 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() } } override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { super.present(viewControllerToPresent, animated: flag, completion: completion) scheduleBottomSheetTimeoutIfNecessary() } private var bottomSheetIsPresented: Bool { bottomSheet.presentingViewController != nil } private func presentBottomSheet() { guard !bottomSheetIsPresented else { return } bottomSheet.setBottomSheetMinimizedHeight() present(self.bottomSheet, animated: true) } private func dismissBottomSheet(animated: Bool = true) { guard bottomSheetIsPresented else { return } bottomSheet.dismiss(animated: animated) } @objc private func didBecomeActive() { if hasUnresolvedSafetyNumberMismatch { resolveSafetyNumberMismatch() } } @objc private func didCompleteAnySpamChallenge() { AppEnvironment.shared.callLinkProfileKeySharingManager.sendProfileKeyToParticipants(ofCall: self.groupCall) self.ringRtcCall.resendMediaKeys() } // MARK: Call members private func updateScrollViewFrames(size: CGSize? = nil) { view.layoutIfNeeded() let size = size ?? view.frame.size if !self.hasAtLeastTwoOthers { 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 let hasOverflowMembersInGridView = videoGrid.maxItems < ringRtcCall.remoteDeviceStates.count let overflowGridHeight = hasOverflowMembersInGridView ? videoOverflow.height + 27 : 0 scrollView.isScrollEnabled = true videoGrid.isHidden = false let height: CGFloat let offset: CGFloat switch bottomSheetStateManager.bottomSheetState { case .callControlsAndOverflow, .callControls, .callInfo, .transitioning: offset = self.bottomSheet.minimizedHeight case .hidden: offset = 16 } height = size.height - view.safeAreaInsets.top - offset - overflowGridHeight 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.inherentPipSize.aspectRatio + 4) if view.width + trailingConstraintConstant > videoOverflow.contentSize.width { trailingConstraintConstant += 16 } videoOverflowTrailingConstraint.constant = trailingConstraintConstant view.layoutIfNeeded() } @discardableResult private func pinWidthWithBottomSheetMaxWidth(_ view: UIView) -> [NSLayoutConstraint] { let maxWidthConstraint = view.autoSetDimension( .width, toSize: bottomSheet.maxWidth, relation: .lessThanOrEqual, ) let edgesConstraints = view.autoPinWidthToSuperviewMargins(relation: .lessThanOrEqual) let edgesConstraints2 = view.autoPinWidthToSuperviewMargins(relation: .equal) edgesConstraints2.forEach { $0.priority = .defaultHigh } return [maxWidthConstraint] + edgesConstraints + edgesConstraints2 } private var addOnsConstraints: [NSLayoutConstraint]? private func constrainAddOnsOutsideBottomVStack() { addOnsConstraints.map(fullscreenLocalMemberAddOnsView.removeConstraints(_:)) addOnsConstraints = [ fullscreenLocalMemberAddOnsView.autoPinLeadingToSuperviewMargin(), fullscreenLocalMemberAddOnsView.autoPinTrailingToSuperviewMargin(), fullscreenLocalMemberAddOnsView.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: Constants.flipCamButtonTrailingToSuperviewEdgePadding), ] } private func constrainAddOnsInsideBottomVStack() { addOnsConstraints.map(fullscreenLocalMemberAddOnsView.removeConstraints(_:)) addOnsConstraints = pinWidthWithBottomSheetMaxWidth(fullscreenLocalMemberAddOnsView) } private func updateAddOnsViewPosition() { let canFitNextToDrawer = view.width >= bottomSheet.maxWidth + view.layoutMargins.totalWidth + view.layoutMargins.trailing + 48 if canFitNextToDrawer { guard fullscreenLocalMemberAddOnsView.superview != view else { return } bottomVStack.removeArrangedSubview(fullscreenLocalMemberAddOnsView) view.addSubview(fullscreenLocalMemberAddOnsView) constrainAddOnsOutsideBottomVStack() } else { guard fullscreenLocalMemberAddOnsView.superview != bottomVStack else { return } fullscreenLocalMemberAddOnsView.removeFromSuperview() if case .callLink = groupCall.concreteType, let toastIndex = bottomVStack.arrangedSubviews.firstIndex(of: callLinkLobbyToast) { bottomVStack.insertArrangedSubview(fullscreenLocalMemberAddOnsView, at: toastIndex) } else { bottomVStack.addArrangedSubview(fullscreenLocalMemberAddOnsView) } constrainAddOnsInsideBottomVStack() } } private var shouldHideAddOnsView: Bool { !groupCall.isJustMe || (groupCall.isJustMe && call.isOutgoingVideoMuted) || hasDismissed } private func updateBottomVStackItems() { let hasRaisedHands = !self.raisedHandsToast.raisedHands.isEmpty self.raisedHandsToastContainer.isHiddenInStackView = !hasRaisedHands self.fullscreenLocalMemberAddOnsView.isHiddenInStackView = self.shouldHideAddOnsView self.updateAddOnsViewPosition() /// If there are no approval requests, `callLinkApprovalViewModel`'s height /// will be zero, but we don't want to hide it because the approval view /// itself is pinned to it, and we want it to retain its position when /// the last item animates out. let hasApprovalRequests: Bool = switch self.groupCall.concreteType { case .groupThread: false case .callLink: !self.callLinkApprovalViewModel.requests.isEmpty } let hasOverflowMembers = self.videoOverflow.hasOverflowMembers if hasOverflowMembers { // Move video overflow to bottom if self.bottomVStack.arrangedSubviews.last != self.videoOverflowContainer { self.bottomVStack.removeArrangedSubview(self.videoOverflowContainer) self.bottomVStack.addArrangedSubview(self.videoOverflowContainer) } } else { // Move video overflow to top if self.bottomVStack.arrangedSubviews.first != self.videoOverflowContainer { self.bottomVStack.removeArrangedSubview(self.videoOverflowContainer) self.bottomVStack.insertArrangedSubview(self.videoOverflowContainer, at: 0) } } enum Item { case raisedHands, approvals } func setSpacing(_ spacing: CGFloat, after item: Item) { let view: UIView = switch item { case .raisedHands: self.raisedHandsToastContainer case .approvals: self.approvalStackHeightView } self.bottomVStack.setCustomSpacing(spacing, after: view) } let overflowNeedsPadding = hasOverflowMembers && self.page == .grid switch (overflowNeedsPadding, hasRaisedHands, hasApprovalRequests) { case (false, _, true): setSpacing(Constants.bottomVStackSpacing, after: .raisedHands) setSpacing(Constants.bottomVStackSpacing, after: .approvals) case (false, _, false): setSpacing(Constants.bottomVStackSpacing, after: .raisedHands) setSpacing(0, after: .approvals) case (true, _, true): setSpacing(Constants.bottomVStackSpacing, after: .raisedHands) setSpacing(Constants.videoOverflowExtraSpacing, after: .approvals) case (true, true, false): setSpacing(Constants.videoOverflowExtraSpacing, after: .raisedHands) setSpacing(0, after: .approvals) case (true, false, false): // Raised hands view is hidden setSpacing(0, after: .approvals) } } private enum Constants { static let spacingTopRaiseHandToastToBottomLocalPip: CGFloat = 12 static let flipCamButtonTrailingToSuperviewEdgePadding: CGFloat = 34 static let bottomVStackSpacing: CGFloat = 8 static let videoOverflowExtraSpacing: CGFloat = 24 } private func updateMemberViewFrames( size: CGSize? = nil, shouldRepositionBottomVStack: Bool = true, ) { 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 return } view.layoutIfNeeded() let size = size ?? view.frame.size let yMax: CGFloat if shouldRepositionBottomVStack { switch bottomSheetStateManager.bottomSheetState { case .callControlsAndOverflow, .callControls, .callInfo, .transitioning: yMax = size.height - bottomSheet.minimizedHeight - 16 case .hidden: yMax = size.height - 32 } bottomVStackTopConstraint.constant = yMax } else { yMax = bottomVStackTopConstraint.constant } updateVideoOverflowTrailingConstraint() localMemberView.applyChangesToCallMemberViewAndVideoView { view in view.removeFromSuperview() } speakerView.applyChangesToCallMemberViewAndVideoView { view in view.removeFromSuperview() } if groupCall.isJustMe { localMemberView.applyChangesToCallMemberViewAndVideoView { view in speakerPage.addSubview(view) view.frame = CGRect(origin: .zero, size: size) } } else { 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, ) let y: CGFloat if nil != expandedPipFrame { // Special case necessary because when the pip is // expanded, the pip height does not follow along // with that of the video overflow, which is tiny. if self.raisedHandsToastContainer.isHiddenInStackView || (self.videoOverflow.hasOverflowMembers && self.page == .grid) { // Bottom of pip should align with bottom of overflow (whether the overflow is hidden or not). y = yMax - pipSize.height } else { // Bottom of pip should align with top of raised hand toast, plus padding. y = yMax - pipSize.height - raisedHandsToastContainer.height - Constants.spacingTopRaiseHandToastToBottomLocalPip } } else { let overflowY = videoOverflow.convert(videoOverflow.bounds.origin, to: self.view).y let overflowPipHeightDifference = pipSize.height - videoOverflow.height y = overflowY - overflowPipHeightDifference } 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, ) } } // MARK: Other UI private func updateSwipeToastView() { let isSpeakerViewAvailable = self.hasAtLeastTwoOthers 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 SSKEnvironment.shared.databaseStorageRef.asyncWrite { writeTx in Self.keyValueStore.setBool(true, key: Self.didUserSwipeToScreenShareKey, transaction: writeTx) } } } else { didUserEverSwipeToSpeakerView = true SSKEnvironment.shared.databaseStorageRef.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 var hasShownCallControls = false private func updateCallUI( size: CGSize? = nil, shouldAnimateViewFrames: Bool = false, bottomSheetChangedStateFrom oldBottomSheetState: BottomSheetState? = nil, ) { let isFullScreen = groupCall.isJustMe 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 = !isJustMe && AppEnvironment.shared.windowManagerRef.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 { dismissBottomSheet(animated: false) createIncomingCallControlsIfNeeded().isHidden = false // These views aren't visible at this point, but we need them to be configured anyway. updateMemberViewFrames(size: size) updateScrollViewFrames(size: size) return } else if !self.hasShownCallControls { self.presentBottomSheet() self.hasShownCallControls = true } 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. presentBottomSheet() incomingCallControls.isHidden = true } self.callControlDisplayStateDidChange( oldState: oldBottomSheetState ?? self.bottomSheetStateManager.bottomSheetState, newState: self.bottomSheetStateManager.bottomSheetState, size: size, shouldAnimateViewFrames: shouldAnimateViewFrames, ) // Update constraints that hug call controls sheet callControlsOverflowBottomConstraint?.constant = callControlsOverflowBottomConstraintConstant callControlsConfirmationToastContainerViewBottomConstraint?.constant = callControlsConfirmationToastContainerViewBottomConstraintConstant if groupCall.isJustMe { flipCameraTooltipManager.dismissTooltip() } updateSwipeToastView() } private var callControlsOverflowBottomConstraintConstant: CGFloat { -self.bottomSheet.minimizedHeight - 12 } private var callControlsConfirmationToastContainerViewBottomConstraintConstant: CGFloat { return -self.bottomSheet.minimizedHeight - 16 } private func callControlDisplayStateDidChange( oldState: BottomSheetState, newState: BottomSheetState, size: CGSize?, shouldAnimateViewFrames: Bool, ) { func updateFrames(controlsAreHidden: Bool, shouldRepositionBottomVStack: Bool = true) { let raisedHandsToastWasAlreadyHidden = self.raisedHandsToastContainer.isHidden let action: () -> Void = { self.updateBottomVStackItems() self.updateMemberViewFrames( size: size, shouldRepositionBottomVStack: shouldRepositionBottomVStack, ) self.updateScrollViewFrames(size: size) } let completion: () -> Void = { if self.raisedHandsToast.raisedHands.isEmpty, !raisedHandsToastWasAlreadyHidden { self.raisedHandsToast.wasHidden() } } if shouldAnimateViewFrames { let animator = UIViewPropertyAnimator(duration: 0.3, springDamping: 1, springResponse: 0.3) animator.addAnimations(action) animator.addCompletion { _ in completion() } animator.startAnimation() } else { action() completion() } } switch oldState { case .callControlsAndOverflow: switch newState { case .callControlsAndOverflow: updateFrames(controlsAreHidden: false) case .callControls: self.callControlsOverflowView.animateOut() updateFrames(controlsAreHidden: false) case .hidden: // This can happen if you tap the root view fast enough in succession. animateCallControls( hideCallControls: true, size: size, ) self.callControlsOverflowView.animateOut() case .callInfo, .transitioning: self.callControlsOverflowView.animateOut() } case .callControls: switch newState { case .callControlsAndOverflow: self.callControlsOverflowView.animateIn() updateFrames(controlsAreHidden: false) case .callControls: updateFrames(controlsAreHidden: false) case .hidden: animateCallControls( hideCallControls: true, size: size, ) case .callInfo, .transitioning: break } case .hidden: switch newState { case .callControlsAndOverflow: owsFailDebug("Impossible bottomSheetStateManager.bottomSheetState transition") // But if you must... animateCallControls( hideCallControls: false, size: size, ) self.callControlsOverflowView.animateIn() case .callControls, .callInfo, .transitioning: animateCallControls( hideCallControls: false, size: size, ) case .hidden: updateFrames(controlsAreHidden: true) } case .callInfo, .transitioning: switch newState { case .callControlsAndOverflow: self.callControlsOverflowView.animateIn() case .callControls: updateFrames(controlsAreHidden: false, shouldRepositionBottomVStack: false) case .callInfo, .transitioning: updateFrames(controlsAreHidden: true, shouldRepositionBottomVStack: false) case .hidden: owsFailDebug("Impossible bottomSheetStateManager.bottomSheetState transition") } } } private func animateCallControls( hideCallControls: Bool, size: CGSize?, ) { if hideCallControls { dismissBottomSheet() } else { bottomSheet.setBottomSheetMinimizedHeight() presentBottomSheet() } bottomSheet.transitionCoordinator?.animateAlongsideTransition(in: view, animation: { _ in self.callHeader.alpha = hideCallControls ? 0 : 1 self.updateBottomVStackItems() self.updateMemberViewFrames(size: size) self.updateScrollViewFrames(size: size) self.view.layoutIfNeeded() }, completion: { _ in self.callHeader.isHidden = hideCallControls // If a hand is raised during this animation, the toast will be // positioned wrong unless this is called again in the completion. self.updateBottomVStackItems() if self.raisedHandsToast.raisedHands.isEmpty { self.raisedHandsToast.wasHidden() } }) callHeader.isHidden = false } private func dismissCall(shouldHangUp: Bool = true) { if shouldHangUp { callService.callUIAdapter.localHangupCall(call) } didHangupCall() } private func didHangupCall() { guard !hasDismissed else { return } hasDismissed = true guard self.isViewLoaded else { // This can happen if the call is canceled before it's ever shown (ie a // ring that's not answered). AppEnvironment.shared.windowManagerRef.endCall(viewController: self) return } bottomSheetStateManager.submitState(.callControls) self.raisedHandsToast.raisedHands.removeAll() self.callLinkApprovalViewModel.requests.removeAll() 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). AppEnvironment.shared.windowManagerRef.endCall(viewController: self) return } splitViewSnapshot.autoPinEdgesToSuperviewEdges() bottomSheet.cancelAnimationAndUpdateConstraints() bottomSheet.dismiss(animated: true) { [self] in dismissSelf(splitViewSnapshot: splitViewSnapshot) } } private func dismissSelf(splitViewSnapshot: UIView) { UIView.animate(withDuration: 0.2, animations: { self.view.alpha = 0 }) { _ in splitViewSnapshot.removeFromSuperview() AppEnvironment.shared.windowManagerRef.endCall(viewController: self) } } override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } override var prefersHomeIndicatorAutoHidden: Bool { return true } private var hasAtLeastTwoOthers: Bool { switch ringRtcCall.localDeviceState.joinState { case .notJoined, .joining, .pending: return false case .joined: return ringRtcCall.remoteDeviceStates.count >= 2 } } /// The view controller to present new view controllers from. private var presenter: UIViewController { presentedViewController ?? self } private func presentApprovalRequestDetails(approvalRequest: CallLinkApprovalRequest) { let presenter = self.presenter // Present request details on top of the bulk request sheet by checking // one layer deeper than `presenter`. let presentingViewController = presenter.presentedViewController ?? presenter CallLinkApprovalRequestDetailsSheet( approvalRequest: approvalRequest, approvalViewModel: self.callLinkApprovalViewModel, ) .present(from: presentingViewController, dismissalDelegate: self) } private func presentBulkApprovalSheet() { CallLinkBulkApprovalSheet(viewModel: callLinkApprovalViewModel) .present(from: presenter, dismissalDelegate: self) } // MARK: - Drawer timeout @objc private func didTouchRootView(sender: UIGestureRecognizer) { switch self.bottomSheetStateManager.bottomSheetState { case .callControlsAndOverflow, .hidden: bottomSheetStateManager.submitState(.callControls) case .callControls: if bottomSheetMustBeVisible { return } bottomSheetStateManager.submitState(.hidden) case .callInfo: bottomSheetStateManager.submitState(.callControls) self.bottomSheet.minimizeHeight() case .transitioning: break } } private var bottomSheetMustBeVisible: Bool { return groupCall.isJustMe } private var sheetTimeoutTimer: Timer? private func scheduleBottomSheetTimeoutIfNecessary() { let shouldAutomaticallyDismissDrawer: Bool = { switch self.bottomSheetStateManager.bottomSheetState { case .callControlsAndOverflow, .hidden: return false case .callControls: break case .callInfo, .transitioning: return false } if bottomSheetMustBeVisible { return false } if isCallMinimized { return false } let isPresentingOtherSheet = presentedViewController != nil && presentedViewController != bottomSheet let otherSheetIsPresented = isPresentingOtherSheet || bottomSheet.presentedViewController != nil if otherSheetIsPresented { return false } return true }() guard shouldAutomaticallyDismissDrawer else { cancelBottomSheetTimeout() return } guard sheetTimeoutTimer == nil else { return } sheetTimeoutTimer = .scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in self?.timeoutBottomSheet() } } private func timeoutBottomSheet() { self.sheetTimeoutTimer = nil bottomSheetStateManager.submitState(.hidden) } private func cancelBottomSheetTimeout() { sheetTimeoutTimer?.invalidate() sheetTimeoutTimer = nil } private func showCallControlsIfTheyMustBeVisible() { if bottomSheetMustBeVisible { showCallControlsIfHidden() } } private func showCallControlsIfHidden() { switch self.bottomSheetStateManager.bottomSheetState { case .callControlsAndOverflow, .callControls: break case .hidden: bottomSheetStateManager.submitState(.callControls) case .callInfo, .transitioning: break } } // 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) } // MARK: Profile updates @objc private func otherUsersProfileChanged(notification: Notification) { AssertIsOnMainThread() guard let changedAddress = notification.userInfo?[UserProfileNotifications.profileAddressKey] as? SignalServiceAddress, changedAddress.isValid else { owsFailDebug("changedAddress was unexpectedly nil") return } if let peekInfo = self.ringRtcCall.peekInfo { let joinedAndPendingMembers = peekInfo.joinedMembers + peekInfo.pendingUsers if joinedAndPendingMembers.contains(where: { uuid in changedAddress == SignalServiceAddress(Aci(fromUUID: uuid)) }) { self.bottomSheet.updateMembers() switch self.ringRtcCall.kind { case .signalGroup: break case .callLink: // Refresh profiles in call link admin approval UI. self.callLinkApprovalViewModel.loadRequestsWithSneakyTransaction(for: peekInfo.pendingUsers) } } } } } // MARK: CallViewControllerWindowReference 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 } var isJustMe: Bool { groupCall.isJustMe } func minimizeIfNeeded() { if !isCallMinimized { didTapBackButton() } } 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 if !isJustMe { view.isHidden = true } else { view.frame = CGRect(origin: .zero, size: pipWindow.bounds.size) } } } 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] { SSKEnvironment.shared.databaseStorageRef.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 let groupThread = TSGroupThread.fetch(forGroupId: groupThreadCall.groupId, tx: transaction) addressesToCheck = 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, ) != nil } } } 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 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 } switch groupCall.concreteType { case .groupThread(let call): let databaseStorage = SSKEnvironment.shared.databaseStorageRef let groupThread = databaseStorage.read { tx in return TSGroupThread.fetch(forGroupId: call.groupId, tx: tx) } guard let groupThread else { owsFail("Missing thread for active call.") } SSKEnvironment.shared.notificationPresenterRef.notifyForGroupCallSafetyNumberChange( callTitle: groupThread.groupNameOrDefault, threadUniqueId: groupThread.uniqueId, roomId: nil, presentAtJoin: atLeastOneUnresolvedPresentAtJoin, ) case .callLink(let call): SSKEnvironment.shared.notificationPresenterRef.notifyForGroupCallSafetyNumberChange( callTitle: call.callLinkState.localizedName, threadUniqueId: nil, roomId: call.callLink.rootKey.deriveRoomId(), 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) ?? (presentedViewController?.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, ) { [weak self] didApprove in if let self, didApprove { self.presentSafetyNumberChangeSheetIfNecessary(untrustedThreshold: newUntrustedThreshold, completion: completion) } else { completion(false) } } sheet.overrideUserInterfaceStyle = .dark sheet.allowsDismissal = localDeviceHasNotJoined presenter.present(sheet, animated: true, completion: nil) } } // MARK: CallObserver extension GroupCallViewController: GroupCallObserver { func groupCallLocalDeviceStateChanged(_ call: GroupCall) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } // It would be nice to animate more device state changes, but some // can cause unwanted animations, so only add them as tested. let addOnsViewVisibilityWillChange = shouldHideAddOnsView != fullscreenLocalMemberAddOnsView.isHiddenInStackView updateCallUI(shouldAnimateViewFrames: addOnsViewVisibilityWillChange) let isCallLink: Bool = switch groupCall.concreteType { case .groupThread: false case .callLink: true } Logger.debug("\(ringRtcCall.localDeviceState.joinState)\t\(hasDismissed)") switch ringRtcCall.localDeviceState.joinState { case .joined: if membersAtJoin == nil { membersAtJoin = Set(ringRtcCall.remoteDeviceStates.lazy.map { $0.value.address }) } if isCallLink { callLinkLobbyToast.isHiddenInStackView = true } case .pending, .joining, .notJoined: membersAtJoin = nil if isCallLink, !hasDismissed { callLinkLobbyToast.isHiddenInStackView = false } } } func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } isAnyRemoteDeviceScreenSharing = ringRtcCall.remoteDeviceStates.values.first { $0.sharingScreen == true } != nil showCallControlsIfTheyMustBeVisible() updateCallUI() scheduleBottomSheetTimeoutIfNecessary() } func groupCallPeekChanged(_ call: GroupCall) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } switch call.concreteType { case .groupThread: break case .callLink: let requests = call.ringRtcCall.peekInfo?.pendingUsers ?? [] self.callLinkApprovalViewModel.loadRequestsWithSneakyTransaction(for: requests) } updateCallUI() } func groupCallEnded(_ call: GroupCall, reason: CallEndReason) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) let title: String let message: String? let shouldDismissCallAfterDismissingActionSheet: Bool 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) message = nil } 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.", ) message = nil } shouldDismissCallAfterDismissingActionSheet = true case .removedFromCall: title = OWSLocalizedString( "GROUP_CALL_REMOVED", comment: "The title of an alert when you've been removed from a group call.", ) message = OWSLocalizedString( "GROUP_CALL_REMOVED_MESSAGE", comment: "The message of an alert when you've been removed from a group call.", ) shouldDismissCallAfterDismissingActionSheet = true case .deniedRequestToJoinCall: title = OWSLocalizedString( "GROUP_CALL_REQUEST_DENIED", comment: "The title of an alert when tried to join a call using a link but the admin rejected your request.", ) message = OWSLocalizedString( "GROUP_CALL_REQUEST_DENIED_MESSAGE", comment: "The message of an alert when tried to join a call using a link but the admin rejected your request.", ) shouldDismissCallAfterDismissingActionSheet = true 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.", ) message = nil shouldDismissCallAfterDismissingActionSheet = false case .localHangup, .remoteHangup, .remoteHangupNeedPermission, .remoteHangupAccepted, .remoteHangupDeclined, .remoteHangupBusy, .remoteBusy, .remoteGlare, .remoteReCall, .timeout, .internalFailure, .signalingFailure, .connectionFailure, .appDroppedCall: Logger.error("Received Direct Call reason in a Group Call context") return } if self.isReadyToHandleObserver { showCallControlsIfTheyMustBeVisible() updateCallUI() } let actionSheet = ActionSheetController(title: title, message: message) actionSheet.addAction(ActionSheetAction( title: CommonStrings.okButton, style: .default, handler: { [weak self] _ in if shouldDismissCallAfterDismissingActionSheet { self?.dismissCall() } }, )) presenter.presentActionSheet(actionSheet) } func groupCallReceivedReactions(_ call: GroupCall, reactions: [SignalRingRTC.Reaction]) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } let localAci = SSKEnvironment.shared.databaseStorageRef.read { tx in return DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aci } guard let localAci else { owsFailDebug("Local user is in call but doesn't have ACI!") return } let mappedReactions = SSKEnvironment.shared.databaseStorageRef.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 = SSKEnvironment.shared.contactManagerRef.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]) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } self.raisedHandsToast.raisedHands = raisedHands self.updateCallUI(shouldAnimateViewFrames: true) } func groupCallReceivedRemoteMute(_ call: GroupCall, muteSource: Aci) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } self.remoteMuteToast.displaySelfMuted(muteSource: muteSource) callService.callUIAdapter.setIsMuted(call: self.call, isMuted: true) self.updateCallUI(shouldAnimateViewFrames: true) } func groupCallObservedRemoteMute(_ call: GroupCall, muteSource: Aci, muteTarget: Aci) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } self.remoteMuteToast.displayOtherMuted(source: muteSource, target: muteTarget) self.updateCallUI(shouldAnimateViewFrames: true) } func handleUntrustedIdentityError(_ call: GroupCall) { AssertIsOnMainThread() owsPrecondition(self.groupCall === call) guard self.isReadyToHandleObserver else { return } if !hasUnresolvedSafetyNumberMismatch { hasUnresolvedSafetyNumberMismatch = true resolveSafetyNumberMismatch() } } } // MARK: CallHeaderDelegate extension GroupCallViewController: CallHeaderDelegate { func didTapBackButton() { if groupCall.hasJoinedOrIsWaitingForAdminApproval { isCallMinimized = true AppEnvironment.shared.windowManagerRef.leaveCallView() // This ensures raised hands are removed updateCallUI() } else { dismissCall() } } func didTapMembersButton() { switch self.bottomSheetStateManager.bottomSheetState { case .callControls, .callControlsAndOverflow, .transitioning: bottomSheetStateManager.submitState(.callInfo) self.bottomSheet.maximizeHeight(animated: true) case .hidden: bottomSheetStateManager.submitState(.callInfo) self.bottomSheet.maximizeHeight(animated: false) case .callInfo: bottomSheetStateManager.submitState(.callControls) self.bottomSheet.minimizeHeight(animated: true) } } } // MARK: RaisedHandsToastDelegate extension GroupCallViewController: RaisedHandsToastDelegate { func didTapViewRaisedHands() { self.didTapMembersButton() } func raisedHandsToastDidChangeHeight() { self.updateCallUI(shouldAnimateViewFrames: true) } } // MARK: GroupCallVideoOverflowDelegate extension GroupCallViewController: GroupCallVideoOverflowDelegate { var firstOverflowMemberIndex: Int { switch self.page { case .grid: return videoGrid.maxItems case .speaker: return 1 } } } // MARK: UIScrollViewDelegate extension GroupCallViewController: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let isScrolledPastHalfway = scrollView.contentOffset.y > view.height / 2 self.page = isScrolledPastHalfway ? .speaker : .grid if isAutoScrollingToScreenShare { isAutoScrollingToScreenShare = scrollView.contentOffset.y != speakerView.frame.origin.y } updateSwipeToastView() } } // MARK: CallControlsDelegate 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 else { return } guard success else { return } self.callService.joinGroupCallIfNecessary(self.call, groupCall: self.groupCall) } } func didPressHangup() { } func didPressMore() { if self.callControlsOverflowView.isHidden { bottomSheetStateManager.submitState(.callControlsAndOverflow) } else { bottomSheetStateManager.submitState(.callControls) } } } // MARK: CallMemberErrorPresenter extension GroupCallViewController: CallMemberErrorPresenter { func presentErrorSheet(title: String, message: String) { let actionSheet = ActionSheetController(title: title, message: message) actionSheet.overrideUserInterfaceStyle = .dark actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton)) presentActionSheet(actionSheet) } } // MARK: AnimatableLocalMemberViewDelegate 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 { updateMemberViewFrames(size: postAnimationUpdateMemberViewFramesSize) self.postAnimationUpdateMemberViewFramesSize = nil } self.shouldRelayoutAfterPipAnimationCompletes = false } } func animatableLocalMemberViewWillBeginAnimation(_ localMemberView: CallMemberView) { self.isPipAnimationInProgress = true self.flipCameraTooltipManager.dismissTooltip() } } // MARK: - CallControlsOverflowPresenter extension GroupCallViewController: CallControlsOverflowPresenter { func callControlsOverflowWillAppear() { self.cancelBottomSheetTimeout() } func callControlsOverflowDidDisappear() { self.scheduleBottomSheetTimeoutIfNecessary() } func willSendReaction() { bottomSheetStateManager.submitState(.callControls) } func didTapRaiseOrLowerHand() { bottomSheetStateManager.submitState(.callControls) } } // MARK: - SheetPanDelegate extension GroupCallViewController: SheetPanDelegate { func sheetPanDidBegin() { bottomSheetStateManager.submitState(.transitioning) self.callControlsConfirmationToastManager.forceDismissToast() } func sheetPanDidEnd() { self.setBottomSheetStateAfterTransition() } func sheetPanDecelerationDidBegin() { bottomSheetStateManager.submitState(.transitioning) } func sheetPanDecelerationDidEnd() { self.setBottomSheetStateAfterTransition() } private func setBottomSheetStateAfterTransition() { if bottomSheet.isPresentingCallInfo() { bottomSheetStateManager.submitState(.callInfo) } else if bottomSheet.isPresentingCallControls() { bottomSheetStateManager.submitState(.callControls) } else if bottomSheet.isCrossFading() { bottomSheetStateManager.submitState(.transitioning) } } } // MARK: - CallDrawerDelegate extension GroupCallViewController: CallDrawerDelegate { func didPresentViewController(_ viewController: UIViewController) { self.scheduleBottomSheetTimeoutIfNecessary() } func didTapDone() { bottomSheetStateManager.submitState(.callControls) self.bottomSheet.minimizeHeight() } } // MARK: - Bottom Sheet State Management enum BottomSheetState { /// "Overflow" refers to the "..." menu that shows reactions & "Raise Hand". case callControlsAndOverflow case callControls case callInfo case transitioning case hidden } /// TODO: It may make sense to pull sheet timeout logic into this class. class GroupCallBottomSheetStateManager { private weak var delegate: GroupCallBottomSheetStateDelegate? private(set) var bottomSheetState: BottomSheetState = .callControls { didSet { guard bottomSheetState != oldValue else { return } delegate?.bottomSheetStateDidChange(oldState: oldValue) } } fileprivate init(delegate: GroupCallBottomSheetStateDelegate) { self.delegate = delegate } func submitState(_ state: BottomSheetState) { if let delegate, !delegate.areStateChangesSuspended { bottomSheetState = state } } } private protocol GroupCallBottomSheetStateDelegate: AnyObject { var areStateChangesSuspended: Bool { get } func bottomSheetStateDidChange(oldState: BottomSheetState) } extension GroupCallViewController: GroupCallBottomSheetStateDelegate { var areStateChangesSuspended: Bool { self.callControlsOverflowView.isAnimating } func bottomSheetStateDidChange(oldState: BottomSheetState) { updateCallUI(bottomSheetChangedStateFrom: oldState) scheduleBottomSheetTimeoutIfNecessary() } } extension GroupCallViewController: SheetDismissalDelegate { func didDismissPresentedSheet() { scheduleBottomSheetTimeoutIfNecessary() } }