signal-desktop/ts/services/calling.ts

2474 lines
76 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2020 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2021-12-09 08:06:04 +00:00
import type { DesktopCapturerSource } from 'electron';
import { ipcRenderer } from 'electron';
import type {
AudioDevice,
CallId,
DeviceId,
PeekInfo,
UserId,
VideoFrameSource,
VideoRequest,
2023-01-09 18:38:57 +00:00
} from '@signalapp/ringrtc';
import {
2023-03-29 21:16:19 +00:00
AnswerMessage,
BusyMessage,
2020-06-04 18:16:19 +00:00
Call,
2020-09-28 19:02:35 +00:00
CallingMessage,
CallMessageUrgency,
2020-06-04 18:16:19 +00:00
CallLogLevel,
CallState,
2020-08-27 00:03:42 +00:00
CanvasVideoRenderer,
2020-11-13 19:57:55 +00:00
ConnectionState,
2023-05-31 23:37:19 +00:00
DataMode,
2020-11-13 19:57:55 +00:00
JoinState,
HttpMethod,
GroupCall,
GroupMemberInfo,
2020-08-27 00:03:42 +00:00
GumVideoCapturer,
2020-09-28 19:02:35 +00:00
HangupMessage,
HangupType,
2023-03-29 21:16:19 +00:00
IceCandidateMessage,
OfferMessage,
2020-11-13 19:57:55 +00:00
OpaqueMessage,
2021-08-20 16:06:15 +00:00
RingCancelReason,
2020-06-04 18:16:19 +00:00
RingRTC,
2021-08-20 16:06:15 +00:00
RingUpdate,
2023-01-09 18:38:57 +00:00
} from '@signalapp/ringrtc';
2020-11-13 19:57:55 +00:00
import { uniqBy, noop } from 'lodash';
2020-09-12 00:53:19 +00:00
2023-08-09 00:53:06 +00:00
import Long from 'long';
import type {
ActionsType as CallingReduxActionsType,
GroupCallParticipantInfoType,
2020-11-20 17:19:28 +00:00
GroupCallPeekInfoType,
} from '../state/ducks/calling';
import type { ConversationType } from '../state/ducks/conversations';
2020-11-13 19:57:55 +00:00
import { getConversationCallMode } from '../state/ducks/conversations';
import { isMe } from '../util/whatTypeOfConversation';
import type {
2021-08-18 20:08:14 +00:00
AvailableIODevicesType,
2023-08-09 00:53:06 +00:00
CallEndedReason,
2021-08-18 20:08:14 +00:00
MediaDeviceSettings,
PresentableSource,
PresentedSource,
} from '../types/Calling';
import {
CallMode,
GroupCallConnectionState,
GroupCallJoinState,
2020-11-13 19:57:55 +00:00
} from '../types/Calling';
import {
findBestMatchingAudioDeviceIndex,
findBestMatchingCameraId,
} from '../calling/findBestMatchingDevice';
import type { LocalizerType } from '../types/Util';
2023-09-14 17:04:48 +00:00
import { normalizeAci } from '../util/normalizeAci';
import { isAciString } from '../util/isAciString';
import * as Errors from '../types/errors';
import type { ConversationModel } from '../models/conversations';
2021-06-29 14:39:53 +00:00
import * as Bytes from '../Bytes';
2023-08-09 00:53:06 +00:00
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
import { drop } from '../util/drop';
2023-03-29 21:16:19 +00:00
import { dropNull } from '../util/dropNull';
2020-11-13 19:57:55 +00:00
import { getOwn } from '../util/getOwn';
import * as durations from '../util/durations';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { handleMessageSend } from '../util/handleMessageSend';
2022-02-16 18:36:21 +00:00
import { fetchMembershipProof, getMembershipList } from '../groups';
import { wrapWithSyncMessageSend } from '../util/wrapWithSyncMessageSend';
import type { ProcessedEnvelope } from '../textsecure/Types.d';
2020-11-13 19:57:55 +00:00
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';
2021-08-20 16:06:15 +00:00
import { callingMessageToProto } from '../util/callingMessageToProto';
import { getSendOptions } from '../util/getSendOptions';
import { requestMicrophonePermissions } from '../util/requestMicrophonePermissions';
import OS from '../util/os/osMain';
2021-07-02 19:21:24 +00:00
import { SignalService as Proto } from '../protobuf';
2021-08-20 16:06:15 +00:00
import dataInterface from '../sql/Client';
import {
notificationService,
NotificationSetting,
FALLBACK_NOTIFICATION_TITLE,
2023-08-01 16:06:29 +00:00
NotificationType,
} from './notifications';
import * as log from '../logging/log';
2023-08-01 16:06:29 +00:00
import { assertDev, strictAssert } from '../util/assert';
2023-04-11 03:54:43 +00:00
import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup';
2023-08-09 00:53:06 +00:00
import {
formatLocalDeviceState,
formatPeekInfo,
getPeerIdFromConversation,
getLocalCallEventFromCallEndedReason,
getCallDetailsFromEndedDirectCall,
getCallEventDetails,
2023-08-10 22:41:53 +00:00
getLocalCallEventFromJoinState,
2023-08-09 00:53:06 +00:00
getLocalCallEventFromDirectCall,
getCallDetailsFromDirectCall,
getCallDetailsFromGroupCallMeta,
updateCallHistoryFromLocalEvent,
getGroupCallMeta,
getCallIdFromRing,
getLocalCallEventFromRingUpdate,
2023-08-10 22:41:53 +00:00
convertJoinState,
2023-08-09 00:53:06 +00:00
} from '../util/callDisposition';
import { isNormalNumber } from '../util/isNormalNumber';
import { LocalCallEvent } from '../types/CallDisposition';
import { isInSystemContacts } from '../util/isInSystemContacts';
2020-11-13 19:57:55 +00:00
2021-08-20 16:06:15 +00:00
const {
processGroupCallRingCancellation,
cleanExpiredGroupCallRingCancellations,
wasGroupCallRingPreviouslyCanceled,
2021-08-20 16:06:15 +00:00
} = dataInterface;
2021-07-09 19:36:10 +00:00
2020-11-13 19:57:55 +00:00
const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
HttpMethod,
2020-11-17 19:49:48 +00:00
'GET' | 'PUT' | 'POST' | 'DELETE'
2020-11-13 19:57:55 +00:00
> = new Map([
[HttpMethod.Get, 'GET'],
[HttpMethod.Put, 'PUT'],
[HttpMethod.Post, 'POST'],
2020-11-17 19:49:48 +00:00
[HttpMethod.Delete, 'DELETE'],
2020-11-13 19:57:55 +00:00
]);
2020-06-04 18:16:19 +00:00
const CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = 10 * durations.MINUTE;
2021-08-20 16:06:15 +00:00
// 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'
2023-12-06 21:52:29 +00:00
| 'groupCallRaisedHandsChange'
| 'groupCallStateChange'
| 'outgoingCall'
2023-11-16 19:55:35 +00:00
| 'receiveGroupCallReactions'
| 'receiveIncomingDirectCall'
| 'receiveIncomingGroupCall'
| 'refreshIODevices'
| 'remoteSharingScreenChange'
| 'remoteVideoChange'
| 'setPresenting'
| 'startCallingLobby'
2023-10-23 20:31:07 +00:00
| 'peekNotConnectedGroupCall'
> & {
areAnyCallsActiveOrRinging(): boolean;
};
function isScreenSource(source: PresentedSource): boolean {
return source.id.startsWith('screen');
}
2022-09-07 01:07:22 +00:00
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') {
2023-03-30 00:03:25 +00:00
return i18n('icu:calling__SelectPresentingSourcesModal--entireScreen');
}
const match = name.match(/^Screen (\d+)$/);
if (match) {
2023-03-30 00:03:25 +00:00
return i18n('icu:calling__SelectPresentingSourcesModal--screen', {
id: match[1],
});
}
return name;
}
2021-07-09 19:36:10 +00:00
function protoToCallingMessage({
offer,
answer,
iceCandidates,
busy,
hangup,
destinationDeviceId,
opaque,
}: Proto.ICallingMessage): CallingMessage {
2023-03-29 21:16:19 +00:00
const newIceCandidates: Array<IceCandidateMessage> = [];
if (iceCandidates) {
iceCandidates.forEach(candidate => {
if (candidate.callId && candidate.opaque) {
newIceCandidates.push(
new IceCandidateMessage(
candidate.callId,
Buffer.from(candidate.opaque)
)
);
}
});
}
2021-07-09 19:36:10 +00:00
2023-03-29 21:16:19 +00:00
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,
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,
2021-07-09 19:36:10 +00:00
destinationDeviceId: dropNull(destinationDeviceId),
opaque: opaque
? {
data: opaque.data ? Buffer.from(opaque.data) : undefined,
}
: undefined,
};
}
2020-06-04 18:16:19 +00:00
export class CallingClass {
2020-08-27 00:03:42 +00:00
readonly videoCapturer: GumVideoCapturer;
2020-09-03 14:59:24 +00:00
2020-08-27 00:03:42 +00:00
readonly videoRenderer: CanvasVideoRenderer;
2020-09-03 14:59:24 +00:00
private reduxInterface?: CallingReduxInterface;
2020-09-03 14:59:24 +00:00
public _sfuUrl?: string;
2020-08-27 00:03:42 +00:00
private lastMediaDeviceSettings?: MediaDeviceSettings;
2020-09-03 14:59:24 +00:00
2020-08-27 00:03:42 +00:00
private deviceReselectionTimer?: NodeJS.Timeout;
2020-11-13 19:57:55 +00:00
private callsByConversation: { [conversationId: string]: Call | GroupCall };
private hadLocalVideoBeforePresenting?: boolean;
2020-08-27 00:03:42 +00:00
constructor() {
this.videoCapturer = new GumVideoCapturer({
maxWidth: REQUESTED_VIDEO_WIDTH,
maxHeight: REQUESTED_VIDEO_HEIGHT,
maxFramerate: REQUESTED_VIDEO_FRAMERATE,
});
2020-08-27 00:03:42 +00:00
this.videoRenderer = new CanvasVideoRenderer();
this.callsByConversation = {};
2020-08-27 00:03:42 +00:00
}
2020-06-04 18:16:19 +00:00
initialize(reduxInterface: CallingReduxInterface, sfuUrl: string): void {
this.reduxInterface = reduxInterface;
if (!reduxInterface) {
2020-06-04 18:16:19 +00:00
throw new Error('CallingClass.initialize: Invalid uxActions.');
}
this._sfuUrl = sfuUrl;
RingRTC.setConfig({
field_trials: undefined,
});
2020-06-04 18:16:19 +00:00
RingRTC.handleOutgoingSignaling = this.handleOutgoingSignaling.bind(this);
RingRTC.handleIncomingCall = this.handleIncomingCall.bind(this);
RingRTC.handleStartCall = this.handleStartCall.bind(this);
2021-11-11 22:43:05 +00:00
RingRTC.handleAutoEndedIncomingCallRequest =
this.handleAutoEndedIncomingCallRequest.bind(this);
2020-06-04 18:16:19 +00:00
RingRTC.handleLogMessage = this.handleLogMessage.bind(this);
2020-11-13 19:57:55 +00:00
RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this);
RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this);
2021-11-11 22:43:05 +00:00
RingRTC.handleSendCallMessageToGroup =
this.handleSendCallMessageToGroup.bind(this);
RingRTC.handleGroupCallRingUpdate =
this.handleGroupCallRingUpdate.bind(this);
2021-08-20 16:06:15 +00:00
2023-08-16 20:54:39 +00:00
this.attemptToGiveOurServiceIdToRingRtc();
2021-09-07 16:21:17 +00:00
window.Whisper.events.on('userChanged', () => {
2023-08-16 20:54:39 +00:00
this.attemptToGiveOurServiceIdToRingRtc();
2021-09-07 16:21:17 +00:00
});
ipcRenderer.on('stop-screen-share', () => {
reduxInterface.setPresenting();
});
2021-08-20 16:06:15 +00:00
void this.cleanExpiredGroupCallRingsAndLoop();
if (process.platform === 'darwin') {
drop(this.enumerateMediaDevices());
}
2021-08-20 16:06:15 +00:00
}
2023-08-16 20:54:39 +00:00
private attemptToGiveOurServiceIdToRingRtc(): void {
const ourAci = window.textsecure.storage.user.getAci();
if (!ourAci) {
2021-08-20 16:06:15 +00:00
// This can happen if we're not linked. It's okay if we hit this case.
return;
}
RingRTC.setSelfUuid(Buffer.from(uuidToBytes(ourAci)));
2020-06-04 18:16:19 +00:00
}
async startCallingLobby({
conversation,
hasLocalAudio,
hasLocalVideo,
}: Readonly<{
conversation: Readonly<ConversationType>;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
}>): Promise<
| undefined
| ({ hasLocalAudio: boolean; hasLocalVideo: boolean } & (
| { callMode: CallMode.Direct }
| {
callMode: CallMode.Group;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
peekInfo?: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
}
))
> {
log.info('CallingClass.startCallingLobby()');
const callMode = getConversationCallMode(conversation);
2020-11-13 19:57:55 +00:00
switch (callMode) {
case null:
log.error('Conversation does not support calls, new call not allowed.');
2020-11-13 19:57:55 +00:00
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.');
2020-11-13 19:57:55 +00:00
return;
}
break;
}
2020-11-13 19:57:55 +00:00
case CallMode.Group:
break;
default:
throw missingCaseError(callMode);
}
if (!this.reduxInterface) {
log.error('Missing uxActions, new call not allowed.');
2020-06-04 18:16:19 +00:00
return;
}
2020-11-13 19:57:55 +00:00
if (!this.localDeviceId) {
log.error('Missing local device identifier, new call not allowed.');
2020-06-04 18:16:19 +00:00
return;
}
const haveMediaPermissions = await this.requestPermissions(hasLocalVideo);
2020-06-04 18:16:19 +00:00
if (!haveMediaPermissions) {
log.info('Permissions were denied, new call not allowed.');
2020-06-04 18:16:19 +00:00
return;
}
log.info('CallingClass.startCallingLobby(): Starting lobby');
2020-10-08 01:25:33 +00:00
// 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;
2020-11-13 19:57:55 +00:00
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 {
2020-11-13 19:57:55 +00:00
callMode: CallMode.Direct,
hasLocalAudio,
hasLocalVideo,
};
2020-11-13 19:57:55 +00:00
case CallMode.Group: {
if (
!conversation.groupId ||
!conversation.publicParams ||
!conversation.secretParams
2020-11-13 19:57:55 +00:00
) {
log.error(
2020-11-13 19:57:55 +00:00
'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,
2020-11-13 19:57:55 +00:00
});
groupCall.setOutgoingAudioMuted(!hasLocalAudio);
groupCall.setOutgoingVideoMuted(!hasLocalVideo);
enableLocalCameraIfNecessary();
2020-11-13 19:57:55 +00:00
return {
2020-11-13 19:57:55 +00:00
callMode: CallMode.Group,
...this.formatGroupCallForRedux(groupCall),
};
2020-11-13 19:57:55 +00:00
}
default:
throw missingCaseError(callMode);
2020-10-08 01:25:33 +00:00
}
}
2020-11-13 19:57:55 +00:00
stopCallingLobby(conversationId?: string): void {
this.disableLocalVideo();
2020-10-08 01:25:33 +00:00
this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined;
2020-11-13 19:57:55 +00:00
if (conversationId) {
this.getGroupCall(conversationId)?.disconnect();
}
2020-10-08 01:25:33 +00:00
}
2020-11-13 19:57:55 +00:00
async startOutgoingDirectCall(
2020-10-08 01:25:33 +00:00
conversationId: string,
hasLocalAudio: boolean,
hasLocalVideo: boolean
2020-10-08 01:25:33 +00:00
): Promise<void> {
log.info('CallingClass.startOutgoingDirectCall()');
2020-10-08 01:25:33 +00:00
if (!this.reduxInterface) {
2020-10-08 01:25:33 +00:00
throw new Error('Redux actions not available');
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error('Could not find conversation, cannot start call');
2020-10-08 01:25:33 +00:00
this.stopCallingLobby();
return;
}
const remoteUserId = this.getRemoteUserIdFromConversation(conversation);
if (!remoteUserId || !this.localDeviceId) {
log.error('Missing identifier, new call not allowed.');
2020-10-08 01:25:33 +00:00
this.stopCallingLobby();
return;
}
const haveMediaPermissions = await this.requestPermissions(hasLocalVideo);
2020-10-08 01:25:33 +00:00
if (!haveMediaPermissions) {
log.info('Permissions were denied, new call not allowed.');
2020-10-08 01:25:33 +00:00
this.stopCallingLobby();
return;
}
log.info('CallingClass.startOutgoingDirectCall(): Getting call settings');
2020-08-27 00:03:42 +00:00
// 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.');
2020-10-08 01:25:33 +00:00
this.stopCallingLobby();
2020-08-27 00:03:42 +00:00
return;
}
log.info('CallingClass.startOutgoingDirectCall(): Starting in RingRTC');
2020-06-04 18:16:19 +00:00
const call = RingRTC.startOutgoingCall(
remoteUserId,
hasLocalVideo,
this.localDeviceId
2020-06-04 18:16:19 +00:00
);
RingRTC.setOutgoingAudio(call.callId, hasLocalAudio);
2020-08-27 00:03:42 +00:00
RingRTC.setVideoCapturer(call.callId, this.videoCapturer);
RingRTC.setVideoRenderer(call.callId, this.videoRenderer);
2020-06-04 18:16:19 +00:00
this.attachToCall(conversation, call);
this.reduxInterface.outgoingCall({
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
2020-06-04 18:16:19 +00:00
});
await this.startDeviceReselectionTimer();
}
2020-11-13 19:57:55 +00:00
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;
}
2020-11-20 17:19:28 +00:00
private getGroupCallMembers(conversationId: string) {
return getMembershipList(conversationId).map(
member =>
new GroupMemberInfo(
2023-08-16 20:54:39 +00:00
Buffer.from(uuidToBytes(member.aci)),
2021-06-29 14:39:53 +00:00
Buffer.from(member.uuidCiphertext)
2020-11-20 17:19:28 +00:00
)
);
}
public async peekGroupCall(conversationId: string): Promise<PeekInfo> {
// 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');
}
2020-11-20 17:19:28 +00:00
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');
}
2021-06-29 14:39:53 +00:00
const membershipProof = Bytes.fromString(proof);
2020-11-20 17:19:28 +00:00
return RingRTC.peekGroupCall(
this._sfuUrl,
2021-06-29 14:39:53 +00:00
Buffer.from(membershipProof),
2020-11-20 17:19:28 +00:00
this.getGroupCallMembers(conversationId)
);
}
2020-11-13 19:57:55 +00:00
/**
* 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');
}
2021-06-29 14:39:53 +00:00
const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId));
2020-11-13 19:57:55 +00:00
let updateMessageState = GroupCallUpdateMessageState.SentNothing;
2020-11-13 19:57:55 +00:00
let isRequestingMembershipProof = false;
2021-12-03 20:22:58 +00:00
const outerGroupCall = RingRTC.getGroupCall(
groupIdBuffer,
this._sfuUrl,
2021-12-03 20:22:58 +00:00
Buffer.alloc(0),
AUDIO_LEVEL_INTERVAL_MS,
2021-12-03 20:22:58 +00:00
{
onLocalDeviceStateChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
2023-08-09 00:53:06 +00:00
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 {
2023-08-10 22:41:53 +00:00
const localCallEvent = getLocalCallEventFromJoinState(
convertJoinState(localDeviceState.joinState),
2023-08-09 00:53:06 +00:00
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,
'RingRTC.onLocalDeviceStateChanged'
2023-08-09 00:53:06 +00:00
);
drop(updateCallHistoryFromLocalEvent(callEvent, null));
}
} catch (error) {
log.error(
'GroupCall#onLocalDeviceStateChanged: Error updating state',
Errors.toLogFormat(error)
);
}
}
2020-11-17 19:49:48 +00:00
if (
2021-12-03 20:22:58 +00:00
localDeviceState.connectionState === ConnectionState.NotConnected
2020-11-17 19:49:48 +00:00
) {
2021-12-03 20:22:58 +00:00
// 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();
2021-12-03 20:22:58 +00:00
delete this.callsByConversation[conversationId];
if (
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
2023-08-09 00:53:06 +00:00
peekInfo?.eraId != null
2021-12-03 20:22:58 +00:00
) {
updateMessageState = GroupCallUpdateMessageState.SentLeft;
2023-08-09 00:53:06 +00:00
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
2021-12-03 20:22:58 +00:00
}
2020-11-17 19:49:48 +00:00
} else {
2021-12-03 20:22:58 +00:00
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 &&
2023-08-09 00:53:06 +00:00
peekInfo?.eraId != null
2021-12-03 20:22:58 +00:00
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
2023-08-09 00:53:06 +00:00
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
2021-12-03 20:22:58 +00:00
}
2020-11-13 19:57:55 +00:00
}
2021-12-03 20:22:58 +00:00
this.syncGroupCallToRedux(conversationId, groupCall);
},
onRemoteDeviceStatesChanged: groupCall => {
2023-08-09 00:53:06 +00:00
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
log.info(
'GroupCall#onRemoteDeviceStatesChanged',
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
2021-12-03 20:22:58 +00:00
this.syncGroupCallToRedux(conversationId, groupCall);
},
onAudioLevels: groupCall => {
const remoteDeviceStates = groupCall.getRemoteDeviceStates();
if (!remoteDeviceStates) {
return;
}
const localAudioLevel = groupCall.getLocalDeviceState().audioLevel;
this.reduxInterface?.groupCallAudioLevelsChange({
conversationId,
localAudioLevel,
remoteDeviceStates,
});
2022-01-16 02:59:01 +00:00
},
2023-09-27 21:22:51 +00:00
onLowBandwidthForVideo: (_groupCall, _recovered) => {
// TODO: Implement handling of "low outgoing bandwidth for video" notification.
},
2023-11-16 19:55:35 +00:00
/**
* @param reactions A list of reactions received by the client ordered
* from oldest to newest.
*/
onReactions: (_groupCall, reactions) => {
this.reduxInterface?.receiveGroupCallReactions({
conversationId,
reactions,
});
2023-09-28 21:52:07 +00:00
},
2023-12-06 21:52:29 +00:00
onRaisedHands: (_groupCall, raisedHands) => {
this.reduxInterface?.groupCallRaisedHandsChange({
conversationId,
raisedHands,
});
2023-10-20 18:08:12 +00:00
},
2021-12-03 20:22:58 +00:00
onPeekChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
2023-08-09 00:53:06 +00:00
const peekInfo = groupCall.getPeekInfo() ?? null;
log.info(
'GroupCall#onPeekChanged',
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
const { eraId } = peekInfo ?? {};
if (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
2021-12-03 20:22:58 +00:00
localDeviceState.connectionState !== ConnectionState.NotConnected &&
localDeviceState.joinState === JoinState.Joined &&
eraId
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
void this.sendGroupCallUpdateMessage(conversationId, eraId);
2020-11-13 19:57:55 +00:00
}
2023-08-10 22:41:53 +00:00
void this.updateCallHistoryForGroupCall(
conversationId,
convertJoinState(localDeviceState.joinState),
peekInfo
);
2021-12-03 20:22:58 +00:00
this.syncGroupCallToRedux(conversationId, groupCall);
},
async requestMembershipProof(groupCall) {
if (isRequestingMembershipProof) {
return;
2020-11-13 19:57:55 +00:00
}
2021-12-03 20:22:58 +00:00
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));
},
2023-08-09 00:53:06 +00:00
onEnded: (groupCall, endedReason) => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
log.info(
'GroupCall#onEnded',
endedReason,
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
},
2021-12-03 20:22:58 +00:00
}
);
2020-11-13 19:57:55 +00:00
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(
2020-11-13 19:57:55 +00:00
conversationId: string,
hasLocalAudio: boolean,
hasLocalVideo: boolean,
shouldRing: boolean
): Promise<void> {
2021-11-11 22:43:05 +00:00
const conversation =
window.ConversationController.get(conversationId)?.format();
2020-11-13 19:57:55 +00:00
if (!conversation) {
log.error('Missing conversation; not joining group call');
2020-11-13 19:57:55 +00:00
return;
}
if (
!conversation.groupId ||
!conversation.publicParams ||
!conversation.secretParams
) {
log.error(
2020-11-13 19:57:55 +00:00
'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();
2020-11-13 19:57:55 +00:00
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) {
2021-08-20 16:06:15 +00:00
groupCall.ringAll();
}
2020-11-13 19:57:55 +00:00
groupCall.join();
}
private getCallIdForConversation(conversationId: string): undefined | CallId {
2020-11-13 19:57:55 +00:00
return this.getDirectCall(conversationId)?.callId;
}
public setGroupCallVideoRequest(
conversationId: string,
2022-09-07 15:52:55 +00:00
resolutions: Array<VideoRequest>,
speakerHeight: number
): void {
2022-09-07 15:52:55 +00:00
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));
}
2020-11-13 19:57:55 +00:00
// 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;
2020-11-13 19:57:55 +00:00
case JoinState.Joined:
return GroupCallJoinState.Joined;
default:
throw missingCaseError(joinState);
}
}
2020-11-20 17:19:28 +00:00
public formatGroupCallPeekInfoForRedux(
2020-12-02 18:14:03 +00:00
peekInfo: PeekInfo
2020-11-20 17:19:28 +00:00
): GroupCallPeekInfoType {
const creatorAci = peekInfo.creator && bytesToUuid(peekInfo.creator);
2020-11-20 17:19:28 +00:00
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(
2021-09-24 00:49:05 +00:00
'Calling.formatGroupCallPeekInfoForRedux: could not convert peek UUID Uint8Array to string; using fallback UUID'
2020-12-02 18:14:03 +00:00
);
} else {
log.error(
'Calling.formatGroupCallPeekInfoForRedux: device had no user ID; using fallback UUID'
);
2020-12-02 18:14:03 +00:00
}
return normalizeAci(
'00000000-0000-4000-8000-000000000000',
'formatGrouPCallPeekInfoForRedux'
);
2020-12-02 18:14:03 +00:00
}),
creatorAci:
creatorAci !== undefined
? normalizeAci(
creatorAci,
'formatGroupCallPeekInfoForRedux.creatorAci'
)
: undefined,
2020-11-20 17:19:28 +00:00
eraId: peekInfo.eraId,
maxDevices: peekInfo.maxDevices ?? Infinity,
deviceCount: peekInfo.deviceCount,
};
}
2020-11-13 19:57:55 +00:00
private formatGroupCallForRedux(groupCall: GroupCall) {
const localDeviceState = groupCall.getLocalDeviceState();
2020-12-02 18:14:03 +00:00
const peekInfo = groupCall.getPeekInfo();
2020-11-13 19:57:55 +00:00
// 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
2020-12-02 18:14:03 +00:00
// participants with the same demux ID in the call. This should be rare.
2020-11-13 19:57:55 +00:00
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,
2023-11-16 19:55:35 +00:00
localDemuxId: localDeviceState.demuxId,
2020-12-02 18:14:03 +00:00
peekInfo: peekInfo
? this.formatGroupCallPeekInfoForRedux(peekInfo)
: undefined,
2020-11-17 15:07:53 +00:00
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => {
let aci = bytesToUuid(remoteDeviceState.userId);
if (!aci) {
log.error(
2021-09-24 00:49:05 +00:00
'Calling.formatGroupCallForRedux: could not convert remote participant UUID Uint8Array to string; using fallback UUID'
2020-12-02 18:14:03 +00:00
);
aci = '00000000-0000-4000-8000-000000000000';
2020-12-02 18:14:03 +00:00
}
assertDev(isAciString(aci), 'remote participant aci must be a aci');
2024-01-23 19:08:21 +00:00
2020-11-17 15:07:53 +00:00
return {
aci,
2024-01-23 19:08:21 +00:00
addedTime: normalizeGroupCallTimestamp(remoteDeviceState.addedTime),
2020-11-17 15:07:53 +00:00
demuxId: remoteDeviceState.demuxId,
hasRemoteAudio: !remoteDeviceState.audioMuted,
hasRemoteVideo: !remoteDeviceState.videoMuted,
2024-01-23 19:08:21 +00:00
mediaKeysReceived: remoteDeviceState.mediaKeysReceived,
presenting: Boolean(remoteDeviceState.presenting),
sharingScreen: Boolean(remoteDeviceState.sharingScreen),
speakerTime: normalizeGroupCallTimestamp(
remoteDeviceState.speakerTime
),
2020-11-17 15:07:53 +00:00
// If RingRTC doesn't send us an aspect ratio, we make a guess.
videoAspectRatio:
remoteDeviceState.videoAspectRatio ||
(remoteDeviceState.videoMuted ? 1 : 4 / 3),
};
}),
2020-11-13 19:57:55 +00:00
};
}
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();
}
2023-12-06 21:52:29 +00:00
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);
}
2023-11-16 19:55:35 +00:00
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);
}
2020-11-13 19:57:55 +00:00
private syncGroupCallToRedux(
conversationId: string,
groupCall: GroupCall
): void {
this.reduxInterface?.groupCallStateChange({
2020-11-13 19:57:55 +00:00
conversationId,
...this.formatGroupCallForRedux(groupCall),
});
2020-06-04 18:16:19 +00:00
}
// Used specifically to send updates about in-progress group calls, nothing else
2021-04-08 16:24:21 +00:00
private async sendGroupCallUpdateMessage(
conversationId: string,
eraId: string
2021-04-08 16:24:21 +00:00
): Promise<void> {
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.
2021-07-02 19:21:24 +00:00
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
wrapWithSyncMessageSend({
conversation,
2021-05-25 22:40:04 +00:00
logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`,
messageIds: [],
2021-05-25 22:40:04 +00:00
send: () =>
2021-09-27 18:29:06 +00:00
conversation.queueJob('sendGroupCallUpdateMessage', () =>
2023-04-11 03:54:43 +00:00
sendToGroup({
contentHint: ContentHint.DEFAULT,
2021-09-27 18:29:06 +00:00
groupSendOptions: {
groupCallUpdate: { eraId },
groupV2,
timestamp,
},
messageId: undefined,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
2021-09-27 18:29:06 +00:00
sendType: 'callingMessage',
urgent: true,
2021-09-27 18:29:06 +00:00
})
),
sendType: 'callingMessage',
timestamp,
}).catch(err => {
log.error('Failed to send group call update:', Errors.toLogFormat(err));
});
}
2021-08-20 16:06:15 +00:00
async acceptDirectCall(
conversationId: string,
asVideoCall: boolean
): Promise<void> {
log.info('CallingClass.acceptDirectCall()');
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
log.warn('Trying to accept a non-existent call');
return;
}
2020-06-04 18:16:19 +00:00
const haveMediaPermissions = await this.requestPermissions(asVideoCall);
if (haveMediaPermissions) {
2020-08-27 00:03:42 +00:00
await this.startDeviceReselectionTimer();
RingRTC.setVideoCapturer(callId, this.videoCapturer);
RingRTC.setVideoRenderer(callId, this.videoRenderer);
2020-06-04 18:16:19 +00:00
RingRTC.accept(callId, asVideoCall);
} else {
log.info('Permissions were denied, call not allowed, hanging up.');
2020-06-04 18:16:19 +00:00
RingRTC.hangup(callId);
}
}
2021-08-20 16:06:15 +00:00
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;
}
2020-06-04 18:16:19 +00:00
RingRTC.decline(callId);
}
2021-08-20 16:06:15 +00:00
declineGroupCall(conversationId: string, ringId: bigint): void {
log.info('CallingClass.declineGroupCall()');
2021-08-20 16:06:15 +00:00
2021-11-11 22:43:05 +00:00
const groupId =
window.ConversationController.get(conversationId)?.get('groupId');
2021-08-20 16:06:15 +00:00
if (!groupId) {
log.error(
2021-08-20 16:06:15 +00:00
'declineGroupCall: could not find the group ID for that conversation'
);
return;
}
const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId));
RingRTC.cancelGroupRing(
groupIdBuffer,
ringId,
RingCancelReason.DeclinedByUser
);
}
2022-08-16 23:52:09 +00:00
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.');
2020-06-04 18:16:19 +00:00
}
hangupAllCalls(reason: string): void {
for (const conversationId of Object.keys(this.callsByConversation)) {
this.hangup(conversationId, reason);
}
}
setOutgoingAudio(conversationId: string, enabled: boolean): void {
2020-11-13 19:57:55 +00:00
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
log.warn('Trying to set outgoing audio for a non-existent call');
return;
}
2020-11-13 19:57:55 +00:00
if (call instanceof Call) {
RingRTC.setOutgoingAudio(call.callId, enabled);
} else if (call instanceof GroupCall) {
call.setOutgoingAudioMuted(!enabled);
} else {
throw missingCaseError(call);
}
2020-06-04 18:16:19 +00:00
}
setOutgoingVideo(conversationId: string, enabled: boolean): void {
2020-11-13 19:57:55 +00:00
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
log.warn('Trying to set outgoing video for a non-existent call');
return;
}
2020-11-13 19:57:55 +00:00
if (call instanceof Call) {
RingRTC.setOutgoingVideo(call.callId, enabled);
} else if (call instanceof GroupCall) {
call.setOutgoingVideoMuted(!enabled);
} else {
throw missingCaseError(call);
}
2020-06-04 18:16:19 +00:00
}
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<Array<PresentableSource>> {
// 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
2021-12-09 08:06:04 +00:00
const sources: ReadonlyArray<DesktopCapturerSource> =
OS.isLinux() && OS.isWaylandEnabled()
? (
await ipcRenderer.invoke('getScreenCaptureSources', ['screen'])
).concat(
await ipcRenderer.invoke('getScreenCaptureSources', ['window'])
)
: await ipcRenderer.invoke('getScreenCaptureSources');
const presentableSources: Array<PresentableSource> = [];
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;
}
2023-08-01 16:06:29 +00:00
async setPresenting(
conversationId: string,
hasLocalVideo: boolean,
source?: PresentedSource
2023-08-01 16:06:29 +00:00
): Promise<void> {
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;
this.videoCapturer.enableCaptureAndSend(call, {
// 15fps is much nicer but takes up a lot more CPU.
maxFramerate: 5,
maxHeight: 1800,
maxWidth: 2880,
screenShareSourceId: source.id,
});
this.setOutgoingVideo(conversationId, true);
} else {
this.setOutgoingVideo(
conversationId,
this.hadLocalVideoBeforePresenting ?? hasLocalVideo
);
this.hadLocalVideoBeforePresenting = undefined;
}
const isPresenting = Boolean(source);
this.setOutgoingVideoIsScreenShare(call, isPresenting);
if (source) {
2023-08-01 16:06:29 +00:00
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);
2021-09-23 18:16:09 +00:00
notificationService.notify({
2023-08-01 16:06:29 +00:00
conversationId,
iconPath: absolutePath,
iconUrl: url,
2023-03-30 00:03:25 +00:00
message: window.i18n('icu:calling__presenting--notification-body'),
2023-08-01 16:06:29 +00:00
type: NotificationType.IsPresenting,
2023-06-14 20:55:50 +00:00
sentAt: 0,
silent: true,
2023-03-30 00:03:25 +00:00
title: window.i18n('icu:calling__presenting--notification-title'),
});
} else {
ipcRenderer.send('close-screen-share-controller');
}
}
2020-08-27 00:03:42 +00:00
private async startDeviceReselectionTimer(): Promise<void> {
// Poll once
await this.pollForMediaDevices();
// Start the timer
if (!this.deviceReselectionTimer) {
this.deviceReselectionTimer = setInterval(async () => {
await this.pollForMediaDevices();
}, 3000);
}
2020-06-04 18:16:19 +00:00
}
2020-08-27 00:03:42 +00:00
private stopDeviceReselectionTimer() {
clearTimeoutIfNecessary(this.deviceReselectionTimer);
this.deviceReselectionTimer = undefined;
2020-08-27 00:03:42 +00:00
}
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;
}
2020-09-03 14:59:24 +00:00
for (let i = 0; i < a.availableCameras.length; i += 1) {
2020-08-27 00:03:42 +00:00
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;
}
}
2020-09-03 14:59:24 +00:00
for (let i = 0; i < a.availableMicrophones.length; i += 1) {
2020-08-27 00:03:42 +00:00
if (
a.availableMicrophones[i].name !== b.availableMicrophones[i].name ||
a.availableMicrophones[i].uniqueId !==
b.availableMicrophones[i].uniqueId
2020-08-27 00:03:42 +00:00
) {
return false;
}
}
2020-09-03 14:59:24 +00:00
for (let i = 0; i < a.availableSpeakers.length; i += 1) {
2020-08-27 00:03:42 +00:00
if (
a.availableSpeakers[i].name !== b.availableSpeakers[i].name ||
a.availableSpeakers[i].uniqueId !== b.availableSpeakers[i].uniqueId
2020-08-27 00:03:42 +00:00
) {
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<void> {
const newSettings = await this.getMediaDeviceSettings();
if (
!this.mediaDeviceSettingsEqual(this.lastMediaDeviceSettings, newSettings)
) {
log.info(
2020-08-27 00:03:42 +00:00
'MediaDevice: available devices changed (from->to)',
2022-09-07 01:07:22 +00:00
cleanForLogging(this.lastMediaDeviceSettings),
cleanForLogging(newSettings)
2020-08-27 00:03:42 +00:00
);
await this.selectPreferredDevices(newSettings);
2020-08-27 00:03:42 +00:00
this.lastMediaDeviceSettings = newSettings;
this.reduxInterface?.refreshIODevices(newSettings);
2020-08-27 00:03:42 +00:00
}
}
2021-08-18 20:08:14 +00:00
async getAvailableIODevices(): Promise<AvailableIODevicesType> {
const availableCameras = await this.videoCapturer.enumerateDevices();
2020-08-27 00:03:42 +00:00
const availableMicrophones = RingRTC.getAudioInputs();
2021-08-18 20:08:14 +00:00
const availableSpeakers = RingRTC.getAudioOutputs();
return {
availableCameras,
availableMicrophones,
availableSpeakers,
};
}
async getMediaDeviceSettings(): Promise<MediaDeviceSettings> {
2021-11-11 22:43:05 +00:00
const { availableCameras, availableMicrophones, availableSpeakers } =
await this.getAvailableIODevices();
2021-08-18 20:08:14 +00:00
const preferredMicrophone = window.Events.getPreferredAudioInputDevice();
const selectedMicIndex = findBestMatchingAudioDeviceIndex({
available: availableMicrophones,
preferred: preferredMicrophone,
});
2020-08-27 00:03:42 +00:00
const selectedMicrophone =
selectedMicIndex !== undefined
? availableMicrophones[selectedMicIndex]
: undefined;
2021-08-18 20:08:14 +00:00
const preferredSpeaker = window.Events.getPreferredAudioOutputDevice();
const selectedSpeakerIndex = findBestMatchingAudioDeviceIndex({
available: availableSpeakers,
preferred: preferredSpeaker,
});
2020-08-27 00:03:42 +00:00
const selectedSpeaker =
selectedSpeakerIndex !== undefined
? availableSpeakers[selectedSpeakerIndex]
: undefined;
2021-08-18 20:08:14 +00:00
const preferredCamera = window.Events.getPreferredVideoInputDevice();
const selectedCamera = findBestMatchingCameraId(
2020-08-27 00:03:42 +00:00
availableCameras,
preferredCamera
);
return {
availableMicrophones,
availableSpeakers,
selectedMicrophone,
selectedSpeaker,
availableCameras,
selectedCamera,
};
}
2020-09-03 14:59:24 +00:00
setPreferredMicrophone(device: AudioDevice): void {
2022-09-07 01:07:22 +00:00
log.info(
'MediaDevice: setPreferredMicrophone',
device.index,
truncateForLogging(device.name)
);
void window.Events.setPreferredAudioInputDevice(device);
2020-08-27 00:03:42 +00:00
RingRTC.setAudioInput(device.index);
}
2020-09-03 14:59:24 +00:00
setPreferredSpeaker(device: AudioDevice): void {
2022-09-07 01:07:22 +00:00
log.info(
'MediaDevice: setPreferredSpeaker',
device.index,
truncateForLogging(device.name)
);
void window.Events.setPreferredAudioOutputDevice(device);
2020-08-27 00:03:42 +00:00
RingRTC.setAudioOutput(device.index);
}
2020-10-08 01:25:33 +00:00
enableLocalCamera(): void {
this.videoCapturer.enableCapture();
}
disableLocalVideo(): void {
2020-10-08 01:25:33 +00:00
this.videoCapturer.disable();
}
2020-09-03 14:59:24 +00:00
async setPreferredCamera(device: string): Promise<void> {
log.info('MediaDevice: setPreferredCamera', device);
void window.Events.setPreferredVideoInputDevice(device);
2020-08-27 00:03:42 +00:00
await this.videoCapturer.setPreferredDevice(device);
2020-06-04 18:16:19 +00:00
}
async handleCallingMessage(
2021-07-09 19:36:10 +00:00
envelope: ProcessedEnvelope,
callingMessage: Proto.ICallingMessage
2020-09-03 14:59:24 +00:00
): Promise<void> {
log.info('CallingClass.handleCallingMessage()');
2021-08-18 20:08:14 +00:00
const enableIncomingCalls = window.Events.getIncomingCallNotification();
2020-06-04 18:16:19 +00:00
if (callingMessage.offer && !enableIncomingCalls) {
// Drop offers silently if incoming call notifications are disabled.
log.info('Incoming calls are disabled, ignoring call offer.');
2020-06-04 18:16:19 +00:00
return;
}
const remoteUserId = envelope.sourceServiceId;
2020-06-04 18:16:19 +00:00
const remoteDeviceId = this.parseDeviceId(envelope.sourceDevice);
if (!remoteUserId || !remoteDeviceId || !this.localDeviceId) {
log.error('Missing identifier, ignoring call message.');
2020-06-04 18:16:19 +00:00
return;
}
const { storage } = window.textsecure;
2021-11-11 22:43:05 +00:00
const senderIdentityRecord =
await storage.protocol.getOrMigrateIdentityRecord(remoteUserId);
2020-09-12 00:53:19 +00:00
if (!senderIdentityRecord) {
log.error('Missing sender identity record; ignoring call message.');
2020-09-12 00:53:19 +00:00
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);
2020-09-12 00:53:19 +00:00
if (!receiverIdentityRecord) {
log.error('Missing receiver identity record; ignoring call message.');
2020-09-12 00:53:19 +00:00
return;
}
const receiverIdentityKey = receiverIdentityRecord.publicKey.slice(1); // Ignore the type header, it is not used.
2020-09-28 19:02:35 +00:00
const conversation = window.ConversationController.get(remoteUserId);
if (!conversation) {
log.error('Missing conversation; ignoring call message.');
2020-09-28 19:02:35 +00:00
return;
}
if (
callingMessage.offer &&
!conversation.getAccepted({ ignoreEmptyConvo: true })
) {
log.info(
2020-09-28 19:02:35 +00:00
'Conversation was not approved by user; rejecting call message.'
);
2023-03-29 21:16:19 +00:00
const { callId } = callingMessage.offer;
assertDev(callId != null, 'Call ID missing from offer');
const hangup = new HangupMessage(
callId,
HangupType.NeedPermission,
remoteDeviceId
);
2020-09-28 19:02:35 +00:00
const message = new CallingMessage();
2023-09-13 23:40:26 +00:00
message.hangup = hangup;
2020-09-28 19:02:35 +00:00
await this.handleOutgoingSignaling(remoteUserId, message);
2023-08-09 00:53:06 +00:00
const wasVideoCall =
callingMessage.offer.type ===
Proto.CallingMessage.Offer.Type.OFFER_VIDEO_CALL;
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromEndedDirectCall(
callId.toString(),
peerId,
remoteUserId, // Incoming call
2023-08-09 00:53:06 +00:00
wasVideoCall,
envelope.timestamp
);
const localCallEvent = LocalCallEvent.Missed;
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'CallingClass.handleCallingMessage'
);
2023-08-09 00:53:06 +00:00
await updateCallHistoryFromLocalEvent(
callEvent,
envelope.receivedAtCounter
2020-09-28 19:02:35 +00:00
);
return;
}
const sourceServiceId = envelope.sourceServiceId
? uuidToBytes(envelope.sourceServiceId)
2020-11-13 19:57:55 +00:00
: null;
const messageAgeSec = envelope.messageAgeSec ? envelope.messageAgeSec : 0;
2020-06-04 18:16:19 +00:00
log.info('CallingClass.handleCallingMessage(): Handling in RingRTC');
2020-06-04 18:16:19 +00:00
RingRTC.handleCallingMessage(
remoteUserId,
sourceServiceId ? Buffer.from(sourceServiceId) : null,
2020-06-04 18:16:19 +00:00
remoteDeviceId,
this.localDeviceId,
messageAgeSec,
2022-01-28 19:55:36 +00:00
envelope.receivedAtCounter,
2021-07-09 19:36:10 +00:00
protoToCallingMessage(callingMessage),
2021-06-29 14:39:53 +00:00
Buffer.from(senderIdentityKey),
Buffer.from(receiverIdentityKey)
2020-06-04 18:16:19 +00:00
);
}
private async selectPreferredDevices(
2020-08-27 00:03:42 +00:00
settings: MediaDeviceSettings
): Promise<void> {
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);
}
2020-09-03 14:59:24 +00:00
// Assume that the MediaDeviceSettings have been obtained very recently and
// the index is still valid (no devices have been plugged in in between).
2020-08-27 00:03:42 +00:00
if (settings.selectedMicrophone) {
log.info(
2020-08-27 00:03:42 +00:00
'MediaDevice: selecting microphone',
2022-09-07 01:07:22 +00:00
settings.selectedMicrophone.index,
truncateForLogging(settings.selectedMicrophone.name)
2020-08-27 00:03:42 +00:00
);
RingRTC.setAudioInput(settings.selectedMicrophone.index);
}
2020-08-27 00:03:42 +00:00
if (settings.selectedSpeaker) {
2022-09-07 01:07:22 +00:00
log.info(
'MediaDevice: selecting speaker',
settings.selectedSpeaker.index,
truncateForLogging(settings.selectedSpeaker.name)
);
2020-08-27 00:03:42 +00:00
RingRTC.setAudioOutput(settings.selectedSpeaker.index);
}
}
2020-06-04 18:16:19 +00:00
private async requestCameraPermissions(): Promise<boolean> {
2023-01-13 00:24:59 +00:00
const cameraPermission = await window.IPC.getMediaCameraPermissions();
2020-06-04 18:16:19 +00:00
if (!cameraPermission) {
2023-01-13 00:24:59 +00:00
await window.IPC.showPermissionsPopup(true, true);
2020-06-04 18:16:19 +00:00
// Check the setting again (from the source of truth).
2023-01-13 00:24:59 +00:00
return window.IPC.getMediaCameraPermissions();
2020-06-04 18:16:19 +00:00
}
return true;
}
private async requestPermissions(isVideoCall: boolean): Promise<boolean> {
2021-11-05 08:47:32 +00:00
const microphonePermission = await requestMicrophonePermissions(true);
2020-06-04 18:16:19 +00:00
if (microphonePermission) {
if (isVideoCall) {
return this.requestCameraPermissions();
}
2020-09-09 00:46:29 +00:00
return true;
2020-06-04 18:16:19 +00:00
}
2020-09-09 00:46:29 +00:00
return false;
2020-06-04 18:16:19 +00:00
}
2020-11-13 19:57:55 +00:00
private async handleSendCallMessage(
2021-06-29 14:39:53 +00:00
recipient: Uint8Array,
2021-08-20 16:06:15 +00:00
data: Uint8Array,
urgency: CallMessageUrgency
2020-11-13 19:57:55 +00:00
): Promise<boolean> {
2021-09-24 00:49:05 +00:00
const userId = bytesToUuid(recipient);
2020-11-13 19:57:55 +00:00
if (!userId) {
log.error('handleSendCallMessage(): bad recipient UUID');
2020-11-13 19:57:55 +00:00
return false;
}
const message = new CallingMessage();
message.opaque = new OpaqueMessage();
2021-06-29 14:39:53 +00:00
message.opaque.data = Buffer.from(data);
2021-08-20 16:06:15 +00:00
return this.handleOutgoingSignaling(userId, message, urgency);
}
// Used to send a variety of group call messages, including the initial call message
2021-08-20 16:06:15 +00:00
private async handleSendCallMessageToGroup(
groupIdBytes: Buffer,
data: Buffer,
urgency: CallMessageUrgency
): Promise<void> {
const groupId = groupIdBytes.toString('base64');
const conversation = window.ConversationController.get(groupId);
if (!conversation) {
log.error('handleSendCallMessageToGroup(): could not find conversation');
2021-08-20 16:06:15 +00:00
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;
2021-08-20 16:06:15 +00:00
// We "fire and forget" because sending this message is non-essential.
// We also don't sync this message.
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
2021-09-27 18:29:06 +00:00
await conversation.queueJob('handleSendCallMessageToGroup', async () =>
handleMessageSend(
2023-04-11 03:54:43 +00:00
sendContentMessageToGroup({
2021-09-27 18:29:06 +00:00
contentHint: ContentHint.DEFAULT,
contentMessage,
isPartialSend: false,
messageId: undefined,
recipients: conversation.getRecipients(),
sendOptions: await getSendOptions(conversation.attributes),
sendTarget: conversation.toSenderKeyTarget(),
2021-09-27 18:29:06 +00:00
sendType: 'callingMessage',
timestamp,
urgent,
2021-09-27 18:29:06 +00:00
}),
{ messageIds: [], sendType: 'callingMessage' }
)
2021-08-20 16:06:15 +00:00
);
}
private async handleGroupCallRingUpdate(
groupIdBytes: Buffer,
ringId: bigint,
ringerBytes: Buffer,
update: RingUpdate
): Promise<void> {
log.info(`handleGroupCallRingUpdate(): got ring update ${update}`);
2021-08-20 16:06:15 +00:00
const groupId = groupIdBytes.toString('base64');
2021-09-24 00:49:05 +00:00
const ringerUuid = bytesToUuid(ringerBytes);
2021-08-20 16:06:15 +00:00
if (!ringerUuid) {
log.error('handleGroupCallRingUpdate(): ringerUuid was invalid');
2021-08-20 16:06:15 +00:00
return;
}
const ringerAci = normalizeAci(ringerUuid, 'handleGroupCallRingUpdate');
2021-08-20 16:06:15 +00:00
const conversation = window.ConversationController.get(groupId);
if (!conversation) {
log.error('handleGroupCallRingUpdate(): could not find conversation');
2021-08-20 16:06:15 +00:00
return;
}
2022-11-19 08:31:31 +00:00
2023-10-23 20:31:07 +00:00
if (update === RingUpdate.Requested) {
this.reduxInterface?.peekNotConnectedGroupCall({
conversationId: conversation.id,
});
}
2022-11-19 08:31:31 +00:00
const logId = `handleGroupCallRingUpdate(${conversation.idForLogging()})`;
if (conversation.isBlocked()) {
log.warn(`${logId}: is blocked`);
return;
}
const ourAci = window.textsecure.storage.user.getCheckedAci();
2022-11-19 08:31:31 +00:00
if (conversation.get('left') || !conversation.hasMember(ourAci)) {
2022-11-19 08:31:31 +00:00
log.warn(`${logId}: we left the group`);
return;
}
if (!conversation.hasMember(ringerAci)) {
2022-11-19 08:31:31 +00:00
log.warn(`${logId}: they left the group`);
return;
}
if (
conversation.get('announcementsOnly') &&
!conversation.isAdmin(ringerAci)
2022-11-19 08:31:31 +00:00
) {
log.warn(`${logId}: non-admin update to announcement-only group`);
return;
}
2021-08-20 16:06:15 +00:00
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'
);
2021-08-20 16:06:15 +00:00
}
} else {
await processGroupCallRingCancellation(ringId);
2021-08-20 16:06:15 +00:00
}
if (shouldRing) {
log.info('handleGroupCallRingUpdate: ringing');
this.reduxInterface?.receiveIncomingGroupCall({
2021-08-20 16:06:15 +00:00
conversationId,
ringId,
ringerAci,
2021-08-20 16:06:15 +00:00
});
} else {
log.info('handleGroupCallRingUpdate: canceling the existing ring');
this.reduxInterface?.cancelIncomingGroupCallRing({
2021-08-20 16:06:15 +00:00
conversationId,
ringId,
});
}
2023-08-09 00:53:06 +00:00
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,
'CallingClass.handleGroupCallRingUpdate'
2023-08-09 00:53:06 +00:00
);
await updateCallHistoryFromLocalEvent(callEvent, null);
}
2020-11-13 19:57:55 +00:00
}
// Used for all 1:1 call messages, including the initial message to start the call
2020-06-04 18:16:19 +00:00
private async handleOutgoingSignaling(
remoteUserId: UserId,
2021-08-20 16:06:15 +00:00
message: CallingMessage,
urgency?: CallMessageUrgency
2020-06-25 21:41:33 +00:00
): Promise<boolean> {
2020-06-04 18:16:19 +00:00
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;
}
2020-06-04 18:16:19 +00:00
// We want 1:1 call initiate messages to wake up recipient devices, but not others
const urgent =
urgency === CallMessageUrgency.HandleImmediately ||
Boolean(message.offer);
2020-06-04 18:16:19 +00:00
try {
assertDev(isAciString(remoteUserId), 'remoteUserId is not a aci');
const result = await handleMessageSend(
window.textsecure.messaging.sendCallingMessage(
remoteUserId,
2021-08-20 16:06:15 +00:00
callingMessageToProto(message, urgency),
urgent,
sendOptions
),
{ messageIds: [], sendType: 'callingMessage' }
2020-06-04 18:16:19 +00:00
);
if (result && result.errors && result.errors.length) {
throw result.errors[0];
}
log.info('handleOutgoingSignaling() completed successfully');
2020-06-25 21:41:33 +00:00
return true;
2020-06-04 18:16:19 +00:00
} catch (err) {
if (err && err.errors && err.errors.length > 0) {
log.error(`handleOutgoingSignaling() failed: ${err.errors[0].reason}`);
2020-06-04 18:16:19 +00:00
} else {
log.error('handleOutgoingSignaling() failed');
2020-06-04 18:16:19 +00:00
}
2020-06-25 21:41:33 +00:00
return false;
2020-06-04 18:16:19 +00:00
}
}
// If we return null here, we hang up the call.
private async handleIncomingCall(call: Call): Promise<boolean> {
log.info('CallingClass.handleIncomingCall()');
if (!this.reduxInterface || !this.localDeviceId) {
log.error('Missing required objects, ignoring incoming call.');
return false;
2020-06-04 18:16:19 +00:00
}
const conversation = window.ConversationController.get(call.remoteUserId);
if (!conversation) {
log.error('Missing conversation, ignoring incoming call.');
return false;
2020-06-04 18:16:19 +00:00
}
2022-11-19 08:31:31 +00:00
if (conversation.isBlocked()) {
log.warn(
`handleIncomingCall(): ${conversation.idForLogging()} is blocked`
);
return false;
2022-11-19 08:31:31 +00:00
}
2020-06-04 18:16:19 +00:00
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()}`
);
2023-08-09 00:53:06 +00:00
const localCallEvent = LocalCallEvent.Missed;
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'CallingClass.handleIncomingCall'
);
2023-08-09 00:53:06 +00:00
await updateCallHistoryFromLocalEvent(callEvent, null);
return false;
2020-06-04 18:16:19 +00:00
}
this.attachToCall(conversation, call);
this.reduxInterface.receiveIncomingDirectCall({
conversationId: conversation.id,
isVideoCall: call.isVideoCall,
2020-06-04 18:16:19 +00:00
});
return true;
2020-06-04 18:16:19 +00:00
} catch (err) {
log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`);
return false;
2020-06-04 18:16:19 +00:00
}
}
2023-01-10 00:52:01 +00:00
private async handleAutoEndedIncomingCallRequest(
2023-08-09 00:53:06 +00:00
callIdValue: CallId,
2020-06-04 18:16:19 +00:00
remoteUserId: UserId,
2023-08-09 00:53:06 +00:00
callEndedReason: CallEndedReason,
2022-01-28 19:55:36 +00:00
ageInSeconds: number,
wasVideoCall: boolean,
receivedAtCounter: number | undefined
2020-06-04 18:16:19 +00:00
) {
const conversation = window.ConversationController.get(remoteUserId);
if (!conversation) {
return;
}
2021-09-23 15:49:33 +00:00
2023-08-09 00:53:06 +00:00
const callId = Long.fromValue(callIdValue).toString();
const peerId = getPeerIdFromConversation(conversation.attributes);
2021-09-23 15:49:33 +00:00
// 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;
2023-08-09 00:53:06 +00:00
const timestamp = Date.now() - ageInMilliseconds;
2021-09-23 15:49:33 +00:00
2023-08-09 00:53:06 +00:00
const callDetails = getCallDetailsFromEndedDirectCall(
callId,
peerId,
remoteUserId,
2022-01-28 19:55:36 +00:00
wasVideoCall,
2023-08-09 00:53:06 +00:00
timestamp
2021-09-23 15:49:33 +00:00
);
2023-08-09 00:53:06 +00:00
const localCallEvent =
getLocalCallEventFromCallEndedReason(callEndedReason);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'CallingClass.handleAutoEndedIncomingCallRequest'
);
2023-08-09 00:53:06 +00:00
await updateCallHistoryFromLocalEvent(callEvent, receivedAtCounter ?? null);
2020-06-04 18:16:19 +00:00
}
private attachToCall(conversation: ConversationModel, call: Call): void {
this.callsByConversation[conversation.id] = call;
const { reduxInterface } = this;
if (!reduxInterface) {
2020-06-04 18:16:19 +00:00
return;
}
let acceptedTime: number | null = null;
2020-09-03 14:59:24 +00:00
// eslint-disable-next-line no-param-reassign
2023-01-10 00:52:01 +00:00
call.handleStateChanged = async () => {
if (call.state === CallState.Accepted) {
acceptedTime = acceptedTime ?? Date.now();
}
2023-08-09 00:53:06 +00:00
if (call.state === CallState.Ended) {
2020-08-27 00:03:42 +00:00
this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined;
delete this.callsByConversation[conversation.id];
2020-06-04 18:16:19 +00:00
}
2023-08-09 00:53:06 +00:00
const localCallEvent = getLocalCallEventFromDirectCall(call);
if (localCallEvent != null) {
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'call.handleStateChanged'
);
2023-08-09 00:53:06 +00:00
await updateCallHistoryFromLocalEvent(callEvent, null);
}
reduxInterface.callStateChange({
conversationId: conversation.id,
2020-06-04 18:16:19 +00:00
callState: call.state,
2020-10-01 19:09:15 +00:00
callEndedReason: call.endedReason,
acceptedTime,
2020-06-04 18:16:19 +00:00
});
};
2020-09-03 14:59:24 +00:00
// eslint-disable-next-line no-param-reassign
2020-06-04 18:16:19 +00:00
call.handleRemoteVideoEnabled = () => {
reduxInterface.remoteVideoChange({
conversationId: conversation.id,
hasVideo: call.remoteVideoEnabled,
2020-06-04 18:16:19 +00:00
});
};
// eslint-disable-next-line no-param-reassign
call.handleRemoteSharingScreen = () => {
reduxInterface.remoteSharingScreenChange({
conversationId: conversation.id,
isSharingScreen: Boolean(call.remoteSharingScreen),
});
};
2023-09-27 21:22:51 +00:00
// eslint-disable-next-line no-param-reassign
call.handleLowBandwidthForVideo = _recovered => {
// TODO: Implement handling of "low outgoing bandwidth for video" notification.
};
2020-06-04 18:16:19 +00:00
}
private async handleLogMessage(
level: CallLogLevel,
fileName: string,
line: number,
message: string
) {
switch (level) {
case CallLogLevel.Info:
log.info(`${fileName}:${line} ${message}`);
2020-06-04 18:16:19 +00:00
break;
case CallLogLevel.Warn:
log.warn(`${fileName}:${line} ${message}`);
2020-06-04 18:16:19 +00:00
break;
case CallLogLevel.Error:
log.error(`${fileName}:${line} ${message}`);
2020-09-03 14:59:24 +00:00
break;
default:
break;
2020-06-04 18:16:19 +00:00
}
}
2020-11-13 19:57:55 +00:00
private async handleSendHttpRequest(
requestId: number,
url: string,
method: HttpMethod,
headers: { [name: string]: string },
2021-06-29 14:39:53 +00:00
body: Uint8Array | undefined
2020-11-13 19:57:55 +00:00
) {
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,
2021-09-24 00:49:05 +00:00
body
2020-11-13 19:57:55 +00:00
);
} catch (err) {
2020-11-19 21:35:11 +00:00
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.
2021-06-29 14:39:53 +00:00
RingRTC.receivedHttpResponse(requestId, err.code, Buffer.alloc(0));
2020-11-19 21:35:11 +00:00
} else {
log.error('handleSendHttpRequest: fetch failed with error', err);
2020-11-19 21:35:11 +00:00
RingRTC.httpRequestFailed(requestId, String(err));
}
2020-11-13 19:57:55 +00:00
return;
}
RingRTC.receivedHttpResponse(
requestId,
result.response.status,
2021-06-29 14:39:53 +00:00
Buffer.from(result.data)
2020-11-13 19:57:55 +00:00
);
}
2020-06-04 18:16:19 +00:00
private getRemoteUserIdFromConversation(
conversation: ConversationModel
): UserId | undefined | null {
2020-06-04 18:16:19 +00:00
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<boolean> {
if (!window.textsecure.messaging) {
log.error('handleStartCall: offline!');
return false;
}
2021-09-22 00:58:03 +00:00
const iceServer = await window.textsecure.messaging.server.getIceServers();
2020-06-04 18:16:19 +00:00
const shouldRelayCalls = window.Events.getAlwaysRelayCalls();
2020-06-04 18:16:19 +00:00
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);
2020-06-04 18:16:19 +00:00
const callSettings = {
2021-09-24 00:49:05 +00:00
iceServer: {
...iceServer,
urls: iceServer.urls.slice(),
},
hideIp: shouldRelayCalls || isContactUntrusted,
2023-05-31 23:37:19 +00:00
dataMode: DataMode.Normal,
// TODO: DESKTOP-3101
// audioLevelsIntervalMillis: AUDIO_LEVEL_INTERVAL_MS,
2020-06-04 18:16:19 +00:00
};
log.info('CallingClass.handleStartCall(): Proceeding');
RingRTC.proceed(call.callId, callSettings);
return true;
2020-06-04 18:16:19 +00:00
}
public async updateCallHistoryForGroupCall(
conversationId: string,
joinState: GroupCallJoinState | null,
2023-08-09 00:53:06 +00:00
peekInfo: PeekInfo | null
): Promise<void> {
2023-08-09 00:53:06 +00:00
const groupCallMeta = getGroupCallMeta(peekInfo);
// If we don't have the necessary pieces to peek, bail. (It's okay if we don't.)
2023-08-09 00:53:06 +00:00
if (groupCallMeta == null) {
return;
}
2023-08-09 00:53:06 +00:00
const creatorConversation = window.ConversationController.get(
groupCallMeta.ringerId
);
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
2023-08-09 00:53:06 +00:00
log.error('maybeNotifyGroupCall(): could not find conversation');
return;
}
2023-08-09 00:53:06 +00:00
const prevMessageId =
await window.Signal.Data.getCallHistoryMessageByCallId({
conversationId: conversation.id,
callId: groupCallMeta.callId,
});
const isNewCall = prevMessageId == null;
2023-08-10 22:41:53 +00:00
if (isNewCall) {
const localCallEvent = getLocalCallEventFromJoinState(
joinState,
2023-08-09 00:53:06 +00:00
groupCallMeta
);
if (localCallEvent != null) {
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromGroupCallMeta(
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'CallingClass.updateCallHistoryForGroupCall'
);
2023-08-09 00:53:06 +00:00
await updateCallHistoryFromLocalEvent(callEvent, null);
}
}
const wasStartedByMe = Boolean(
creatorConversation && isMe(creatorConversation.attributes)
);
2023-08-09 00:53:06 +00:00
const isAnybodyElseInGroupCall = Boolean(peekInfo?.devices.length);
if (
isNewCall &&
!wasStartedByMe &&
isAnybodyElseInGroupCall &&
!conversation.isMuted()
) {
2023-08-01 16:06:29 +00:00
await this.notifyForGroupCall(conversation, creatorConversation);
}
}
2023-08-01 16:06:29 +00:00
private async notifyForGroupCall(
conversation: Readonly<ConversationModel>,
creatorConversation: undefined | Readonly<ConversationModel>
2023-08-01 16:06:29 +00:00
): Promise<void> {
let notificationTitle: string;
let notificationMessage: string;
switch (notificationService.getNotificationSetting()) {
case NotificationSetting.Off:
return;
case NotificationSetting.NoNameOrMessage:
notificationTitle = FALLBACK_NOTIFICATION_TITLE;
notificationMessage = window.i18n(
2023-03-30 00:03:25 +00:00
'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
2023-03-30 00:03:25 +00:00
? window.i18n('icu:calling__call-notification__started', {
2023-03-27 23:37:39 +00:00
name: creatorConversation.getTitle(),
})
2023-03-30 00:03:25 +00:00
: window.i18n('icu:calling__call-notification__started-by-someone');
break;
}
2023-08-01 16:06:29 +00:00
const { url, absolutePath } = await conversation.getAvatarOrIdenticon();
notificationService.notify({
2023-08-01 16:06:29 +00:00
conversationId: conversation.id,
iconPath: absolutePath,
iconUrl: url,
message: notificationMessage,
2023-08-01 16:06:29 +00:00
type: NotificationType.IncomingGroupCall,
2023-06-14 20:55:50 +00:00
sentAt: 0,
silent: false,
title: notificationTitle,
});
}
private areAnyCallsActiveOrRinging(): boolean {
return this.reduxInterface?.areAnyCallsActiveOrRinging() ?? false;
}
2021-08-20 16:06:15 +00:00
private async cleanExpiredGroupCallRingsAndLoop(): Promise<void> {
try {
await cleanExpiredGroupCallRingCancellations();
2021-08-20 16:06:15 +00:00
} 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();
2021-08-20 16:06:15 +00:00
}, 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<void> {
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));
}
}
2020-06-04 18:16:19 +00:00
}
export const calling = new CallingClass();