//
// C o p y r i g h t ( c ) 2 0 2 1 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
import Foundation
import WebRTC
import PromiseKit
import SignalRingRTC
// TODO: A d d c a t e g o r y s o t h a t b u t t o n h a n d l e r s c a n b e d e f i n e d w h e r e b u t t o n i s c r e a t e d .
// TODO: E n s u r e b u t t o n s e n a b l e d & d i s a b l e d a s n e c e s s a r y .
class IndividualCallViewController : OWSViewController , CallObserver , CallAudioServiceDelegate {
// MARK: - P r o p e r t i e s
let thread : TSContactThread
let call : SignalCall
var hasDismissed = false
// MARK: - V i e w s
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 ?
// MARK: - G r a d i e n t V i e w s
private lazy var topGradientView : UIView = {
let gradientLayer = CAGradientLayer ( )
gradientLayer . colors = [
UIColor . black . withAlphaComponent ( 0.6 ) . 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 . black . withAlphaComponent ( 0.6 ) . cgColor
]
let view = OWSLayerView ( frame : . zero ) { view in
gradientLayer . frame = view . bounds
}
view . layer . addSublayer ( gradientLayer )
return view
} ( )
let gradientMargin : CGFloat = 46
// MARK: - C o n t a c t V i e w s
private lazy var contactNameLabel = MarqueeLabel ( )
private lazy var profilePictureView = ProfilePictureView ( )
private lazy var contactAvatarContainerView = UIView . container ( )
private lazy var callStatusLabel = UILabel ( )
private lazy var backButton = UIButton ( )
// MARK: - O n g o i n g A u d i o C a l l C o n t r o l s
private lazy var ongoingAudioCallControls = UIStackView (
arrangedSubviews : [
UIView . hStretchingSpacer ( ) ,
audioModeSourceButton ,
audioModeVideoButton ,
audioModeMuteButton ,
audioModeHangUpButton ,
UIView . hStretchingSpacer ( )
]
)
private lazy var audioModeHangUpButton = createButton ( iconName : " phone-down-solid-28 " , action : #selector ( didPressHangup ) )
private lazy var audioModeSourceButton = createButton ( iconName : " speaker-solid-28 " , action : #selector ( didPressAudioSource ) )
private lazy var audioModeMuteButton = createButton ( iconName : " mic-off-solid-28 " , action : #selector ( didPressMute ) )
private lazy var audioModeVideoButton = createButton ( iconName : " video-solid-28 " , action : #selector ( didPressVideo ) )
// MARK: - O n g o i n g V i d e o C a l l C o n t r o l s
private lazy var ongoingVideoCallControls = UIStackView (
arrangedSubviews : [
UIView . hStretchingSpacer ( ) ,
videoModeAudioSourceButton ,
videoModeFlipCameraButton ,
videoModeVideoButton ,
videoModeMuteButton ,
videoModeHangUpButton ,
UIView . hStretchingSpacer ( )
]
)
private lazy var videoModeHangUpButton = createButton ( iconName : " phone-down-solid-28 " , action : #selector ( didPressHangup ) )
private lazy var videoModeAudioSourceButton = createButton ( iconName : " speaker-solid-28 " , action : #selector ( didPressAudioSource ) )
private lazy var videoModeMuteButton = createButton ( iconName : " mic-off-solid-28 " , action : #selector ( didPressMute ) )
private lazy var videoModeVideoButton = createButton ( iconName : " video-solid-28 " , action : #selector ( didPressVideo ) )
private lazy var videoModeFlipCameraButton = createButton ( iconName : " switch-camera-28 " , action : #selector ( didPressFlipCamera ) )
// MARK: - I n c o m i n g A u d i o C a l l C o n t r o l s
private lazy var incomingAudioCallControls = UIStackView (
arrangedSubviews : [
UIView . hStretchingSpacer ( ) ,
audioDeclineIncomingButton ,
UIView . spacer ( withWidth : 124 ) ,
audioAnswerIncomingButton ,
UIView . hStretchingSpacer ( )
]
)
private lazy var audioAnswerIncomingButton = createButton ( iconName : " phone-solid-28 " , action : #selector ( didPressAnswerCall ) )
private lazy var audioDeclineIncomingButton = createButton ( iconName : " phone-down-solid-28 " , action : #selector ( didPressDeclineCall ) )
// MARK: - I n c o m i n g V i d e o C a l l C o n t r o l s
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-solid-28 " , action : #selector ( didPressAnswerCall ) )
private lazy var videoAnswerIncomingAudioOnlyButton = createButton ( iconName : " video-off-solid-28 " , action : #selector ( didPressAnswerCall ) )
private lazy var videoDeclineIncomingButton = createButton ( iconName : " phone-down-solid-28 " , action : #selector ( didPressDeclineCall ) )
// MARK: - V i d e o V i e w s
private lazy var remoteVideoView = RemoteVideoView ( )
private weak var remoteVideoTrack : RTCVideoTrack ?
private lazy var localVideoView : LocalVideoView = {
let localVideoView = LocalVideoView ( )
localVideoView . captureSession = call . videoCaptureController . captureSession
return localVideoView
} ( )
// MARK: - G e s t u r e s
lazy var tapGesture = UITapGestureRecognizer ( target : self , action : #selector ( didTouchRootView ) )
lazy var panGesture = UIPanGestureRecognizer ( target : self , action : #selector ( handleLocalVideoPan ) )
var shouldRemoteVideoControlsBeHidden = false {
didSet {
updateCallUI ( )
}
}
// MARK: - A u d i o S o u r c e
var hasAlternateAudioSources : Bool {
Logger . info ( " available audio sources: \( allAudioSources ) " )
// i n t e r n a l m i c a n d s p e a k e r p h o n e w i l l b e t h e f i r s t t w o , a n y m o r e t h a n o n e i n d i c a t e s e . g . a n a t t a c h e d b l u e t o o t h d e v i c e .
// T O D O i s t h i s s u f f i c i e n t ? A r e t h e i r d e v i c e s w / b l u e t o o t h b u t n o e x t e r n a l s p e a k e r ? e . g . i p o d ?
return allAudioSources . count > 2
}
var allAudioSources : Set < AudioSource > = Set ( )
var appropriateAudioSources : Set < AudioSource > {
if call . individualCall . hasLocalVideo {
let appropriateForVideo = allAudioSources . filter { audioSource in
if audioSource . isBuiltInSpeaker {
return true
} else {
guard let portDescription = audioSource . portDescription else {
owsFailDebug ( " Only built in speaker should be lacking a port description. " )
return false
}
// D o n ' t u s e r e c e i v e r w h e n v i d e o i s e n a b l e d . O n l y b l u e t o o t h o r s p e a k e r
return portDescription . portType != AVAudioSession . Port . builtInMic
}
}
return Set ( appropriateForVideo )
} else {
return allAudioSources
}
}
// MARK: - I n i t i a l i z e r s
required init ( call : SignalCall ) {
// TODO: E v e n t u a l l y u n i f y U I f o r g r o u p a n d i n d i v i d u a l c a l l s
owsAssertDebug ( call . isIndividualCall )
self . call = call
self . thread = TSContactThread . getOrCreateThread ( contactSessionID : call . individualCall . publicKey )
super . init ( )
allAudioSources = Set ( callService . audioService . availableInputs )
self . shouldUseTheme = false
}
required init ? ( coder : NSCoder ) {
preconditionFailure ( )
}
deinit {
// T h e s e v i e w s m i g h t b e i n t h e r e t u r n t o c a l l P I P ' s h i e r a r c h y ,
// w e w a n t t o r e m o v e t h e m s o t h e y a r e f r e e ' d w h e n t h e c a l l e n d s
remoteVideoView . removeFromSuperview ( )
localVideoView . removeFromSuperview ( )
}
// MARK: - V i e w L i f e c y c l e
@objc func didBecomeActive ( ) {
if self . isViewLoaded {
shouldRemoteVideoControlsBeHidden = false
}
}
public 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 )
callDurationTimer ? . invalidate ( )
callDurationTimer = nil
}
override func viewWillAppear ( _ animated : Bool ) {
super . viewWillAppear ( animated )
updateCallUI ( )
}
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 ( )
contactNameLabel . text = contactsManager . displayName ( for : thread . contactAddress )
updateAvatarImage ( )
NotificationCenter . default . addObserver (
self ,
selector : #selector ( updateAvatarImage ) ,
name : . OWSContactsManagerSignalAccountsDidChange ,
object : nil
)
// S u b s c r i b e f o r f u t u r e c a l l u p d a t e s
call . addObserverAndSyncState ( observer : self )
assert ( callService . audioService . delegate = = nil )
callService . audioService . delegate = 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: - C r e a t e V i e w s
func createViews ( ) {
view . isUserInteractionEnabled = true
view . addGestureRecognizer ( tapGesture )
localVideoView . addGestureRecognizer ( panGesture )
panGesture . delegate = self
tapGesture . require ( toFail : panGesture )
// T h e c a l l e e ' s a v a t a r i s r e n d e r e d b e h i n d t h e b l u r r e d b a c k g r o u n d .
backgroundAvatarView . contentMode = . scaleAspectFill
backgroundAvatarView . isUserInteractionEnabled = false
view . addSubview ( backgroundAvatarView )
backgroundAvatarView . autoPinEdgesToSuperviewEdges ( )
// D a r k b l u r r e d b a c k g r o u n d .
blurView . isUserInteractionEnabled = false
view . addSubview ( blurView )
blurView . autoPinEdgesToSuperviewEdges ( )
// C r e a t e t h e v i d e o v i e w s f i r s t , a s t h e y a r e u n d e r t h e o t h e r v i e w s .
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 )
createContactViews ( )
createOngoingCallControls ( )
createIncomingCallControls ( )
}
@objc func didTouchRootView ( sender : UIGestureRecognizer ) {
if ! remoteVideoView . isHidden {
shouldRemoteVideoControlsBeHidden = ! shouldRemoteVideoControlsBeHidden
}
}
func createVideoViews ( ) {
remoteVideoView . isUserInteractionEnabled = false
remoteVideoView . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " remoteVideoView " )
remoteVideoView . isHidden = true
remoteVideoView . isGroupCall = false
view . addSubview ( remoteVideoView )
// W e w a n t t h e l o c a l v i d e o v i e w t o u s e t h e a s p e c t r a t i o o f t h e s c r e e n , s o w e c h a n g e i t t o " a s p e c t f i l l " .
localVideoView . contentMode = . scaleAspectFill
localVideoView . clipsToBounds = true
localVideoView . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " localVideoView " )
localVideoView . isHidden = true
view . addSubview ( localVideoView )
}
func createContactViews ( ) {
let backButtonImage = CurrentAppContext ( ) . isRTL ? # imageLiteral ( resourceName : " NavBarBackRTL " ) : # imageLiteral ( resourceName : " NavBarBack " )
backButton . setImage ( backButtonImage , for : . normal )
backButton . autoSetDimensions ( to : CGSize ( square : 40 ) )
backButton . addTarget ( self , action : #selector ( didTapLeaveCall ( sender : ) ) , for : . touchUpInside )
topGradientView . addSubview ( backButton )
// m a r q u e e c o n f i g
contactNameLabel . type = . continuous
// T h i s f e e l s p r e t t y s l o w w h e n y o u ' r e i n i t i a l l y w a i t i n g f o r i t , b u t w h e n y o u ' r e o v e r l a y i n g v i d e o c a l l s , a n y t h i n g f a s t e r i s d i s t r a c t i n g .
contactNameLabel . speed = . duration ( 30.0 )
contactNameLabel . animationCurve = . linear
contactNameLabel . fadeLength = 10.0
contactNameLabel . animationDelay = 5
// A d d t r a i l i n g s p a c e a f t e r t h e n a m e s c r o l l s b e f o r e i t w r a p s a r o u n d a n d s c r o l l s b a c k i n .
contactNameLabel . trailingBuffer = ScaleFromIPhone5 ( 80.0 )
// l a b e l c o n f i g
contactNameLabel . font = UIFont . ows_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 . ows_dynamicTypeBody
callStatusLabel . textAlignment = . center
callStatusLabel . textColor = UIColor . white
callStatusLabel . layer . shadowOffset = . zero
callStatusLabel . layer . shadowOpacity = 0.25
callStatusLabel . layer . shadowRadius = 4
topGradientView . addSubview ( callStatusLabel )
contactAvatarContainerView . addSubview ( profilePictureView )
view . insertSubview ( contactAvatarContainerView , belowSubview : localVideoView )
}
func createOngoingCallControls ( ) {
audioModeSourceButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_AUDIO_SOURCE_LABEL " ,
comment : " Accessibility label for selection the audio source " )
audioModeHangUpButton . unselectedBackgroundColor = . ows_accentRed
audioModeHangUpButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_HANGUP_LABEL " ,
comment : " Accessibility label for hang up call " )
audioModeMuteButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_MUTE_LABEL " ,
comment : " Accessibility label for muting the microphone " )
audioModeVideoButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_SWITCH_TO_VIDEO_LABEL " , comment : " Accessibility label to switch to video call " )
videoModeAudioSourceButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_AUDIO_SOURCE_LABEL " ,
comment : " Accessibility label for selection the audio source " )
videoModeHangUpButton . unselectedBackgroundColor = . ows_accentRed
videoModeHangUpButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_HANGUP_LABEL " ,
comment : " Accessibility label for hang up call " )
videoModeMuteButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_MUTE_LABEL " , comment : " Accessibility label for muting the microphone " )
videoModeMuteButton . alpha = 0.9
videoModeFlipCameraButton . selectedIconColor = videoModeFlipCameraButton . iconColor
videoModeFlipCameraButton . selectedBackgroundColor = videoModeFlipCameraButton . unselectedBackgroundColor
videoModeFlipCameraButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_SWITCH_CAMERA_DIRECTION " , comment : " Accessibility label to toggle front- vs. rear-facing camera " )
videoModeFlipCameraButton . alpha = 0.9
videoModeVideoButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_SWITCH_TO_AUDIO_LABEL " , comment : " Accessibility label to switch to audio only " )
videoModeVideoButton . alpha = 0.9
ongoingAudioCallControls . spacing = 16
ongoingAudioCallControls . axis = . horizontal
bottomGradientView . addSubview ( ongoingAudioCallControls )
ongoingVideoCallControls . spacing = 16
ongoingVideoCallControls . axis = . horizontal
bottomGradientView . addSubview ( ongoingVideoCallControls )
// E n s u r e t h a t t h e c o n t r o l s a r e a l w a y s h o r i z o n t a l l y c e n t e r e d
for stackView in [ ongoingAudioCallControls , ongoingVideoCallControls ] {
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 )
}
audioModeHangUpButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " audioHangUpButton " )
audioModeSourceButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " audioSourceButton " )
audioModeMuteButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " audioModeMuteButton " )
audioModeVideoButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " audioModeVideoButton " )
videoModeHangUpButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " videoHangUpButton " )
videoModeAudioSourceButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " videoAudioSourceButton " )
videoModeMuteButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " videoModeMuteButton " )
videoModeFlipCameraButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " videoModeFlipCameraButton " )
videoModeVideoButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " videoModeVideoButton " )
}
func presentAudioSourcePicker ( ) {
AssertIsOnMainThread ( )
guard ! callService . audioService . presentRoutePicker ( ) else { return }
// F a l l b a c k t o a c t i o n s h e e t b a s e d p i c k e r , w h i c h i s b u g g y
owsFailDebug ( " Failed to present native route picker, maybe a new iOS version broke it? " )
let actionSheetController = ActionSheetController ( title : nil , message : nil )
let dismissAction = ActionSheetAction ( title : CommonStrings . dismissButton , style : . cancel )
actionSheetController . addAction ( dismissAction )
let currentAudioSource = callService . audioService . currentAudioSource
for audioSource in self . appropriateAudioSources {
let routeAudioAction = ActionSheetAction ( title : audioSource . localizedName , style : . default ) { _ in
self . callService . audioService . currentAudioSource = audioSource
}
// c r e a t e c h e c k m a r k f o r a c t i v e a u d i o s o u r c e .
if currentAudioSource = = audioSource {
routeAudioAction . trailingIcon = . checkCircle
}
actionSheetController . addAction ( routeAudioAction )
}
// N o t e : I t ' s c r i t i c a l t h a t w e p r e s e n t f r o m t h i s v i e w a n d
// n o t t h e " f r o n t m o s t v i e w c o n t r o l l e r " s i n c e t h i s v i e w m a y
// r e s i d e o n a s e p a r a t e w i n d o w .
presentActionSheet ( actionSheetController )
}
@objc
func updateAvatarImage ( ) {
profilePictureView . update ( for : thread . contactSessionID ( ) )
}
func createIncomingCallControls ( ) {
audioAnswerIncomingButton . text = NSLocalizedString ( " CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL " ,
comment : " label for accepting incoming calls " )
audioAnswerIncomingButton . unselectedBackgroundColor = . ows_accentGreen
audioAnswerIncomingButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL " ,
comment : " label for accepting incoming calls " )
audioDeclineIncomingButton . text = NSLocalizedString ( " CALL_VIEW_DECLINE_INCOMING_CALL_LABEL " ,
comment : " label for declining incoming calls " )
audioDeclineIncomingButton . unselectedBackgroundColor = . ows_accentRed
audioDeclineIncomingButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_DECLINE_INCOMING_CALL_LABEL " ,
comment : " label for declining incoming calls " )
incomingAudioCallControls . axis = . horizontal
incomingAudioCallControls . alignment = . center
bottomGradientView . addSubview ( incomingAudioCallControls )
audioAnswerIncomingButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " audioAnswerIncomingButton " )
audioDeclineIncomingButton . accessibilityIdentifier = UIView . accessibilityIdentifier ( in : self , name : " audioDeclineIncomingButton " )
videoAnswerIncomingButton . text = NSLocalizedString ( " CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL " ,
comment : " label for accepting incoming calls " )
videoAnswerIncomingButton . unselectedBackgroundColor = . ows_accentGreen
videoAnswerIncomingButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_ACCEPT_INCOMING_CALL_LABEL " ,
comment : " label for accepting incoming calls " )
videoAnswerIncomingAudioOnlyButton . text = NSLocalizedString ( " CALL_VIEW_ACCEPT_INCOMING_CALL_AUDIO_ONLY_LABEL " ,
comment : " label for accepting incoming video calls as audio only " )
videoAnswerIncomingAudioOnlyButton . accessibilityLabel = NSLocalizedString ( " CALL_VIEW_ACCEPT_INCOMING_CALL_AUDIO_ONLY_LABEL " ,
comment : " label for accepting incoming video calls as audio only " )
videoDeclineIncomingButton . text = NSLocalizedString ( " CALL_VIEW_DECLINE_INCOMING_CALL_LABEL " ,
comment : " label for declining incoming calls " )
videoDeclineIncomingButton . unselectedBackgroundColor = . ows_accentRed
videoDeclineIncomingButton . accessibilityLabel = NSLocalizedString ( " 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 )
// E n s u r e t h a t t h e c o n t r o l s a r e a l w a y s h o r i z o n t a l l y c e n t e r e d
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: - L a y o u t
func createViewConstraints ( ) {
let contactVSpacing : CGFloat = 3
let bottomMargin = ScaleFromIPhone5To7Plus ( 23 , 41 )
let avatarMargin = ScaleFromIPhone5To7Plus ( 25 , 50 )
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 ( )
remoteVideoView . autoPinEdgesToSuperviewEdges ( )
contactAvatarContainerView . autoPinEdge ( . top , to : . bottom , of : callStatusLabel , withOffset : + avatarMargin )
contactAvatarContainerView . autoPinEdge ( . bottom , to : . top , of : ongoingAudioCallControls , withOffset : - avatarMargin )
contactAvatarContainerView . autoPinWidthToSuperview ( withMargin : avatarMargin )
let size = Values . smallProfilePictureSize
profilePictureView . size = size
profilePictureView . set ( . width , to : size )
profilePictureView . set ( . height , to : size )
profilePictureView . autoCenterInSuperview ( )
ongoingAudioCallControls . autoPinEdge ( toSuperviewEdge : . top , withInset : gradientMargin )
incomingVideoCallControls . autoPinEdge ( toSuperviewEdge : . top )
for controls in [ incomingVideoCallControls , incomingAudioCallControls , ongoingAudioCallControls , ongoingVideoCallControls ] {
controls . autoPinWidthToSuperviewMargins ( )
controls . autoPinEdge ( toSuperviewEdge : . bottom , withInset : bottomMargin )
controls . setContentHuggingVerticalHigh ( )
}
}
internal func updateRemoteVideoLayout ( ) {
remoteVideoView . isHidden = ! self . hasRemoteVideoTrack
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 topInset = shouldRemoteVideoControlsBeHidden
? view . layoutMargins . top
: topGradientView . height - gradientMargin + 14
let bottomInset = shouldRemoteVideoControlsBeHidden
? view . layoutMargins . bottom
: bottomGradientView . height - gradientMargin + 14
rect . origin . y += topInset
rect . size . height -= topInset + bottomInset
lastLocalVideoBoundingRect = rect
return rect
}
private var isRenderingLocalVanityVideo : Bool {
return [ . idle , . dialing , . remoteRinging , . localRinging ] . contains ( call . individualCall . state ) && ! localVideoView . isHidden
}
private var previousOrigin : CGPoint !
private func updateLocalVideoLayout ( ) {
guard localVideoView . superview = = view else { return }
guard ! call . individualCall . isEnded else { return }
guard ! isRenderingLocalVanityVideo else {
view . bringSubviewToFront ( topGradientView )
view . bringSubviewToFront ( bottomContainerView )
view . layoutIfNeeded ( )
localVideoView . frame = view . frame
return
}
guard ! localVideoView . isHidden else { return }
view . bringSubviewToFront ( localVideoView )
let pipSize = ReturnToCallViewController . pipSize
let lastBoundingRect = lastLocalVideoBoundingRect
let boundingRect = localVideoBoundingRect
// P r e f e r t o s t a r t i n t h e t o p r i g h t
if previousOrigin = = nil {
previousOrigin = CGPoint (
x : boundingRect . maxX - pipSize . width ,
y : boundingRect . minY
)
// I f t h e b o u n d i n g r e c t h a s g o t t e n b i g g e r , a n d w e w e r e a t t h e t o p o r
// b o t t o m e d g e m o v e t h e p i p s o i t s t a y s a t t h e t o p o r b o t t o m e d g e .
} 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 ) { self . localVideoView . frame = newFrame }
}
private var startingTranslation : CGPoint ?
@objc func handleLocalVideoPan ( sender : UIPanGestureRecognizer ) {
switch sender . state {
case . began , . changed :
let translation = sender . translation ( in : localVideoView )
sender . setTranslation ( . zero , in : localVideoView )
localVideoView . frame . origin . y += translation . y
localVideoView . 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 }
default :
break
}
}
// MARK: - M e t h o d s
func showCallFailed ( error : Error ) {
// T O D O S h o w s o m e t h i n g i n U I .
Logger . error ( " call failed with error: \( error ) " )
}
// MARK: - V i e w S t a t e
func localizedTextForCallState ( ) -> String {
assert ( Thread . isMainThread )
switch call . individualCall . state {
case . idle , . remoteHangup , . remoteHangupNeedPermission , . localHangup :
return NSLocalizedString ( " IN_CALL_TERMINATED " , comment : " Call setup status label " )
case . dialing :
return NSLocalizedString ( " IN_CALL_CONNECTING " , comment : " Call setup status label " )
case . remoteRinging :
return NSLocalizedString ( " IN_CALL_RINGING " , comment : " Call setup status label " )
case . localRinging :
switch call . individualCall . offerMediaType {
case . audio :
return NSLocalizedString ( " IN_CALL_RINGING_AUDIO " , comment : " Call setup status label " )
case . video :
return NSLocalizedString ( " IN_CALL_RINGING_VIDEO " , comment : " Call setup status label " )
}
case . answering :
return NSLocalizedString ( " IN_CALL_SECURING " , comment : " Call setup status label " )
case . connected :
let callDuration = call . connectionDuration ( )
let callDurationDate = Date ( timeIntervalSinceReferenceDate : callDuration )
var formattedDate = dateFormatter . string ( from : callDurationDate )
if formattedDate . hasPrefix ( " 00: " ) {
// D o n ' t s h o w t h e " h o u r s " p o r t i o n o f t h e d a t e f o r m a t u n l e s s t h e
// c a l l d u r a t i o n i s a t l e a s t 1 h o u r .
formattedDate = String ( formattedDate [ formattedDate . index ( formattedDate . startIndex , offsetBy : 3 ) . . . ] )
} else {
// I f s h o w i n g t h e " h o u r s " p o r t i o n o f t h e d a t e f o r m a t , s t r i p a n y l e a d i n g
// z e r o e s .
if formattedDate . hasPrefix ( " 0 " ) {
formattedDate = String ( formattedDate [ formattedDate . index ( formattedDate . startIndex , offsetBy : 1 ) . . . ] )
}
}
return formattedDate
case . reconnecting :
return NSLocalizedString ( " IN_CALL_RECONNECTING " , comment : " Call setup status label " )
case . remoteBusy :
return NSLocalizedString ( " END_CALL_RESPONDER_IS_BUSY " , comment : " Call setup status label " )
case . localFailure :
if let error = call . error {
switch error {
case . timeout ( description : _ ) :
if self . call . individualCall . direction = = . outgoing {
return NSLocalizedString ( " CALL_SCREEN_STATUS_NO_ANSWER " , comment : " Call setup status label after outgoing call times out " )
}
default :
break
}
}
return NSLocalizedString ( " END_CALL_UNCATEGORIZED_FAILURE " , comment : " Call setup status label " )
case . answeredElsewhere :
return NSLocalizedString ( " 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 NSLocalizedString ( " 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 :
owsFailDebug ( " busy elsewhere triggered on call screen, this should never happen " )
return NSLocalizedString ( " 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. " )
}
}
var isBlinkingReconnectLabel = false
func updateCallStatusLabel ( ) {
assert ( Thread . isMainThread )
let text = String ( format : CallStrings . callStatusFormat ,
localizedTextForCallState ( ) )
self . callStatusLabel . text = text
// H a n d l e r e c o n n e c t i n g b l i n k i n g
if case . reconnecting = call . 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 {
// a l r e a d y b l i n k i n g
}
} else {
// W e ' r e n o l o n g e r i n a r e c o n n e c t i n g s t a t e , e i t h e r t h e c a l l f a i l e d o r w e r e c o n n e c t e d .
// S t o p t h e b l i n k i n g a n i m a t i o n
if isBlinkingReconnectLabel {
self . callStatusLabel . layer . removeAllAnimations ( )
self . callStatusLabel . alpha = 1
isBlinkingReconnectLabel = false
}
}
}
func updateCallUI ( ) {
assert ( Thread . isMainThread )
updateCallStatusLabel ( )
// M a r q u e e s c r o l l i n g i s d i s t r a c t i n g d u r i n g a v i d e o c a l l , d i s a b l e i t .
contactNameLabel . labelize = call . individualCall . hasLocalVideo
audioModeMuteButton . isSelected = call . individualCall . isMuted
videoModeMuteButton . isSelected = call . individualCall . isMuted
audioModeVideoButton . isSelected = call . individualCall . hasLocalVideo
videoModeVideoButton . isSelected = call . individualCall . hasLocalVideo
localVideoView . isHidden = ! call . individualCall . hasLocalVideo
updateRemoteVideoTrack (
remoteVideoTrack : call . individualCall . isRemoteVideoEnabled ? call . individualCall . remoteVideoTrack : nil
)
// S h o w I n c o m i n g v s . O n g o i n g c a l l c o n t r o l s
if call . individualCall . state = = . localRinging {
let isVideoOffer = call . individualCall . offerMediaType = = . video
incomingVideoCallControls . isHidden = ! isVideoOffer
incomingAudioCallControls . isHidden = isVideoOffer
ongoingVideoCallControls . isHidden = true
ongoingAudioCallControls . isHidden = true
} else {
incomingVideoCallControls . isHidden = true
incomingAudioCallControls . isHidden = true
ongoingVideoCallControls . isHidden = ! call . individualCall . hasLocalVideo
ongoingAudioCallControls . isHidden = call . individualCall . hasLocalVideo
}
// R e w o r k c o n t r o l s t a t e i f r e m o t e v i d e o i s a v a i l a b l e .
let hasRemoteVideo = ! remoteVideoView . isHidden
remoteVideoView . isFullScreen = true
remoteVideoView . isScreenShare = call . individualCall . isRemoteSharingScreen
profilePictureView . isHidden = hasRemoteVideo || isRenderingLocalVanityVideo
// L a y o u t c o n t r o l s i m m e d i a t e l y t o a v o i d s p u r i o u s a n i m a t i o n .
for controls in [ incomingVideoCallControls , incomingAudioCallControls , ongoingAudioCallControls , ongoingVideoCallControls ] {
controls . layoutIfNeeded ( )
}
// A l s o h i d e o t h e r c o n t r o l s i f u s e r h a s t a p p e d t o h i d e t h e m .
let hideRemoteControls = shouldRemoteVideoControlsBeHidden && ! remoteVideoView . isHidden
let remoteControlsAreHidden = bottomContainerView . isHidden && topGradientView . isHidden
if hideRemoteControls != remoteControlsAreHidden {
self . bottomContainerView . isHidden = false
self . topGradientView . isHidden = false
UIView . animate ( withDuration : 0.15 , animations : {
self . bottomContainerView . alpha = hideRemoteControls ? 0 : 1
self . topGradientView . alpha = hideRemoteControls ? 0 : 1
} ) { _ in
self . bottomContainerView . isHidden = hideRemoteControls
self . topGradientView . isHidden = hideRemoteControls
}
}
let videoControls = [ videoModeAudioSourceButton , videoModeFlipCameraButton , videoModeVideoButton , videoModeMuteButton , videoModeHangUpButton ]
// A u d i o S o u r c e H a n d l i n g ( b l u e t o o t h )
if self . hasAlternateAudioSources , let audioSource = callService . audioService . currentAudioSource {
audioModeSourceButton . isHidden = false
videoModeAudioSourceButton . isHidden = false
videoModeAudioSourceButton . isHidden = ! call . individualCall . hasLocalVideo
videoModeAudioSourceButton . showDropdownArrow = true
audioModeSourceButton . isHidden = call . individualCall . hasLocalVideo
audioModeSourceButton . showDropdownArrow = true
// U s e s m a l l c o n t r o l s , b e c a u s e w e h a v e 5 b u t t o n s n o w .
videoControls . forEach { $0 . isSmall = true }
if audioSource . isBuiltInEarPiece {
audioModeSourceButton . iconName = " phone-solid-28 "
videoModeAudioSourceButton . iconName = " phone-solid-28 "
} else if audioSource . isBuiltInSpeaker {
audioModeSourceButton . iconName = " speaker-solid-28 "
videoModeAudioSourceButton . iconName = " speaker-solid-28 "
} else {
audioModeSourceButton . iconName = " speaker-bt-solid-28 "
videoModeAudioSourceButton . iconName = " speaker-bt-solid-28 "
}
} else if UIDevice . current . isIPad {
// i P a d * o n l y * s u p p o r t s s p e a k e r m o d e , i f t h e r e a r e n o e x t e r n a l
// d e v i c e s c o n n e c t e d , s o w e d o n ' t n e e d t o s h o w t h e b u t t o n u n l e s s
// w e h a v e a l t e r n a t e a u d i o s o u r c e s .
audioModeSourceButton . isHidden = true
videoModeAudioSourceButton . isHidden = true
} else {
audioModeSourceButton . isHidden = false
videoModeAudioSourceButton . isHidden = false
// N o b l u e t o o t h a u d i o d e t e c t e d
audioModeSourceButton . iconName = " speaker-solid-28 "
audioModeSourceButton . showDropdownArrow = false
videoModeAudioSourceButton . iconName = " speaker-solid-28 "
videoModeAudioSourceButton . showDropdownArrow = false
videoControls . forEach { $0 . isSmall = false }
videoModeAudioSourceButton . isHidden = true
}
// U p d a t e l o c a l v i d e o
localVideoView . layer . cornerRadius = isRenderingLocalVanityVideo ? 0 : 8
updateLocalVideoLayout ( )
// D i s m i s s H a n d l i n g
switch call . individualCall . state {
case . remoteHangupNeedPermission :
displayNeedPermissionErrorAndDismiss ( )
case . remoteHangup , . remoteBusy , . localFailure , . answeredElsewhere , . declinedElsewhere , . busyElsewhere :
Logger . debug ( " dismissing after delay because new state is \( call . individualCall . state ) " )
dismissIfPossible ( shouldDelay : true )
case . localHangup :
Logger . debug ( " dismissing immediately from local hangup " )
dismissIfPossible ( shouldDelay : false )
default : break
}
if call . 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
}
scheduleControlTimeoutIfNecessary ( )
}
func displayNeedPermissionErrorAndDismiss ( ) {
guard ! hasDismissed else { return }
hasDismissed = true
callService . audioService . delegate = nil
contactNameLabel . removeFromSuperview ( )
callStatusLabel . removeFromSuperview ( )
incomingAudioCallControls . removeFromSuperview ( )
incomingVideoCallControls . removeFromSuperview ( )
ongoingAudioCallControls . removeFromSuperview ( )
ongoingVideoCallControls . removeFromSuperview ( )
backButton . removeFromSuperview ( )
let needPermissionStack = UIStackView ( )
needPermissionStack . axis = . vertical
needPermissionStack . spacing = 20
view . addSubview ( needPermissionStack )
needPermissionStack . autoPinWidthToSuperview ( withMargin : 16 )
needPermissionStack . autoVCenterInSuperview ( )
needPermissionStack . addArrangedSubview ( contactAvatarContainerView )
contactAvatarContainerView . autoSetDimension ( . height , toSize : 200 )
let shortName = SDSDatabaseStorage . shared . read {
return self . contactsManager . shortDisplayName (
for : self . thread . contactAddress ,
transaction : $0
)
}
let needPermissionLabel = UILabel ( )
needPermissionLabel . text = String (
format : NSLocalizedString ( " 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 = . ows_dynamicTypeBody
needPermissionStack . addArrangedSubview ( needPermissionLabel )
let okayButton = OWSFlatButton ( )
okayButton . useDefaultCornerRadius ( )
okayButton . setTitle ( title : CommonStrings . okayButton , font : UIFont . ows_dynamicTypeBody . ows_semibold , titleColor : Theme . accentBlueColor )
okayButton . setBackgroundColors ( upColor : . ows_gray05 )
okayButton . contentEdgeInsets = UIEdgeInsets ( top : 13 , left : 34 , bottom : 13 , right : 34 )
okayButton . setPressedBlock { [ weak self ] in
self ? . dismissImmediately ( completion : nil )
}
let okayButtonContainer = UIView ( )
okayButtonContainer . addSubview ( okayButton )
okayButton . autoPinHeightToSuperview ( )
okayButton . autoHCenterInSuperview ( )
needPermissionStack . addArrangedSubview ( okayButtonContainer )
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 10 ) { [ weak self ] in
self ? . dismissImmediately ( completion : nil )
}
}
func updateCallDuration ( ) {
updateCallStatusLabel ( )
}
// MARK: - V i d e o c o n t r o l t i m e o u t
private var controlTimeoutTimer : Timer ?
private func scheduleControlTimeoutIfNecessary ( ) {
if remoteVideoView . isHidden || shouldRemoteVideoControlsBeHidden {
controlTimeoutTimer ? . invalidate ( )
controlTimeoutTimer = nil
}
guard controlTimeoutTimer = = nil else { return }
controlTimeoutTimer = . weakScheduledTimer (
withTimeInterval : 5 ,
target : self ,
selector : #selector ( timeoutControls ) ,
userInfo : nil ,
repeats : false
)
}
@objc
private func timeoutControls ( ) {
controlTimeoutTimer ? . invalidate ( )
controlTimeoutTimer = nil
guard ! remoteVideoView . isHidden && ! shouldRemoteVideoControlsBeHidden else { return }
shouldRemoteVideoControlsBeHidden = true
}
// MARK: - A c t i o n s
/* *
* Ends a connected call . Do not confuse with ` didPressDeclineCall ` .
*/
@objc func didPressHangup ( sender : UIButton ) {
Logger . info ( " " )
individualCallUIAdapter . localHangupCall ( call )
dismissIfPossible ( shouldDelay : false )
}
@objc func didPressMute ( sender : UIButton ) {
Logger . info ( " " )
let isMuted = ! sender . isSelected
individualCallUIAdapter . setIsMuted ( call : call , isMuted : isMuted )
}
@objc func didPressAudioSource ( sender button : UIButton ) {
Logger . info ( " " )
if self . hasAlternateAudioSources {
presentAudioSourcePicker ( )
} else {
didPressSpeakerphone ( sender : button )
}
}
func didPressSpeakerphone ( sender button : UIButton ) {
Logger . info ( " " )
button . isSelected = ! button . isSelected
callService . audioService . requestSpeakerphone ( isEnabled : button . isSelected )
}
func didPressTextMessage ( sender button : UIButton ) {
Logger . info ( " " )
dismissIfPossible ( shouldDelay : false )
}
@objc func didPressAnswerCall ( sender : UIButton ) {
Logger . info ( " " )
individualCallUIAdapter . answerCall ( call )
// A n s w e r w i t h o u t v i d e o .
if sender = = videoAnswerIncomingAudioOnlyButton {
individualCallUIAdapter . setHasLocalVideo ( call : call , hasLocalVideo : false )
}
// W e s h o u l d a l w a y s b e u n m u t e d w h e n w e a n s w e r a n i n c o m i n g c a l l .
// E x p l i c i t l y s e t t i n g i t s o w i l l c a u s e u s t o p r o m p t f o r
// m i c r o p h o n e p e r m i s s i o n s i f n e c e s s a r y .
individualCallUIAdapter . setIsMuted ( call : call , isMuted : false )
}
@objc func didPressVideo ( sender : UIButton ) {
Logger . info ( " " )
let hasLocalVideo = ! sender . isSelected
individualCallUIAdapter . setHasLocalVideo ( call : call , hasLocalVideo : hasLocalVideo )
}
@objc func didPressFlipCamera ( sender : UIButton ) {
sender . isSelected = ! sender . isSelected
let isUsingFrontCamera = ! sender . isSelected
Logger . info ( " with isUsingFrontCamera: \( isUsingFrontCamera ) " )
individualCallUIAdapter . setCameraSource ( call : call , isUsingFrontCamera : isUsingFrontCamera )
}
/* *
* Denies an incoming not - yet - connected call , Do not confuse with ` didPressHangup ` .
*/
@objc func didPressDeclineCall ( sender : UIButton ) {
Logger . info ( " " )
individualCallUIAdapter . localHangupCall ( call )
dismissIfPossible ( shouldDelay : false )
}
@objc func didTapLeaveCall ( sender : UIButton ) {
OWSWindowManager . shared . leaveCallView ( )
}
// MARK: - C a l l O b s e r v e r
internal func individualCallStateDidChange ( _ call : SignalCall , state : CallState ) {
AssertIsOnMainThread ( )
Logger . info ( " new call status: \( state ) " )
self . updateCallUI ( )
}
internal func individualCallLocalVideoMuteDidChange ( _ call : SignalCall , isVideoMuted : Bool ) {
AssertIsOnMainThread ( )
self . updateCallUI ( )
}
internal func individualCallLocalAudioMuteDidChange ( _ call : SignalCall , isAudioMuted : Bool ) {
AssertIsOnMainThread ( )
self . updateCallUI ( )
}
func individualCallHoldDidChange ( _ call : SignalCall , isOnHold : Bool ) {
AssertIsOnMainThread ( )
self . updateCallUI ( )
}
func individualCallRemoteVideoMuteDidChange ( _ call : SignalCall , isVideoMuted : Bool ) {
AssertIsOnMainThread ( )
updateRemoteVideoTrack ( remoteVideoTrack : isVideoMuted ? nil : call . individualCall . remoteVideoTrack )
}
func individualCallRemoteSharingScreenDidChange ( _ call : SignalCall , isRemoteSharingScreen : Bool ) {
AssertIsOnMainThread ( )
self . updateCallUI ( )
}
// MARK: - C a l l A u d i o S e r v i c e D e l e g a t e
func callAudioServiceDidChangeAudioSession ( _ callAudioService : CallAudioService ) {
AssertIsOnMainThread ( )
// W h i c h s o u r c e s a r e a v a i l a b l e d e p e n d s o n t h e s t a t e o f y o u r S e s s i o n .
// W h e n t h e a u d i o s e s s i o n i s n o t y e t i n P l a y A n d R e c o r d n o n e a r e a v a i l a b l e
// T h e n i f w e ' r e i n s p e a k e r p h o n e , b l u e t o o t h i s n ' t a v a i l a b l e .
// S o w e a c c r u e a l l p o s s i b l e a u d i o s o u r c e s i n a s e t , a n d t h a t l i s t l i v e s a s l o n g s a s t h e C a l l V i e w C o n t r o l l e r
// T h e d o w n s i d e o f t h i s i s t h a t i f y o u e . g . u n p a i r y o u r b l u e t o o t h m i d c a l l , i t w i l l s t i l l a p p e a r a s a n o p t i o n
// u n t i l y o u r n e x t c a l l .
// FIXME: T h e r e ' s g o t t o b e a b e t t e r w a y , b u t t h i s i s w h e r e I l a n d e d a f t e r a b i t o f w o r k , a n d s e e m s t o w o r k
// p r e t t y w e l l i n p r a c t i c e .
let availableInputs = callAudioService . availableInputs
self . allAudioSources . formUnion ( availableInputs )
updateCallUI ( )
}
func callAudioServiceDidChangeAudioSource ( _ callAudioService : CallAudioService , audioSource : AudioSource ? ) {
// Y o u m i g h t h a v e s w i t c h e d t o a n e w l y a v a i l a b l e r o u t e ( l i k e a b l u e t o o t h d e v i c e c o n n e c t i n g ) ,
// u p d a t e t h e U I t o r e f l e c t i t .
let availableInputs = callAudioService . availableInputs
self . allAudioSources . formUnion ( availableInputs )
updateCallUI ( )
guard let audioSource = audioSource else {
audioModeSourceButton . isSelected = false
videoModeAudioSourceButton . isSelected = false
return
}
audioModeSourceButton . isSelected = ! audioSource . isBuiltInEarPiece
videoModeAudioSourceButton . isSelected = ! audioSource . isBuiltInEarPiece
}
// MARK: - V i d e o
var hasRemoteVideoTrack : Bool {
return self . remoteVideoTrack != nil
}
internal func updateRemoteVideoTrack ( remoteVideoTrack : RTCVideoTrack ? ) {
AssertIsOnMainThread ( )
guard self . remoteVideoTrack != remoteVideoTrack else {
Logger . debug ( " ignoring redundant update " )
return
}
self . remoteVideoTrack ? . remove ( remoteVideoView )
self . remoteVideoTrack = nil
remoteVideoView . renderFrame ( nil )
self . remoteVideoTrack = remoteVideoTrack
self . remoteVideoTrack ? . add ( remoteVideoView )
shouldRemoteVideoControlsBeHidden = false
if remoteVideoTrack != nil {
playRemoteEnabledVideoHapticFeedback ( )
}
updateRemoteVideoLayout ( )
}
// MARK: V i d e o H a p t i c s
let feedbackGenerator = NotificationHapticFeedback ( )
var lastHapticTime : TimeInterval = CACurrentMediaTime ( )
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: - D i s m i s s
internal func dismissIfPossible ( shouldDelay : Bool , completion : ( ( ) -> Void ) ? = nil ) {
callService . audioService . delegate = nil
if hasDismissed {
// D o n ' t d i s m i s s t w i c e .
return
} else if shouldDelay {
hasDismissed = true
if UIApplication . shared . applicationState = = . active {
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 1.5 ) { [ weak self ] in
guard let strongSelf = self else { return }
strongSelf . dismissImmediately ( completion : completion )
}
} else {
dismissImmediately ( completion : completion )
}
} else {
hasDismissed = true
dismissImmediately ( completion : completion )
}
}
internal func dismissImmediately ( completion : ( ( ) -> Void ) ? ) {
OWSWindowManager . shared . endCall ( self )
completion ? ( )
}
}
extension IndividualCallViewController : UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin ( _ gestureRecognizer : UIGestureRecognizer ) -> Bool {
return ! localVideoView . isHidden && localVideoView . superview = = view && call . individualCall . state = = . connected
}
}
extension IndividualCallViewController : CallViewControllerWindowReference {
var remoteVideoViewReference : UIView { remoteVideoView }
var localVideoViewReference : UIView { localVideoView }
var remoteVideoAddress : String { thread . contactSessionID ( ) }
@objc
public func returnFromPip ( pipWindow : UIWindow ) {
// T h e c a l l " p i p " u s e s o u r r e m o t e a n d l o c a l v i d e o v i e w s s i n c e o n l y
// o n e ` A V C a p t u r e V i d e o P r e v i e w L a y e r ` p e r c a p t u r e s e s s i o n i s s u p p o r t e d .
// W e n e e d t o r e - a d d t h e m w h e n w e r e t u r n t o t h i s v i e w .
guard remoteVideoView . 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 " )
}
view . insertSubview ( remoteVideoView , aboveSubview : blurView )
remoteVideoView . autoPinEdgesToSuperviewEdges ( )
view . insertSubview ( localVideoView , aboveSubview : contactAvatarContainerView )
updateLocalVideoLayout ( )
shouldRemoteVideoControlsBeHidden = false
animateReturnFromPip ( pipSnapshot : pipSnapshot , pipFrame : pipWindow . frame , splitViewSnapshot : splitViewSnapshot )
}
private func animateReturnFromPip ( pipSnapshot : UIView , pipFrame : CGRect , splitViewSnapshot : UIView ) {
guard let window = view . window else { return owsFailDebug ( " missing window " ) }
view . superview ? . insertSubview ( splitViewSnapshot , belowSubview : view )
splitViewSnapshot . autoPinEdgesToSuperviewEdges ( )
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 ( )
}
}
}