signal-desktop/ts/services/calling.ts

1329 lines
39 KiB
TypeScript
Raw Normal View History

2020-10-30 20:34:04 +00:00
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2020-09-03 14:59:24 +00:00
/* eslint-disable class-methods-use-this */
2020-06-04 18:16:19 +00:00
import {
Call,
CallEndedReason,
CallId,
2020-09-28 19:02:35 +00:00
CallingMessage,
2020-06-04 18:16:19 +00:00
CallLogLevel,
CallSettings,
CallState,
2020-08-27 00:03:42 +00:00
CanvasVideoRenderer,
2020-11-13 19:57:55 +00:00
ConnectionState,
JoinState,
HttpMethod,
2020-06-04 18:16:19 +00:00
DeviceId,
2020-11-13 19:57:55 +00:00
GroupCall,
GroupMemberInfo,
2020-08-27 00:03:42 +00:00
GumVideoCapturer,
2020-09-28 19:02:35 +00:00
HangupMessage,
HangupType,
OfferType,
2020-11-13 19:57:55 +00:00
OpaqueMessage,
2020-06-04 18:16:19 +00:00
RingRTC,
UserId,
2020-11-13 19:57:55 +00:00
VideoFrameSource,
2020-06-04 18:16:19 +00:00
} from 'ringrtc';
2020-11-13 19:57:55 +00:00
import { uniqBy, noop } from 'lodash';
2020-09-12 00:53:19 +00:00
import { ActionsType as UxActionsType } from '../state/ducks/calling';
2020-11-13 19:57:55 +00:00
import { getConversationCallMode } from '../state/ducks/conversations';
2020-09-28 19:02:35 +00:00
import { EnvelopeClass } from '../textsecure.d';
2020-11-13 19:57:55 +00:00
import {
CallMode,
AudioDevice,
MediaDeviceSettings,
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import { ConversationModel } from '../models/conversations';
2020-11-13 19:57:55 +00:00
import {
base64ToArrayBuffer,
uuidToArrayBuffer,
arrayBufferToUuid,
} from '../Crypto';
import { getOwn } from '../util/getOwn';
import { fetchMembershipProof, getMembershipList } from '../groups';
import { missingCaseError } from '../util/missingCaseError';
const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
HttpMethod,
'GET' | 'PUT' | 'POST'
> = new Map([
[HttpMethod.Get, 'GET'],
[HttpMethod.Put, 'PUT'],
[HttpMethod.Post, 'POST'],
]);
2020-06-04 18:16:19 +00:00
export {
CallState,
CanvasVideoRenderer,
GumVideoCapturer,
VideoCapturer,
VideoRenderer,
} from 'ringrtc';
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
2020-06-04 18:16:19 +00:00
private uxActions?: UxActionsType;
2020-09-03 14:59:24 +00:00
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 };
2020-08-27 00:03:42 +00:00
constructor() {
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
this.videoRenderer = new CanvasVideoRenderer();
this.callsByConversation = {};
2020-08-27 00:03:42 +00:00
}
2020-06-04 18:16:19 +00:00
initialize(uxActions: UxActionsType): void {
this.uxActions = uxActions;
if (!uxActions) {
throw new Error('CallingClass.initialize: Invalid uxActions.');
}
RingRTC.handleOutgoingSignaling = this.handleOutgoingSignaling.bind(this);
RingRTC.handleIncomingCall = this.handleIncomingCall.bind(this);
RingRTC.handleAutoEndedIncomingCallRequest = this.handleAutoEndedIncomingCallRequest.bind(
this
);
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);
2020-06-04 18:16:19 +00:00
}
2020-10-08 01:25:33 +00:00
async startCallingLobby(
conversation: ConversationModel,
2020-06-04 18:16:19 +00:00
isVideoCall: boolean
2020-09-03 14:59:24 +00:00
): Promise<void> {
2020-10-08 01:25:33 +00:00
window.log.info('CallingClass.startCallingLobby()');
2020-11-13 19:57:55 +00:00
const conversationProps = conversation.format();
const callMode = getConversationCallMode(conversationProps);
switch (callMode) {
case CallMode.None:
window.log.error(
'Conversation does not support calls, new call not allowed.'
);
return;
case CallMode.Direct:
if (!this.getRemoteUserIdFromConversation(conversation)) {
window.log.error(
'Missing remote user identifier, new call not allowed.'
);
return;
}
break;
case CallMode.Group:
break;
default:
throw missingCaseError(callMode);
}
2020-06-04 18:16:19 +00:00
if (!this.uxActions) {
window.log.error('Missing uxActions, new call not allowed.');
return;
}
2020-11-13 19:57:55 +00:00
if (!this.localDeviceId) {
window.log.error(
'Missing local device identifier, new call not allowed.'
);
2020-06-04 18:16:19 +00:00
return;
}
const haveMediaPermissions = await this.requestPermissions(isVideoCall);
if (!haveMediaPermissions) {
window.log.info('Permissions were denied, new call not allowed.');
return;
}
2020-11-13 19:57:55 +00:00
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
2020-10-08 01:25:33 +00:00
2020-11-13 19:57:55 +00:00
switch (callMode) {
case CallMode.Direct:
this.uxActions.showCallLobby({
callMode: CallMode.Direct,
conversationId: conversationProps.id,
hasLocalAudio: true,
hasLocalVideo: isVideoCall,
});
break;
case CallMode.Group: {
if (
!conversationProps.groupId ||
!conversationProps.publicParams ||
!conversationProps.secretParams
) {
window.log.error(
'Conversation is missing required parameters. Cannot connect group call'
);
return;
}
const groupCall = this.connectGroupCall(conversationProps.id, {
groupId: conversationProps.groupId,
publicParams: conversationProps.publicParams,
secretParams: conversationProps.secretParams,
});
groupCall.setOutgoingAudioMuted(false);
groupCall.setOutgoingVideoMuted(!isVideoCall);
this.uxActions.showCallLobby({
callMode: CallMode.Group,
conversationId: conversationProps.id,
...this.formatGroupCallForRedux(groupCall),
});
break;
}
default:
throw missingCaseError(callMode);
2020-10-08 01:25:33 +00:00
}
await this.startDeviceReselectionTimer();
if (isVideoCall) {
this.enableLocalCamera();
}
2020-10-08 01:25:33 +00:00
}
2020-11-13 19:57:55 +00:00
stopCallingLobby(conversationId?: string): void {
2020-10-08 01:25:33 +00:00
this.disableLocalCamera();
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> {
2020-11-13 19:57:55 +00:00
window.log.info('CallingClass.startOutgoingDirectCall()');
2020-10-08 01:25:33 +00:00
if (!this.uxActions) {
throw new Error('Redux actions not available');
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
window.log.error('Could not find conversation, cannot start call');
this.stopCallingLobby();
return;
}
const remoteUserId = this.getRemoteUserIdFromConversation(conversation);
if (!remoteUserId || !this.localDeviceId) {
window.log.error('Missing identifier, new call not allowed.');
this.stopCallingLobby();
return;
}
const haveMediaPermissions = await this.requestPermissions(hasLocalVideo);
2020-10-08 01:25:33 +00:00
if (!haveMediaPermissions) {
window.log.info('Permissions were denied, new call not allowed.');
this.stopCallingLobby();
return;
}
2020-11-13 19:57:55 +00:00
window.log.info(
'CallingClass.startOutgoingDirectCall(): Getting call settings'
);
2020-08-27 00:03:42 +00:00
const callSettings = await this.getCallSettings(conversation);
// Check state after awaiting to debounce call button.
if (RingRTC.call && RingRTC.call.state !== CallState.Ended) {
window.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;
}
2020-11-13 19:57:55 +00:00
window.log.info(
'CallingClass.startOutgoingDirectCall(): Starting in RingRTC'
);
2020-06-04 18:16:19 +00:00
// We could make this faster by getting the call object
// from the RingRTC before we lookup the ICE servers.
const call = RingRTC.startOutgoingCall(
remoteUserId,
hasLocalVideo,
2020-06-04 18:16:19 +00:00
this.localDeviceId,
2020-08-27 00:03:42 +00:00
callSettings
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.uxActions.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;
}
/**
* 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;
}
const groupIdBuffer = base64ToArrayBuffer(groupId);
let isRequestingMembershipProof = false;
const outerGroupCall = RingRTC.getGroupCall(groupIdBuffer, {
onLocalDeviceStateChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
if (localDeviceState.connectionState === ConnectionState.NotConnected) {
if (localDeviceState.videoMuted) {
this.disableLocalCamera();
}
delete this.callsByConversation[conversationId];
} else {
this.callsByConversation[conversationId] = groupCall;
if (localDeviceState.videoMuted) {
this.disableLocalCamera();
} else {
this.enableLocalCamera();
}
}
this.syncGroupCallToRedux(conversationId, groupCall);
},
onRemoteDeviceStatesChanged: groupCall => {
this.syncGroupCallToRedux(conversationId, groupCall);
},
onJoinedMembersChanged: groupCall => {
this.syncGroupCallToRedux(conversationId, groupCall);
},
async requestMembershipProof(groupCall) {
if (isRequestingMembershipProof) {
return;
}
isRequestingMembershipProof = true;
try {
const proof = await fetchMembershipProof({
publicParams,
secretParams,
});
if (proof) {
const proofArray = new TextEncoder().encode(proof);
groupCall.setMembershipProof(proofArray.buffer);
}
} catch (err) {
window.log.error('Failed to fetch membership proof', err);
} finally {
isRequestingMembershipProof = false;
}
},
requestGroupMembers(groupCall) {
groupCall.setGroupMembers(
getMembershipList(conversationId).map(
member =>
new GroupMemberInfo(
uuidToArrayBuffer(member.uuid),
member.uuidCiphertext
)
)
);
},
onEnded: noop,
});
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 joinGroupCall(
conversationId: string,
hasLocalAudio: boolean,
hasLocalVideo: boolean
): void {
const conversation = window.ConversationController.get(
conversationId
)?.format();
if (!conversation) {
window.log.error('Missing conversation; not joining group call');
return;
}
if (
!conversation.groupId ||
!conversation.publicParams ||
!conversation.secretParams
) {
window.log.error(
'Conversation is missing required parameters. Cannot join group call'
);
return;
}
const groupCall = this.connectGroupCall(conversationId, {
groupId: conversation.groupId,
publicParams: conversation.publicParams,
secretParams: conversation.secretParams,
});
groupCall.setOutgoingAudioMuted(!hasLocalAudio);
groupCall.setOutgoingVideoMuted(!hasLocalVideo);
this.videoCapturer.enableCaptureAndSend(groupCall);
groupCall.join();
}
private getCallIdForConversation(conversationId: string): undefined | CallId {
2020-11-13 19:57:55 +00:00
return this.getDirectCall(conversationId)?.callId;
}
// 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.Joined:
return GroupCallJoinState.Joined;
default:
throw missingCaseError(joinState);
}
}
private formatGroupCallForRedux(groupCall: GroupCall) {
const localDeviceState = groupCall.getLocalDeviceState();
// 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.
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,
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => ({
demuxId: remoteDeviceState.demuxId,
userId: arrayBufferToUuid(remoteDeviceState.userId) || '',
hasRemoteAudio: !remoteDeviceState.audioMuted,
hasRemoteVideo: !remoteDeviceState.videoMuted,
// 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);
}
private syncGroupCallToRedux(
conversationId: string,
groupCall: GroupCall
): void {
this.uxActions?.groupCallStateChange({
conversationId,
...this.formatGroupCallForRedux(groupCall),
});
2020-06-04 18:16:19 +00:00
}
async accept(conversationId: string, asVideoCall: boolean): Promise<void> {
window.log.info('CallingClass.accept()');
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
window.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 {
window.log.info('Permissions were denied, call not allowed, hanging up.');
RingRTC.hangup(callId);
}
}
decline(conversationId: string): void {
window.log.info('CallingClass.decline()');
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
window.log.warn('Trying to decline a non-existent call');
return;
}
2020-06-04 18:16:19 +00:00
RingRTC.decline(callId);
}
hangup(conversationId: string): void {
window.log.info('CallingClass.hangup()');
2020-11-13 19:57:55 +00:00
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
window.log.warn('Trying to hang up a non-existent call');
return;
}
2020-11-13 19:57:55 +00:00
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);
}
2020-06-04 18:16:19 +00:00
}
setOutgoingAudio(conversationId: string, enabled: boolean): void {
2020-11-13 19:57:55 +00:00
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
window.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) {
window.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
}
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() {
if (this.deviceReselectionTimer) {
clearInterval(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;
}
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)
) {
window.log.info(
'MediaDevice: available devices changed (from->to)',
this.lastMediaDeviceSettings,
newSettings
);
await this.selectPreferredDevices(newSettings);
2020-08-27 00:03:42 +00:00
this.lastMediaDeviceSettings = newSettings;
this.uxActions?.refreshIODevices(newSettings);
}
}
async getMediaDeviceSettings(): Promise<MediaDeviceSettings> {
const availableMicrophones = RingRTC.getAudioInputs();
const preferredMicrophone = window.storage.get(
'preferred-audio-input-device'
);
const selectedMicIndex = this.findBestMatchingDeviceIndex(
availableMicrophones,
preferredMicrophone
);
const selectedMicrophone =
selectedMicIndex !== undefined
? availableMicrophones[selectedMicIndex]
: undefined;
const availableSpeakers = RingRTC.getAudioOutputs();
const preferredSpeaker = window.storage.get(
'preferred-audio-output-device'
);
const selectedSpeakerIndex = this.findBestMatchingDeviceIndex(
availableSpeakers,
preferredSpeaker
);
const selectedSpeaker =
selectedSpeakerIndex !== undefined
? availableSpeakers[selectedSpeakerIndex]
: undefined;
2020-09-04 18:27:12 +00:00
const availableCameras = await this.videoCapturer.enumerateDevices();
2020-08-27 00:03:42 +00:00
const preferredCamera = window.storage.get('preferred-video-input-device');
const selectedCamera = this.findBestMatchingCamera(
availableCameras,
preferredCamera
);
return {
availableMicrophones,
availableSpeakers,
selectedMicrophone,
selectedSpeaker,
availableCameras,
selectedCamera,
};
}
findBestMatchingDeviceIndex(
available: Array<AudioDevice>,
preferred: AudioDevice | undefined
): number | undefined {
if (preferred) {
// Match by uniqueId first, if available
if (preferred.uniqueId) {
const matchIndex = available.findIndex(
d => d.uniqueId === preferred.uniqueId
);
if (matchIndex !== -1) {
return matchIndex;
}
}
// Match by name second
const matchingNames = available.filter(d => d.name === preferred.name);
if (matchingNames.length > 0) {
return matchingNames[0].index;
2020-08-27 00:03:42 +00:00
}
}
// Nothing matches or no preference; take the first device if there are any
2020-08-27 00:03:42 +00:00
return available.length > 0 ? 0 : undefined;
}
findBestMatchingCamera(
available: Array<MediaDeviceInfo>,
preferred?: string
): string | undefined {
const matchingId = available.filter(d => d.deviceId === preferred);
const nonInfrared = available.filter(d => !d.label.includes('IR Camera'));
2020-09-03 14:59:24 +00:00
// By default, pick the first non-IR camera (but allow the user to pick the
// infrared if they so desire)
2020-08-27 00:03:42 +00:00
if (matchingId.length > 0) {
return matchingId[0].deviceId;
2020-09-09 00:46:29 +00:00
}
if (nonInfrared.length > 0) {
2020-08-27 00:03:42 +00:00
return nonInfrared[0].deviceId;
}
2020-09-09 00:46:29 +00:00
return undefined;
2020-08-27 00:03:42 +00:00
}
2020-09-03 14:59:24 +00:00
setPreferredMicrophone(device: AudioDevice): void {
2020-08-27 00:03:42 +00:00
window.log.info('MediaDevice: setPreferredMicrophone', device);
window.storage.put('preferred-audio-input-device', device);
RingRTC.setAudioInput(device.index);
}
2020-09-03 14:59:24 +00:00
setPreferredSpeaker(device: AudioDevice): void {
2020-08-27 00:03:42 +00:00
window.log.info('MediaDevice: setPreferredSpeaker', device);
window.storage.put('preferred-audio-output-device', device);
RingRTC.setAudioOutput(device.index);
}
2020-10-08 01:25:33 +00:00
enableLocalCamera(): void {
this.videoCapturer.enableCapture();
}
disableLocalCamera(): void {
this.videoCapturer.disable();
}
2020-09-03 14:59:24 +00:00
async setPreferredCamera(device: string): Promise<void> {
2020-08-27 00:03:42 +00:00
window.log.info('MediaDevice: setPreferredCamera', device);
window.storage.put('preferred-video-input-device', device);
await this.videoCapturer.setPreferredDevice(device);
2020-06-04 18:16:19 +00:00
}
async handleCallingMessage(
envelope: EnvelopeClass,
2020-09-28 19:02:35 +00:00
callingMessage: CallingMessage
2020-09-03 14:59:24 +00:00
): Promise<void> {
window.log.info('CallingClass.handleCallingMessage()');
2020-06-04 18:16:19 +00:00
const enableIncomingCalls = await window.getIncomingCallNotification();
if (callingMessage.offer && !enableIncomingCalls) {
// Drop offers silently if incoming call notifications are disabled.
window.log.info('Incoming calls are disabled, ignoring call offer.');
return;
}
2020-09-12 00:53:19 +00:00
const remoteUserId = envelope.sourceUuid || envelope.source;
2020-06-04 18:16:19 +00:00
const remoteDeviceId = this.parseDeviceId(envelope.sourceDevice);
if (!remoteUserId || !remoteDeviceId || !this.localDeviceId) {
window.log.error('Missing identifier, ignoring call message.');
return;
}
2020-09-12 00:53:19 +00:00
const senderIdentityRecord = window.textsecure.storage.protocol.getIdentityRecord(
remoteUserId
);
if (!senderIdentityRecord) {
window.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 receiverIdentityRecord = window.textsecure.storage.protocol.getIdentityRecord(
window.textsecure.storage.user.getUuid() ||
window.textsecure.storage.user.getNumber()
);
if (!receiverIdentityRecord) {
window.log.error(
'Missing receiver identity record; ignoring call message.'
);
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) {
window.log.error('Missing conversation; ignoring call message.');
return;
}
if (callingMessage.offer && !conversation.getAccepted()) {
window.log.info(
'Conversation was not approved by user; rejecting call message.'
);
const hangup = new HangupMessage();
hangup.callId = callingMessage.offer.callId;
hangup.deviceId = remoteDeviceId;
hangup.type = HangupType.NeedPermission;
const message = new CallingMessage();
message.legacyHangup = hangup;
await this.handleOutgoingSignaling(remoteUserId, message);
this.addCallHistoryForFailedIncomingCall(
conversation,
callingMessage.offer.type === OfferType.VideoCall
);
return;
}
2020-11-13 19:57:55 +00:00
const sourceUuid = envelope.sourceUuid
? uuidToArrayBuffer(envelope.sourceUuid)
: null;
const messageAgeSec = envelope.messageAgeSec ? envelope.messageAgeSec : 0;
2020-06-04 18:16:19 +00:00
window.log.info('CallingClass.handleCallingMessage(): Handling in RingRTC');
2020-06-04 18:16:19 +00:00
RingRTC.handleCallingMessage(
remoteUserId,
2020-11-13 19:57:55 +00:00
sourceUuid,
2020-06-04 18:16:19 +00:00
remoteDeviceId,
this.localDeviceId,
messageAgeSec,
2020-09-12 00:53:19 +00:00
callingMessage,
senderIdentityKey,
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)
) {
window.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) {
window.log.info(
'MediaDevice: selecting microphone',
settings.selectedMicrophone
);
RingRTC.setAudioInput(settings.selectedMicrophone.index);
}
2020-08-27 00:03:42 +00:00
if (settings.selectedSpeaker) {
window.log.info(
'MediaDevice: selecting speaker',
settings.selectedMicrophone
);
RingRTC.setAudioOutput(settings.selectedSpeaker.index);
}
}
2020-06-04 18:16:19 +00:00
private async requestCameraPermissions(): Promise<boolean> {
const cameraPermission = await window.getMediaCameraPermissions();
if (!cameraPermission) {
await window.showCallingPermissionsPopup(true);
// Check the setting again (from the source of truth).
return window.getMediaCameraPermissions();
}
return true;
}
private async requestMicrophonePermissions(): Promise<boolean> {
const microphonePermission = await window.getMediaPermissions();
if (!microphonePermission) {
await window.showCallingPermissionsPopup(false);
// Check the setting again (from the source of truth).
return window.getMediaPermissions();
}
return true;
}
private async requestPermissions(isVideoCall: boolean): Promise<boolean> {
const microphonePermission = await this.requestMicrophonePermissions();
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(
recipient: ArrayBuffer,
data: ArrayBuffer
): Promise<boolean> {
const userId = arrayBufferToUuid(recipient);
if (!userId) {
window.log.error('handleSendCallMessage(): bad recipient UUID');
return false;
}
const message = new CallingMessage();
message.opaque = new OpaqueMessage();
message.opaque.data = data;
return this.handleOutgoingSignaling(userId, message);
}
2020-06-04 18:16:19 +00:00
private async handleOutgoingSignaling(
remoteUserId: UserId,
2020-09-28 19:02:35 +00:00
message: CallingMessage
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
? conversation.getSendOptions()
: undefined;
if (!window.textsecure.messaging) {
window.log.warn('handleOutgoingSignaling() returning false; offline');
return false;
}
2020-06-04 18:16:19 +00:00
try {
await window.textsecure.messaging.sendCallingMessage(
remoteUserId,
message,
sendOptions
);
window.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) {
window.log.error(
`handleOutgoingSignaling() failed: ${err.errors[0].reason}`
);
} else {
window.log.error('handleOutgoingSignaling() failed');
}
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.
2020-06-04 18:16:19 +00:00
private async handleIncomingCall(call: Call): Promise<CallSettings | null> {
window.log.info('CallingClass.handleIncomingCall()');
2020-06-04 18:16:19 +00:00
if (!this.uxActions || !this.localDeviceId) {
window.log.error('Missing required objects, ignoring incoming call.');
return null;
}
const conversation = window.ConversationController.get(call.remoteUserId);
if (!conversation) {
window.log.error('Missing conversation, ignoring incoming call.');
return null;
}
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
) {
window.log.info(
`Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}`
);
2020-09-28 19:02:35 +00:00
this.addCallHistoryForFailedIncomingCall(
conversation,
call.isVideoCall
);
2020-06-04 18:16:19 +00:00
return null;
}
this.attachToCall(conversation, call);
this.uxActions.receiveIncomingCall({
conversationId: conversation.id,
isVideoCall: call.isVideoCall,
2020-06-04 18:16:19 +00:00
});
window.log.info('CallingClass.handleIncomingCall(): Proceeding');
2020-06-04 18:16:19 +00:00
return await this.getCallSettings(conversation);
} catch (err) {
window.log.error(`Ignoring incoming call: ${err.stack}`);
2020-09-28 19:02:35 +00:00
this.addCallHistoryForFailedIncomingCall(conversation, call.isVideoCall);
2020-06-04 18:16:19 +00:00
return null;
}
}
private handleAutoEndedIncomingCallRequest(
remoteUserId: UserId,
reason: CallEndedReason
) {
const conversation = window.ConversationController.get(remoteUserId);
if (!conversation) {
return;
}
this.addCallHistoryForAutoEndedIncomingCall(conversation, reason);
}
private attachToCall(conversation: ConversationModel, call: Call): void {
this.callsByConversation[conversation.id] = call;
2020-06-04 18:16:19 +00:00
const { uxActions } = this;
if (!uxActions) {
return;
}
let acceptedTime: number | undefined;
2020-09-03 14:59:24 +00:00
// eslint-disable-next-line no-param-reassign
2020-06-04 18:16:19 +00:00
call.handleStateChanged = () => {
if (call.state === CallState.Accepted) {
acceptedTime = acceptedTime || Date.now();
2020-06-04 18:16:19 +00:00
} else if (call.state === CallState.Ended) {
this.addCallHistoryForEndedCall(conversation, call, acceptedTime);
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
}
uxActions.callStateChange({
conversationId: conversation.id,
acceptedTime,
2020-06-04 18:16:19 +00:00
callState: call.state,
2020-10-01 19:09:15 +00:00
callEndedReason: call.endedReason,
isIncoming: call.isIncoming,
isVideoCall: call.isVideoCall,
title: conversation.getTitle(),
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 = () => {
uxActions.remoteVideoChange({
conversationId: conversation.id,
hasVideo: call.remoteVideoEnabled,
2020-06-04 18:16:19 +00:00
});
};
}
private async handleLogMessage(
level: CallLogLevel,
fileName: string,
line: number,
message: string
) {
switch (level) {
case CallLogLevel.Info:
window.log.info(`${fileName}:${line} ${message}`);
break;
case CallLogLevel.Warn:
window.log.warn(`${fileName}:${line} ${message}`);
break;
case CallLogLevel.Error:
window.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 },
body: ArrayBuffer | 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) {
window.log.error('handleSendHttpRequest: fetch failed with error', err);
RingRTC.httpRequestFailed(requestId, String(err));
return;
}
RingRTC.receivedHttpResponse(
requestId,
result.response.status,
result.data
);
}
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 getCallSettings(
conversation: ConversationModel
2020-06-04 18:16:19 +00:00
): Promise<CallSettings> {
if (!window.textsecure.messaging) {
throw new Error('getCallSettings: offline!');
}
2020-06-04 18:16:19 +00:00
const iceServerJson = await window.textsecure.messaging.server.getIceServers();
const shouldRelayCalls = Boolean(await window.getAlwaysRelayCalls());
// If the peer is 'unknown', i.e. not in the contact list, force IP hiding.
2020-07-29 16:29:57 +00:00
const isContactUnknown = !conversation.isFromOrAddedByTrustedContact();
2020-06-04 18:16:19 +00:00
return {
iceServer: JSON.parse(iceServerJson),
hideIp: shouldRelayCalls || isContactUnknown,
};
}
private addCallHistoryForEndedCall(
conversation: ConversationModel,
2020-06-04 18:16:19 +00:00
call: Call,
2020-09-03 14:59:24 +00:00
acceptedTimeParam: number | undefined
2020-06-04 18:16:19 +00:00
) {
2020-09-03 14:59:24 +00:00
let acceptedTime = acceptedTimeParam;
2020-06-04 18:16:19 +00:00
const { endedReason, isIncoming } = call;
const wasAccepted = Boolean(acceptedTime);
const isOutgoing = !isIncoming;
const wasDeclined =
!wasAccepted &&
(endedReason === CallEndedReason.Declined ||
endedReason === CallEndedReason.DeclinedOnAnotherDevice ||
(isIncoming && endedReason === CallEndedReason.LocalHangup) ||
2020-07-07 00:37:43 +00:00
(isOutgoing && endedReason === CallEndedReason.RemoteHangup) ||
(isOutgoing &&
endedReason === CallEndedReason.RemoteHangupNeedPermission));
2020-06-04 18:16:19 +00:00
if (call.endedReason === CallEndedReason.AcceptedOnAnotherDevice) {
acceptedTime = Date.now();
}
2020-10-08 01:26:42 +00:00
conversation.addCallHistory({
2020-06-04 18:16:19 +00:00
wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall,
wasDeclined,
acceptedTime,
endedTime: Date.now(),
2020-10-08 01:26:42 +00:00
});
2020-06-04 18:16:19 +00:00
}
private addCallHistoryForFailedIncomingCall(
conversation: ConversationModel,
2020-09-28 19:02:35 +00:00
wasVideoCall: boolean
2020-06-04 18:16:19 +00:00
) {
2020-10-08 01:26:42 +00:00
conversation.addCallHistory({
2020-06-04 18:16:19 +00:00
wasIncoming: true,
2020-09-28 19:02:35 +00:00
wasVideoCall,
2020-06-04 18:16:19 +00:00
// Since the user didn't decline, make sure it shows up as a missed call instead
wasDeclined: false,
acceptedTime: undefined,
endedTime: Date.now(),
2020-10-08 01:26:42 +00:00
});
2020-06-04 18:16:19 +00:00
}
private addCallHistoryForAutoEndedIncomingCall(
conversation: ConversationModel,
2020-06-04 18:16:19 +00:00
_reason: CallEndedReason
) {
2020-10-08 01:26:42 +00:00
conversation.addCallHistory({
2020-06-04 18:16:19 +00:00
wasIncoming: true,
// We don't actually know, but it doesn't seem that important in this case,
// but we could maybe plumb this info through RingRTC
wasVideoCall: false,
// Since the user didn't decline, make sure it shows up as a missed call instead
wasDeclined: false,
acceptedTime: undefined,
endedTime: Date.now(),
2020-10-08 01:26:42 +00:00
});
2020-06-04 18:16:19 +00:00
}
}
export const calling = new CallingClass();