// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { DesktopCapturerSource } from 'electron'; import { ipcRenderer } from 'electron'; import type { AudioDevice, CallId, DeviceId, PeekInfo, UserId, VideoFrameSource, VideoRequest, } from '@signalapp/ringrtc'; import { AnswerMessage, BusyMessage, Call, CallingMessage, CallMessageUrgency, CallLogLevel, CallState, CanvasVideoRenderer, ConnectionState, DataMode, JoinState, HttpMethod, GroupCall, GroupMemberInfo, GumVideoCapturer, HangupMessage, HangupType, IceCandidateMessage, OfferMessage, OpaqueMessage, RingCancelReason, RingRTC, RingUpdate, } from '@signalapp/ringrtc'; import { uniqBy, noop } from 'lodash'; import Long from 'long'; import * as RemoteConfig from '../RemoteConfig'; import type { ActionsType as CallingReduxActionsType, GroupCallParticipantInfoType, GroupCallPeekInfoType, } from '../state/ducks/calling'; import type { ConversationType } from '../state/ducks/conversations'; import { getConversationCallMode } from '../state/ducks/conversations'; import { isMe } from '../util/whatTypeOfConversation'; import type { AvailableIODevicesType, CallEndedReason, MediaDeviceSettings, PresentableSource, PresentedSource, } from '../types/Calling'; import { CallMode, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; import { AudioDeviceModule, getAudioDeviceModule, parseAudioDeviceModule, } from '../calling/audioDeviceModule'; import { findBestMatchingAudioDeviceIndex, findBestMatchingCameraId, } from '../calling/findBestMatchingDevice'; import type { LocalizerType } from '../types/Util'; import { normalizeAci } from '../util/normalizeAci'; import { isAciString } from '../util/isAciString'; import * as Errors from '../types/errors'; import type { ConversationModel } from '../models/conversations'; import * as Bytes from '../Bytes'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes'; import { drop } from '../util/drop'; import { dropNull } from '../util/dropNull'; import { getOwn } from '../util/getOwn'; import * as durations from '../util/durations'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { handleMessageSend } from '../util/handleMessageSend'; import { fetchMembershipProof, getMembershipList } from '../groups'; import { wrapWithSyncMessageSend } from '../util/wrapWithSyncMessageSend'; import type { ProcessedEnvelope } from '../textsecure/Types.d'; import { missingCaseError } from '../util/missingCaseError'; import { normalizeGroupCallTimestamp } from '../util/ringrtc/normalizeGroupCallTimestamp'; import { AUDIO_LEVEL_INTERVAL_MS, REQUESTED_VIDEO_WIDTH, REQUESTED_VIDEO_HEIGHT, REQUESTED_VIDEO_FRAMERATE, } from '../calling/constants'; import { callingMessageToProto } from '../util/callingMessageToProto'; import { getSendOptions } from '../util/getSendOptions'; import { requestMicrophonePermissions } from '../util/requestMicrophonePermissions'; import OS from '../util/os/osMain'; import { SignalService as Proto } from '../protobuf'; import dataInterface from '../sql/Client'; import { notificationService, NotificationSetting, FALLBACK_NOTIFICATION_TITLE, NotificationType, } from './notifications'; import * as log from '../logging/log'; import { assertDev, strictAssert } from '../util/assert'; import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup'; import { formatLocalDeviceState, formatPeekInfo, getPeerIdFromConversation, getLocalCallEventFromCallEndedReason, getCallDetailsFromEndedDirectCall, getCallEventDetails, getLocalCallEventFromJoinState, getLocalCallEventFromDirectCall, getCallDetailsFromDirectCall, getCallDetailsFromGroupCallMeta, updateCallHistoryFromLocalEvent, getGroupCallMeta, getCallIdFromRing, getLocalCallEventFromRingUpdate, convertJoinState, } from '../util/callDisposition'; import { isNormalNumber } from '../util/isNormalNumber'; import { LocalCallEvent } from '../types/CallDisposition'; import { isInSystemContacts } from '../util/isInSystemContacts'; const { processGroupCallRingCancellation, cleanExpiredGroupCallRingCancellations, wasGroupCallRingPreviouslyCanceled, } = dataInterface; const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map< HttpMethod, 'GET' | 'PUT' | 'POST' | 'DELETE' > = new Map([ [HttpMethod.Get, 'GET'], [HttpMethod.Put, 'PUT'], [HttpMethod.Post, 'POST'], [HttpMethod.Delete, 'DELETE'], ]); const CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = 10 * durations.MINUTE; // We send group call update messages to tell other clients to peek, which triggers // notifications, timeline messages, big green "Join" buttons, and so on. This enum // represents the three possible states we can be in. This helps ensure that we don't // send an update on disconnect if we never sent one when we joined. enum GroupCallUpdateMessageState { SentNothing, SentJoin, SentLeft, } type CallingReduxInterface = Pick< CallingReduxActionsType, | 'callStateChange' | 'cancelIncomingGroupCallRing' | 'groupCallAudioLevelsChange' | 'groupCallRaisedHandsChange' | 'groupCallStateChange' | 'outgoingCall' | 'receiveGroupCallReactions' | 'receiveIncomingDirectCall' | 'receiveIncomingGroupCall' | 'refreshIODevices' | 'remoteSharingScreenChange' | 'remoteVideoChange' | 'setPresenting' | 'startCallingLobby' | 'peekNotConnectedGroupCall' > & { areAnyCallsActiveOrRinging(): boolean; }; function isScreenSource(source: PresentedSource): boolean { return source.id.startsWith('screen'); } function truncateForLogging(name: string | undefined): string | undefined { if (!name || name.length <= 4) { return name; } return `${name.slice(0, 2)}...${name.slice(-2)}`; } function cleanForLogging(settings?: MediaDeviceSettings): unknown { if (!settings) { return settings; } return { availableCameras: settings.availableCameras.map(camera => { const { deviceId, kind, label, groupId } = camera; return { deviceId, kind, label: truncateForLogging(label), groupId, }; }), availableMicrophones: settings.availableMicrophones.map(device => { return truncateForLogging(device.name); }), availableSpeakers: settings.availableSpeakers.map(device => { return truncateForLogging(device.name); }), selectedMicrophone: truncateForLogging(settings.selectedMicrophone?.name), selectedSpeaker: truncateForLogging(settings.selectedSpeaker?.name), selectedCamera: settings.selectedCamera, }; } function translateSourceName( i18n: LocalizerType, source: PresentedSource ): string { const { name } = source; if (!isScreenSource(source)) { return name; } if (name === 'Entire Screen') { return i18n('icu:calling__SelectPresentingSourcesModal--entireScreen'); } const match = name.match(/^Screen (\d+)$/); if (match) { return i18n('icu:calling__SelectPresentingSourcesModal--screen', { id: match[1], }); } return name; } function protoToCallingMessage({ offer, answer, iceCandidates, legacyHangup, busy, hangup, supportsMultiRing, destinationDeviceId, opaque, }: Proto.ICallingMessage): CallingMessage { const newIceCandidates: Array = []; if (iceCandidates) { iceCandidates.forEach(candidate => { if (candidate.callId && candidate.opaque) { newIceCandidates.push( new IceCandidateMessage( candidate.callId, Buffer.from(candidate.opaque) ) ); } }); } return { offer: offer && offer.callId && offer.opaque ? new OfferMessage( offer.callId, dropNull(offer.type) as number, Buffer.from(offer.opaque) ) : undefined, answer: answer && answer.callId && answer.opaque ? new AnswerMessage(answer.callId, Buffer.from(answer.opaque)) : undefined, iceCandidates: newIceCandidates.length > 0 ? newIceCandidates : undefined, legacyHangup: legacyHangup && legacyHangup.callId ? new HangupMessage( legacyHangup.callId, dropNull(legacyHangup.type) as number, legacyHangup.deviceId || 0 ) : undefined, busy: busy && busy.callId ? new BusyMessage(busy.callId) : undefined, hangup: hangup && hangup.callId ? new HangupMessage( hangup.callId, dropNull(hangup.type) as number, hangup.deviceId || 0 ) : undefined, supportsMultiRing: dropNull(supportsMultiRing), destinationDeviceId: dropNull(destinationDeviceId), opaque: opaque ? { data: opaque.data ? Buffer.from(opaque.data) : undefined, } : undefined, }; } export class CallingClass { readonly videoCapturer: GumVideoCapturer; readonly videoRenderer: CanvasVideoRenderer; private reduxInterface?: CallingReduxInterface; public _sfuUrl?: string; private lastMediaDeviceSettings?: MediaDeviceSettings; private previousAudioDeviceModule?: AudioDeviceModule; private currentAudioDeviceModule?: AudioDeviceModule; private deviceReselectionTimer?: NodeJS.Timeout; private callsByConversation: { [conversationId: string]: Call | GroupCall }; private hadLocalVideoBeforePresenting?: boolean; constructor() { this.videoCapturer = new GumVideoCapturer({ maxWidth: REQUESTED_VIDEO_WIDTH, maxHeight: REQUESTED_VIDEO_HEIGHT, maxFramerate: REQUESTED_VIDEO_FRAMERATE, }); this.videoRenderer = new CanvasVideoRenderer(); this.callsByConversation = {}; } initialize(reduxInterface: CallingReduxInterface, sfuUrl: string): void { this.reduxInterface = reduxInterface; if (!reduxInterface) { throw new Error('CallingClass.initialize: Invalid uxActions.'); } this._sfuUrl = sfuUrl; this.previousAudioDeviceModule = parseAudioDeviceModule( window.storage.get('previousAudioDeviceModule') ); this.currentAudioDeviceModule = getAudioDeviceModule(); drop( window.storage.put( 'previousAudioDeviceModule', this.currentAudioDeviceModule ) ); RingRTC.setConfig({ use_new_audio_device_module: this.currentAudioDeviceModule === AudioDeviceModule.WindowsAdm2, field_trials: undefined, }); RingRTC.handleOutgoingSignaling = this.handleOutgoingSignaling.bind(this); RingRTC.handleIncomingCall = this.handleIncomingCall.bind(this); RingRTC.handleStartCall = this.handleStartCall.bind(this); RingRTC.handleAutoEndedIncomingCallRequest = this.handleAutoEndedIncomingCallRequest.bind(this); RingRTC.handleLogMessage = this.handleLogMessage.bind(this); RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this); RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this); RingRTC.handleSendCallMessageToGroup = this.handleSendCallMessageToGroup.bind(this); RingRTC.handleGroupCallRingUpdate = this.handleGroupCallRingUpdate.bind(this); this.attemptToGiveOurServiceIdToRingRtc(); window.Whisper.events.on('userChanged', () => { this.attemptToGiveOurServiceIdToRingRtc(); }); ipcRenderer.on('stop-screen-share', () => { reduxInterface.setPresenting(); }); void this.cleanExpiredGroupCallRingsAndLoop(); if (process.platform === 'darwin') { drop(this.enumerateMediaDevices()); } } private attemptToGiveOurServiceIdToRingRtc(): void { const ourAci = window.textsecure.storage.user.getAci(); if (!ourAci) { // This can happen if we're not linked. It's okay if we hit this case. return; } RingRTC.setSelfUuid(Buffer.from(uuidToBytes(ourAci))); } async startCallingLobby({ conversation, hasLocalAudio, hasLocalVideo, }: Readonly<{ conversation: Readonly; hasLocalAudio: boolean; hasLocalVideo: boolean; }>): Promise< | undefined | ({ hasLocalAudio: boolean; hasLocalVideo: boolean } & ( | { callMode: CallMode.Direct } | { callMode: CallMode.Group; connectionState: GroupCallConnectionState; joinState: GroupCallJoinState; peekInfo?: GroupCallPeekInfoType; remoteParticipants: Array; } )) > { log.info('CallingClass.startCallingLobby()'); const callMode = getConversationCallMode(conversation); switch (callMode) { case null: log.error('Conversation does not support calls, new call not allowed.'); return; case CallMode.Direct: { const conversationModel = window.ConversationController.get( conversation.id ); if ( !conversationModel || !this.getRemoteUserIdFromConversation(conversationModel) ) { log.error('Missing remote user identifier, new call not allowed.'); return; } break; } case CallMode.Group: break; default: throw missingCaseError(callMode); } if (!this.reduxInterface) { log.error('Missing uxActions, new call not allowed.'); return; } if (!this.localDeviceId) { log.error('Missing local device identifier, new call not allowed.'); return; } const haveMediaPermissions = await this.requestPermissions(hasLocalVideo); if (!haveMediaPermissions) { log.info('Permissions were denied, new call not allowed.'); return; } log.info('CallingClass.startCallingLobby(): Starting lobby'); // It's important that this function comes before any calls to // `videoCapturer.enableCapture` or `videoCapturer.enableCaptureAndSend` because of // a small RingRTC bug. // // If we tell RingRTC to start capturing video (with those methods or with // `RingRTC.setPreferredDevice`, which also captures video) multiple times in quick // succession, it will call the asynchronous `getUserMedia` twice. It'll save the // results in the same variable, which means the first call can be overridden. // Later, when we try to turn the camera off, we'll only disable the *second* result // of `getUserMedia` and the camera will stay on. // // We get around this by `await`ing, making sure we're all done with `getUserMedia`, // and then continuing. // // We should be able to move this below `this.connectGroupCall` once that RingRTC bug // is fixed. See DESKTOP-1032. await this.startDeviceReselectionTimer(); const enableLocalCameraIfNecessary = hasLocalVideo ? () => this.enableLocalCamera() : noop; switch (callMode) { case CallMode.Direct: // We could easily support this in the future if we need to. assertDev( hasLocalAudio, 'Expected local audio to be enabled for direct call lobbies' ); enableLocalCameraIfNecessary(); return { callMode: CallMode.Direct, hasLocalAudio, hasLocalVideo, }; case CallMode.Group: { if ( !conversation.groupId || !conversation.publicParams || !conversation.secretParams ) { log.error( 'Conversation is missing required parameters. Cannot connect group call' ); return; } const groupCall = this.connectGroupCall(conversation.id, { groupId: conversation.groupId, publicParams: conversation.publicParams, secretParams: conversation.secretParams, }); groupCall.setOutgoingAudioMuted(!hasLocalAudio); groupCall.setOutgoingVideoMuted(!hasLocalVideo); enableLocalCameraIfNecessary(); return { callMode: CallMode.Group, ...this.formatGroupCallForRedux(groupCall), }; } default: throw missingCaseError(callMode); } } stopCallingLobby(conversationId?: string): void { this.disableLocalVideo(); this.stopDeviceReselectionTimer(); this.lastMediaDeviceSettings = undefined; if (conversationId) { this.getGroupCall(conversationId)?.disconnect(); } } async startOutgoingDirectCall( conversationId: string, hasLocalAudio: boolean, hasLocalVideo: boolean ): Promise { log.info('CallingClass.startOutgoingDirectCall()'); if (!this.reduxInterface) { throw new Error('Redux actions not available'); } const conversation = window.ConversationController.get(conversationId); if (!conversation) { log.error('Could not find conversation, cannot start call'); this.stopCallingLobby(); return; } const remoteUserId = this.getRemoteUserIdFromConversation(conversation); if (!remoteUserId || !this.localDeviceId) { log.error('Missing identifier, new call not allowed.'); this.stopCallingLobby(); return; } const haveMediaPermissions = await this.requestPermissions(hasLocalVideo); if (!haveMediaPermissions) { log.info('Permissions were denied, new call not allowed.'); this.stopCallingLobby(); return; } log.info('CallingClass.startOutgoingDirectCall(): Getting call settings'); // Check state after awaiting to debounce call button. if (RingRTC.call && RingRTC.call.state !== CallState.Ended) { log.info('Call already in progress, new call not allowed.'); this.stopCallingLobby(); return; } log.info('CallingClass.startOutgoingDirectCall(): Starting in RingRTC'); const call = RingRTC.startOutgoingCall( remoteUserId, hasLocalVideo, this.localDeviceId ); RingRTC.setOutgoingAudio(call.callId, hasLocalAudio); RingRTC.setVideoCapturer(call.callId, this.videoCapturer); RingRTC.setVideoRenderer(call.callId, this.videoRenderer); this.attachToCall(conversation, call); this.reduxInterface.outgoingCall({ conversationId: conversation.id, hasLocalAudio, hasLocalVideo, }); await this.startDeviceReselectionTimer(); } private getDirectCall(conversationId: string): undefined | Call { const call = getOwn(this.callsByConversation, conversationId); return call instanceof Call ? call : undefined; } private getGroupCall(conversationId: string): undefined | GroupCall { const call = getOwn(this.callsByConversation, conversationId); return call instanceof GroupCall ? call : undefined; } private getGroupCallMembers(conversationId: string) { return getMembershipList(conversationId).map( member => new GroupMemberInfo( Buffer.from(uuidToBytes(member.aci)), Buffer.from(member.uuidCiphertext) ) ); } public async peekGroupCall(conversationId: string): Promise { // This can be undefined in two cases: // // 1. There is no group call instance. This is "stateless peeking", and is expected // when we want to peek on a call that we've never connected to. // 2. There is a group call instance but RingRTC doesn't have the peek info yet. This // should only happen for a brief period as you connect to the call. (You probably // don't want to call this function while a group call is connected—you should // instead be grabbing the peek info off of the instance—but we handle it here // to avoid possible race conditions.) const statefulPeekInfo = this.getGroupCall(conversationId)?.getPeekInfo(); if (statefulPeekInfo) { return statefulPeekInfo; } if (!this._sfuUrl) { throw new Error('Missing SFU URL; not peeking group call'); } const conversation = window.ConversationController.get(conversationId); if (!conversation) { throw new Error('Missing conversation; not peeking group call'); } const publicParams = conversation.get('publicParams'); const secretParams = conversation.get('secretParams'); if (!publicParams || !secretParams) { throw new Error( 'Conversation is missing required parameters. Cannot peek group call' ); } const proof = await fetchMembershipProof({ publicParams, secretParams }); if (!proof) { throw new Error('No membership proof. Cannot peek group call'); } const membershipProof = Bytes.fromString(proof); return RingRTC.peekGroupCall( this._sfuUrl, Buffer.from(membershipProof), this.getGroupCallMembers(conversationId) ); } /** * Connect to a conversation's group call and connect it to Redux. * * Should only be called with group call-compatible conversations. * * Idempotent. */ connectGroupCall( conversationId: string, { groupId, publicParams, secretParams, }: { groupId: string; publicParams: string; secretParams: string; } ): GroupCall { const existing = this.getGroupCall(conversationId); if (existing) { const isExistingCallNotConnected = existing.getLocalDeviceState().connectionState === ConnectionState.NotConnected; if (isExistingCallNotConnected) { existing.connect(); } return existing; } if (!this._sfuUrl) { throw new Error('Missing SFU URL; not connecting group call'); } const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId)); let updateMessageState = GroupCallUpdateMessageState.SentNothing; let isRequestingMembershipProof = false; const outerGroupCall = RingRTC.getGroupCall( groupIdBuffer, this._sfuUrl, Buffer.alloc(0), AUDIO_LEVEL_INTERVAL_MS, { onLocalDeviceStateChanged: groupCall => { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo() ?? null; log.info( 'GroupCall#onLocalDeviceStateChanged', formatLocalDeviceState(localDeviceState), peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)' ); const groupCallMeta = getGroupCallMeta(peekInfo); if (groupCallMeta != null) { try { const localCallEvent = getLocalCallEventFromJoinState( convertJoinState(localDeviceState.joinState), groupCallMeta ); if (localCallEvent != null && peekInfo != null) { const conversation = window.ConversationController.get(conversationId); strictAssert( conversation != null, 'GroupCall#onLocalDeviceStateChanged: Missing conversation' ); const peerId = getPeerIdFromConversation( conversation.attributes ); const callDetails = getCallDetailsFromGroupCallMeta( peerId, groupCallMeta ); const callEvent = getCallEventDetails( callDetails, localCallEvent ); drop(updateCallHistoryFromLocalEvent(callEvent, null)); } } catch (error) { log.error( 'GroupCall#onLocalDeviceStateChanged: Error updating state', Errors.toLogFormat(error) ); } } if ( localDeviceState.connectionState === ConnectionState.NotConnected ) { // NOTE: This assumes that only one call is active at a time. For example, if // there are two calls using the camera, this will disable both of them. // That's fine for now, but this will break if that assumption changes. this.disableLocalVideo(); delete this.callsByConversation[conversationId]; if ( updateMessageState === GroupCallUpdateMessageState.SentJoin && peekInfo?.eraId != null ) { updateMessageState = GroupCallUpdateMessageState.SentLeft; void this.sendGroupCallUpdateMessage( conversationId, peekInfo?.eraId ); } } else { this.callsByConversation[conversationId] = groupCall; // NOTE: This assumes only one active call at a time. See comment above. if (localDeviceState.videoMuted) { this.disableLocalVideo(); } else { this.videoCapturer.enableCaptureAndSend(groupCall); } if ( updateMessageState === GroupCallUpdateMessageState.SentNothing && localDeviceState.joinState === JoinState.Joined && peekInfo?.eraId != null ) { updateMessageState = GroupCallUpdateMessageState.SentJoin; void this.sendGroupCallUpdateMessage( conversationId, peekInfo?.eraId ); } } this.syncGroupCallToRedux(conversationId, groupCall); }, onRemoteDeviceStatesChanged: groupCall => { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo(); log.info( 'GroupCall#onRemoteDeviceStatesChanged', formatLocalDeviceState(localDeviceState), peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' ); this.syncGroupCallToRedux(conversationId, groupCall); }, onAudioLevels: groupCall => { const remoteDeviceStates = groupCall.getRemoteDeviceStates(); if (!remoteDeviceStates) { return; } const localAudioLevel = groupCall.getLocalDeviceState().audioLevel; this.reduxInterface?.groupCallAudioLevelsChange({ conversationId, localAudioLevel, remoteDeviceStates, }); }, onLowBandwidthForVideo: (_groupCall, _recovered) => { // TODO: Implement handling of "low outgoing bandwidth for video" notification. }, /** * @param reactions A list of reactions received by the client ordered * from oldest to newest. */ onReactions: (_groupCall, reactions) => { this.reduxInterface?.receiveGroupCallReactions({ conversationId, reactions, }); }, onRaisedHands: (_groupCall, raisedHands) => { this.reduxInterface?.groupCallRaisedHandsChange({ conversationId, raisedHands, }); }, onPeekChanged: groupCall => { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo() ?? null; log.info( 'GroupCall#onPeekChanged', formatLocalDeviceState(localDeviceState), peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' ); const { eraId } = peekInfo ?? {}; if ( updateMessageState === GroupCallUpdateMessageState.SentNothing && localDeviceState.connectionState !== ConnectionState.NotConnected && localDeviceState.joinState === JoinState.Joined && eraId ) { updateMessageState = GroupCallUpdateMessageState.SentJoin; void this.sendGroupCallUpdateMessage(conversationId, eraId); } void this.updateCallHistoryForGroupCall( conversationId, convertJoinState(localDeviceState.joinState), peekInfo ); this.syncGroupCallToRedux(conversationId, groupCall); }, async requestMembershipProof(groupCall) { if (isRequestingMembershipProof) { return; } isRequestingMembershipProof = true; try { const proof = await fetchMembershipProof({ publicParams, secretParams, }); if (proof) { groupCall.setMembershipProof( Buffer.from(Bytes.fromString(proof)) ); } } catch (err) { log.error('Failed to fetch membership proof', err); } finally { isRequestingMembershipProof = false; } }, requestGroupMembers: groupCall => { groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); }, onEnded: (groupCall, endedReason) => { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo(); log.info( 'GroupCall#onEnded', endedReason, formatLocalDeviceState(localDeviceState), peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' ); }, } ); if (!outerGroupCall) { // This should be very rare, likely due to RingRTC not being able to get a lock // or memory or something like that. throw new Error('Failed to get a group call instance; cannot start call'); } outerGroupCall.connect(); this.syncGroupCallToRedux(conversationId, outerGroupCall); return outerGroupCall; } public async joinGroupCall( conversationId: string, hasLocalAudio: boolean, hasLocalVideo: boolean, shouldRing: boolean ): Promise { const conversation = window.ConversationController.get(conversationId)?.format(); if (!conversation) { log.error('Missing conversation; not joining group call'); return; } if ( !conversation.groupId || !conversation.publicParams || !conversation.secretParams ) { log.error( 'Conversation is missing required parameters. Cannot join group call' ); return; } const haveMediaPermissions = await this.requestPermissions(hasLocalVideo); if (!haveMediaPermissions) { log.info('Permissions were denied, but allow joining group call'); } await this.startDeviceReselectionTimer(); const groupCall = this.connectGroupCall(conversationId, { groupId: conversation.groupId, publicParams: conversation.publicParams, secretParams: conversation.secretParams, }); groupCall.setOutgoingAudioMuted(!hasLocalAudio); groupCall.setOutgoingVideoMuted(!hasLocalVideo); this.videoCapturer.enableCaptureAndSend(groupCall); if (shouldRing) { groupCall.ringAll(); } groupCall.join(); } private getCallIdForConversation(conversationId: string): undefined | CallId { return this.getDirectCall(conversationId)?.callId; } public setGroupCallVideoRequest( conversationId: string, resolutions: Array, speakerHeight: number ): void { this.getGroupCall(conversationId)?.requestVideo(resolutions, speakerHeight); } public groupMembersChanged(conversationId: string): void { // This will be called for any conversation change, so it's likely that there won't // be a group call available; that's fine. const groupCall = this.getGroupCall(conversationId); if (!groupCall) { return; } groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); } // See the comment in types/Calling.ts to explain why we have to do this conversion. private convertRingRtcConnectionState( connectionState: ConnectionState ): GroupCallConnectionState { switch (connectionState) { case ConnectionState.NotConnected: return GroupCallConnectionState.NotConnected; case ConnectionState.Connecting: return GroupCallConnectionState.Connecting; case ConnectionState.Connected: return GroupCallConnectionState.Connected; case ConnectionState.Reconnecting: return GroupCallConnectionState.Reconnecting; default: throw missingCaseError(connectionState); } } // See the comment in types/Calling.ts to explain why we have to do this conversion. private convertRingRtcJoinState(joinState: JoinState): GroupCallJoinState { switch (joinState) { case JoinState.NotJoined: return GroupCallJoinState.NotJoined; case JoinState.Joining: return GroupCallJoinState.Joining; case JoinState.Pending: return GroupCallJoinState.Pending; case JoinState.Joined: return GroupCallJoinState.Joined; default: throw missingCaseError(joinState); } } public formatGroupCallPeekInfoForRedux( peekInfo: PeekInfo ): GroupCallPeekInfoType { const creatorAci = peekInfo.creator && bytesToUuid(peekInfo.creator); return { acis: peekInfo.devices.map(peekDeviceInfo => { if (peekDeviceInfo.userId) { const uuid = bytesToUuid(peekDeviceInfo.userId); if (uuid) { assertDev( isAciString(uuid), 'peeked participant uuid must be an ACI' ); return uuid; } log.error( 'Calling.formatGroupCallPeekInfoForRedux: could not convert peek UUID Uint8Array to string; using fallback UUID' ); } else { log.error( 'Calling.formatGroupCallPeekInfoForRedux: device had no user ID; using fallback UUID' ); } return normalizeAci( '00000000-0000-4000-8000-000000000000', 'formatGrouPCallPeekInfoForRedux' ); }), creatorAci: creatorAci !== undefined ? normalizeAci( creatorAci, 'formatGroupCallPeekInfoForRedux.creatorAci' ) : undefined, eraId: peekInfo.eraId, maxDevices: peekInfo.maxDevices ?? Infinity, deviceCount: peekInfo.deviceCount, }; } private formatGroupCallForRedux(groupCall: GroupCall) { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo(); // RingRTC doesn't ensure that the demux ID is unique. This can happen if someone // leaves the call and quickly rejoins; RingRTC will tell us that there are two // participants with the same demux ID in the call. This should be rare. const remoteDeviceStates = uniqBy( groupCall.getRemoteDeviceStates() || [], remoteDeviceState => remoteDeviceState.demuxId ); // It should be impossible to be disconnected and Joining or Joined. Just in case, we // try to handle that case. const joinState: GroupCallJoinState = localDeviceState.connectionState === ConnectionState.NotConnected ? GroupCallJoinState.NotJoined : this.convertRingRtcJoinState(localDeviceState.joinState); return { connectionState: this.convertRingRtcConnectionState( localDeviceState.connectionState ), joinState, hasLocalAudio: !localDeviceState.audioMuted, hasLocalVideo: !localDeviceState.videoMuted, localDemuxId: localDeviceState.demuxId, peekInfo: peekInfo ? this.formatGroupCallPeekInfoForRedux(peekInfo) : undefined, remoteParticipants: remoteDeviceStates.map(remoteDeviceState => { let aci = bytesToUuid(remoteDeviceState.userId); if (!aci) { log.error( 'Calling.formatGroupCallForRedux: could not convert remote participant UUID Uint8Array to string; using fallback UUID' ); aci = '00000000-0000-4000-8000-000000000000'; } assertDev(isAciString(aci), 'remote participant aci must be a aci'); return { aci, demuxId: remoteDeviceState.demuxId, hasRemoteAudio: !remoteDeviceState.audioMuted, hasRemoteVideo: !remoteDeviceState.videoMuted, presenting: Boolean(remoteDeviceState.presenting), sharingScreen: Boolean(remoteDeviceState.sharingScreen), speakerTime: normalizeGroupCallTimestamp( remoteDeviceState.speakerTime ), // If RingRTC doesn't send us an aspect ratio, we make a guess. videoAspectRatio: remoteDeviceState.videoAspectRatio || (remoteDeviceState.videoMuted ? 1 : 4 / 3), }; }), }; } public getGroupCallVideoFrameSource( conversationId: string, demuxId: number ): VideoFrameSource { const groupCall = this.getGroupCall(conversationId); if (!groupCall) { throw new Error('Could not find matching call'); } return groupCall.getVideoSource(demuxId); } public resendGroupCallMediaKeys(conversationId: string): void { const groupCall = this.getGroupCall(conversationId); if (!groupCall) { throw new Error('Could not find matching call'); } groupCall.resendMediaKeys(); } public sendGroupCallRaiseHand(conversationId: string, raise: boolean): void { const groupCall = this.getGroupCall(conversationId); if (!groupCall) { throw new Error('Could not find matching call'); } groupCall.raiseHand(raise); } public sendGroupCallReaction(conversationId: string, value: string): void { const groupCall = this.getGroupCall(conversationId); if (!groupCall) { throw new Error('Could not find matching call'); } groupCall.react(value); } private syncGroupCallToRedux( conversationId: string, groupCall: GroupCall ): void { this.reduxInterface?.groupCallStateChange({ conversationId, ...this.formatGroupCallForRedux(groupCall), }); } // Used specifically to send updates about in-progress group calls, nothing else private async sendGroupCallUpdateMessage( conversationId: string, eraId: string ): Promise { const conversation = window.ConversationController.get(conversationId); if (!conversation) { log.error( 'Unable to send group call update message for non-existent conversation' ); return; } const groupV2 = conversation.getGroupV2Info(); const sendOptions = await getSendOptions(conversation.attributes); if (!groupV2) { log.error( 'Unable to send group call update message for conversation that lacks groupV2 info' ); return; } const timestamp = Date.now(); // We "fire and forget" because sending this message is non-essential. const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; wrapWithSyncMessageSend({ conversation, logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`, messageIds: [], send: () => conversation.queueJob('sendGroupCallUpdateMessage', () => sendToGroup({ contentHint: ContentHint.DEFAULT, groupSendOptions: { groupCallUpdate: { eraId }, groupV2, timestamp, }, messageId: undefined, sendOptions, sendTarget: conversation.toSenderKeyTarget(), sendType: 'callingMessage', urgent: true, }) ), sendType: 'callingMessage', timestamp, }).catch(err => { log.error('Failed to send group call update:', Errors.toLogFormat(err)); }); } async acceptDirectCall( conversationId: string, asVideoCall: boolean ): Promise { log.info('CallingClass.acceptDirectCall()'); const callId = this.getCallIdForConversation(conversationId); if (!callId) { log.warn('Trying to accept a non-existent call'); return; } const haveMediaPermissions = await this.requestPermissions(asVideoCall); if (haveMediaPermissions) { await this.startDeviceReselectionTimer(); RingRTC.setVideoCapturer(callId, this.videoCapturer); RingRTC.setVideoRenderer(callId, this.videoRenderer); RingRTC.accept(callId, asVideoCall); } else { log.info('Permissions were denied, call not allowed, hanging up.'); RingRTC.hangup(callId); } } declineDirectCall(conversationId: string): void { log.info('CallingClass.declineDirectCall()'); const callId = this.getCallIdForConversation(conversationId); if (!callId) { log.warn('declineDirectCall: Trying to decline a non-existent call'); return; } RingRTC.decline(callId); } declineGroupCall(conversationId: string, ringId: bigint): void { log.info('CallingClass.declineGroupCall()'); const groupId = window.ConversationController.get(conversationId)?.get('groupId'); if (!groupId) { log.error( 'declineGroupCall: could not find the group ID for that conversation' ); return; } const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId)); RingRTC.cancelGroupRing( groupIdBuffer, ringId, RingCancelReason.DeclinedByUser ); } hangup(conversationId: string, reason: string): void { log.info(`CallingClass.hangup(${conversationId}): ${reason}`); const specificCall = getOwn(this.callsByConversation, conversationId); if (!specificCall) { log.error( `hangup: Trying to hang up a non-existent call for conversation ${conversationId}` ); } ipcRenderer.send('close-screen-share-controller'); const entries = Object.entries(this.callsByConversation); log.info(`hangup: ${entries.length} call(s) to hang up...`); entries.forEach(([callConversationId, call]) => { log.info(`hangup: Hanging up conversation ${callConversationId}`); if (call instanceof Call) { RingRTC.hangup(call.callId); } else if (call instanceof GroupCall) { // This ensures that we turn off our devices. call.setOutgoingAudioMuted(true); call.setOutgoingVideoMuted(true); call.disconnect(); } else { throw missingCaseError(call); } }); log.info('hangup: Done.'); } hangupAllCalls(reason: string): void { for (const conversationId of Object.keys(this.callsByConversation)) { this.hangup(conversationId, reason); } } setOutgoingAudio(conversationId: string, enabled: boolean): void { const call = getOwn(this.callsByConversation, conversationId); if (!call) { log.warn('Trying to set outgoing audio for a non-existent call'); return; } if (call instanceof Call) { RingRTC.setOutgoingAudio(call.callId, enabled); } else if (call instanceof GroupCall) { call.setOutgoingAudioMuted(!enabled); } else { throw missingCaseError(call); } } setOutgoingVideo(conversationId: string, enabled: boolean): void { const call = getOwn(this.callsByConversation, conversationId); if (!call) { log.warn('Trying to set outgoing video for a non-existent call'); return; } if (call instanceof Call) { RingRTC.setOutgoingVideo(call.callId, enabled); } else if (call instanceof GroupCall) { call.setOutgoingVideoMuted(!enabled); } else { throw missingCaseError(call); } } private setOutgoingVideoIsScreenShare( call: Call | GroupCall, enabled: boolean ): void { if (call instanceof Call) { RingRTC.setOutgoingVideoIsScreenShare(call.callId, enabled); // Note: there is no "presenting" API for direct calls. } else if (call instanceof GroupCall) { call.setOutgoingVideoIsScreenShare(enabled); call.setPresenting(enabled); } else { throw missingCaseError(call); } } async getPresentingSources(): Promise> { // There's a Linux Wayland Electron bug where requesting desktopCapturer. // getSources() with types as ['screen', 'window'] (the default) pops 2 // OS permissions dialogs in an unusable state (Dialog 1 for Share Window // is the foreground and ignores input; Dialog 2 for Share Screen is background // and requires input. As a workaround, request both sources sequentially. // https://github.com/signalapp/Signal-Desktop/issues/5350#issuecomment-1688614149 const sources: ReadonlyArray = OS.isLinux() && OS.isWaylandEnabled() ? ( await ipcRenderer.invoke('getScreenCaptureSources', ['screen']) ).concat( await ipcRenderer.invoke('getScreenCaptureSources', ['window']) ) : await ipcRenderer.invoke('getScreenCaptureSources'); const presentableSources: Array = []; sources.forEach(source => { // If electron can't retrieve a thumbnail then it won't be able to // present this source so we filter these out. if (source.thumbnail.isEmpty()) { return; } presentableSources.push({ appIcon: source.appIcon && !source.appIcon.isEmpty() ? source.appIcon.toDataURL() : undefined, id: source.id, name: translateSourceName(window.i18n, source), isScreen: isScreenSource(source), thumbnail: source.thumbnail.toDataURL(), }); }); return presentableSources; } async setPresenting( conversationId: string, hasLocalVideo: boolean, source?: PresentedSource ): Promise { const call = getOwn(this.callsByConversation, conversationId); if (!call) { log.warn('Trying to set presenting for a non-existent call'); return; } this.videoCapturer.disable(); if (source) { this.hadLocalVideoBeforePresenting = hasLocalVideo; const options = { // 15fps is much nicer but takes up a lot more CPU. maxFramerate: 5, maxHeight: 1080, maxWidth: 1920, screenShareSourceId: source.id, }; if (RemoteConfig.isEnabled('desktop.calling.sendScreenShare1800')) { options.maxWidth = 2880; options.maxHeight = 1800; } this.videoCapturer.enableCaptureAndSend(call, options); this.setOutgoingVideo(conversationId, true); } else { this.setOutgoingVideo( conversationId, this.hadLocalVideoBeforePresenting ?? hasLocalVideo ); this.hadLocalVideoBeforePresenting = undefined; } const isPresenting = Boolean(source); this.setOutgoingVideoIsScreenShare(call, isPresenting); if (source) { const conversation = window.ConversationController.get(conversationId); strictAssert(conversation, 'setPresenting: conversation not found'); const { url, absolutePath } = await conversation.getAvatarOrIdenticon(); ipcRenderer.send('show-screen-share', source.name); notificationService.notify({ conversationId, iconPath: absolutePath, iconUrl: url, message: window.i18n('icu:calling__presenting--notification-body'), type: NotificationType.IsPresenting, sentAt: 0, silent: true, title: window.i18n('icu:calling__presenting--notification-title'), }); } else { ipcRenderer.send('close-screen-share-controller'); } } private async startDeviceReselectionTimer(): Promise { // Poll once await this.pollForMediaDevices(); // Start the timer if (!this.deviceReselectionTimer) { this.deviceReselectionTimer = setInterval(async () => { await this.pollForMediaDevices(); }, 3000); } } private stopDeviceReselectionTimer() { clearTimeoutIfNecessary(this.deviceReselectionTimer); this.deviceReselectionTimer = undefined; } private mediaDeviceSettingsEqual( a?: MediaDeviceSettings, b?: MediaDeviceSettings ): boolean { if (!a && !b) { return true; } if (!a || !b) { return false; } if ( a.availableCameras.length !== b.availableCameras.length || a.availableMicrophones.length !== b.availableMicrophones.length || a.availableSpeakers.length !== b.availableSpeakers.length ) { return false; } for (let i = 0; i < a.availableCameras.length; i += 1) { if ( a.availableCameras[i].deviceId !== b.availableCameras[i].deviceId || a.availableCameras[i].groupId !== b.availableCameras[i].groupId || a.availableCameras[i].label !== b.availableCameras[i].label ) { return false; } } for (let i = 0; i < a.availableMicrophones.length; i += 1) { if ( a.availableMicrophones[i].name !== b.availableMicrophones[i].name || a.availableMicrophones[i].uniqueId !== b.availableMicrophones[i].uniqueId ) { return false; } } for (let i = 0; i < a.availableSpeakers.length; i += 1) { if ( a.availableSpeakers[i].name !== b.availableSpeakers[i].name || a.availableSpeakers[i].uniqueId !== b.availableSpeakers[i].uniqueId ) { return false; } } if ( (a.selectedCamera && !b.selectedCamera) || (!a.selectedCamera && b.selectedCamera) || (a.selectedMicrophone && !b.selectedMicrophone) || (!a.selectedMicrophone && b.selectedMicrophone) || (a.selectedSpeaker && !b.selectedSpeaker) || (!a.selectedSpeaker && b.selectedSpeaker) ) { return false; } if ( a.selectedCamera && b.selectedCamera && a.selectedCamera !== b.selectedCamera ) { return false; } if ( a.selectedMicrophone && b.selectedMicrophone && a.selectedMicrophone.index !== b.selectedMicrophone.index ) { return false; } if ( a.selectedSpeaker && b.selectedSpeaker && a.selectedSpeaker.index !== b.selectedSpeaker.index ) { return false; } return true; } private async pollForMediaDevices(): Promise { const newSettings = await this.getMediaDeviceSettings(); if ( !this.mediaDeviceSettingsEqual(this.lastMediaDeviceSettings, newSettings) ) { log.info( 'MediaDevice: available devices changed (from->to)', cleanForLogging(this.lastMediaDeviceSettings), cleanForLogging(newSettings) ); await this.selectPreferredDevices(newSettings); this.lastMediaDeviceSettings = newSettings; this.reduxInterface?.refreshIODevices(newSettings); } } async getAvailableIODevices(): Promise { const availableCameras = await this.videoCapturer.enumerateDevices(); const availableMicrophones = RingRTC.getAudioInputs(); const availableSpeakers = RingRTC.getAudioOutputs(); return { availableCameras, availableMicrophones, availableSpeakers, }; } async getMediaDeviceSettings(): Promise { const { previousAudioDeviceModule, currentAudioDeviceModule } = this; if (!previousAudioDeviceModule || !currentAudioDeviceModule) { throw new Error( 'Calling#getMediaDeviceSettings cannot be called before audio device settings are set' ); } const { availableCameras, availableMicrophones, availableSpeakers } = await this.getAvailableIODevices(); const preferredMicrophone = window.Events.getPreferredAudioInputDevice(); const selectedMicIndex = findBestMatchingAudioDeviceIndex({ available: availableMicrophones, preferred: preferredMicrophone, previousAudioDeviceModule, currentAudioDeviceModule, }); const selectedMicrophone = selectedMicIndex !== undefined ? availableMicrophones[selectedMicIndex] : undefined; const preferredSpeaker = window.Events.getPreferredAudioOutputDevice(); const selectedSpeakerIndex = findBestMatchingAudioDeviceIndex({ available: availableSpeakers, preferred: preferredSpeaker, previousAudioDeviceModule, currentAudioDeviceModule, }); const selectedSpeaker = selectedSpeakerIndex !== undefined ? availableSpeakers[selectedSpeakerIndex] : undefined; const preferredCamera = window.Events.getPreferredVideoInputDevice(); const selectedCamera = findBestMatchingCameraId( availableCameras, preferredCamera ); return { availableMicrophones, availableSpeakers, selectedMicrophone, selectedSpeaker, availableCameras, selectedCamera, }; } setPreferredMicrophone(device: AudioDevice): void { log.info( 'MediaDevice: setPreferredMicrophone', device.index, truncateForLogging(device.name) ); void window.Events.setPreferredAudioInputDevice(device); RingRTC.setAudioInput(device.index); } setPreferredSpeaker(device: AudioDevice): void { log.info( 'MediaDevice: setPreferredSpeaker', device.index, truncateForLogging(device.name) ); void window.Events.setPreferredAudioOutputDevice(device); RingRTC.setAudioOutput(device.index); } enableLocalCamera(): void { this.videoCapturer.enableCapture(); } disableLocalVideo(): void { this.videoCapturer.disable(); } async setPreferredCamera(device: string): Promise { log.info('MediaDevice: setPreferredCamera', device); void window.Events.setPreferredVideoInputDevice(device); await this.videoCapturer.setPreferredDevice(device); } async handleCallingMessage( envelope: ProcessedEnvelope, callingMessage: Proto.ICallingMessage ): Promise { log.info('CallingClass.handleCallingMessage()'); const enableIncomingCalls = window.Events.getIncomingCallNotification(); if (callingMessage.offer && !enableIncomingCalls) { // Drop offers silently if incoming call notifications are disabled. log.info('Incoming calls are disabled, ignoring call offer.'); return; } const remoteUserId = envelope.sourceServiceId; const remoteDeviceId = this.parseDeviceId(envelope.sourceDevice); if (!remoteUserId || !remoteDeviceId || !this.localDeviceId) { log.error('Missing identifier, ignoring call message.'); return; } const { storage } = window.textsecure; const senderIdentityRecord = await storage.protocol.getOrMigrateIdentityRecord(remoteUserId); if (!senderIdentityRecord) { log.error('Missing sender identity record; ignoring call message.'); return; } const senderIdentityKey = senderIdentityRecord.publicKey.slice(1); // Ignore the type header, it is not used. const ourAci = storage.user.getCheckedAci(); const receiverIdentityRecord = storage.protocol.getIdentityRecord(ourAci); if (!receiverIdentityRecord) { log.error('Missing receiver identity record; ignoring call message.'); return; } const receiverIdentityKey = receiverIdentityRecord.publicKey.slice(1); // Ignore the type header, it is not used. const conversation = window.ConversationController.get(remoteUserId); if (!conversation) { log.error('Missing conversation; ignoring call message.'); return; } if (callingMessage.offer && !conversation.getAccepted()) { log.info( 'Conversation was not approved by user; rejecting call message.' ); const { callId } = callingMessage.offer; assertDev(callId != null, 'Call ID missing from offer'); const hangup = new HangupMessage( callId, HangupType.NeedPermission, remoteDeviceId ); const message = new CallingMessage(); message.hangup = hangup; await this.handleOutgoingSignaling(remoteUserId, message); const wasVideoCall = callingMessage.offer.type === Proto.CallingMessage.Offer.Type.OFFER_VIDEO_CALL; const peerId = getPeerIdFromConversation(conversation.attributes); const callDetails = getCallDetailsFromEndedDirectCall( callId.toString(), peerId, peerId, // Incoming call wasVideoCall, envelope.timestamp ); const localCallEvent = LocalCallEvent.Missed; const callEvent = getCallEventDetails(callDetails, localCallEvent); await updateCallHistoryFromLocalEvent( callEvent, envelope.receivedAtCounter ); return; } const sourceServiceId = envelope.sourceServiceId ? uuidToBytes(envelope.sourceServiceId) : null; const messageAgeSec = envelope.messageAgeSec ? envelope.messageAgeSec : 0; log.info('CallingClass.handleCallingMessage(): Handling in RingRTC'); RingRTC.handleCallingMessage( remoteUserId, sourceServiceId ? Buffer.from(sourceServiceId) : null, remoteDeviceId, this.localDeviceId, messageAgeSec, envelope.receivedAtCounter, protoToCallingMessage(callingMessage), Buffer.from(senderIdentityKey), Buffer.from(receiverIdentityKey) ); } private async selectPreferredDevices( settings: MediaDeviceSettings ): Promise { if ( (!this.lastMediaDeviceSettings && settings.selectedCamera) || (this.lastMediaDeviceSettings && settings.selectedCamera && this.lastMediaDeviceSettings.selectedCamera !== settings.selectedCamera) ) { log.info('MediaDevice: selecting camera', settings.selectedCamera); await this.videoCapturer.setPreferredDevice(settings.selectedCamera); } // Assume that the MediaDeviceSettings have been obtained very recently and // the index is still valid (no devices have been plugged in in between). if (settings.selectedMicrophone) { log.info( 'MediaDevice: selecting microphone', settings.selectedMicrophone.index, truncateForLogging(settings.selectedMicrophone.name) ); RingRTC.setAudioInput(settings.selectedMicrophone.index); } if (settings.selectedSpeaker) { log.info( 'MediaDevice: selecting speaker', settings.selectedSpeaker.index, truncateForLogging(settings.selectedSpeaker.name) ); RingRTC.setAudioOutput(settings.selectedSpeaker.index); } } private async requestCameraPermissions(): Promise { const cameraPermission = await window.IPC.getMediaCameraPermissions(); if (!cameraPermission) { await window.IPC.showPermissionsPopup(true, true); // Check the setting again (from the source of truth). return window.IPC.getMediaCameraPermissions(); } return true; } private async requestPermissions(isVideoCall: boolean): Promise { const microphonePermission = await requestMicrophonePermissions(true); if (microphonePermission) { if (isVideoCall) { return this.requestCameraPermissions(); } return true; } return false; } private async handleSendCallMessage( recipient: Uint8Array, data: Uint8Array, urgency: CallMessageUrgency ): Promise { const userId = bytesToUuid(recipient); if (!userId) { log.error('handleSendCallMessage(): bad recipient UUID'); return false; } const message = new CallingMessage(); message.opaque = new OpaqueMessage(); message.opaque.data = Buffer.from(data); return this.handleOutgoingSignaling(userId, message, urgency); } // Used to send a variety of group call messages, including the initial call message private async handleSendCallMessageToGroup( groupIdBytes: Buffer, data: Buffer, urgency: CallMessageUrgency ): Promise { const groupId = groupIdBytes.toString('base64'); const conversation = window.ConversationController.get(groupId); if (!conversation) { log.error('handleSendCallMessageToGroup(): could not find conversation'); return; } const timestamp = Date.now(); const callingMessage = new CallingMessage(); callingMessage.opaque = new OpaqueMessage(); callingMessage.opaque.data = data; const contentMessage = new Proto.Content(); contentMessage.callingMessage = callingMessageToProto( callingMessage, urgency ); // If this message isn't droppable, we'll wake up recipient devices. The important one // is the first message to start the call. const urgent = urgency === CallMessageUrgency.HandleImmediately; // We "fire and forget" because sending this message is non-essential. // We also don't sync this message. const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; await conversation.queueJob('handleSendCallMessageToGroup', async () => handleMessageSend( sendContentMessageToGroup({ contentHint: ContentHint.DEFAULT, contentMessage, isPartialSend: false, messageId: undefined, recipients: conversation.getRecipients(), sendOptions: await getSendOptions(conversation.attributes), sendTarget: conversation.toSenderKeyTarget(), sendType: 'callingMessage', timestamp, urgent, }), { messageIds: [], sendType: 'callingMessage' } ) ); } private async handleGroupCallRingUpdate( groupIdBytes: Buffer, ringId: bigint, ringerBytes: Buffer, update: RingUpdate ): Promise { log.info(`handleGroupCallRingUpdate(): got ring update ${update}`); const groupId = groupIdBytes.toString('base64'); const ringerUuid = bytesToUuid(ringerBytes); if (!ringerUuid) { log.error('handleGroupCallRingUpdate(): ringerUuid was invalid'); return; } const ringerAci = normalizeAci(ringerUuid, 'handleGroupCallRingUpdate'); const conversation = window.ConversationController.get(groupId); if (!conversation) { log.error('handleGroupCallRingUpdate(): could not find conversation'); return; } if (update === RingUpdate.Requested) { this.reduxInterface?.peekNotConnectedGroupCall({ conversationId: conversation.id, }); } const logId = `handleGroupCallRingUpdate(${conversation.idForLogging()})`; if (conversation.isBlocked()) { log.warn(`${logId}: is blocked`); return; } const ourAci = window.textsecure.storage.user.getCheckedAci(); if (conversation.get('left') || !conversation.hasMember(ourAci)) { log.warn(`${logId}: we left the group`); return; } if (!conversation.hasMember(ringerAci)) { log.warn(`${logId}: they left the group`); return; } if ( conversation.get('announcementsOnly') && !conversation.isAdmin(ringerAci) ) { log.warn(`${logId}: non-admin update to announcement-only group`); return; } const conversationId = conversation.id; let shouldRing = false; if (update === RingUpdate.Requested) { if (await wasGroupCallRingPreviouslyCanceled(ringId)) { RingRTC.cancelGroupRing(groupIdBytes, ringId, null); } else if (this.areAnyCallsActiveOrRinging()) { RingRTC.cancelGroupRing(groupIdBytes, ringId, RingCancelReason.Busy); } else if (window.Events.getIncomingCallNotification()) { shouldRing = true; } else { log.info( 'Incoming calls are disabled. Ignoring group call ring request' ); } } else { await processGroupCallRingCancellation(ringId); } if (shouldRing) { log.info('handleGroupCallRingUpdate: ringing'); this.reduxInterface?.receiveIncomingGroupCall({ conversationId, ringId, ringerAci, }); } else { log.info('handleGroupCallRingUpdate: canceling the existing ring'); this.reduxInterface?.cancelIncomingGroupCallRing({ conversationId, ringId, }); } const localEvent = getLocalCallEventFromRingUpdate(update); if (localEvent != null) { const callId = getCallIdFromRing(ringId); const callDetails = getCallDetailsFromGroupCallMeta(groupId, { callId, ringerId: ringerUuid, }); const callEvent = getCallEventDetails( callDetails, shouldRing ? LocalCallEvent.Ringing : LocalCallEvent.Started ); await updateCallHistoryFromLocalEvent(callEvent, null); } } // Used for all 1:1 call messages, including the initial message to start the call private async handleOutgoingSignaling( remoteUserId: UserId, message: CallingMessage, urgency?: CallMessageUrgency ): Promise { const conversation = window.ConversationController.get(remoteUserId); const sendOptions = conversation ? await getSendOptions(conversation.attributes) : undefined; if (!window.textsecure.messaging) { log.warn('handleOutgoingSignaling() returning false; offline'); return false; } // We want 1:1 call initiate messages to wake up recipient devices, but not others const urgent = urgency === CallMessageUrgency.HandleImmediately || Boolean(message.offer); try { assertDev(isAciString(remoteUserId), 'remoteUserId is not a aci'); const result = await handleMessageSend( window.textsecure.messaging.sendCallingMessage( remoteUserId, callingMessageToProto(message, urgency), urgent, sendOptions ), { messageIds: [], sendType: 'callingMessage' } ); if (result && result.errors && result.errors.length) { throw result.errors[0]; } log.info('handleOutgoingSignaling() completed successfully'); return true; } catch (err) { if (err && err.errors && err.errors.length > 0) { log.error(`handleOutgoingSignaling() failed: ${err.errors[0].reason}`); } else { log.error('handleOutgoingSignaling() failed'); } return false; } } // If we return null here, we hang up the call. private async handleIncomingCall(call: Call): Promise { log.info('CallingClass.handleIncomingCall()'); if (!this.reduxInterface || !this.localDeviceId) { log.error('Missing required objects, ignoring incoming call.'); return false; } const conversation = window.ConversationController.get(call.remoteUserId); if (!conversation) { log.error('Missing conversation, ignoring incoming call.'); return false; } if (conversation.isBlocked()) { log.warn( `handleIncomingCall(): ${conversation.idForLogging()} is blocked` ); return false; } try { // The peer must be 'trusted' before accepting a call from them. // This is mostly the safety number check, unverified meaning that they were // verified before but now they are not. const verifiedEnum = await conversation.safeGetVerified(); if ( verifiedEnum === window.textsecure.storage.protocol.VerifiedStatus.UNVERIFIED ) { log.info( `Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}` ); const localCallEvent = LocalCallEvent.Missed; const peerId = getPeerIdFromConversation(conversation.attributes); const callDetails = getCallDetailsFromDirectCall(peerId, call); const callEvent = getCallEventDetails(callDetails, localCallEvent); await updateCallHistoryFromLocalEvent(callEvent, null); return false; } this.attachToCall(conversation, call); this.reduxInterface.receiveIncomingDirectCall({ conversationId: conversation.id, isVideoCall: call.isVideoCall, }); return true; } catch (err) { log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`); return false; } } private async handleAutoEndedIncomingCallRequest( callIdValue: CallId, remoteUserId: UserId, callEndedReason: CallEndedReason, ageInSeconds: number, wasVideoCall: boolean, receivedAtCounter: number | undefined ) { const conversation = window.ConversationController.get(remoteUserId); if (!conversation) { return; } const callId = Long.fromValue(callIdValue).toString(); const peerId = getPeerIdFromConversation(conversation.attributes); // This is extra defensive, just in case RingRTC passes us a bad value. (It probably // won't.) const ageInMilliseconds = isNormalNumber(ageInSeconds) && ageInSeconds >= 0 ? ageInSeconds * durations.SECOND : 0; const timestamp = Date.now() - ageInMilliseconds; const callDetails = getCallDetailsFromEndedDirectCall( callId, peerId, remoteUserId, wasVideoCall, timestamp ); const localCallEvent = getLocalCallEventFromCallEndedReason(callEndedReason); const callEvent = getCallEventDetails(callDetails, localCallEvent); await updateCallHistoryFromLocalEvent(callEvent, receivedAtCounter ?? null); } private attachToCall(conversation: ConversationModel, call: Call): void { this.callsByConversation[conversation.id] = call; const { reduxInterface } = this; if (!reduxInterface) { return; } let acceptedTime: number | null = null; // eslint-disable-next-line no-param-reassign call.handleStateChanged = async () => { if (call.state === CallState.Accepted) { acceptedTime = acceptedTime ?? Date.now(); } if (call.state === CallState.Ended) { this.stopDeviceReselectionTimer(); this.lastMediaDeviceSettings = undefined; delete this.callsByConversation[conversation.id]; } const localCallEvent = getLocalCallEventFromDirectCall(call); if (localCallEvent != null) { const peerId = getPeerIdFromConversation(conversation.attributes); const callDetails = getCallDetailsFromDirectCall(peerId, call); const callEvent = getCallEventDetails(callDetails, localCallEvent); await updateCallHistoryFromLocalEvent(callEvent, null); } reduxInterface.callStateChange({ conversationId: conversation.id, callState: call.state, callEndedReason: call.endedReason, acceptedTime, }); }; // eslint-disable-next-line no-param-reassign call.handleRemoteVideoEnabled = () => { reduxInterface.remoteVideoChange({ conversationId: conversation.id, hasVideo: call.remoteVideoEnabled, }); }; // eslint-disable-next-line no-param-reassign call.handleRemoteSharingScreen = () => { reduxInterface.remoteSharingScreenChange({ conversationId: conversation.id, isSharingScreen: Boolean(call.remoteSharingScreen), }); }; // eslint-disable-next-line no-param-reassign call.handleLowBandwidthForVideo = _recovered => { // TODO: Implement handling of "low outgoing bandwidth for video" notification. }; } private async handleLogMessage( level: CallLogLevel, fileName: string, line: number, message: string ) { switch (level) { case CallLogLevel.Info: log.info(`${fileName}:${line} ${message}`); break; case CallLogLevel.Warn: log.warn(`${fileName}:${line} ${message}`); break; case CallLogLevel.Error: log.error(`${fileName}:${line} ${message}`); break; default: break; } } private async handleSendHttpRequest( requestId: number, url: string, method: HttpMethod, headers: { [name: string]: string }, body: Uint8Array | undefined ) { if (!window.textsecure.messaging) { RingRTC.httpRequestFailed(requestId, 'We are offline'); return; } const httpMethod = RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD.get(method); if (httpMethod === undefined) { RingRTC.httpRequestFailed( requestId, `Unknown method: ${JSON.stringify(method)}` ); return; } let result; try { result = await window.textsecure.messaging.server.makeSfuRequest( url, httpMethod, headers, body ); } catch (err) { if (err.code !== -1) { // WebAPI treats certain response codes as errors, but RingRTC still needs to // see them. It does not currently look at the response body, so we're giving // it an empty one. RingRTC.receivedHttpResponse(requestId, err.code, Buffer.alloc(0)); } else { log.error('handleSendHttpRequest: fetch failed with error', err); RingRTC.httpRequestFailed(requestId, String(err)); } return; } RingRTC.receivedHttpResponse( requestId, result.response.status, Buffer.from(result.data) ); } private getRemoteUserIdFromConversation( conversation: ConversationModel ): UserId | undefined | null { const recipients = conversation.getRecipients(); if (recipients.length !== 1) { return undefined; } return recipients[0]; } private get localDeviceId(): DeviceId | null { return this.parseDeviceId(window.textsecure.storage.user.getDeviceId()); } private parseDeviceId( deviceId: number | string | undefined ): DeviceId | null { if (typeof deviceId === 'string') { return parseInt(deviceId, 10); } if (typeof deviceId === 'number') { return deviceId; } return null; } private async handleStartCall(call: Call): Promise { if (!window.textsecure.messaging) { log.error('handleStartCall: offline!'); return false; } const iceServer = await window.textsecure.messaging.server.getIceServers(); const shouldRelayCalls = window.Events.getAlwaysRelayCalls(); const conversation = window.ConversationController.get(call.remoteUserId); if (!conversation) { log.error('Missing conversation, ignoring incoming call.'); return false; } // If the peer is not in the user's system contacts, force IP hiding. const isContactUntrusted = !isInSystemContacts(conversation.attributes); const callSettings = { iceServer: { ...iceServer, urls: iceServer.urls.slice(), }, hideIp: shouldRelayCalls || isContactUntrusted, dataMode: DataMode.Normal, // TODO: DESKTOP-3101 // audioLevelsIntervalMillis: AUDIO_LEVEL_INTERVAL_MS, }; log.info('CallingClass.handleStartCall(): Proceeding'); RingRTC.proceed(call.callId, callSettings); return true; } public async updateCallHistoryForGroupCall( conversationId: string, joinState: GroupCallJoinState | null, peekInfo: PeekInfo | null ): Promise { const groupCallMeta = getGroupCallMeta(peekInfo); // If we don't have the necessary pieces to peek, bail. (It's okay if we don't.) if (groupCallMeta == null) { return; } const creatorConversation = window.ConversationController.get( groupCallMeta.ringerId ); const conversation = window.ConversationController.get(conversationId); if (!conversation) { log.error('maybeNotifyGroupCall(): could not find conversation'); return; } const prevMessageId = await window.Signal.Data.getCallHistoryMessageByCallId({ conversationId: conversation.id, callId: groupCallMeta.callId, }); const isNewCall = prevMessageId == null; if (isNewCall) { const localCallEvent = getLocalCallEventFromJoinState( joinState, groupCallMeta ); if (localCallEvent != null) { const peerId = getPeerIdFromConversation(conversation.attributes); const callDetails = getCallDetailsFromGroupCallMeta( peerId, groupCallMeta ); const callEvent = getCallEventDetails(callDetails, localCallEvent); await updateCallHistoryFromLocalEvent(callEvent, null); } } const wasStartedByMe = Boolean( creatorConversation && isMe(creatorConversation.attributes) ); const isAnybodyElseInGroupCall = Boolean(peekInfo?.devices.length); if ( isNewCall && !wasStartedByMe && isAnybodyElseInGroupCall && !conversation.isMuted() ) { await this.notifyForGroupCall(conversation, creatorConversation); } } private async notifyForGroupCall( conversation: Readonly, creatorConversation: undefined | Readonly ): Promise { let notificationTitle: string; let notificationMessage: string; switch (notificationService.getNotificationSetting()) { case NotificationSetting.Off: return; case NotificationSetting.NoNameOrMessage: notificationTitle = FALLBACK_NOTIFICATION_TITLE; notificationMessage = window.i18n( 'icu:calling__call-notification__started-by-someone' ); break; default: // These fallbacks exist just in case something unexpected goes wrong. notificationTitle = conversation?.getTitle() || FALLBACK_NOTIFICATION_TITLE; notificationMessage = creatorConversation ? window.i18n('icu:calling__call-notification__started', { name: creatorConversation.getTitle(), }) : window.i18n('icu:calling__call-notification__started-by-someone'); break; } const { url, absolutePath } = await conversation.getAvatarOrIdenticon(); notificationService.notify({ conversationId: conversation.id, iconPath: absolutePath, iconUrl: url, message: notificationMessage, type: NotificationType.IncomingGroupCall, sentAt: 0, silent: false, title: notificationTitle, }); } private areAnyCallsActiveOrRinging(): boolean { return this.reduxInterface?.areAnyCallsActiveOrRinging() ?? false; } private async cleanExpiredGroupCallRingsAndLoop(): Promise { try { await cleanExpiredGroupCallRingCancellations(); } catch (err: unknown) { // These errors are ignored here. They should be logged elsewhere and it's okay if // we don't do a cleanup this time. } setTimeout(() => { void this.cleanExpiredGroupCallRingsAndLoop(); }, CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL); } // MacOS: Preload devices to work around delay when first entering call lobby // https://bugs.chromium.org/p/chromium/issues/detail?id=1287628 private async enumerateMediaDevices(): Promise { try { const microphoneStatus = await window.IPC.getMediaAccessStatus( 'microphone' ); if (microphoneStatus !== 'granted') { return; } drop(window.navigator.mediaDevices.enumerateDevices()); } catch (error) { log.error('enumerateMediaDevices failed:', Errors.toLogFormat(error)); } } } export const calling = new CallingClass();