TM-SGNL-iOS/Signal/Calls/UserInterface/IndividualCallViewController.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1439 lines
55 KiB
Swift

//
// Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalRingRTC
import SignalServiceKit
import SignalUI
import WebRTC
// TODO: Add category so that button handlers can be defined where button is created.
// TODO: Ensure buttons enabled & disabled as necessary.
class IndividualCallViewController: OWSViewController, IndividualCallObserver {
// MARK: - Properties
let thread: TSContactThread
let call: SignalCall
let individualCall: IndividualCall
private var hasDismissed = false
private var isCallMinimized = false {
didSet {
scheduleBottomSheetTimeoutIfNecessary()
}
}
// MARK: - Views
private lazy var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
private lazy var backgroundAvatarView = UIImageView()
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm:ss"
dateFormatter.timeZone = TimeZone(identifier: "UTC")!
dateFormatter.locale = Locale(identifier: "en_US")
return dateFormatter
}()
private var callDurationTimer: Timer?
private lazy var callControlsConfirmationToastManager = CallControlsConfirmationToastManager(
presentingContainerView: callControlsConfirmationToastContainerView
)
private lazy var callControlsConfirmationToastContainerView = UIView()
private lazy var bottomSheet = CallDrawerSheet(
call: call,
callSheetDataSource: IndividualCallSheetDataSource(
thread: thread,
call: call,
individualCall: individualCall
),
callService: callService,
confirmationToastManager: callControlsConfirmationToastManager,
callControlsDelegate: self,
sheetPanDelegate: self,
callDrawerDelegate: self
)
private var callService: CallService { AppEnvironment.shared.callService }
/// 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 flipCameraTooltipManager = FlipCameraTooltipManager(db: DependenciesBridge.shared.db)
// MARK: - Gradient Views
private lazy var topGradientView: UIView = {
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [
UIColor.ows_blackAlpha60.cgColor,
UIColor.black.withAlphaComponent(0).cgColor
]
let view = OWSLayerView(frame: .zero) { view in
gradientLayer.frame = view.bounds
}
view.layer.addSublayer(gradientLayer)
return view
}()
private lazy var bottomContainerView = UIView.container()
private lazy var bottomGradientView: UIView = {
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [
UIColor.black.withAlphaComponent(0).cgColor,
UIColor.ows_blackAlpha60.cgColor
]
let view = OWSLayerView(frame: .zero) { view in
gradientLayer.frame = view.bounds
}
view.layer.addSublayer(gradientLayer)
return view
}()
let gradientMargin: CGFloat = 46
// MARK: - Contact Views
private lazy var contactNameLabel = MarqueeLabel()
private lazy var callStatusLabel = UILabel()
private lazy var backButton = UIButton()
// MARK: - Incoming Voice Call Controls
private lazy var incomingAudioCallControls = UIStackView(
arrangedSubviews: [
UIView.hStretchingSpacer(),
audioDeclineIncomingButton,
UIView.spacer(withWidth: 124),
audioAnswerIncomingButton,
UIView.hStretchingSpacer()
]
)
private lazy var audioAnswerIncomingButton = createButton(iconName: "phone-fill-28", action: #selector(didPressAnswerCall))
private lazy var audioDeclineIncomingButton = createButton(iconName: "phone-down-fill-28", action: #selector(didPressDeclineCall))
// MARK: - Incoming Video Call Controls
private lazy var incomingVideoCallControls = UIStackView(
arrangedSubviews: [
videoAnswerIncomingAudioOnlyButton,
incomingVideoCallBottomControls
]
)
private lazy var incomingVideoCallBottomControls = UIStackView(
arrangedSubviews: [
UIView.hStretchingSpacer(),
videoDeclineIncomingButton,
UIView.spacer(withWidth: 124),
videoAnswerIncomingButton,
UIView.hStretchingSpacer()
]
)
private lazy var videoAnswerIncomingButton = createButton(iconName: "video-fill-28", action: #selector(didPressAnswerCall))
private lazy var videoAnswerIncomingAudioOnlyButton = createButton(iconName: "video-slash-fill-28", action: #selector(didPressAnswerCall))
private lazy var videoDeclineIncomingButton = createButton(iconName: "phone-down-fill-28", action: #selector(didPressDeclineCall))
// MARK: - Video Views
private var remoteMemberView: CallMemberView
private weak var remoteVideoTrack: RTCVideoTrack?
private var localVideoView: CallMemberView
// MARK: - Gestures
private lazy var tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTouchRootView))
private lazy var panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleLocalVideoPan))
private var bottomSheetState: BottomSheetState = .hidden {
didSet {
guard oldValue != bottomSheetState else { return }
updateCallUI()
}
}
// MARK: - Initializers
init(call: SignalCall, individualCall: IndividualCall) {
// TODO: Eventually unify UI for group and individual calls
self.call = call
self.individualCall = individualCall
self.thread = TSContactThread.getOrCreateThread(contactAddress: individualCall.remoteAddress)
let type = CallMemberView.MemberType.remoteInIndividual(individualCall)
remoteMemberView = CallMemberView(type: type)
localVideoView = CallMemberView(type: CallMemberView.MemberType.local)
super.init()
self.localVideoView.animatableLocalMemberViewDelegate = self
self.callService.callServiceState.addObserver(self)
}
deinit {
// These views might be in the return to call PIP's hierarchy,
// we want to remove them so they are free'd when the call ends
remoteMemberView.applyChangesToCallMemberViewAndVideoView { view in
view.removeFromSuperview()
}
localVideoView.applyChangesToCallMemberViewAndVideoView { view in
view.removeFromSuperview()
}
}
// MARK: - View Lifecycle
@objc
private func didBecomeActive() {
if self.isViewLoaded {
bottomSheetState = .callControls
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { [weak self] _ in
self?.updateLocalVideoLayout()
}, completion: nil)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.dismissBottomSheet(animated)
callService.audioService.delegate = nil
callDurationTimer?.invalidate()
callDurationTimer = nil
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
updateCallUI()
if call.offerMediaType == .video {
callService.sendInitialPhoneOrientationNotification()
}
}
override func loadView() {
view = UIView()
view.clipsToBounds = true
view.backgroundColor = UIColor.black
view.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)
createViews()
createViewConstraints()
}
override func viewDidLoad() {
super.viewDidLoad()
self.callService.callServiceState.removeObserver(self)
self.individualCall.isViewLoaded = true
self.callService.updateIsVideoEnabled()
remoteMemberView.applyChangesToCallMemberViewAndVideoView { view in
view.isHidden = false
}
contactNameLabel.text = SSKEnvironment.shared.databaseStorageRef.read { tx in
return SSKEnvironment.shared.contactManagerRef.displayName(for: thread.contactAddress, tx: tx).resolvedValue()
}
updateAvatarImage()
NotificationCenter.default.addObserver(
self,
selector: #selector(updateAvatarImage),
name: .OWSContactsManagerSignalAccountsDidChange,
object: nil
)
// Subscribe for future call updates
individualCall.addObserverAndSyncState(self)
NotificationCenter.default.addObserver(
self,
selector: #selector(didBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil
)
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return UIDevice.current.isIPad ? .all : .portrait
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
// MARK: - Create Views
private func createViews() {
view.isUserInteractionEnabled = true
view.addGestureRecognizer(tapGesture)
localVideoView.addGestureRecognizer(panGesture)
panGesture.delegate = self
tapGesture.require(toFail: panGesture)
// The callee's avatar is rendered behind the blurred background.
backgroundAvatarView.contentMode = .scaleAspectFill
backgroundAvatarView.isUserInteractionEnabled = false
view.addSubview(backgroundAvatarView)
backgroundAvatarView.autoPinEdgesToSuperviewEdges()
// Dark blurred background.
blurView.isUserInteractionEnabled = false
view.addSubview(blurView)
blurView.autoPinEdgesToSuperviewEdges()
// Create the video views first, as they are under the other views.
createVideoViews()
view.addSubview(topGradientView)
topGradientView.autoPinWidthToSuperview()
topGradientView.autoPinEdge(toSuperviewEdge: .top)
view.addSubview(bottomContainerView)
bottomContainerView.autoPinWidthToSuperview()
bottomContainerView.autoPinEdge(toSuperviewEdge: .bottom)
bottomContainerView.addSubview(bottomGradientView)
bottomGradientView.autoPinWidthToSuperview()
bottomGradientView.autoPinEdge(toSuperviewEdge: .bottom)
// Confirmation toasts should sit on top of the `localVideoView`
// and most other UI elements, so this `addSubview` should remain towards
// the end of the setup.
view.addSubview(callControlsConfirmationToastContainerView)
self.callControlsConfirmationToastContainerViewBottomConstraint = callControlsConfirmationToastContainerView.autoPinEdge(
.bottom,
to: .bottom,
of: self.view,
withOffset: callControlsConfirmationToastContainerViewBottomConstraintConstant
)
callControlsConfirmationToastContainerView.autoHCenterInSuperview()
createContactViews()
createIncomingCallControls()
}
private var callControlsConfirmationToastContainerViewBottomConstraint: NSLayoutConstraint?
private var callControlsConfirmationToastContainerViewBottomConstraintConstant: CGFloat {
return -self.bottomSheet.minimizedHeight - 16
}
private func presentBottomSheet(_ animated: Bool) {
guard bottomSheet.presentingViewController == nil else { return }
bottomSheet.setBottomSheetMinimizedHeight()
present(bottomSheet, animated: animated)
}
private func dismissBottomSheet(_ animated: Bool = true) {
guard bottomSheet.presentingViewController != nil else { return }
bottomSheet.dismiss(animated: animated)
}
private func createVideoViews() {
remoteMemberView.applyChangesToCallMemberViewAndVideoView { aView in
aView.isUserInteractionEnabled = false
aView.isHidden = true
view.addSubview(aView)
}
remoteMemberView.isGroupCall = false
localVideoView.applyChangesToCallMemberViewAndVideoView { aView in
// We want the local video view to use the aspect ratio of the screen, so we change it to "aspect fill".
aView.contentMode = .scaleAspectFill
aView.clipsToBounds = true
aView.isHidden = true
view.addSubview(aView)
}
}
private func createContactViews() {
backButton.setImage(UIImage(imageLiteralResourceName: "NavBarBack"), for: .normal)
backButton.tintColor = Theme.darkThemeNavbarIconColor
backButton.autoSetDimensions(to: CGSize(square: 40))
backButton.addTarget(self, action: #selector(didTapLeaveCall(sender:)), for: .touchUpInside)
topGradientView.addSubview(backButton)
// marquee config
contactNameLabel.type = .continuous
// This feels pretty slow when you're initially waiting for it, but when you're overlaying video calls, anything faster is distracting.
contactNameLabel.speed = .duration(30.0)
contactNameLabel.animationCurve = .linear
contactNameLabel.fadeLength = 10.0
contactNameLabel.animationDelay = 5
// Add trailing space after the name scrolls before it wraps around and scrolls back in.
contactNameLabel.trailingBuffer = .scaleFromIPhone5(80)
// label config
contactNameLabel.font = UIFont.dynamicTypeTitle1
contactNameLabel.textAlignment = .center
contactNameLabel.textColor = UIColor.white
contactNameLabel.layer.shadowOffset = .zero
contactNameLabel.layer.shadowOpacity = 0.25
contactNameLabel.layer.shadowRadius = 4
topGradientView.addSubview(contactNameLabel)
callStatusLabel.font = UIFont.dynamicTypeBody
callStatusLabel.textAlignment = .center
callStatusLabel.textColor = UIColor.white
callStatusLabel.layer.shadowOffset = .zero
callStatusLabel.layer.shadowOpacity = 0.25
callStatusLabel.layer.shadowRadius = 4
topGradientView.addSubview(callStatusLabel)
backButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "leaveCallViewButton")
contactNameLabel.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "contactNameLabel")
callStatusLabel.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "callStatusLabel")
}
@objc
private func updateAvatarImage() {
SSKEnvironment.shared.databaseStorageRef.read { transaction in
backgroundAvatarView.image = SSKEnvironment.shared.contactManagerImplRef.avatarImage(forAddress: thread.contactAddress,
shouldValidate: true,
transaction: transaction)
}
}
private func createIncomingCallControls() {
audioAnswerIncomingButton.text = OWSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL",
comment: "label for accepting incoming calls")
audioAnswerIncomingButton.unselectedBackgroundColor = .ows_accentGreen
audioAnswerIncomingButton.accessibilityLabel = OWSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL",
comment: "label for accepting incoming calls")
audioDeclineIncomingButton.text = OWSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL",
comment: "label for declining incoming calls")
audioDeclineIncomingButton.unselectedBackgroundColor = .ows_accentRed
audioDeclineIncomingButton.accessibilityLabel = OWSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL",
comment: "label for declining incoming calls")
incomingAudioCallControls.axis = .horizontal
incomingAudioCallControls.alignment = .center
bottomContainerView.addSubview(incomingAudioCallControls)
audioAnswerIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioAnswerIncomingButton")
audioDeclineIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "audioDeclineIncomingButton")
videoAnswerIncomingButton.text = OWSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL",
comment: "label for accepting incoming calls")
videoAnswerIncomingButton.unselectedBackgroundColor = .ows_accentGreen
videoAnswerIncomingButton.accessibilityLabel = OWSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL",
comment: "label for accepting incoming calls")
videoAnswerIncomingAudioOnlyButton.text = OWSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_AUDIO_ONLY_LABEL",
comment: "label for accepting incoming video calls as audio only")
videoAnswerIncomingAudioOnlyButton.accessibilityLabel = OWSLocalizedString("CALL_VIEW_ACCEPT_INCOMING_CALL_AUDIO_ONLY_LABEL",
comment: "label for accepting incoming video calls as audio only")
videoDeclineIncomingButton.text = OWSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL",
comment: "label for declining incoming calls")
videoDeclineIncomingButton.unselectedBackgroundColor = .ows_accentRed
videoDeclineIncomingButton.accessibilityLabel = OWSLocalizedString("CALL_VIEW_DECLINE_INCOMING_CALL_LABEL",
comment: "label for declining incoming calls")
incomingVideoCallBottomControls.axis = .horizontal
incomingVideoCallBottomControls.alignment = .center
incomingVideoCallControls.axis = .vertical
incomingVideoCallControls.spacing = 20
bottomContainerView.addSubview(incomingVideoCallControls)
// Ensure that the controls are always horizontally centered
for stackView in [incomingAudioCallControls, incomingVideoCallBottomControls] {
guard let leadingSpacer = stackView.arrangedSubviews.first, let trailingSpacer = stackView.arrangedSubviews.last else {
return owsFailDebug("failed to get spacers")
}
leadingSpacer.autoMatch(.width, to: .width, of: trailingSpacer)
}
videoAnswerIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoAnswerIncomingButton")
videoAnswerIncomingAudioOnlyButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoAnswerIncomingAudioOnlyButton")
videoDeclineIncomingButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "videoDeclineIncomingButton")
}
private func createButton(iconName: String, action: Selector) -> CallButton {
let button = CallButton(iconName: iconName)
button.addTarget(self, action: action, for: .touchUpInside)
button.setContentHuggingHorizontalHigh()
button.setCompressionResistanceHorizontalLow()
return button
}
// MARK: - Layout
private func createViewConstraints() {
let contactVSpacing: CGFloat = 3
let bottomMargin = CGFloat.scaleFromIPhone5To7Plus(23, 41)
backButton.autoPinEdge(toSuperviewEdge: .leading)
backButton.autoPinEdge(toSuperviewMargin: .top)
contactNameLabel.autoPinEdge(toSuperviewMargin: .top)
contactNameLabel.autoPinEdge(.leading, to: .trailing, of: backButton, withOffset: 8, relation: .greaterThanOrEqual)
contactNameLabel.autoHCenterInSuperview()
contactNameLabel.setContentHuggingVerticalHigh()
contactNameLabel.setCompressionResistanceHigh()
callStatusLabel.autoPinEdge(.top, to: .bottom, of: contactNameLabel, withOffset: contactVSpacing)
callStatusLabel.autoPinEdge(toSuperviewEdge: .bottom, withInset: gradientMargin)
callStatusLabel.autoHCenterInSuperview()
callStatusLabel.setContentHuggingVerticalHigh()
callStatusLabel.setCompressionResistanceHigh()
remoteMemberView.applyChangesToCallMemberViewAndVideoView { view in
view.autoPinEdgesToSuperviewEdges()
}
incomingVideoCallControls.autoPinEdge(toSuperviewEdge: .top)
for controls in [incomingVideoCallControls, incomingAudioCallControls] {
controls.autoPinWidthToSuperviewMargins()
controls.autoPinEdge(toSuperviewEdge: .bottom, withInset: bottomMargin)
controls.setContentHuggingVerticalHigh()
}
}
private func updateRemoteVideoLayout() {
updateCallUI()
}
private var lastLocalVideoBoundingRect: CGRect = .zero
private var localVideoBoundingRect: CGRect {
view.layoutIfNeeded()
var rect = view.frame
rect.origin.x += view.layoutMargins.left
rect.size.width -= view.layoutMargins.left + view.layoutMargins.right
let useTighterBounding: Bool
switch bottomSheetState {
case .callControls, .transitioning, .callInfo:
useTighterBounding = true
case .hidden:
useTighterBounding = false
}
let topInset = !useTighterBounding
? view.layoutMargins.top
: topGradientView.height - gradientMargin + 14
let bottomInset = (
!useTighterBounding
? view.layoutMargins.bottom
: bottomSheet.minimizedHeight + 14
)
rect.origin.y += topInset
rect.size.height -= topInset + bottomInset
lastLocalVideoBoundingRect = rect
return rect
}
private var isRenderingLocalVanityVideo: Bool {
return [.idle, .dialing, .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(individualCall.state) && !localVideoView.isHidden
}
private var previousOrigin: CGPoint!
private func updateLocalVideoLayout() {
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 `updateLocalVideoLayout`.
self.shouldRelayoutAfterPipAnimationCompletes = true
return
}
localVideoView.configure(
call: call,
isFullScreen: isRenderingLocalVanityVideo,
remoteGroupMemberDeviceState: nil
)
guard localVideoView.superview == view else { return }
guard !individualCall.isEnded else { return }
guard !isRenderingLocalVanityVideo else {
view.bringSubviewToFront(topGradientView)
view.bringSubviewToFront(bottomContainerView)
view.layoutIfNeeded()
localVideoView.applyChangesToCallMemberViewAndVideoView { aView in
aView.frame = view.frame
}
return
}
guard !localVideoView.isHidden else { return }
localVideoView.applyChangesToCallMemberViewAndVideoView { aView in
view.bringSubviewToFront(aView)
}
view.bringSubviewToFront(callControlsConfirmationToastContainerView)
let pipSize = CallMemberView.pipSize(expandedPipFrame: self.expandedPipFrame, remoteDeviceCount: 1)
let lastBoundingRect = lastLocalVideoBoundingRect
let boundingRect = localVideoBoundingRect
// Prefer to start in the top right
if previousOrigin == nil {
previousOrigin = CGPoint(
x: boundingRect.maxX - pipSize.width,
y: boundingRect.minY
)
// If the bounding rect has gotten bigger, and we were at the top or
// bottom edge move the pip so it stays at the top or bottom edge.
} else if boundingRect.minY < lastBoundingRect.minY && previousOrigin.y == lastBoundingRect.minY {
previousOrigin.y = boundingRect.minY
} else if boundingRect.maxY > lastBoundingRect.maxY && previousOrigin.y + pipSize.height == lastBoundingRect.maxY {
previousOrigin.y += boundingRect.maxY - lastBoundingRect.maxY
}
let newFrame = CGRect(origin: previousOrigin, size: pipSize).pinnedToVerticalEdge(of: localVideoBoundingRect)
previousOrigin = newFrame.origin
UIView.animate(
withDuration: 0.25,
animations: {
self.localVideoView.applyChangesToCallMemberViewAndVideoView { view in
view.frame = newFrame
}
},
completion: { [weak self] _ in
guard let self else { return }
self.flipCameraTooltipManager.presentTooltipIfNecessary(
fromView: self.view,
widthReferenceView: self.view,
tailReferenceView: self.localVideoView,
tailDirection: .up,
isVideoMuted: self.call.isOutgoingVideoMuted
)
}
)
}
private var startingTranslation: CGPoint?
@objc
private func handleLocalVideoPan(sender: UIPanGestureRecognizer) {
guard !isPipAnimationInProgress else {
/// `localVideoView` and its `associatedCallMemberVideoView`
/// can get disaligned if we attempt to perform this pan
/// before the expand/contract animation completes.
return
}
switch sender.state {
case .began, .changed:
let translation = sender.translation(in: localVideoView)
sender.setTranslation(.zero, in: localVideoView)
flipCameraTooltipManager.dismissTooltip()
localVideoView.applyChangesToCallMemberViewAndVideoView { view in
view.frame.origin.y += translation.y
view.frame.origin.x += translation.x
}
case .ended, .cancelled, .failed:
localVideoView.animateDecelerationToVerticalEdge(
withDuration: 0.35,
velocity: sender.velocity(in: localVideoView),
boundingRect: localVideoBoundingRect
) { _ in self.previousOrigin = self.localVideoView.frame.origin }
if let videoView = localVideoView.associatedCallMemberVideoView {
videoView.animateDecelerationToVerticalEdge(
withDuration: 0.35,
velocity: sender.velocity(in: videoView),
boundingRect: localVideoBoundingRect
) { _ in }
}
default:
break
}
}
// MARK: - View State
private func localizedTextForCallState() -> String {
assert(Thread.isMainThread)
switch individualCall.state {
case .idle, .remoteHangup, .remoteHangupNeedPermission, .localHangup:
return OWSLocalizedString("IN_CALL_TERMINATED", comment: "Call setup status label")
case .dialing:
return OWSLocalizedString("IN_CALL_CONNECTING", comment: "Call setup status label")
case .remoteRinging:
return OWSLocalizedString("IN_CALL_RINGING", comment: "Call setup status label")
case .localRinging_Anticipatory, .localRinging_ReadyToAnswer:
switch individualCall.offerMediaType {
case .audio:
return OWSLocalizedString("IN_CALL_RINGING_AUDIO", comment: "Call setup status label")
case .video:
return OWSLocalizedString("IN_CALL_RINGING_VIDEO", comment: "Call setup status label")
}
case .answering, .accepting:
return OWSLocalizedString("IN_CALL_SECURING", comment: "Call setup status label")
case .connected:
let callDuration = individualCall.commonState.connectionDuration()
let callDurationDate = Date(timeIntervalSinceReferenceDate: callDuration)
var formattedDate = dateFormatter.string(from: callDurationDate)
if formattedDate.hasPrefix("00:") {
// Don't show the "hours" portion of the date format unless the
// call duration is at least 1 hour.
formattedDate = String(formattedDate[formattedDate.index(formattedDate.startIndex, offsetBy: 3)...])
} else {
// If showing the "hours" portion of the date format, strip any leading
// zeroes.
if formattedDate.hasPrefix("0") {
formattedDate = String(formattedDate[formattedDate.index(formattedDate.startIndex, offsetBy: 1)...])
}
}
return formattedDate
case .reconnecting:
return OWSLocalizedString("IN_CALL_RECONNECTING", comment: "Call setup status label")
case .remoteBusy:
return OWSLocalizedString("END_CALL_RESPONDER_IS_BUSY", comment: "Call setup status label")
case .localFailure where individualCall.direction == .outgoing && { () -> Bool in
if case .timeout = individualCall.error {
return true
}
return false
}():
return OWSLocalizedString("CALL_SCREEN_STATUS_NO_ANSWER", comment: "Call setup status label after outgoing call times out")
case .localFailure:
return OWSLocalizedString("END_CALL_UNCATEGORIZED_FAILURE", comment: "Call setup status label")
case .answeredElsewhere:
return OWSLocalizedString("IN_CALL_ENDED_BECAUSE_ANSWERED_ELSEWHERE", comment: "Call screen label when call was canceled on this device because the call recipient answered on another device.")
case .declinedElsewhere:
return OWSLocalizedString("IN_CALL_ENDED_BECAUSE_DECLINED_ELSEWHERE", comment: "Call screen label when call was canceled on this device because the call recipient declined on another device.")
case .busyElsewhere:
return OWSLocalizedString("IN_CALL_ENDED_BECAUSE_BUSY_ELSEWHERE", comment: "Call screen label when call was canceled on this device because the call recipient has a call in progress on another device.")
}
}
private var isBlinkingReconnectLabel = false
private func updateCallStatusLabel() {
assert(Thread.isMainThread)
let text = localizedTextForCallState()
if text != self.callStatusLabel.text {
self.callStatusLabel.text = text
}
// Handle reconnecting blinking
if case .reconnecting = individualCall.state {
if !isBlinkingReconnectLabel {
isBlinkingReconnectLabel = true
UIView.animate(withDuration: 0.7, delay: 0, options: [.autoreverse, .repeat],
animations: {
self.callStatusLabel.alpha = 0.2
}, completion: nil)
} else {
// already blinking
}
} else {
// We're no longer in a reconnecting state, either the call failed or we reconnected.
// Stop the blinking animation
if isBlinkingReconnectLabel {
self.callStatusLabel.layer.removeAllAnimations()
self.callStatusLabel.alpha = 1
isBlinkingReconnectLabel = false
}
}
}
private var isIncomingRing: Bool {
[.localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(individualCall.state)
}
private func updateCallUI() {
assert(Thread.isMainThread)
updateCallStatusLabel()
// Marquee scrolling is distracting during a video call, disable it.
contactNameLabel.labelize = individualCall.hasLocalVideo
localVideoView.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 = !individualCall.hasLocalVideo || AppEnvironment.shared.windowManagerRef.isCallInPip
}
updateRemoteVideoTrack(
remoteVideoTrack: individualCall.isRemoteVideoEnabled ? individualCall.remoteVideoTrack : nil
)
// Show Incoming vs. Ongoing call controls
if [.localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(individualCall.state) {
let isVideoOffer = individualCall.offerMediaType == .video
incomingVideoCallControls.isHidden = !isVideoOffer
incomingAudioCallControls.isHidden = isVideoOffer
} else {
incomingVideoCallControls.isHidden = true
incomingAudioCallControls.isHidden = true
}
// Rework control state if remote video is available.
remoteMemberView.isFullScreen = true
remoteMemberView.isScreenShare = individualCall.isRemoteSharingScreen
// Layout controls immediately to avoid spurious animation.
for controls in [incomingVideoCallControls, incomingAudioCallControls] {
controls.layoutIfNeeded()
}
let hideCallControls: Bool
switch (isIncomingRing, bottomSheetState) {
case (true, _):
// When incoming ring, on-screen call controls should always
// be displayed, but the bottom sheet should never.
dismissBottomSheet(true)
hideCallControls = false
case (false, .callControls):
presentBottomSheet(true)
hideCallControls = false
case (false, .hidden):
if !self.individualCall.isRemoteVideoEnabled {
// When the remote video is enabled, call controls should
// be forced at all times.
bottomSheetState = .callControls
hideCallControls = false
break
}
dismissBottomSheet(true)
hideCallControls = true
case (false, .transitioning), (false, .callInfo):
hideCallControls = false
}
self.bottomContainerView.isHidden = false
self.topGradientView.isHidden = false
UIView.animate(withDuration: 0.15) {
self.bottomContainerView.alpha = hideCallControls ? 0 : 1
self.topGradientView.alpha = hideCallControls ? 0 : 1
} completion: { _ in
self.bottomContainerView.isHidden = hideCallControls
self.topGradientView.isHidden = hideCallControls
}
// Update local video
localVideoView.applyChangesToCallMemberViewAndVideoView { view in
view.layer.cornerRadius = isRenderingLocalVanityVideo ? 0 : CallMemberView.Constants.defaultPipCornerRadius
}
updateLocalVideoLayout()
// Update remote video
remoteMemberView.configure(
call: call,
isFullScreen: true,
remoteGroupMemberDeviceState: nil
)
// Dismiss Handling
switch individualCall.state {
case .remoteHangupNeedPermission:
displayNeedPermissionErrorAndDismiss()
case .remoteHangup, .remoteBusy, .localFailure, .answeredElsewhere, .declinedElsewhere, .busyElsewhere:
Logger.debug("dismissing after delay because new state is \(individualCall.state)")
dismissIfPossible(shouldDelay: true)
case .localHangup:
Logger.debug("dismissing immediately from local hangup")
dismissIfPossible(shouldDelay: false)
default: break
}
if individualCall.state == .connected {
if callDurationTimer == nil {
let kDurationUpdateFrequencySeconds = 1 / 20.0
callDurationTimer = WeakTimer.scheduledTimer(
timeInterval: TimeInterval(kDurationUpdateFrequencySeconds),
target: self,
userInfo: nil,
repeats: true
) { [weak self] _ in
self?.updateCallDuration()
}
}
} else {
callDurationTimer?.invalidate()
callDurationTimer = nil
}
callControlsConfirmationToastContainerViewBottomConstraint?.constant = callControlsConfirmationToastContainerViewBottomConstraintConstant
scheduleBottomSheetTimeoutIfNecessary()
}
private func displayNeedPermissionErrorAndDismiss() {
if hasDismissed {
return
}
hasDismissed = true
contactNameLabel.removeFromSuperview()
callStatusLabel.removeFromSuperview()
incomingAudioCallControls.removeFromSuperview()
incomingVideoCallControls.removeFromSuperview()
backButton.removeFromSuperview()
remoteMemberView.applyChangesToCallMemberViewAndVideoView { view in
view.removeFromSuperview()
}
localVideoView.applyChangesToCallMemberViewAndVideoView { view in
view.removeFromSuperview()
}
let permissionErrorView = PermissionErrorView(
thread: self.thread,
contactManager: SSKEnvironment.shared.contactManagerRef,
okayButtonWasTapped: { [weak self] in self?.dismissImmediately() }
)
view.addSubview(permissionErrorView)
permissionErrorView.autoPinWidthToSuperview(withMargin: 16)
permissionErrorView.autoVCenterInSuperview()
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
self?.dismissImmediately()
}
}
private func updateCallDuration() {
updateCallStatusLabel()
}
// MARK: - Drawer timeout
private enum BottomSheetState {
case callControls
case transitioning
case callInfo
case hidden
}
@objc
private func didTouchRootView(sender: UIGestureRecognizer) {
switch bottomSheetState {
case .callControls:
if bottomSheetMustBeVisible {
return
}
bottomSheetState = .hidden
case .callInfo:
bottomSheetState = .callControls
bottomSheet.minimizeHeight(animated: true)
case .hidden:
bottomSheetState = .callControls
case .transitioning:
break
}
}
private var sheetTimeoutTimer: Timer?
private func scheduleBottomSheetTimeoutIfNecessary() {
let shouldAutomaticallyDismissDrawer: Bool = {
switch bottomSheetState {
case .hidden:
return false
case .callControls:
break
case .callInfo, .transitioning:
return false
}
if bottomSheetMustBeVisible {
return false
}
if isCallMinimized {
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 var bottomSheetMustBeVisible: Bool {
return !self.individualCall.isRemoteVideoEnabled && !self.isIncomingRing
}
private func cancelBottomSheetTimeout() {
sheetTimeoutTimer?.invalidate()
sheetTimeoutTimer = nil
}
private func timeoutBottomSheet() {
self.sheetTimeoutTimer = nil
bottomSheetState = .hidden
}
private func showCallControlsIfHidden() {
switch bottomSheetState {
case .callControls:
break
case .hidden:
bottomSheetState = .callControls
case .callInfo, .transitioning:
break
}
}
private func showCallControlsIfTheyMustBeVisible() {
if bottomSheetMustBeVisible {
showCallControlsIfHidden()
}
}
@objc
private func didPressAnswerCall(sender: UIButton) {
Logger.info("")
if sender == videoAnswerIncomingAudioOnlyButton {
// Answer without video, set state before answering.
callService.callUIAdapter.setHasLocalVideo(call: call, hasLocalVideo: false)
}
callService.callUIAdapter.answerCall(call)
// We should always be unmuted when we answer an incoming call.
// Explicitly setting it so will cause us to prompt for
// microphone permissions if necessary.
callService.callUIAdapter.setIsMuted(call: call, isMuted: false)
}
/**
* Denies an incoming not-yet-connected call, Do not confuse with `didPressHangup`.
*/
@objc
private func didPressDeclineCall(sender: UIButton) {
Logger.info("")
callService.callUIAdapter.localHangupCall(call)
dismissIfPossible(shouldDelay: false)
}
@objc
private func didTapLeaveCall(sender: UIButton) {
isCallMinimized = true
cancelBottomSheetTimeout()
AppEnvironment.shared.windowManagerRef.leaveCallView()
}
// MARK: - CallObserver
func individualCallStateDidChange(_ call: IndividualCall, state: CallState) {
AssertIsOnMainThread()
Logger.info("new call status: \(state)")
self.updateCallUI()
}
func individualCallLocalVideoMuteDidChange(_ call: IndividualCall, isVideoMuted: Bool) {
AssertIsOnMainThread()
self.updateCallUI()
}
func individualCallLocalAudioMuteDidChange(_ call: IndividualCall, isAudioMuted: Bool) {
AssertIsOnMainThread()
self.updateCallUI()
}
func individualCallHoldDidChange(_ call: IndividualCall, isOnHold: Bool) {
AssertIsOnMainThread()
self.updateCallUI()
}
func individualCallRemoteVideoMuteDidChange(_ call: IndividualCall, isVideoMuted: Bool) {
AssertIsOnMainThread()
updateRemoteVideoTrack(remoteVideoTrack: isVideoMuted ? nil : call.remoteVideoTrack)
showCallControlsIfTheyMustBeVisible()
scheduleBottomSheetTimeoutIfNecessary()
}
func individualCallRemoteSharingScreenDidChange(_ call: IndividualCall, isRemoteSharingScreen: Bool) {
AssertIsOnMainThread()
self.updateCallUI()
}
// MARK: - Video
private var hasRemoteVideoTrack: Bool {
return self.remoteVideoTrack != nil
}
private func updateRemoteVideoTrack(remoteVideoTrack: RTCVideoTrack?) {
AssertIsOnMainThread()
guard self.remoteVideoTrack != remoteVideoTrack else {
Logger.debug("ignoring redundant update")
return
}
if let remoteVideoView = remoteMemberView.remoteVideoView {
self.remoteVideoTrack?.remove(remoteVideoView)
self.remoteVideoTrack = nil
remoteVideoView.renderFrame(nil)
self.remoteVideoTrack = remoteVideoTrack
self.remoteVideoTrack?.add(remoteVideoView)
}
bottomSheetState = .callControls
if remoteVideoTrack != nil {
playRemoteEnabledVideoHapticFeedback()
}
updateRemoteVideoLayout()
}
// MARK: Video Haptics
private let feedbackGenerator = NotificationHapticFeedback()
private var lastHapticTime: TimeInterval = CACurrentMediaTime()
private func playRemoteEnabledVideoHapticFeedback() {
let currentTime = CACurrentMediaTime()
guard currentTime - lastHapticTime > 5 else {
Logger.debug("ignoring haptic feedback since it's too soon")
return
}
feedbackGenerator.notificationOccurred(.success)
lastHapticTime = currentTime
}
// MARK: - Dismiss
private func dismissIfPossible(shouldDelay: Bool) {
if hasDismissed {
return
}
hasDismissed = true
if shouldDelay, UIApplication.shared.applicationState == .active {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.dismissImmediately()
}
} else {
dismissImmediately()
}
}
private func dismissImmediately() {
AppEnvironment.shared.windowManagerRef.endCall(viewController: self)
}
}
extension IndividualCallViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return !localVideoView.isHidden && localVideoView.superview == view && individualCall.state == .connected
}
}
extension IndividualCallViewController: CallViewControllerWindowReference {
var remoteVideoViewReference: CallMemberView { remoteMemberView }
var localVideoViewReference: CallMemberView { localVideoView }
var remoteVideoAddress: SignalServiceAddress { thread.contactAddress }
var isJustMe: Bool { isRenderingLocalVanityVideo }
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 remoteMemberView.superview != view && localVideoView.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()
remoteMemberView.applyChangesToCallMemberViewAndVideoView(startWithVideoView: false) { aView in
view.insertSubview(aView, aboveSubview: blurView)
aView.autoPinEdgesToSuperviewEdges()
}
localVideoView.applyChangesToCallMemberViewAndVideoView(startWithVideoView: false) { aView in
view.insertSubview(aView, aboveSubview: remoteMemberView)
}
updateLocalVideoLayout()
bottomSheetState = .callControls
animateReturnFromPip(pipSnapshot: pipSnapshot, pipFrame: pipWindow.frame, splitViewSnapshot: splitViewSnapshot)
}
func willMoveToPip(pipWindow: UIWindow) {
flipCameraTooltipManager.dismissTooltip()
if !isJustMe {
localVideoView.applyChangesToCallMemberViewAndVideoView { view in
view.isHidden = true
}
} else {
localVideoView.applyChangesToCallMemberViewAndVideoView { view in
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()
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.view.layoutIfNeeded()
}) { _ in
self.updateCallUI()
splitViewSnapshot.removeFromSuperview()
pipSnapshot.removeFromSuperview()
}
}
}
extension IndividualCallViewController: CallControlsDelegate {
func didPressRing() {
owsFailDebug("Ring button should not be available in Call Controls for individual calls!")
}
func didPressJoin() {
owsFailDebug("Join button should not be available in Call Controls for individual calls!")
}
func didPressHangup() {
dismissIfPossible(shouldDelay: false)
}
func didPressMore() {
owsFailDebug("More button should not be available in Call Controls for individual calls!")
}
}
extension IndividualCallViewController: AnimatableLocalMemberViewDelegate {
var enclosingBounds: CGRect {
return self.view.bounds
}
var remoteDeviceCount: Int {
return 1
}
func animatableLocalMemberViewDidCompleteExpandAnimation(_ localMemberView: CallMemberView) {
self.expandedPipFrame = localMemberView.frame
self.isPipAnimationInProgress = false
if self.shouldRelayoutAfterPipAnimationCompletes {
updateLocalVideoLayout()
self.shouldRelayoutAfterPipAnimationCompletes = false
}
}
func animatableLocalMemberViewDidCompleteShrinkAnimation(_ localMemberView: CallMemberView) {
self.expandedPipFrame = nil
self.isPipAnimationInProgress = false
if self.shouldRelayoutAfterPipAnimationCompletes {
updateLocalVideoLayout()
self.shouldRelayoutAfterPipAnimationCompletes = false
}
}
func animatableLocalMemberViewWillBeginAnimation(_ localMemberView: CallMemberView) {
self.isPipAnimationInProgress = true
self.flipCameraTooltipManager.dismissTooltip()
}
}
extension IndividualCallViewController: CallServiceStateObserver {
func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
/// If the call ends before the view is ever loaded, just "dismiss" it
/// immediately. We don't need to wait or have animations or anything
/// because it's not even visible yet.
owsAssertDebug(!self.isViewLoaded)
if self.call === oldValue {
self.dismissIfPossible(shouldDelay: false)
}
}
}
private class PermissionErrorView: UIView {
private lazy var okayButton: OWSFlatButton = {
let okayButton = OWSFlatButton()
okayButton.useDefaultCornerRadius()
okayButton.setTitle(title: CommonStrings.okayButton, font: UIFont.dynamicTypeBody.semibold(), titleColor: Theme.accentBlueColor)
okayButton.setBackgroundColors(upColor: .ows_gray05)
okayButton.contentEdgeInsets = UIEdgeInsets(top: 13, left: 34, bottom: 13, right: 34)
return okayButton
}()
private lazy var contactAvatarView: ConversationAvatarView = {
let contactAvatarView = ConversationAvatarView(
sizeClass: .customDiameter(200),
localUserDisplayMode: .asUser,
badged: false
)
SSKEnvironment.shared.databaseStorageRef.read { transaction in
contactAvatarView.update(transaction) { config in
config.dataSource = .thread(thread)
}
}
return contactAvatarView
}()
private lazy var needPermissionLabel: UILabel = {
let shortName = SSKEnvironment.shared.databaseStorageRef.read {
return contactManager.displayName(
for: thread.contactAddress,
tx: $0
).resolvedValue(useShortNameIfAvailable: true)
}
let needPermissionLabel = UILabel()
needPermissionLabel.text = String(
format: OWSLocalizedString(
"CALL_VIEW_NEED_PERMISSION_ERROR_FORMAT",
comment: "Error displayed on the 'call' view when the callee needs to grant permission before we can call them. Embeds {callee short name}."
),
shortName
)
needPermissionLabel.numberOfLines = 0
needPermissionLabel.lineBreakMode = .byWordWrapping
needPermissionLabel.textAlignment = .center
needPermissionLabel.textColor = Theme.darkThemePrimaryColor
needPermissionLabel.font = .dynamicTypeBody
return needPermissionLabel
}()
private let thread: TSContactThread
private let contactManager: ContactManager
init(
thread: TSContactThread,
contactManager: ContactManager,
okayButtonWasTapped: @escaping () -> Void
) {
self.thread = thread
self.contactManager = contactManager
super.init(frame: .zero)
self.addSubview(contactAvatarView)
contactAvatarView.autoSetDimension(.height, toSize: 200)
self.addSubview(needPermissionLabel)
okayButton.setPressedBlock(okayButtonWasTapped)
self.addSubview(okayButton)
contactAvatarView.translatesAutoresizingMaskIntoConstraints = false
needPermissionLabel.translatesAutoresizingMaskIntoConstraints = false
okayButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contactAvatarView.topAnchor.constraint(equalTo: self.topAnchor),
contactAvatarView.bottomAnchor.constraint(equalTo: needPermissionLabel.topAnchor, constant: -20),
needPermissionLabel.bottomAnchor.constraint(equalTo: okayButton.topAnchor, constant: -20),
okayButton.bottomAnchor.constraint(equalTo: self.bottomAnchor),
needPermissionLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor),
needPermissionLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor)
])
contactAvatarView.autoHCenterInSuperview()
okayButton.autoHCenterInSuperview()
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension IndividualCallViewController: SheetPanDelegate {
func sheetPanDidBegin() {
bottomSheetState = .transitioning
self.callControlsConfirmationToastManager.forceDismissToast()
}
func sheetPanDidEnd() {
self.setBottomSheetStateAfterTransition()
}
func sheetPanDecelerationDidBegin() {
bottomSheetState = .transitioning
}
func sheetPanDecelerationDidEnd() {
self.setBottomSheetStateAfterTransition()
}
private func setBottomSheetStateAfterTransition() {
if bottomSheet.isPresentingCallInfo() {
bottomSheetState = .callInfo
} else if bottomSheet.isPresentingCallControls() {
bottomSheetState = .callControls
} else if bottomSheet.isCrossFading() {
bottomSheetState = .transitioning
}
}
}
extension IndividualCallViewController: CallDrawerDelegate {
func didPresentViewController(_ viewController: UIViewController) {
}
func didTapDone() {
bottomSheetState = .callControls
bottomSheet.minimizeHeight(animated: true)
}
}