/* 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 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 *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)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