element-ios/Riot/Modules/MatrixKit/Controllers/MXKCallViewController.m

1541 lines
52 KiB
Objective-C

/*
Copyright 2018-2024 New Vector Ltd.
Copyright 2015 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
#import "MXKCallViewController.h"
@import MatrixSDK;
#import "MXKAppSettings.h"
#import "MXKSoundPlayer.h"
#import "MXKTools.h"
#import "NSBundle+MatrixKit.h"
#import "MXKSwiftHeader.h"
NSString *const kMXKCallViewControllerWillAppearNotification = @"kMXKCallViewControllerWillAppearNotification";
NSString *const kMXKCallViewControllerAppearedNotification = @"kMXKCallViewControllerAppearedNotification";
NSString *const kMXKCallViewControllerWillDisappearNotification = @"kMXKCallViewControllerWillDisappearNotification";
NSString *const kMXKCallViewControllerDisappearedNotification = @"kMXKCallViewControllerDisappearedNotification";
NSString *const kMXKCallViewControllerBackToAppNotification = @"kMXKCallViewControllerBackToAppNotification";
static const CGFloat kLocalPreviewMargin = 20;
@interface MXKCallViewController ()
{
NSTimer *hideOverlayTimer;
NSTimer *updateStatusTimer;
Boolean isMovingLocalPreview;
Boolean isSelectingLocalPreview;
CGPoint startNewLocalMove;
/**
The popup showed in case of call stack error.
*/
UIAlertController *errorAlert;
// the room events listener
id roomListener;
// Observe kMXRoomDidFlushDataNotification to take into account the updated room members when the room history is flushed.
id roomDidFlushDataNotificationObserver;
// Observe AVAudioSessionRouteChangeNotification
id audioSessionRouteChangeNotificationObserver;
// Current alert (if any).
UIAlertController *currentAlert;
// Current peer display name
NSString *peerDisplayName;
}
@property (nonatomic, assign) Boolean isRinging;
@property (nonatomic, nullable) UIView *incomingCallView;
@property (nonatomic, strong) UITapGestureRecognizer *onHoldCallContainerTapRecognizer;
@end
@implementation MXKCallViewController
@synthesize backgroundImageView;
@synthesize localPreviewContainerView, localPreviewVideoView, localPreviewActivityView, remotePreviewContainerView;
@synthesize overlayContainerView, callContainerView, callerImageView, callerNameLabel, callStatusLabel;
@synthesize callToolBar, rejectCallButton, answerCallButton, endCallButton;
@synthesize callControlContainerView, speakerButton, audioMuteButton, videoMuteButton;
@synthesize backToAppButton, cameraSwitchButton;
@synthesize backToAppStatusWindow;
@synthesize mxCall;
@synthesize mxCallOnHold;
@synthesize onHoldCallerImageView;
@synthesize onHoldCallContainerView;
#pragma mark - Class methods
+ (UINib *)nib
{
return [UINib nibWithNibName:NSStringFromClass(self.class)
bundle:[NSBundle bundleForClass:self.class]];
}
+ (instancetype)callViewController:(MXCall*)call
{
MXKCallViewController *instance = [[[self class] alloc] initWithNibName:NSStringFromClass(self.class)
bundle:[NSBundle bundleForClass:self.class]];
// Load the view controller's view now (buttons and views will then be available).
if ([instance respondsToSelector:@selector(loadViewIfNeeded)])
{
// iOS 9 and later
[instance loadViewIfNeeded];
}
else if (instance.view)
{
// Patch: on iOS < 9.0, we load the view by calling its getter.
}
instance.mxCall = call;
return instance;
}
#pragma mark -
- (void)finalizeInit
{
[super finalizeInit];
_playRingtone = YES;
}
- (void)viewDidLoad
{
[super viewDidLoad];
updateStatusTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(updateTimeStatusLabel) userInfo:nil repeats:YES];
self.callerImageView.defaultBackgroundColor = [UIColor clearColor];
self.backToAppButton.backgroundColor = [UIColor clearColor];
self.audioMuteButton.backgroundColor = [UIColor clearColor];
self.videoMuteButton.backgroundColor = [UIColor clearColor];
self.resumeButton.backgroundColor = [UIColor clearColor];
self.moreButton.backgroundColor = [UIColor clearColor];
self.speakerButton.backgroundColor = [UIColor clearColor];
self.transferButton.backgroundColor = [UIColor clearColor];
[self.backToAppButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_backtoapp"] forState:UIControlStateNormal];
[self.backToAppButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_backtoapp"] forState:UIControlStateHighlighted];
[self.audioMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_audio_unmute"] forState:UIControlStateNormal];
[self.audioMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_audio_mute"] forState:UIControlStateSelected];
[self.videoMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video_unmute"] forState:UIControlStateNormal];
[self.videoMuteButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_video_mute"] forState:UIControlStateSelected];
[self.moreButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_call_more"] forState:UIControlStateNormal];
[self.moreButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_call_more"] forState:UIControlStateSelected];
[self.speakerButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_speaker_off"] forState:UIControlStateNormal];
[self.speakerButton setImage:[NSBundle mxk_imageFromMXKAssetsBundleWithName:@"icon_speaker_on"] forState:UIControlStateSelected];
// Localize string
[answerCallButton setTitle:[VectorL10n answerCall] forState:UIControlStateNormal];
[answerCallButton setTitle:[VectorL10n answerCall] forState:UIControlStateHighlighted];
[rejectCallButton setTitle:[VectorL10n rejectCall] forState:UIControlStateNormal];
[rejectCallButton setTitle:[VectorL10n rejectCall] forState:UIControlStateHighlighted];
[endCallButton setTitle:[VectorL10n endCall] forState:UIControlStateNormal];
[endCallButton setTitle:[VectorL10n endCall] forState:UIControlStateHighlighted];
[_resumeButton setTitle:[VectorL10n resumeCall] forState:UIControlStateNormal];
[_resumeButton setTitle:[VectorL10n resumeCall] forState:UIControlStateHighlighted];
// Refresh call information
self.mxCall = mxCall;
// Listen to AVAudioSession activation notification if CallKit is available and enabled
BOOL isCallKitAvailable = [MXCallKitAdapter callKitAvailable] && [MXKAppSettings standardAppSettings].isCallKitEnabled;
if (isCallKitAvailable)
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleAudioSessionActivationNotification)
name:kMXCallKitAdapterAudioSessionDidActive
object:nil];
}
}
- (void)didReceiveMemoryWarning
{
[super didReceiveMemoryWarning];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self name:kMXCallKitAdapterAudioSessionDidActive object:nil];
[self removeObservers];
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerWillAppearNotification object:nil];
[self updateLocalPreviewLayout];
[self showOverlayContainer:YES];
if (mxCall)
{
// Refresh call display according to the call room state.
[self callRoomStateDidChange:^{
// Refresh call status
[self call:self->mxCall stateDidChange:self->mxCall.state reason:nil];
}];
}
if (_delegate)
{
backToAppButton.hidden = NO;
}
else
{
backToAppButton.hidden = YES;
}
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerAppearedNotification object:nil];
// trick to hide the volume at launch
// as the mininum volume is forced by the application
// the volume popup can be displayed
// volumeView = [[MPVolumeView alloc] initWithFrame: CGRectMake(5000, 5000, 0, 0)];
// [self.view addSubview: volumeView];
//
// dispatch_after(dispatch_walltime(DISPATCH_TIME_NOW, 0.5 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
// [volumeView removeFromSuperview];
// });
}
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerWillDisappearNotification object:nil];
}
- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];
[[NSNotificationCenter defaultCenter] postNotificationName:kMXKCallViewControllerDisappearedNotification object:nil];
}
- (void)dismiss
{
if (_delegate)
{
[_delegate dismissCallViewController:self completion:nil];
}
else
{
// Auto dismiss after few seconds
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self dismissViewControllerAnimated:YES completion:nil];
});
}
}
#pragma mark - override MXKViewController
- (void)destroy
{
self.peer = nil;
self.mxCall = nil;
_delegate = nil;
self.isRinging = NO;
[hideOverlayTimer invalidate];
[updateStatusTimer invalidate];
_incomingCallView = nil;
_onHoldCallContainerTapRecognizer = nil;
[super destroy];
}
#pragma mark - Properties
- (UIImage *)picturePlaceholder
{
return [NSBundle mxk_imageFromMXKAssetsBundleWithName:@"default-profile"];
}
- (void)setMxCall:(MXCall *)call
{
// Remove previous call (if any)
if (mxCall)
{
mxCall.delegate = nil;
mxCall.selfVideoView = nil;
mxCall.remoteVideoView = nil;
[self removeMatrixSession:self.mainSession];
[self removeObservers];
mxCall = nil;
}
if (call && call.room)
{
mxCall = call;
[self addMatrixSession:mxCall.room.mxSession];
MXWeakify(self);
// Register a listener to handle messages related to room name, members...
roomListener = [mxCall.room listenToEventsOfTypes:@[kMXEventTypeStringRoomName, kMXEventTypeStringRoomTopic, kMXEventTypeStringRoomAliases, kMXEventTypeStringRoomAvatar, kMXEventTypeStringRoomCanonicalAlias, kMXEventTypeStringRoomMember] onEvent:^(MXEvent *event, MXTimelineDirection direction, MXRoomState *roomState) {
MXStrongifyAndReturnIfNil(self);
// Consider only live events
if (self->mxCall && direction == MXTimelineDirectionForwards)
{
// The room state has been changed
[self callRoomStateDidChange:nil];
}
}];
// Observe room history flush (sync with limited timeline, or state event redaction)
roomDidFlushDataNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kMXRoomDidFlushDataNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
MXStrongifyAndReturnIfNil(self);
MXRoom *room = notif.object;
if (self->mxCall && self.mainSession == room.mxSession && [self->mxCall.room.roomId isEqualToString:room.roomId])
{
// The existing room history has been flushed during server sync.
// Take into account the updated room state
[self callRoomStateDidChange:nil];
}
}];
audioSessionRouteChangeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) {
[self updateProximityAndSleep];
}];
// Hide video mute on voice call
self.videoMuteButton.hidden = !call.isVideoCall;
// Hide camera switch on voice call
self.cameraSwitchButton.hidden = !call.isVideoCall;
_moreButtonForVideo.hidden = !call.isVideoCall;
_moreButtonForVoice.hidden = call.isVideoCall;
// Observe call state change
call.delegate = self;
// Display room call information
[self callRoomStateDidChange:^{
[self call:call stateDidChange:call.state reason:nil];
}];
if (call.isVideoCall && localPreviewContainerView)
{
// Access to the camera is mandatory to display the self view
// Check the permission right now
NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"];
[MXKTools checkAccessForMediaType:AVMediaTypeVideo
manualChangeMessage:[VectorL10n cameraAccessNotGrantedForCall:appDisplayName]
showPopUpInViewController:self completionHandler:^(BOOL granted) {
if (granted)
{
self->localPreviewContainerView.hidden = NO;
self->remotePreviewContainerView.hidden = NO;
call.selfVideoView = self->localPreviewVideoView;
call.remoteVideoView = self->remotePreviewContainerView;
[self applyDeviceOrientation:YES];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(deviceOrientationDidChange)
name:UIDeviceOrientationDidChangeNotification
object:nil];
}
}];
}
else
{
localPreviewContainerView.hidden = YES;
remotePreviewContainerView.hidden = YES;
}
}
}
- (void)setMxCallOnHold:(MXCall *)callOnHold
{
if (mxCallOnHold == callOnHold)
{
// setting same property, return
return;
}
mxCallOnHold = callOnHold;
if (mxCallOnHold)
{
self.onHoldCallContainerView.hidden = NO;
[self.onHoldCallContainerView addGestureRecognizer:self.onHoldCallContainerTapRecognizer];
[self.onHoldCallContainerView setUserInteractionEnabled:YES];
// Handle peer here
if (mxCallOnHold.isIncoming)
{
self.peerOnHold = [mxCallOnHold.room.mxSession getOrCreateUser:mxCallOnHold.callerId];
}
else
{
// For 1:1 call, find the other peer
// Else, the room information will be used to display information about the call
MXWeakify(self);
[mxCallOnHold.room state:^(MXRoomState *roomState) {
MXStrongifyAndReturnIfNil(self);
MXUser *theMember = nil;
NSArray *members = roomState.members.joinedMembers;
for (MXUser *member in members)
{
if (![member.userId isEqualToString:self->mxCallOnHold.callerId])
{
theMember = member;
break;
}
}
self.peerOnHold = theMember;
}];
}
}
else
{
[self.onHoldCallContainerView removeGestureRecognizer:self.onHoldCallContainerTapRecognizer];
[self.onHoldCallContainerView setUserInteractionEnabled:NO];
self.onHoldCallContainerView.hidden = YES;
self.peerOnHold = nil;
}
}
- (void)setPeer:(MXUser *)peer
{
_peer = peer;
[self updatePeerInfoDisplay];
}
- (void)setPeerOnHold:(MXUser *)peerOnHold
{
_peerOnHold = peerOnHold;
NSString *peerAvatarURL;
if (_peerOnHold)
{
peerAvatarURL = _peerOnHold.avatarUrl;
}
else if (mxCall.isConferenceCall)
{
peerAvatarURL = mxCallOnHold.room.summary.avatar;
}
onHoldCallerImageView.imageView.contentMode = UIViewContentModeScaleAspectFill;
if (peerAvatarURL)
{
// Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server
onHoldCallerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder;
onHoldCallerImageView.enableInMemoryCache = YES;
[onHoldCallerImageView setImageURI:peerAvatarURL
withType:nil
andImageOrientation:UIImageOrientationUp
toFitViewSize:onHoldCallerImageView.frame.size
withMethod:MXThumbnailingMethodCrop
previewImage:self.picturePlaceholder
mediaManager:self.mainSession.mediaManager];
}
else
{
onHoldCallerImageView.image = self.picturePlaceholder;
}
}
- (void)updatePeerInfoDisplay
{
NSString *peerAvatarURL;
if (_peer)
{
peerDisplayName = [_peer displayname];
if (!peerDisplayName.length)
{
peerDisplayName = _peer.userId;
}
peerAvatarURL = _peer.avatarUrl;
}
else if (mxCall.isConferenceCall)
{
peerDisplayName = mxCall.room.summary.displayName;
peerAvatarURL = mxCall.room.summary.avatar;
}
if (mxCall.isConsulting)
{
callerNameLabel.text = [VectorL10n callConsultingWithUser:peerDisplayName];
}
else
{
if (mxCall.isVideoCall)
{
callerNameLabel.text = [VectorL10n callVideoWithUser:peerDisplayName];
}
else
{
callerNameLabel.text = [VectorL10n callVoiceWithUser:peerDisplayName];
}
}
if (peerAvatarURL)
{
// Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server
callerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder;
callerImageView.enableInMemoryCache = YES;
[callerImageView setImageURI:peerAvatarURL
withType:nil
andImageOrientation:UIImageOrientationUp
toFitViewSize:callerImageView.frame.size
withMethod:MXThumbnailingMethodCrop
previewImage:self.picturePlaceholder
mediaManager:self.mainSession.mediaManager];
}
else
{
callerImageView.image = self.picturePlaceholder;
}
// Round caller image view
[callerImageView.layer setCornerRadius:callerImageView.frame.size.width / 2];
callerImageView.clipsToBounds = YES;
}
- (void)setIsRinging:(Boolean)isRinging
{
if (_isRinging != isRinging)
{
if (isRinging)
{
NSURL *audioUrl;
if (mxCall.isIncoming)
{
if (self.playRingtone)
audioUrl = [self audioURLWithName:@"ring"];
}
else
{
audioUrl = [self audioURLWithName:@"ringback"];
}
if (audioUrl)
{
[[MXKSoundPlayer sharedInstance] playSoundAt:audioUrl repeat:YES vibrate:mxCall.isIncoming routeToBuiltInReceiver:!mxCall.isIncoming];
}
}
else
{
[[MXKSoundPlayer sharedInstance] stopPlayingWithAudioSessionDeactivation:NO];
}
_isRinging = isRinging;
}
}
- (void)setDelegate:(id<MXKCallViewControllerDelegate>)delegate
{
_delegate = delegate;
if (_delegate)
{
backToAppButton.hidden = NO;
}
else
{
backToAppButton.hidden = YES;
}
}
- (UITapGestureRecognizer *)onHoldCallContainerTapRecognizer
{
if (_onHoldCallContainerTapRecognizer == nil)
{
_onHoldCallContainerTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(onHoldCallContainerTapped:)];
}
return _onHoldCallContainerTapRecognizer;
}
- (BOOL)isDisplayingAlert
{
return errorAlert != nil;
}
- (UIButton *)moreButton
{
if (mxCall.isVideoCall)
{
return _moreButtonForVideo;
}
return _moreButtonForVoice;
}
#pragma mark - Sounds
- (NSURL *)audioURLWithName:(NSString *)soundName
{
return [NSBundle mxk_audioURLFromMXKAssetsBundleWithName:soundName];
}
#pragma mark - Actions
- (void)onHoldCallContainerTapped:(UITapGestureRecognizer *)recognizer
{
if ([self.delegate respondsToSelector:@selector(callViewControllerDidTapOnHoldCall:)])
{
[self.delegate callViewControllerDidTapOnHoldCall:self];
}
}
- (IBAction)onButtonPressed:(id)sender
{
if (sender == answerCallButton)
{
// If we are here, we have access to the camera
// The following check is mainly to check microphone access permission
NSString *appDisplayName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleDisplayName"];
[MXKTools checkAccessForCall:mxCall.isVideoCall
manualChangeMessageForAudio:[VectorL10n microphoneAccessNotGrantedForCall:appDisplayName]
manualChangeMessageForVideo:[VectorL10n cameraAccessNotGrantedForCall:appDisplayName]
showPopUpInViewController:self completionHandler:^(BOOL granted) {
if (granted)
{
[self->mxCall answer];
}
}];
}
else if (sender == rejectCallButton || sender == endCallButton)
{
if (mxCall.state != MXCallStateEnded)
{
[mxCall hangup];
}
else
{
[self dismiss];
}
}
else if (sender == audioMuteButton)
{
mxCall.audioMuted = !mxCall.audioMuted;
audioMuteButton.selected = mxCall.audioMuted;
}
else if (sender == videoMuteButton)
{
mxCall.videoMuted = !mxCall.videoMuted;
videoMuteButton.selected = mxCall.videoMuted;
}
else if (sender == _resumeButton)
{
[mxCall hold:NO];
}
else if (sender == self.moreButton)
{
[currentAlert dismissViewControllerAnimated:NO completion:nil];
MXWeakify(self);
NSMutableArray<UIAlertAction *> *actions = [NSMutableArray arrayWithCapacity:4];
if (self.speakerButton == nil)
{
// audio device action
UIAlertAction *audioDeviceAction = [UIAlertAction actionWithTitle:[VectorL10n callMoreActionsChangeAudioDevice]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
[self showAudioDeviceOptions];
}];
[actions addObject:audioDeviceAction];
}
// check the call can be up/downgraded
// check the call can send DTMF tones
if (self.mxCall.supportsDTMF)
{
UIAlertAction *dialpadAction = [UIAlertAction actionWithTitle:[VectorL10n callMoreActionsDialpad]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
[self openDialpad];
}];
[actions addObject:dialpadAction];
}
// check the call be holded/unholded
if (mxCall.supportsHolding)
{
NSString *actionLocKey = (mxCall.state == MXCallStateOnHold) ? [VectorL10n callMoreActionsUnhold] : [VectorL10n callMoreActionsHold];
UIAlertAction *holdAction = [UIAlertAction actionWithTitle:actionLocKey
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
[self->mxCall hold:(self.mxCall.state != MXCallStateOnHold)];
}];
[actions addObject:holdAction];
}
// check the call be transferred
if (mxCall.supportsTransferring && self.peer)
{
UIAlertAction *transferAction = [UIAlertAction actionWithTitle:[VectorL10n callMoreActionsTransfer]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
[self openCallTransfer];
}];
[actions addObject:transferAction];
}
if (actions.count > 0)
{
// create the alert
currentAlert = [UIAlertController alertControllerWithTitle:nil
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
// add actions
[actions enumerateObjectsUsingBlock:^(UIAlertAction * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[currentAlert addAction:obj];
}];
// add cancel action always
[currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel]
style:UIAlertActionStyleCancel
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
}]];
[currentAlert popoverPresentationController].sourceView = self.moreButton;
[currentAlert popoverPresentationController].sourceRect = self.moreButton.bounds;
[self presentViewController:currentAlert animated:YES completion:nil];
}
}
else if (sender == speakerButton)
{
[self showAudioDeviceOptions];
}
else if (sender == cameraSwitchButton)
{
switch (mxCall.cameraPosition)
{
case AVCaptureDevicePositionFront:
mxCall.cameraPosition = AVCaptureDevicePositionBack;
break;
default:
mxCall.cameraPosition = AVCaptureDevicePositionFront;
break;
}
}
else if (sender == backToAppButton)
{
if (_delegate)
{
// Dismiss the view controller whereas the call is still running
[_delegate dismissCallViewController:self completion:nil];
}
}
else if (sender == _transferButton)
{
// actually transfer the call without consulting
[self.mainSession.callManager transferCall:mxCall.callWithTransferee
to:mxCall.transferTarget
withTransferee:mxCall.transferee
consultFirst:NO
success:^(NSString * _Nullable newCallId) {
}
failure:^(NSError * _Nullable error) {
}];
}
[self updateProximityAndSleep];
}
- (void)showAudioDeviceOptions
{
NSMutableArray<UIAlertAction *> *actions = [NSMutableArray new];
NSArray<MXiOSAudioOutputRoute *> *availableRoutes = mxCall.audioOutputRouter.availableOutputRoutes;
for (MXiOSAudioOutputRoute *route in availableRoutes)
{
// route action
NSString *name = route.name;
if (route.routeType == MXiOSAudioOutputRouteTypeLoudSpeakers)
{
name = [VectorL10n callMoreActionsAudioUseDevice];
}
MXWeakify(self);
UIAlertAction *routeAction = [UIAlertAction actionWithTitle:name
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
[self->mxCall.audioOutputRouter changeCurrentRouteTo:route];
}];
[actions addObject:routeAction];
}
if (actions.count > 0)
{
// create the alert
currentAlert = [UIAlertController alertControllerWithTitle:nil
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
for (UIAlertAction *action in actions)
{
[currentAlert addAction:action];
}
// add cancel action
MXWeakify(self);
[currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel]
style:UIAlertActionStyleCancel
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
}]];
[currentAlert popoverPresentationController].sourceView = self.moreButton;
[currentAlert popoverPresentationController].sourceRect = self.moreButton.bounds;
[self presentViewController:currentAlert animated:YES completion:nil];
}
}
#pragma mark - DTMF
- (void)openDialpad
{
// no-op
}
#pragma mark - Call Transfer
- (void)openCallTransfer
{
// no-op
}
#pragma mark - MXCallDelegate
- (void)call:(MXCall *)call stateDidChange:(MXCallState)state reason:(MXEvent *)event
{
// Set default configuration of bottom bar
endCallButton.hidden = NO;
rejectCallButton.hidden = YES;
answerCallButton.hidden = YES;
self.moreButton.enabled = YES;
_resumeButton.hidden = state != MXCallStateOnHold;
_pausedIcon.hidden = state != MXCallStateOnHold && state != MXCallStateRemotelyOnHold;
_transferButton.hidden = YES;
[localPreviewActivityView stopAnimating];
switch (state)
{
case MXCallStateFledgling:
self.isRinging = NO;
callStatusLabel.text = [VectorL10n callConnecting];
break;
case MXCallStateWaitLocalMedia:
self.isRinging = NO;
[self configureSpeakerButton];
[localPreviewActivityView startAnimating];
// Try to show a special view for incoming view
[self configureIncomingCallViewIfRequiredWith:call];
break;
case MXCallStateCreateOffer:
{
// When CallKit is enabled and we have an outgoing call, we need to start playing ringback sound
// only after AVAudioSession will be activated by the system otherwise the sound will be gone.
// We always receive signal about MXCallStateCreateOffer earlier than the system activates AVAudioSession
// so we start playing ringback sound only on AVAudioSession activation in handleAudioSessionActivationNotification
BOOL isCallKitAvailable = [MXCallKitAdapter callKitAvailable] && [MXKAppSettings standardAppSettings].isCallKitEnabled;
if (!isCallKitAvailable)
{
self.isRinging = YES;
}
callStatusLabel.text = [VectorL10n callConnecting];
break;
}
case MXCallStateInviteSent:
{
callStatusLabel.text = [VectorL10n callRinging];
break;
}
case MXCallStateRinging:
self.isRinging = YES;
[self configureSpeakerButton];
if (call.isVideoCall)
{
callStatusLabel.text = [VectorL10n incomingVideoCall];
}
else
{
callStatusLabel.text = [VectorL10n incomingVoiceCall];
}
// Update bottom bar
endCallButton.hidden = YES;
rejectCallButton.hidden = NO;
answerCallButton.hidden = NO;
// Try to show a special view for incoming view
[self configureIncomingCallViewIfRequiredWith:call];
break;
case MXCallStateConnecting:
self.isRinging = NO;
// User has accepted the call and we can remove incomingCallView
if (self.incomingCallView)
{
[UIView transitionWithView:self.view
duration:0.33
options:UIViewAnimationOptionTransitionCrossDissolve | UIViewAnimationOptionCurveEaseOut
animations:^{
[self.incomingCallView removeFromSuperview];
}
completion:^(BOOL finished) {
self.incomingCallView = nil;
}];
}
break;
case MXCallStateConnected:
self.isRinging = NO;
[self updateTimeStatusLabel];
if (call.isVideoCall)
{
self.callerImageView.hidden = YES;
if (call.isConferenceCall)
{
// Do not show self view anymore because it is returned by the conference bridge
self.localPreviewContainerView.hidden = YES;
// Well, hide does not work. So, shrink the view to nil
self.localPreviewContainerView.frame = CGRectZero;
}
}
audioMuteButton.enabled = YES;
videoMuteButton.enabled = YES;
speakerButton.enabled = YES;
cameraSwitchButton.enabled = YES;
if (call.isConsulting)
{
_transferButton.hidden = NO;
}
break;
case MXCallStateOnHold:
callStatusLabel.text = [VectorL10n callHolded];
break;
case MXCallStateRemotelyOnHold:
audioMuteButton.enabled = NO;
videoMuteButton.enabled = NO;
speakerButton.enabled = NO;
cameraSwitchButton.enabled = NO;
self.moreButton.enabled = NO;
callStatusLabel.text = [VectorL10n callRemoteHolded:peerDisplayName];
break;
case MXCallStateInviteExpired:
// MXCallStateInviteExpired state is sent as an notification
// MXCall will move quickly to the MXCallStateEnded state
self.isRinging = NO;
callStatusLabel.text = [VectorL10n callInviteExpired];
break;
case MXCallStateEnded:
{
self.isRinging = NO;
callStatusLabel.text = [VectorL10n callEnded];
NSString *soundName = [self soundNameForCallEnding];
if (soundName)
{
NSURL *audioUrl = [self audioURLWithName:soundName];
[[MXKSoundPlayer sharedInstance] playSoundAt:audioUrl repeat:NO vibrate:NO routeToBuiltInReceiver:YES];
}
else
{
[[MXKSoundPlayer sharedInstance] stopPlayingWithAudioSessionDeactivation:YES];
}
// Except in case of call error, quit the screen right now
if (!errorAlert)
{
[self dismiss];
}
break;
}
default:
break;
}
[self updateProximityAndSleep];
}
- (void)call:(MXCall *)call didEncounterError:(NSError *)error reason:(MXCallHangupReason)reason
{
MXLogDebug(@"[MXKCallViewController] didEncounterError. mxCall.state: %tu. Stop call due to error: %@", mxCall.state, error);
if (mxCall.state != MXCallStateEnded)
{
// Popup the error to the user
NSString *title = [error.userInfo valueForKey:NSLocalizedFailureReasonErrorKey];
if (!title)
{
title = [VectorL10n error];
}
NSString *msg = [error.userInfo valueForKey:NSLocalizedDescriptionKey];
if (!msg)
{
msg = [VectorL10n errorCommonMessage];
}
MXWeakify(self);
errorAlert = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert];
[errorAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->errorAlert = nil;
[self dismiss];
}]];
[self presentViewController:errorAlert animated:YES completion:nil];
// And interrupt the call
[mxCall hangupWithReason:reason];
}
}
- (void)callConsultingStatusDidChange:(MXCall *)call
{
[self updatePeerInfoDisplay];
if (call.isConsulting)
{
NSString *title = [VectorL10n callTransferToUser:call.transferee.displayname];
[_transferButton setTitle:title forState:UIControlStateNormal];
_transferButton.hidden = call.state != MXCallStateConnected;
}
else
{
_transferButton.hidden = YES;
}
}
- (void)callAssertedIdentityDidChange:(MXCall *)call
{
MXAssertedIdentityModel *assertedIdentity = call.assertedIdentity;
if (assertedIdentity)
{
// update caller display name and avatar with the asserted identity
NSString *peerAvatarURL = assertedIdentity.avatarUrl;
if (assertedIdentity.displayname)
{
peerDisplayName = assertedIdentity.displayname;
}
else if (assertedIdentity.userId)
{
peerDisplayName = assertedIdentity.userId;
}
if (mxCall.isVideoCall)
{
callerNameLabel.text = [VectorL10n callVideoWithUser:peerDisplayName];
}
else
{
callerNameLabel.text = [VectorL10n callVoiceWithUser:peerDisplayName];
}
if (peerAvatarURL)
{
// Suppose avatar url is a matrix content uri, we use SDK to get the well adapted thumbnail from server
callerImageView.mediaFolder = kMXMediaManagerAvatarThumbnailFolder;
callerImageView.enableInMemoryCache = YES;
[callerImageView setImageURI:peerAvatarURL
withType:nil
andImageOrientation:UIImageOrientationUp
toFitViewSize:callerImageView.frame.size
withMethod:MXThumbnailingMethodCrop
previewImage:self.picturePlaceholder
mediaManager:self.mainSession.mediaManager];
}
else
{
callerImageView.image = self.picturePlaceholder;
}
[updateStatusTimer fire];
}
else
{
// go back to the original display name and avatar
[self updatePeerInfoDisplay];
}
}
- (void)callAudioOutputRouteTypeDidChange:(MXCall *)call
{
[self configureSpeakerButton];
}
- (void)callAvailableAudioOutputsDidChange:(MXCall *)call
{
}
#pragma mark - Internal
- (void)removeObservers
{
if (roomDidFlushDataNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:roomDidFlushDataNotificationObserver];
roomDidFlushDataNotificationObserver = nil;
}
if (audioSessionRouteChangeNotificationObserver)
{
[[NSNotificationCenter defaultCenter] removeObserver:audioSessionRouteChangeNotificationObserver];
audioSessionRouteChangeNotificationObserver = nil;
}
[[NSNotificationCenter defaultCenter] removeObserver:self];
if (roomListener && mxCall.room)
{
MXWeakify(self);
[mxCall.room liveTimeline:^(id<MXEventTimeline> liveTimeline) {
MXStrongifyAndReturnIfNil(self);
[liveTimeline removeListener:self->roomListener];
self->roomListener = nil;
}];
}
}
- (void)callRoomStateDidChange:(dispatch_block_t)onComplete
{
// Handle peer here
if (mxCall.isIncoming)
{
self.peer = [mxCall.room.mxSession getOrCreateUser:mxCall.callerId];
if (onComplete)
{
onComplete();
}
}
else
{
// For 1:1 call, find the other peer
// Else, the room information will be used to display information about the call
if (!mxCall.isConferenceCall)
{
MXWeakify(self);
[mxCall.room state:^(MXRoomState *roomState) {
MXStrongifyAndReturnIfNil(self);
MXUser *theMember = nil;
NSArray *members = roomState.members.joinedMembers;
for (MXUser *member in members)
{
if (![member.userId isEqualToString:self->mxCall.callerId])
{
theMember = member;
break;
}
}
self.peer = theMember;
if (onComplete)
{
onComplete();
}
}];
}
else
{
self.peer = nil;
if (onComplete)
{
onComplete();
}
}
}
}
- (BOOL)isBuiltInReceiverAudioOuput
{
#if TARGET_IPHONE_SIMULATOR
return YES;
#endif
BOOL isBuiltInReceiverUsed = NO;
// Check whether the audio output is the built-in receiver
AVAudioSessionRouteDescription *audioRoute = [[AVAudioSession sharedInstance] currentRoute];
if (audioRoute.outputs.count)
{
// TODO: handle the case where multiple outputs are returned
AVAudioSessionPortDescription *audioOutputs = audioRoute.outputs.firstObject;
isBuiltInReceiverUsed = ([audioOutputs.portType isEqualToString:AVAudioSessionPortBuiltInReceiver]);
}
return isBuiltInReceiverUsed;
}
- (NSString *)soundNameForCallEnding
{
if (mxCall.endReason == MXCallEndReasonUnknown)
return nil;
if (mxCall.isEstablished)
return @"callend";
if (mxCall.endReason == MXCallEndReasonBusy || (!mxCall.isIncoming && mxCall.endReason == MXCallEndReasonMissed))
return @"busy";
return nil;
}
- (void)handleAudioSessionActivationNotification
{
// It's only relevant for outgoing calls which aren't in connected state
if (self.mxCall.state >= MXCallStateCreateOffer && self.mxCall.state != MXCallStateConnected && self.mxCall.state != MXCallStateEnded)
{
self.isRinging = YES;
}
}
#pragma mark - UI methods
- (void)configureSpeakerButton
{
switch (mxCall.audioOutputRouter.currentRoute.routeType)
{
case MXiOSAudioOutputRouteTypeBuiltIn:
self.speakerButton.selected = NO;
break;
case MXiOSAudioOutputRouteTypeLoudSpeakers:
case MXiOSAudioOutputRouteTypeExternalWired:
case MXiOSAudioOutputRouteTypeExternalBluetooth:
case MXiOSAudioOutputRouteTypeExternalCar:
self.speakerButton.selected = YES;
break;
}
}
- (void)configureIncomingCallViewIfRequiredWith:(MXCall *)call
{
if (call.isIncoming && !self.incomingCallView)
{
UIView *incomingCallView = [self createIncomingCallView];
if (incomingCallView)
{
self.incomingCallView = incomingCallView;
[self.view addSubview:incomingCallView];
incomingCallView.translatesAutoresizingMaskIntoConstraints = NO;
[incomingCallView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:0].active = YES;
[incomingCallView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:0].active = YES;
[incomingCallView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor constant:0].active = YES;
[incomingCallView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:0].active = YES;
}
}
}
- (void)updateLocalPreviewLayout
{
// On IOS 8 and later, the screen size is oriented.
CGRect bounds = [[UIScreen mainScreen] bounds];
BOOL isLandscapeOriented = (bounds.size.width > bounds.size.height);
CGFloat maxPreviewFrameSize, minPreviewFrameSize;
if (_localPreviewContainerViewWidthConstraint.constant < _localPreviewContainerViewHeightConstraint.constant)
{
maxPreviewFrameSize = _localPreviewContainerViewHeightConstraint.constant;
minPreviewFrameSize = _localPreviewContainerViewWidthConstraint.constant;
}
else
{
minPreviewFrameSize = _localPreviewContainerViewHeightConstraint.constant;
maxPreviewFrameSize = _localPreviewContainerViewWidthConstraint.constant;
}
if (isLandscapeOriented)
{
_localPreviewContainerViewHeightConstraint.constant = minPreviewFrameSize;
_localPreviewContainerViewWidthConstraint.constant = maxPreviewFrameSize;
}
else
{
_localPreviewContainerViewHeightConstraint.constant = maxPreviewFrameSize;
_localPreviewContainerViewWidthConstraint.constant = minPreviewFrameSize;
}
CGPoint previewOrigin = self.localPreviewContainerView.frame.origin;
if (previewOrigin.x != (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - kLocalPreviewMargin))
{
CGFloat posX = (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - kLocalPreviewMargin);
_localPreviewContainerViewLeadingConstraint.constant = posX;
}
if (previewOrigin.y != kLocalPreviewMargin)
{
CGFloat posY = (bounds.size.height - _localPreviewContainerViewHeightConstraint.constant - kLocalPreviewMargin);
_localPreviewContainerViewTopConstraint.constant = posY;
}
}
- (void)showOverlayContainer:(BOOL)isShown
{
if (mxCall && !mxCall.isVideoCall) isShown = YES;
if (mxCall.state != MXCallStateConnected) isShown = YES;
if (isShown)
{
overlayContainerView.hidden = NO;
if (mxCall && mxCall.isVideoCall)
{
[hideOverlayTimer invalidate];
hideOverlayTimer = [NSTimer scheduledTimerWithTimeInterval:5.0 target:self selector:@selector(hideOverlay:) userInfo:nil repeats:NO];
}
}
else
{
overlayContainerView.hidden = YES;
}
}
- (void)toggleOverlay
{
[self showOverlayContainer:overlayContainerView.isHidden];
}
- (void)hideOverlay:(NSTimer*)theTimer
{
[self showOverlayContainer:NO];
hideOverlayTimer = nil;
}
- (void)updateTimeStatusLabel
{
if (mxCall.state == MXCallStateConnected)
{
NSUInteger duration = mxCall.duration / 1000;
NSUInteger secs = duration % 60;
NSUInteger mins = (duration - secs) / 60;
callStatusLabel.text = [NSString stringWithFormat:@"%02tu:%02tu", mins, secs];
}
}
- (void)updateProximityAndSleep
{
BOOL inCall = (mxCall.state == MXCallStateConnected || mxCall.state == MXCallStateRinging || mxCall.state == MXCallStateInviteSent || mxCall.state == MXCallStateConnecting || mxCall.state == MXCallStateCreateOffer || mxCall.state == MXCallStateCreateAnswer);
BOOL isBuiltInReceiverUsed = self.isBuiltInReceiverAudioOuput;
// Enable the proximity monitoring when the built in receiver is used as the audio output.
BOOL enableProxMonitoring = inCall && isBuiltInReceiverUsed;
UIDevice *device = [UIDevice currentDevice];
if (device && device.isProximityMonitoringEnabled != enableProxMonitoring)
{
[device setProximityMonitoringEnabled:enableProxMonitoring];
}
// Disable the idle timer during a video call, or during a voice call which is performed with the built-in receiver.
// Note: if the device is locked, VoIP calling get dropped if an incoming GSM call is received.
BOOL disableIdleTimer = inCall && (mxCall.isVideoCall || isBuiltInReceiverUsed);
UIApplication *sharedApplication = [UIApplication performSelector:@selector(sharedApplication)];
if (sharedApplication && sharedApplication.isIdleTimerDisabled != disableIdleTimer)
{
sharedApplication.idleTimerDisabled = disableIdleTimer;
}
}
- (UIView *)createIncomingCallView
{
return nil;
}
#pragma mark - UIResponder Touch Events
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint point = [touch locationInView:self.view];
if ((!self.localPreviewContainerView.hidden) && CGRectContainsPoint(self.localPreviewContainerView.frame, point))
{
// Starting to move the local preview view
if (mxCallOnHold)
{
// if there is a call on hold, do not move local preview for now
// TODO: Instead of wholly avoiding mobility of local preview, just avoid the on hold call's corner here
return;
}
isSelectingLocalPreview = YES;
}
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
isMovingLocalPreview = NO;
isSelectingLocalPreview = NO;
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
if (isMovingLocalPreview)
{
UITouch *touch = [touches anyObject];
CGPoint point = [touch locationInView:self.view];
CGRect bounds = self.view.bounds;
CGFloat midX = bounds.size.width / 2.0;
CGFloat midY = bounds.size.height / 2.0;
CGFloat posX = (point.x < midX) ? 20.0 : (bounds.size.width - _localPreviewContainerViewWidthConstraint.constant - 20.0);
CGFloat posY = (point.y < midY) ? 20.0 : (bounds.size.height - _localPreviewContainerViewHeightConstraint.constant - 20.0);
_localPreviewContainerViewLeadingConstraint.constant = posX;
_localPreviewContainerViewTopConstraint.constant = posY;
[self.view setNeedsUpdateConstraints];
}
else
{
[self toggleOverlay];
}
isMovingLocalPreview = NO;
isSelectingLocalPreview = NO;
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint point = [touch locationInView:self.view];
if (isSelectingLocalPreview)
{
isMovingLocalPreview = YES;
self.localPreviewContainerView.center = point;
}
}
#pragma mark - UIDeviceOrientationDidChangeNotification
- (void)deviceOrientationDidChange
{
[self applyDeviceOrientation:NO];
[self showOverlayContainer:YES];
}
- (void)applyDeviceOrientation:(BOOL)forcePortrait
{
if (mxCall)
{
UIDeviceOrientation deviceOrientation = [[UIDevice currentDevice] orientation];
// Set the camera orientation according to the orientation supported by the app
if (UIDeviceOrientationPortrait == deviceOrientation || UIDeviceOrientationLandscapeLeft == deviceOrientation || UIDeviceOrientationLandscapeRight == deviceOrientation)
{
mxCall.selfOrientation = deviceOrientation;
[self updateLocalPreviewLayout];
}
else if (forcePortrait)
{
mxCall.selfOrientation = UIDeviceOrientationPortrait;
[self updateLocalPreviewLayout];
}
}
}
@end