element-ios/Riot/Modules/Call/CallViewController.m

765 lines
29 KiB
Objective-C

/*
Copyright 2018-2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
#import "CallViewController.h"
#import "GeneratedInterface-Swift.h"
#import "AvatarGenerator.h"
#import "UsersDevicesViewController.h"
#import "RiotNavigationController.h"
#import "IncomingCallView.h"
@interface CallViewController () <
PictureInPicturable,
DialpadViewControllerDelegate,
CallTransferMainViewControllerDelegate,
CallAudioRouteMenuViewDelegate>
{
// Current alert (if any).
UIAlertController *currentAlert;
// Flag to compute self.shouldPromptForStunServerFallback
BOOL promptForStunServerFallback;
}
@property (nonatomic, weak) IBOutlet UIView *pipViewContainer;
@property (nonatomic, strong) id<Theme> overriddenTheme;
@property (nonatomic, assign) BOOL inPiP;
@property (nonatomic, strong) CallPiPView *pipView;
@property (nonatomic, strong) CustomSizedPresentationController *customSizedPresentationController;
@property (nonatomic, strong) SlidingModalPresenter *slidingModalPresenter;
@property (nonatomic, strong) CallAudioRouteMenuView *audioRoutesMenuView;
@end
@implementation CallViewController
- (void)finalizeInit
{
[super finalizeInit];
// Setup `MXKViewControllerHandling` properties
self.enableBarTintColorStatusChange = NO;
self.rageShakeManager = [RageShakeManager sharedManager];
promptForStunServerFallback = NO;
_shouldPromptForStunServerFallback = NO;
}
- (void)viewDidLoad
{
[super viewDidLoad];
// Back button
UIImage *backButtonImage = AssetImages.backIcon.image;
[self.backToAppButton setImage:backButtonImage forState:UIControlStateNormal];
[self.backToAppButton setImage:backButtonImage forState:UIControlStateHighlighted];
// Camera switch
UIImage *cameraSwitchButtonImage = AssetImages.cameraSwitch.image;
[self.cameraSwitchButton setImage:cameraSwitchButtonImage forState:UIControlStateNormal];
[self.cameraSwitchButton setImage:cameraSwitchButtonImage forState:UIControlStateHighlighted];
// Audio mute
UIImage *audioMuteOffButtonImage = AssetImages.callAudioMuteOffIcon.image;
UIImage *audioMuteOnButtonImage = AssetImages.callAudioMuteOnIcon.image;
[self.audioMuteButton setImage:audioMuteOffButtonImage forState:UIControlStateNormal];
[self.audioMuteButton setImage:audioMuteOffButtonImage forState:UIControlStateHighlighted];
[self.audioMuteButton setImage:audioMuteOnButtonImage forState:UIControlStateSelected];
// Video mute
UIImage *videoOffButtonImage = AssetImages.callVideoMuteOffIcon.image;
UIImage *videoOnButtonImage = AssetImages.callVideoMuteOnIcon.image;
[self.videoMuteButton setImage:videoOffButtonImage forState:UIControlStateNormal];
[self.videoMuteButton setImage:videoOffButtonImage forState:UIControlStateHighlighted];
[self.videoMuteButton setImage:videoOnButtonImage forState:UIControlStateSelected];
// More
UIImage *moreButtonImage = AssetImages.callMoreIcon.image;
[self.moreButtonForVoice setImage:moreButtonImage forState:UIControlStateNormal];
[self.moreButtonForVideo setImage:moreButtonImage forState:UIControlStateNormal];
// Hang up
UIImage *hangUpButtonImage = AssetImages.callHangupLarge.image;
[self.endCallButton setTitle:nil forState:UIControlStateNormal];
[self.endCallButton setTitle:nil forState:UIControlStateHighlighted];
[self.endCallButton setImage:hangUpButtonImage forState:UIControlStateNormal];
[self.endCallButton setImage:hangUpButtonImage forState:UIControlStateHighlighted];
// force orientation to portrait if phone
if ([UIDevice currentDevice].isPhone)
{
[[UIDevice currentDevice] setValue:[NSNumber numberWithInteger: UIInterfaceOrientationPortrait] forKey:@"orientation"];
}
[self updateLocalPreviewLayout];
[self configureUserInterface];
}
- (UIStatusBarStyle)preferredStatusBarStyle
{
return self.overriddenTheme.statusBarStyle;
}
- (void)configureUserInterface
{
if (@available(iOS 13.0, *)) {
self.overrideUserInterfaceStyle = self.overriddenTheme.userInterfaceStyle;
}
[self.overriddenTheme applyStyleOnNavigationBar:self.navigationController.navigationBar];
self.barTitleColor = self.overriddenTheme.textPrimaryColor;
self.activityIndicator.backgroundColor = self.overriddenTheme.overlayBackgroundColor;
self.backToAppButton.tintColor = self.overriddenTheme.callScreenButtonTintColor;
self.cameraSwitchButton.tintColor = self.overriddenTheme.callScreenButtonTintColor;
self.callerNameLabel.textColor = self.overriddenTheme.baseTextPrimaryColor;
self.callStatusLabel.textColor = self.overriddenTheme.baseTextPrimaryColor;
[self.resumeButton setTitleColor:self.overriddenTheme.tintColor
forState:UIControlStateNormal];
[self.transferButton setTitleColor:self.overriddenTheme.tintColor
forState:UIControlStateNormal];
self.localPreviewContainerView.layer.borderColor = self.overriddenTheme.tintColor.CGColor;
self.localPreviewContainerView.layer.borderWidth = 2;
self.localPreviewContainerView.layer.cornerRadius = 5;
self.localPreviewContainerView.clipsToBounds = YES;
}
- (void)viewWillDisappear:(BOOL)animated
{
if (currentAlert)
{
[currentAlert dismissViewControllerAnimated:NO completion:nil];
currentAlert = nil;
}
[super viewWillDisappear:animated];
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
// limit orientation to portrait only for phone
if ([UIDevice currentDevice].isPhone)
{
return UIInterfaceOrientationMaskPortrait;
}
return [super supportedInterfaceOrientations];
}
- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation
{
if ([UIDevice currentDevice].isPhone)
{
return UIInterfaceOrientationPortrait;
}
return [super preferredInterfaceOrientationForPresentation];
}
- (BOOL)shouldAutorotate
{
return NO;
}
#pragma mark - override MXKViewController
- (UIView *)createIncomingCallView
{
if ([MXCallKitAdapter callKitAvailable])
{
return nil;
}
NSString *callInfo;
if (self.mxCall.isVideoCall)
callInfo = [VectorL10n callIncomingVideo];
else
callInfo = [VectorL10n callIncomingVoice];
IncomingCallView *incomingCallView = [[IncomingCallView alloc] initWithCallerAvatar:self.peer.avatarUrl
mediaManager:self.mainSession.mediaManager
placeholderImage:self.picturePlaceholder
callerName:self.peer.displayname
callInfo:callInfo];
// Incoming call is retained by call vc so use weak to avoid retain cycle
__weak typeof(self) weakSelf = self;
incomingCallView.onAnswer = ^{
[weakSelf onButtonPressed:weakSelf.answerCallButton];
};
incomingCallView.onReject = ^{
[weakSelf onButtonPressed:weakSelf.rejectCallButton];
};
return incomingCallView;
}
- (void)showAudioDeviceOptions
{
MXiOSAudioOutputRouter *router = self.mxCall.audioOutputRouter;
if (router.isAnyExternalDeviceConnected)
{
self.slidingModalPresenter = [SlidingModalPresenter new];
_audioRoutesMenuView = [[CallAudioRouteMenuView alloc] initWithRoutes:router.availableOutputRoutes
currentRoute:router.currentRoute];
_audioRoutesMenuView.delegate = self;
[self.slidingModalPresenter presentView:_audioRoutesMenuView
from:self
animated:true
options:SlidingModalPresenter.CenterInScreenOption
completion:nil];
}
else
{
// toggle between built-in and loud speakers
switch (router.currentRoute.routeType)
{
case MXiOSAudioOutputRouteTypeBuiltIn:
[router changeCurrentRouteTo:router.loudSpeakersRoute];
break;
case MXiOSAudioOutputRouteTypeLoudSpeakers:
[router changeCurrentRouteTo:router.builtInRoute];
break;
default:
break;
}
}
}
- (void)configureSpeakerButton
{
switch (self.mxCall.audioOutputRouter.currentRoute.routeType)
{
case MXiOSAudioOutputRouteTypeBuiltIn:
[self.speakerButton setImage:AssetImages.callSpeakerOffIcon.image
forState:UIControlStateNormal];
break;
case MXiOSAudioOutputRouteTypeLoudSpeakers:
[self.speakerButton setImage:AssetImages.callSpeakerOnIcon.image
forState:UIControlStateNormal];
break;
case MXiOSAudioOutputRouteTypeExternalWired:
case MXiOSAudioOutputRouteTypeExternalBluetooth:
case MXiOSAudioOutputRouteTypeExternalCar:
[self.speakerButton setImage:AssetImages.callSpeakerExternalIcon.image
forState:UIControlStateNormal];
break;
}
}
#pragma mark - MXCallDelegate
- (void)call:(MXCall *)call stateDidChange:(MXCallState)state reason:(MXEvent *)event
{
[super call:call stateDidChange:state reason:event];
[self configurePiPView];
[self checkStunServerFallbackWithCallState:state];
}
- (void)call:(MXCall *)call didEncounterError:(NSError *)error reason:(MXCallHangupReason)reason
{
if ([error.domain isEqualToString:MXEncryptingErrorDomain]
&& error.code == MXEncryptingErrorUnknownDeviceCode)
{
// There are unknown devices, check what the user wants to do
__weak __typeof(self) weakSelf = self;
MXUsersDevicesMap<MXDeviceInfo*> *unknownDevices = error.userInfo[MXEncryptingErrorUnknownDeviceDevicesKey];
[currentAlert dismissViewControllerAnimated:NO completion:nil];
currentAlert = [UIAlertController alertControllerWithTitle:[VectorL10n unknownDevicesAlertTitle]
message:[VectorL10n unknownDevicesAlert]
preferredStyle:UIAlertControllerStyleAlert];
[currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n unknownDevicesVerify]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
if (weakSelf)
{
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Get the UsersDevicesViewController from the storyboard
UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main" bundle:[NSBundle mainBundle]];
UsersDevicesViewController *usersDevicesViewController = [storyboard instantiateViewControllerWithIdentifier:@"UsersDevicesViewControllerStoryboardId"];
[usersDevicesViewController displayUsersDevices:unknownDevices andMatrixSession:self.mainSession onComplete:^(BOOL doneButtonPressed) {
if (doneButtonPressed)
{
// Retry the call
if (call.isIncoming)
{
[call answer];
}
else
{
[call callWithVideo:call.isVideoCall];
}
}
else
{
// Ignore the call
[call hangupWithReason:reason];
}
}];
// Show this screen within a navigation controller
UINavigationController *usersDevicesNavigationController = [[RiotNavigationController alloc] init];
// Set Riot navigation bar colors
[ThemeService.shared.theme applyStyleOnNavigationBar:usersDevicesNavigationController.navigationBar];
usersDevicesNavigationController.navigationBar.barTintColor = ThemeService.shared.theme.backgroundColor;
[usersDevicesNavigationController pushViewController:usersDevicesViewController animated:NO];
[self presentViewController:usersDevicesNavigationController animated:YES completion:nil];
}
}]];
[currentAlert addAction:[UIAlertAction actionWithTitle:(call.isIncoming ? [VectorL10n unknownDevicesAnswerAnyway] : [VectorL10n unknownDevicesCallAnyway])
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
if (weakSelf)
{
typeof(self) self = weakSelf;
self->currentAlert = nil;
// Retry the call
if (call.isIncoming)
{
[call answer];
}
else
{
[call callWithVideo:call.isVideoCall];
}
}
}]];
[currentAlert mxk_setAccessibilityIdentifier:@"CallVCUnknownDevicesAlert"];
[self presentViewController:currentAlert animated:YES completion:nil];
}
else
{
[super call:call didEncounterError:error reason:reason];
}
}
#pragma mark - Fallback STUN server
- (void)checkStunServerFallbackWithCallState:(MXCallState)callState
{
// Detect if we should display the prompt to fallback to the STUN server defined
// in the app plist if the homeserver does not provide STUN or TURN servers.
// We should display it if the call ends while we were in connecting state
if (!self.mainSession.callManager.turnServers
&& !self.mainSession.callManager.fallbackSTUNServer
&& !RiotSettings.shared.isAllowStunServerFallbackHasBeenSetOnce)
{
switch (callState)
{
case MXCallStateConnecting:
promptForStunServerFallback = YES;
break;
case MXCallStateConnected:
promptForStunServerFallback = NO;
break;
case MXCallStateEnded:
if (promptForStunServerFallback)
{
_shouldPromptForStunServerFallback = YES;
}
default:
// There is nothing to do for other states
break;
}
}
}
#pragma mark - Properties
- (id<Theme>)overriddenTheme
{
if (_overriddenTheme == nil)
{
_overriddenTheme = [DarkTheme new];
}
return _overriddenTheme;
}
- (CallPiPView *)pipView
{
if (_pipView == nil)
{
_pipView = [CallPiPView instantiateWithSession:self.mainSession];
[_pipView updateWithTheme:self.overriddenTheme];
}
return _pipView;
}
- (void)setMxCallOnHold:(MXCall *)mxCallOnHold
{
[super setMxCallOnHold:mxCallOnHold];
[self configurePiPView];
}
- (UIImage*)picturePlaceholder
{
CGFloat fontSize = floor(self.callerImageViewWidthConstraint.constant * 0.7);
if (self.peer)
{
// Use the vector style placeholder
return [AvatarGenerator generateAvatarForMatrixItem:self.peer.userId
withDisplayName:self.peer.displayname
size:self.callerImageViewWidthConstraint.constant
andFontSize:fontSize];
}
else if (self.mxCall.room)
{
return [AvatarGenerator generateAvatarForMatrixItem:self.mxCall.room.roomId
withDisplayName:self.mxCall.room.summary.displayName
size:self.callerImageViewWidthConstraint.constant
andFontSize:fontSize];
}
return [MXKTools paintImage:AssetImages.placeholder.image
withColor:self.overriddenTheme.tintColor];
}
- (void)updatePeerInfoDisplay
{
[super updatePeerInfoDisplay];
NSString *peerAvatarURL;
if (self.peer)
{
peerAvatarURL = self.peer.avatarUrl;
}
else if (self.mxCall.isConferenceCall)
{
peerAvatarURL = self.mxCall.room.summary.avatar;
}
self.blurredCallerImageView.contentMode = UIViewContentModeScaleAspectFill;
self.callerImageView.contentMode = UIViewContentModeScaleAspectFill;
if (peerAvatarURL)
{
// Retrieve the avatar in full resolution
[self.blurredCallerImageView setImageURI:peerAvatarURL
withType:nil
andImageOrientation:UIImageOrientationUp
previewImage:self.picturePlaceholder
mediaManager:self.mainSession.mediaManager];
// Retrieve the avatar in full resolution
[self.callerImageView setImageURI:peerAvatarURL
withType:nil
andImageOrientation:UIImageOrientationUp
previewImage:self.picturePlaceholder
mediaManager:self.mainSession.mediaManager];
}
else
{
self.blurredCallerImageView.image = self.picturePlaceholder;
self.callerImageView.image = self.picturePlaceholder;
}
}
#pragma mark - Sounds
- (NSURL*)audioURLWithName:(NSString*)soundName
{
NSURL *audioUrl;
NSString *path = [[NSBundle mainBundle] pathForResource:soundName ofType:@"mp3"];
if (path)
{
audioUrl = [NSURL fileURLWithPath:path];
}
// Use by default the matrix kit sounds.
if (!audioUrl)
{
audioUrl = [super audioURLWithName:soundName];
}
return audioUrl;
}
#pragma mark - Actions
- (IBAction)onButtonPressed:(id)sender
{
if (sender == _chatButton)
{
if (self.delegate)
{
// Dismiss the view controller whereas the call is still running
[self.delegate dismissCallViewController:self completion:^{
if (self.mxCall.room)
{
// Open the room page
Analytics.shared.viewRoomTrigger = AnalyticsViewRoomTriggerInCall;
[[AppDelegate theDelegate] showRoom:self.mxCall.room.roomId andEventId:nil withMatrixSession:self.mxCall.room.mxSession];
}
}];
}
}
else
{
[super onButtonPressed:sender];
}
}
- (void)setInPiP:(BOOL)inPiP
{
_inPiP = inPiP;
if (_inPiP)
{
self.overlayContainerView.hidden = YES;
self.callerImageView.hidden = YES;
self.callerNameLabel.hidden = YES;
self.callStatusLabel.hidden = YES;
self.localPreviewContainerView.hidden = YES;
self.localPreviewActivityView.hidden = YES;
if (self.pipViewContainer.subviews.count == 0)
{
[self.pipViewContainer vc_addSubViewMatchingParent:self.pipView];
}
[self configurePiPView];
self.pipViewContainer.hidden = NO;
}
else
{
self.pipViewContainer.hidden = YES;
self.localPreviewContainerView.hidden = !self.mxCall.isVideoCall;
self.callerImageView.hidden = self.mxCall.isVideoCall && self.mxCall.state == MXCallStateConnected;
self.callerNameLabel.hidden = NO;
self.callStatusLabel.hidden = NO;
// show controls when coming back from PiP mode
[self showOverlayContainer:YES];
}
}
- (void)showOverlayContainer:(BOOL)isShown
{
if (self.inPiP)
{
return;
}
[super showOverlayContainer:isShown];
}
#pragma mark - DTMF
- (void)openDialpad
{
DialpadConfiguration *config = [[DialpadConfiguration alloc] initWithShowsTitle:YES
showsCloseButton:YES
showsBackspaceButton:NO
showsCallButton:NO
formattingEnabled:NO
editingEnabled:NO
playTones:YES];
DialpadViewController *controller = [DialpadViewController instantiateWithConfiguration:config];
controller.delegate = self;
self.customSizedPresentationController = [[CustomSizedPresentationController alloc] initWithPresentedViewController:controller presentingViewController:self];
self.customSizedPresentationController.dismissOnBackgroundTap = NO;
self.customSizedPresentationController.cornerRadius = 16;
controller.transitioningDelegate = self.customSizedPresentationController;
[self presentViewController:controller animated:YES completion:nil];
}
#pragma mark - Call Transfer
- (void)openCallTransfer
{
CallTransferMainViewController *controller = [CallTransferMainViewController instantiateWithSession:self.mainSession ignoredUserIds:@[self.peer.userId]];
controller.delegate = self;
UINavigationController *navController = [[RiotNavigationController alloc] initWithRootViewController:controller];
[self.mxCall hold:YES];
[self presentViewController:navController animated:YES completion:nil];
}
#pragma mark - DialpadViewControllerDelegate
- (void)dialpadViewControllerDidTapClose:(DialpadViewController *)viewController
{
[viewController dismissViewControllerAnimated:YES completion:nil];
self.customSizedPresentationController = nil;
}
- (void)dialpadViewControllerDidTapDigit:(DialpadViewController *)viewController digit:(NSString *)digit
{
if (digit.length == 0)
{
return;
}
BOOL result = [self.mxCall sendDTMF:digit];
MXLogDebug(@"[CallViewController] Sending DTMF tones %@", result ? @"succeeded": @"failed");
}
#pragma mark - CallTransferMainViewControllerDelegate
- (void)callTransferMainViewControllerDidComplete:(CallTransferMainViewController *)viewController consult:(BOOL)consult contact:(MXKContact *)contact phoneNumber:(NSString *)phoneNumber
{
[viewController dismissViewControllerAnimated:YES completion:nil];
void(^failureBlock)(NSError *_Nullable) = ^(NSError *error) {
[self->currentAlert dismissViewControllerAnimated:NO completion:nil];
MXWeakify(self);
self->currentAlert = [UIAlertController alertControllerWithTitle:[VectorL10n callTransferErrorTitle]
message:[VectorL10n callTransferErrorMessage]
preferredStyle:UIAlertControllerStyleAlert];
[self->currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n ok]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
self->currentAlert = nil;
}]];
[self presentViewController:self->currentAlert animated:YES completion:nil];
};
void(^continueBlock)(NSString *_Nonnull) = ^(NSString *targetUserId) {
MXUserModel *targetUser = [[MXUserModel alloc] initWithUserId:targetUserId
displayname:contact.displayName
avatarUrl:contact.matrixAvatarURL];
MXUserModel *transfereeUser = [[MXUserModel alloc] initWithUser:self.peer];
[self.mainSession.callManager transferCall:self.mxCall
to:targetUser
withTransferee:transfereeUser
consultFirst:consult
success:^(NSString * _Nonnull newCallId){
MXLogDebug(@"Call transfer succeeded with new call ID: %@", newCallId);
} failure:^(NSError * _Nullable error) {
MXLogDebug(@"Call transfer failed with error: %@", error);
failureBlock(error);
}];
};
if (contact)
{
continueBlock(contact.matrixIdentifiers.firstObject);
}
else if (phoneNumber)
{
MXWeakify(self);
[self.mainSession.callManager getThirdPartyUserFrom:phoneNumber success:^(MXThirdPartyUserInstance * _Nonnull user) {
if (weakself == nil) {
return;
}
continueBlock(user.userId);
} failure:^(NSError * _Nullable error) {
failureBlock(error);
}];
}
}
- (void)callTransferMainViewControllerDidCancel:(CallTransferMainViewController *)viewController
{
[self.mxCall hold:NO];
[viewController dismissViewControllerAnimated:YES completion:nil];
}
#pragma mark - PiP
- (void)configurePiPView
{
if (self.inPiP)
{
[self.pipView configureWithCall:self.mxCall
peer:self.peer
onHoldCall:self.mxCallOnHold
onHoldPeer:self.peerOnHold];
}
}
#pragma mark - PictureInPicturable
- (void)didEnterPiP
{
self.inPiP = YES;
}
- (void)willExitPiP
{
self.pipViewContainer.hidden = YES;
}
- (void)didExitPiP
{
self.inPiP = NO;
}
#pragma mark - CallAudioRouteMenuViewDelegate
- (void)callAudioRouteMenuView:(CallAudioRouteMenuView *)view didSelectRoute:(MXiOSAudioOutputRoute *)route
{
[self.mxCall.audioOutputRouter changeCurrentRouteTo:route];
[self.slidingModalPresenter dismissWithAnimated:YES completion:nil];
}
@end