/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. Copyright 2021 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; import { CallError, CallErrorCode, CallEvent, CallParty, CallState, CallType, MatrixCall, } from "matrix-js-sdk/src/webrtc/call"; import { logger } from 'matrix-js-sdk/src/logger'; import EventEmitter from 'events'; import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules"; import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; import { SyncState } from "matrix-js-sdk/src/sync"; import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import SettingsStore from './settings/SettingsStore'; import { WidgetType } from "./widgets/WidgetType"; import { SettingLevel } from "./settings/SettingLevel"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import WidgetStore from "./stores/WidgetStore"; import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; import { UIFeature } from "./settings/UIFeature"; import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import SdkConfig from './SdkConfig'; import { ensureDMExists } from './createRoom'; import { Container, WidgetLayoutStore } from './stores/widgets/WidgetLayoutStore'; import IncomingCallToast, { getIncomingCallToastKey } from './toasts/IncomingCallToast'; import ToastStore from './stores/ToastStore'; import Resend from './Resend'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { findDMForUser } from "./utils/direct-messages"; import { KIND_CALL_TRANSFER } from "./components/views/dialogs/InviteDialogTypes"; import { OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload"; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; export const PROTOCOL_SIP_NATIVE = 'im.vector.protocol.sip_native'; export const PROTOCOL_SIP_VIRTUAL = 'im.vector.protocol.sip_virtual'; const CHECK_PROTOCOLS_ATTEMPTS = 3; enum AudioID { Ring = 'ringAudio', Ringback = 'ringbackAudio', CallEnd = 'callendAudio', Busy = 'busyAudio', } interface ThirdpartyLookupResponseFields { /* eslint-disable camelcase */ // im.vector.sip_native virtual_mxid?: string; is_virtual?: boolean; // im.vector.sip_virtual native_mxid?: string; is_native?: boolean; // common lookup_success?: boolean; /* eslint-enable camelcase */ } interface ThirdpartyLookupResponse { userid: string; protocol: string; fields: ThirdpartyLookupResponseFields; } export enum CallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", SilencedCallsChanged = "silenced_calls_changed", CallState = "call_state", } /** * CallHandler manages all currently active calls. It should be used for * placing, answering, rejecting and hanging up calls. It also handles ringing, * PSTN support and other things. */ export default class CallHandler extends EventEmitter { private calls = new Map(); // roomId -> call // Calls started as an attended transfer, ie. with the intention of transferring another // call with a different party to this one. private transferees = new Map(); // callId (target) -> call (transferee) private audioPromises = new Map>(); private supportsPstnProtocol = null; private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native // Map of the asserted identity users after we've looked them up using the API. // We need to be be able to determine the mapped room synchronously, so we // do the async lookup when we get new information and then store these mappings here private assertedIdentityNativeUsers = new Map(); private silencedCalls = new Set(); // callIds public static get instance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler(); } return window.mxCallHandler; } /* * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room" * if a voip_mxid_translate_pattern is set in the config) */ public roomIdForCall(call: MatrixCall): string { if (!call) return null; // check asserted identity: if we're not obeying asserted identity, // this map will never be populated, but we check anyway for sanity if (this.shouldObeyAssertedfIdentity()) { const nativeUser = this.assertedIdentityNativeUsers[call.callId]; if (nativeUser) { const room = findDMForUser(MatrixClientPeg.get(), nativeUser); if (room) return room.roomId; } } return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId; } public start(): void { // add empty handlers for media actions, otherwise the media keys // end up causing the audio elements with our ring/ringback etc // audio clips in to play. if (navigator.mediaSession) { navigator.mediaSession.setActionHandler('play', function() {}); navigator.mediaSession.setActionHandler('pause', function() {}); navigator.mediaSession.setActionHandler('seekbackward', function() {}); navigator.mediaSession.setActionHandler('seekforward', function() {}); navigator.mediaSession.setActionHandler('previoustrack', function() {}); navigator.mediaSession.setActionHandler('nexttrack', function() {}); } if (SettingsStore.getValue(UIFeature.Voip)) { MatrixClientPeg.get().on(CallEventHandlerEvent.Incoming, this.onCallIncoming); } this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS); } public stop(): void { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener(CallEventHandlerEvent.Incoming, this.onCallIncoming); } } public silenceCall(callId: string): void { this.silencedCalls.add(callId); this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); // Don't pause audio if we have calls which are still ringing if (this.areAnyCallsUnsilenced()) return; this.pause(AudioID.Ring); } public unSilenceCall(callId: string): void { this.silencedCalls.delete(callId); this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); this.play(AudioID.Ring); } public isCallSilenced(callId: string): boolean { return this.silencedCalls.has(callId); } /** * Returns true if there is at least one unsilenced call * @returns {boolean} */ private areAnyCallsUnsilenced(): boolean { for (const call of this.calls.values()) { if ( call.state === CallState.Ringing && !this.isCallSilenced(call.callId) ) { return true; } } return false; } private async checkProtocols(maxTries: number): Promise { try { const protocols = await MatrixClientPeg.get().getThirdpartyProtocols(); if (protocols[PROTOCOL_PSTN] !== undefined) { this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN]); if (this.supportsPstnProtocol) this.pstnSupportPrefixed = false; } else if (protocols[PROTOCOL_PSTN_PREFIXED] !== undefined) { this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN_PREFIXED]); if (this.supportsPstnProtocol) this.pstnSupportPrefixed = true; } else { this.supportsPstnProtocol = null; } dis.dispatch({ action: Action.PstnSupportUpdated }); if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) { this.supportsSipNativeVirtual = Boolean( protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL], ); } dis.dispatch({ action: Action.VirtualRoomSupportUpdated }); } catch (e) { if (maxTries === 1) { logger.log("Failed to check for protocol support and no retries remain: assuming no support", e); } else { logger.log("Failed to check for protocol support: will retry", e); setTimeout(() => { this.checkProtocols(maxTries - 1); }, 10000); } } } private shouldObeyAssertedfIdentity(): boolean { return SdkConfig.getObject("voip")?.get("obey_asserted_identity"); } public getSupportsPstnProtocol(): boolean { return this.supportsPstnProtocol; } public getSupportsVirtualRooms(): boolean { return this.supportsSipNativeVirtual; } public pstnLookup(phoneNumber: string): Promise { return MatrixClientPeg.get().getThirdpartyUser( this.pstnSupportPrefixed ? PROTOCOL_PSTN_PREFIXED : PROTOCOL_PSTN, { 'm.id.phone': phoneNumber, }, ); } public sipVirtualLookup(nativeMxid: string): Promise { return MatrixClientPeg.get().getThirdpartyUser( PROTOCOL_SIP_VIRTUAL, { 'native_mxid': nativeMxid, }, ); } public sipNativeLookup(virtualMxid: string): Promise { return MatrixClientPeg.get().getThirdpartyUser( PROTOCOL_SIP_NATIVE, { 'virtual_mxid': virtualMxid, }, ); } private onCallIncoming = (call: MatrixCall): void => { // if the runtime env doesn't do VoIP, stop here. if (!MatrixClientPeg.get().supportsVoip()) { return; } const mappedRoomId = CallHandler.instance.roomIdForCall(call); if (this.getCallForRoom(mappedRoomId)) { logger.log( "Got incoming call for room " + mappedRoomId + " but there's already a call for this room: ignoring", ); return; } this.addCallForRoom(mappedRoomId, call); this.setCallListeners(call); // Explicitly handle first state change this.onCallStateChanged(call.state, null, call); // get ready to send encrypted events in the room, so if the user does answer // the call, we'll be ready to send. NB. This is the protocol-level room ID not // the mapped one: that's where we'll send the events. const cli = MatrixClientPeg.get(); cli.prepareToEncrypt(cli.getRoom(call.roomId)); }; public getCallById(callId: string): MatrixCall { for (const call of this.calls.values()) { if (call.callId === callId) return call; } return null; } public getCallForRoom(roomId: string): MatrixCall | null { return this.calls.get(roomId) || null; } public getAnyActiveCall(): MatrixCall | null { for (const call of this.calls.values()) { if (call.state !== CallState.Ended) { return call; } } return null; } public getAllActiveCalls(): MatrixCall[] { const activeCalls = []; for (const call of this.calls.values()) { if (call.state !== CallState.Ended && call.state !== CallState.Ringing) { activeCalls.push(call); } } return activeCalls; } public getAllActiveCallsNotInRoom(notInThisRoomId: string): MatrixCall[] { const callsNotInThatRoom = []; for (const [roomId, call] of this.calls.entries()) { if (roomId !== notInThisRoomId && call.state !== CallState.Ended) { callsNotInThatRoom.push(call); } } return callsNotInThatRoom; } public getAllActiveCallsForPip(roomId: string) { const room = MatrixClientPeg.get().getRoom(roomId); if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { // This checks if there is space for the call view in the aux panel // If there is no space any call should be displayed in PiP return this.getAllActiveCalls(); } return this.getAllActiveCallsNotInRoom(roomId); } public getTransfereeForCallId(callId: string): MatrixCall { return this.transferees[callId]; } public play(audioId: AudioID): void { const logPrefix = `CallHandler.play(${audioId}):`; logger.debug(`${logPrefix} beginning of function`); // TODO: Attach an invisible element for this instead // which listens? const audio = document.getElementById(audioId) as HTMLMediaElement; if (audio) { const playAudio = async () => { try { // This still causes the chrome debugger to break on promise rejection if // the promise is rejected, even though we're catching the exception. logger.debug(`${logPrefix} attempting to play audio`); await audio.play(); logger.debug(`${logPrefix} playing audio successfully`); } catch (e) { // This is usually because the user hasn't interacted with the document, // or chrome doesn't think so and is denying the request. Not sure what // we can really do here... // https://github.com/vector-im/element-web/issues/7657 logger.warn(`${logPrefix} unable to play audio clip`, e); } }; if (this.audioPromises.has(audioId)) { this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => { audio.load(); return playAudio(); })); } else { this.audioPromises.set(audioId, playAudio()); } } else { logger.warn(`${logPrefix} unable to find