Initial group calling support

This commit is contained in:
Evan Hahn 2020-11-13 13:57:55 -06:00 committed by Josh Perez
parent e398520db0
commit 022c4bd0f4
31 changed files with 2530 additions and 414 deletions

View file

@ -1,42 +1,90 @@
diff --git a/node_modules/node-fetch/lib/index.es.js b/node_modules/node-fetch/lib/index.es.js
index 61906c9..51ee2bd 100644
index 61906c9..f09f5bd 100644
--- a/node_modules/node-fetch/lib/index.es.js
+++ b/node_modules/node-fetch/lib/index.es.js
@@ -1404,6 +1404,9 @@ function fetch(url, opts) {
// build request object
const request = new Request(url, opts);
const options = getNodeRequestOptions(request);
+ if (opts && opts.ca) {
+ options.ca = opts.ca;
+ }
@@ -1231,6 +1231,9 @@ class Request {
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
this.counter = init.counter || input.counter || 0;
this.agent = init.agent || input.agent;
+
+ // Custom Signal Desktop option
+ this.ca = init.ca || input.ca;
}
const send = (options.protocol === 'https:' ? https : http).request;
const signal = request.signal;
get method() {
@@ -1350,7 +1353,7 @@ function getNodeRequestOptions(request) {
method: request.method,
headers: exportNodeCompatibleHeaders(headers),
agent
- });
+ }, request.ca ? { ca: request.ca } : {});
}
/**
@@ -1514,7 +1517,8 @@ function fetch(url, opts) {
body: request.body,
signal: request.signal,
timeout: request.timeout,
- size: request.size
+ size: request.size,
+ ca: request.ca,
};
// HTTP-redirect fetch step 9
diff --git a/node_modules/node-fetch/lib/index.js b/node_modules/node-fetch/lib/index.js
index 4b241bf..88de03f 100644
index 4b241bf..23fa901 100644
--- a/node_modules/node-fetch/lib/index.js
+++ b/node_modules/node-fetch/lib/index.js
@@ -1408,6 +1408,9 @@ function fetch(url, opts) {
// build request object
const request = new Request(url, opts);
const options = getNodeRequestOptions(request);
+ if (opts && opts.ca) {
+ options.ca = opts.ca;
+ }
@@ -1235,6 +1235,9 @@ class Request {
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
this.counter = init.counter || input.counter || 0;
this.agent = init.agent || input.agent;
+
+ // Custom Signal Desktop option
+ this.ca = init.ca || input.ca;
}
const send = (options.protocol === 'https:' ? https : http).request;
const signal = request.signal;
get method() {
@@ -1354,7 +1357,7 @@ function getNodeRequestOptions(request) {
method: request.method,
headers: exportNodeCompatibleHeaders(headers),
agent
- });
+ }, request.ca ? { ca: request.ca } : {});
}
/**
@@ -1518,7 +1521,8 @@ function fetch(url, opts) {
body: request.body,
signal: request.signal,
timeout: request.timeout,
- size: request.size
+ size: request.size,
+ ca: request.ca,
};
// HTTP-redirect fetch step 9
diff --git a/node_modules/node-fetch/lib/index.mjs b/node_modules/node-fetch/lib/index.mjs
index ecf59af..b4af5ff 100644
index ecf59af..b723a5c 100644
--- a/node_modules/node-fetch/lib/index.mjs
+++ b/node_modules/node-fetch/lib/index.mjs
@@ -1402,6 +1402,9 @@ function fetch(url, opts) {
// build request object
const request = new Request(url, opts);
const options = getNodeRequestOptions(request);
+ if (opts && opts.ca) {
+ options.ca = opts.ca;
+ }
@@ -1229,6 +1229,9 @@ class Request {
this.compress = init.compress !== undefined ? init.compress : input.compress !== undefined ? input.compress : true;
this.counter = init.counter || input.counter || 0;
this.agent = init.agent || input.agent;
+
+ // Custom Signal Desktop option
+ this.ca = init.ca || input.ca;
}
const send = (options.protocol === 'https:' ? https : http).request;
const signal = request.signal;
get method() {
@@ -1512,7 +1515,8 @@ function fetch(url, opts) {
body: request.body,
signal: request.signal,
timeout: request.timeout,
- size: request.size
+ size: request.size,
+ ca: request.ca,
};
// HTTP-redirect fetch step 9

View file

@ -16,6 +16,8 @@ try {
const { app } = remote;
const { nativeTheme } = remote.require('electron');
window.GROUP_CALLING = true;
window.PROTO_ROOT = 'protos';
const config = require('url').parse(window.location.toString(), true).query;
@ -564,6 +566,7 @@ try {
/* eslint-disable global-require, import/no-extraneous-dependencies */
require('./ts/test-electron/models/messages_test');
require('./ts/test-electron/linkPreviews/linkPreviewFetch_test');
require('./ts/test-electron/state/ducks/conversations_test');
require('./ts/test-electron/state/ducks/calling_test');
require('./ts/test-electron/state/selectors/calling_test');

View file

@ -151,3 +151,7 @@ message GroupAttributeBlob {
uint32 disappearingMessagesDuration = 3;
}
}
message GroupExternalCredential {
string token = 1;
}

View file

@ -87,6 +87,10 @@ message CallingMessage {
optional uint32 deviceId = 3;
}
message Opaque {
optional bytes data = 1;
}
optional Offer offer = 1;
optional Answer answer = 2;
repeated IceCandidate iceCandidates = 3;
@ -95,6 +99,7 @@ message CallingMessage {
optional Hangup hangup = 7;
optional bool supportsMultiRing = 8;
optional uint32 destinationDeviceId = 9;
optional Opaque opaque = 10;
}
message DataMessage {

View file

@ -5860,6 +5860,7 @@ button.module-image__border-overlay:focus {
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
position: relative;
width: 100%;
}
@ -6225,8 +6226,18 @@ button.module-image__border-overlay:focus {
justify-content: center;
}
&__header {
&__container {
&--direct {
.module-ongoing-call__header {
position: absolute;
}
.module-ongoing-call__footer {
position: absolute;
}
}
}
&__header {
background: linear-gradient($color-black-alpha-40, transparent);
}
@ -6237,12 +6248,33 @@ button.module-image__border-overlay:focus {
letter-spacing: -0.0025em;
}
&__grid {
flex-grow: 1;
width: 100%;
position: relative;
}
&__group-call-remote-participant {
display: flex;
justify-content: center;
line-height: 0;
overflow: hidden;
border-radius: 5px;
transition: top 200ms linear, left 200ms linear, width 200ms linear,
height 200ms linear;
&__remote-video {
// The background-color is seen while the video loads.
background-color: $color-gray-75;
}
}
&__footer {
background: linear-gradient(transparent, $color-black-alpha-40);
bottom: 0;
display: flex;
justify-content: space-between;
position: absolute;
width: 100%;
&__actions {

View file

@ -175,4 +175,93 @@ describe('Crypto', () => {
}
});
});
describe('uuidToArrayBuffer', () => {
const { uuidToArrayBuffer } = Signal.Crypto;
it('converts valid UUIDs to ArrayBuffers', () => {
const expectedResult = new Uint8Array([
0x22,
0x6e,
0x44,
0x02,
0x7f,
0xfc,
0x45,
0x43,
0x85,
0xc9,
0x46,
0x22,
0xc5,
0x0a,
0x5b,
0x14,
]).buffer;
assert.deepEqual(
uuidToArrayBuffer('226e4402-7ffc-4543-85c9-4622c50a5b14'),
expectedResult
);
assert.deepEqual(
uuidToArrayBuffer('226E4402-7FFC-4543-85C9-4622C50A5B14'),
expectedResult
);
});
it('returns an empty ArrayBuffer for strings of the wrong length', () => {
assert.deepEqual(uuidToArrayBuffer(''), new ArrayBuffer(0));
assert.deepEqual(uuidToArrayBuffer('abc'), new ArrayBuffer(0));
assert.deepEqual(
uuidToArrayBuffer('032deadf0d5e4ee78da28e75b1dfb284'),
new ArrayBuffer(0)
);
assert.deepEqual(
uuidToArrayBuffer('deaed5eb-d983-456a-a954-9ad7a006b271aaaaaaaaaa'),
new ArrayBuffer(0)
);
});
});
describe('arrayBufferToUuid', () => {
const { arrayBufferToUuid } = Signal.Crypto;
it('converts valid ArrayBuffers to UUID strings', () => {
const buf = new Uint8Array([
0x22,
0x6e,
0x44,
0x02,
0x7f,
0xfc,
0x45,
0x43,
0x85,
0xc9,
0x46,
0x22,
0xc5,
0x0a,
0x5b,
0x14,
]).buffer;
assert.deepEqual(
arrayBufferToUuid(buf),
'226e4402-7ffc-4543-85c9-4622c50a5b14'
);
});
it('returns undefined if passed an all-zero buffer', () => {
assert.isUndefined(arrayBufferToUuid(new ArrayBuffer(16)));
});
it('returns undefined if passed the wrong number of bytes', () => {
assert.isUndefined(arrayBufferToUuid(new ArrayBuffer(0)));
assert.isUndefined(arrayBufferToUuid(new Uint8Array([0x22]).buffer));
assert.isUndefined(
arrayBufferToUuid(new Uint8Array(Array(17).fill(0x22)).buffer)
);
});
});
});

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import pProps from 'p-props';
import { chunk } from 'lodash';
export function typedArrayToArrayBuffer(typedArray: Uint8Array): ArrayBuffer {
const { buffer, byteOffset, byteLength } = typedArray;
@ -706,6 +707,36 @@ export async function encryptCdsDiscoveryRequest(
};
}
export function uuidToArrayBuffer(uuid: string): ArrayBuffer {
if (uuid.length !== 36) {
window.log.warn(
'uuidToArrayBuffer: received a string of invalid length. Returning an empty ArrayBuffer'
);
return new ArrayBuffer(0);
}
return Uint8Array.from(
chunk(uuid.replace(/-/g, ''), 2).map(pair => parseInt(pair.join(''), 16))
).buffer;
}
export function arrayBufferToUuid(
arrayBuffer: ArrayBuffer
): undefined | string {
if (arrayBuffer.byteLength !== 16) {
window.log.warn(
'arrayBufferToUuid: received an ArrayBuffer of invalid length. Returning undefined'
);
return undefined;
}
const uuids = splitUuids(arrayBuffer);
if (uuids.length === 1) {
return uuids[0] || undefined;
}
return undefined;
}
export function splitUuids(arrayBuffer: ArrayBuffer): Array<string | null> {
const uuids = [];
for (let i = 0; i < arrayBuffer.byteLength; i += 16) {

View file

@ -2,11 +2,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { noop } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { CallManager } from './CallManager';
import { CallEndedReason, CallState } from '../types/Calling';
import { CallManager, PropsType } from './CallManager';
import {
CallEndedReason,
CallMode,
CallState,
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import { ConversationTypeType } from '../state/ducks/conversations';
import { ColorType } from '../types/Colors';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
@ -21,6 +29,9 @@ const conversation = {
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct' as ConversationTypeType,
lastUpdated: Date.now(),
};
const defaultProps = {
@ -29,6 +40,17 @@ const defaultProps = {
cancelCall: action('cancel-call'),
closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'),
// We allow `any` here because these are fake and actually come from RingRTC, which we
// can't import.
/* eslint-disable @typescript-eslint/no-explicit-any */
createCanvasVideoRenderer: () =>
({
setCanvas: noop,
enable: noop,
disable: noop,
} as any),
getGroupCallVideoFrameSource: noop as any,
/* eslint-enable @typescript-eslint/no-explicit-any */
hangUp: action('hang-up'),
i18n,
me: {
@ -52,10 +74,11 @@ const permutations = [
props: {},
},
{
title: 'Call Manager (ongoing)',
title: 'Call Manager (ongoing direct call)',
props: {
activeCall: {
call: {
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Accepted,
isIncoming: false,
@ -75,11 +98,36 @@ const permutations = [
},
},
},
{
title: 'Call Manager (ongoing group call)',
props: {
activeCall: {
call: {
callMode: CallMode.Group as CallMode.Group,
conversationId: '3051234567',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
remoteParticipants: [],
},
activeCallState: {
conversationId: '3051234567',
joinedAt: Date.now(),
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
},
},
},
{
title: 'Call Manager (ringing)',
props: {
incomingCall: {
call: {
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Ringing,
isIncoming: true,
@ -95,6 +143,7 @@ const permutations = [
props: {
activeCall: {
call: {
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Ended,
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
@ -118,10 +167,12 @@ const permutations = [
];
storiesOf('Components/CallManager', module).add('Iterations', () => {
return permutations.map(({ props, title }) => (
return permutations.map(
({ props, title }: { props: Partial<PropsType>; title: string }) => (
<>
<h3>{title}</h3>
<CallManager {...defaultProps} {...props} />
</>
));
)
);
});

View file

@ -1,56 +1,58 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useCallback } from 'react';
import { CallingPip } from './CallingPip';
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
import { CallingLobby } from './CallingLobby';
import { CallScreen } from './CallScreen';
import { IncomingCallBar } from './IncomingCallBar';
import { CallState, CallEndedReason } from '../types/Calling';
import {
ActiveCallStateType,
CallMode,
CallState,
CallEndedReason,
CanvasVideoRenderer,
VideoFrameSource,
GroupCallJoinState,
} from '../types/Calling';
import { ConversationType } from '../state/ducks/conversations';
import {
AcceptCallType,
ActiveCallStateType,
CancelCallType,
DeclineCallType,
DirectCallStateType,
StartCallType,
SetLocalAudioType,
GroupCallStateType,
HangUpType,
SetLocalAudioType,
SetLocalPreviewType,
SetLocalVideoType,
SetRendererCanvasType,
StartCallType,
} from '../state/ducks/calling';
import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
import { missingCaseError } from '../util/missingCaseError';
interface PropsType {
activeCall?: {
call: DirectCallStateType;
interface ActiveCallType {
call: DirectCallStateType | GroupCallStateType;
activeCallState: ActiveCallStateType;
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
};
conversation: ConversationType;
}
export interface PropsType {
activeCall?: ActiveCallType;
availableCameras: Array<MediaDeviceInfo>;
cancelCall: () => void;
cancelCall: (_: CancelCallType) => void;
createCanvasVideoRenderer: () => CanvasVideoRenderer;
closeNeedPermissionScreen: () => void;
getGroupCallVideoFrameSource: (
conversationId: string,
demuxId: number
) => VideoFrameSource;
incomingCall?: {
call: DirectCallStateType;
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
conversation: ConversationType;
};
renderDeviceSelection: () => JSX.Element;
startCall: (payload: StartCallType) => void;
@ -75,16 +77,19 @@ interface PropsType {
toggleSettings: () => void;
}
export const CallManager = ({
acceptCall,
interface ActiveCallManagerPropsType extends PropsType {
activeCall: ActiveCallType;
}
const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
activeCall,
availableCameras,
cancelCall,
closeNeedPermissionScreen,
declineCall,
createCanvasVideoRenderer,
hangUp,
i18n,
incomingCall,
getGroupCallVideoFrameSource,
me,
renderDeviceSelection,
setLocalAudio,
@ -95,10 +100,8 @@ export const CallManager = ({
toggleParticipants,
togglePip,
toggleSettings,
}: PropsType): JSX.Element | null => {
if (activeCall) {
}) => {
const { call, activeCallState, conversation } = activeCall;
const { callState, callEndedReason } = call;
const {
joinedAt,
hasLocalAudio,
@ -107,9 +110,36 @@ export const CallManager = ({
pip,
} = activeCallState;
const cancelActiveCall = useCallback(() => {
cancelCall({ conversationId: conversation.id });
}, [cancelCall, conversation.id]);
const joinActiveCall = useCallback(() => {
startCall({
callMode: call.callMode,
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
});
}, [startCall, call.callMode, conversation.id, hasLocalAudio, hasLocalVideo]);
const getGroupCallVideoFrameSourceForActiveCall = useCallback(
(demuxId: number) => {
return getGroupCallVideoFrameSource(conversation.id, demuxId);
},
[getGroupCallVideoFrameSource, conversation.id]
);
let showCallLobby: boolean;
switch (call.callMode) {
case CallMode.Direct: {
const { callState, callEndedReason } = call;
const ended = callState === CallState.Ended;
if (ended) {
if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) {
if (
ended &&
callEndedReason === CallEndedReason.RemoteHangupNeedPermission
) {
return (
<CallNeedPermissionScreen
close={closeNeedPermissionScreen}
@ -118,9 +148,18 @@ export const CallManager = ({
/>
);
}
showCallLobby = !callState;
break;
}
case CallMode.Group: {
showCallLobby = call.joinState === GroupCallJoinState.NotJoined;
break;
}
default:
throw missingCaseError(call);
}
if (!callState) {
if (showCallLobby) {
return (
<>
<CallingLobby
@ -129,16 +168,12 @@ export const CallManager = ({
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
// TODO: Set this to `true` for group calls. We can get away with this for
// now because it only affects rendering. See DESKTOP-888 and DESKTOP-889.
isGroupCall={false}
me={me}
onCallCanceled={cancelCall}
onJoinCall={() => {
startCall({
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
});
}}
onCallCanceled={cancelActiveCall}
onJoinCall={joinActiveCall}
setLocalPreview={setLocalPreview}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
@ -150,9 +185,10 @@ export const CallManager = ({
);
}
// TODO: Group calls should also support the PiP. See DESKTOP-886.
if (pip && call.callMode === CallMode.Direct) {
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
if (pip) {
return (
<CallingPip
conversation={conversation}
@ -170,15 +206,16 @@ export const CallManager = ({
return (
<>
<CallScreen
call={call}
conversation={conversation}
callState={callState}
createCanvasVideoRenderer={createCanvasVideoRenderer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUp={hangUp}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
joinedAt={joinedAt}
me={me}
hasRemoteVideo={hasRemoteVideo}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio}
@ -189,6 +226,15 @@ export const CallManager = ({
{settingsDialogOpen && renderDeviceSelection()}
</>
);
};
export const CallManager: React.FC<PropsType> = props => {
const { activeCall, incomingCall, acceptCall, declineCall, i18n } = props;
if (activeCall) {
// `props` should logically have an `activeCall` at this point, but TypeScript can't
// figure that out, so we pass it in again.
return <ActiveCallManager {...props} activeCall={activeCall} />;
}
// In the future, we may want to show the incoming call bar when a call is active.

View file

@ -2,11 +2,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { noop } from 'lodash';
import { storiesOf } from '@storybook/react';
import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { CallState } from '../types/Calling';
import { CallMode, CallState } from '../types/Calling';
import { ColorType } from '../types/Colors';
import { CallScreen, PropsType } from './CallScreen';
import { setup as setupI18n } from '../../js/modules/i18n';
@ -14,7 +15,29 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
const createProps = (
overrideProps: {
callState?: CallState;
hasLocalAudio?: boolean;
hasLocalVideo?: boolean;
hasRemoteVideo?: boolean;
} = {}
): PropsType => ({
call: {
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: select(
'callState',
CallState,
overrideProps.callState || CallState.Accepted
),
isIncoming: false,
isVideoCall: true,
hasRemoteVideo: boolean(
'hasRemoteVideo',
overrideProps.hasRemoteVideo || false
),
},
conversation: {
id: '3051234567',
avatarPath: undefined,
@ -23,19 +46,24 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct',
lastUpdated: Date.now(),
},
callState: select(
'callState',
CallState,
overrideProps.callState || CallState.Accepted
),
// We allow `any` here because these are fake and actually come from RingRTC, which we
// can't import.
/* eslint-disable @typescript-eslint/no-explicit-any */
createCanvasVideoRenderer: () =>
({
setCanvas: noop,
enable: noop,
disable: noop,
} as any),
getGroupCallVideoFrameSource: noop as any,
/* eslint-enable @typescript-eslint/no-explicit-any */
hangUp: action('hang-up'),
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
hasRemoteVideo: boolean(
'hasRemoteVideo',
overrideProps.hasRemoteVideo || false
),
i18n,
joinedAt: Date.now(),
me: {

View file

@ -4,7 +4,10 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { noop } from 'lodash';
import classNames from 'classnames';
import { ConversationType } from '../state/ducks/conversations';
import {
DirectCallStateType,
GroupCallStateType,
HangUpType,
SetLocalAudioType,
SetLocalPreviewType,
@ -14,25 +17,27 @@ import {
import { Avatar } from './Avatar';
import { CallingButton, CallingButtonType } from './CallingButton';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import { CallState } from '../types/Calling';
import {
CallMode,
CallState,
GroupCallConnectionState,
CanvasVideoRenderer,
VideoFrameSource,
} from '../types/Calling';
import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
export type PropsType = {
conversation: {
id: string;
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
callState: CallState;
call: DirectCallStateType | GroupCallStateType;
conversation: ConversationType;
createCanvasVideoRenderer: () => CanvasVideoRenderer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hangUp: (_: HangUpType) => void;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
hasRemoteVideo: boolean;
i18n: LocalizerType;
joinedAt?: number;
me: {
@ -52,12 +57,13 @@ export type PropsType = {
};
export const CallScreen: React.FC<PropsType> = ({
callState,
call,
conversation,
createCanvasVideoRenderer,
getGroupCallVideoFrameSource,
hangUp,
hasLocalAudio,
hasLocalVideo,
hasRemoteVideo,
i18n,
joinedAt,
me,
@ -84,15 +90,11 @@ export const CallScreen: React.FC<PropsType> = ({
const [showControls, setShowControls] = useState(true);
const localVideoRef = useRef<HTMLVideoElement | null>(null);
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
setLocalPreview({ element: localVideoRef });
setRendererCanvas({ element: remoteVideoRef });
return () => {
setLocalPreview({ element: undefined });
setRendererCanvas({ element: undefined });
};
}, [setLocalPreview, setRendererCanvas]);
@ -142,14 +144,39 @@ export const CallScreen: React.FC<PropsType> = ({
};
}, [toggleAudio, toggleVideo]);
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
let hasRemoteVideo: boolean;
let isConnected: boolean;
let remoteParticipants: JSX.Element;
const controlsFadeClass = classNames({
'module-ongoing-call__controls--fadeIn':
(showControls || isAudioOnly) && callState !== CallState.Accepted,
'module-ongoing-call__controls--fadeOut':
!showControls && !isAudioOnly && callState === CallState.Accepted,
});
switch (call.callMode) {
case CallMode.Direct:
hasRemoteVideo = Boolean(call.hasRemoteVideo);
isConnected = call.callState === CallState.Accepted;
remoteParticipants = (
<DirectCallRemoteParticipant
conversation={conversation}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
/>
);
break;
case CallMode.Group:
hasRemoteVideo = call.remoteParticipants.some(
remoteParticipant => remoteParticipant.hasRemoteVideo
);
isConnected = call.connectionState === GroupCallConnectionState.Connected;
remoteParticipants = (
<GroupCallRemoteParticipants
remoteParticipants={call.remoteParticipants}
createCanvasVideoRenderer={createCanvasVideoRenderer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
/>
);
break;
default:
throw missingCaseError(call);
}
const videoButtonType = hasLocalVideo
? CallingButtonType.VIDEO_ON
@ -158,9 +185,23 @@ export const CallScreen: React.FC<PropsType> = ({
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
const controlsFadeClass = classNames({
'module-ongoing-call__controls--fadeIn':
(showControls || isAudioOnly) && !isConnected,
'module-ongoing-call__controls--fadeOut':
!showControls && !isAudioOnly && isConnected,
});
return (
<div
className="module-calling__container"
className={classNames(
'module-calling__container',
`module-ongoing-call__container--${getCallModeClassSuffix(
call.callMode
)}`
)}
onMouseMove={() => {
setShowControls(true);
}}
@ -176,7 +217,12 @@ export const CallScreen: React.FC<PropsType> = ({
<div className="module-calling__header--header-name">
{conversation.title}
</div>
{renderHeaderMessage(i18n, callState, acceptedDuration)}
{call.callMode === CallMode.Direct &&
renderHeaderMessage(
i18n,
call.callState || CallState.Prering,
acceptedDuration
)}
<div className="module-calling-tools">
<button
type="button"
@ -184,22 +230,18 @@ export const CallScreen: React.FC<PropsType> = ({
className="module-calling-tools__button module-calling-button__settings"
onClick={toggleSettings}
/>
{/* TODO: Group calls should also support the PiP. See DESKTOP-886. */}
{call.callMode === CallMode.Direct && (
<button
type="button"
aria-label={i18n('calling__pip')}
className="module-calling-tools__button module-calling-button__pip"
onClick={togglePip}
/>
</div>
</div>
{hasRemoteVideo ? (
<canvas
className="module-ongoing-call__remote-video-enabled"
ref={remoteVideoRef}
/>
) : (
renderAvatar(i18n, conversation)
)}
</div>
</div>
{remoteParticipants}
<div className="module-ongoing-call__footer">
{/* This layout-only element is not ideal.
See the comment in _modules.css for more. */}
@ -260,40 +302,17 @@ export const CallScreen: React.FC<PropsType> = ({
);
};
function renderAvatar(
i18n: LocalizerType,
{
avatarPath,
color,
name,
phoneNumber,
profileName,
title,
}: {
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
function getCallModeClassSuffix(
callMode: CallMode.Direct | CallMode.Group
): string {
switch (callMode) {
case CallMode.Direct:
return 'direct';
case CallMode.Group:
return 'group';
default:
throw missingCaseError(callMode);
}
): JSX.Element {
return (
<div className="module-ongoing-call__remote-video-disabled">
<Avatar
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={112}
/>
</div>
);
}
function renderHeaderMessage(

View file

@ -0,0 +1,77 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useEffect } from 'react';
import { SetRendererCanvasType } from '../state/ducks/calling';
import { ConversationType } from '../state/ducks/conversations';
import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { Avatar } from './Avatar';
interface PropsType {
conversation: ConversationType;
hasRemoteVideo: boolean;
i18n: LocalizerType;
setRendererCanvas: (_: SetRendererCanvasType) => void;
}
export const DirectCallRemoteParticipant: React.FC<PropsType> = ({
conversation,
hasRemoteVideo,
i18n,
setRendererCanvas,
}) => {
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
setRendererCanvas({ element: remoteVideoRef });
return () => {
setRendererCanvas({ element: undefined });
};
}, [setRendererCanvas]);
return hasRemoteVideo ? (
<canvas
className="module-ongoing-call__remote-video-enabled"
ref={remoteVideoRef}
/>
) : (
renderAvatar(i18n, conversation)
);
};
function renderAvatar(
i18n: LocalizerType,
{
avatarPath,
color,
name,
phoneNumber,
profileName,
title,
}: {
avatarPath?: string;
color?: ColorType;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
}
): JSX.Element {
return (
<div className="module-ongoing-call__remote-video-disabled">
<Avatar
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={112}
/>
</div>
);
}

View file

@ -0,0 +1,86 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useEffect, CSSProperties } from 'react';
import { noop } from 'lodash';
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
import { CallBackgroundBlur } from './CallBackgroundBlur';
interface PropsType {
createCanvasVideoRenderer: () => CanvasVideoRenderer;
demuxId: number;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hasRemoteVideo: boolean;
height: number;
left: number;
top: number;
width: number;
}
export const GroupCallRemoteParticipant: React.FC<PropsType> = ({
createCanvasVideoRenderer,
demuxId,
getGroupCallVideoFrameSource,
hasRemoteVideo,
height,
left,
top,
width,
}) => {
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
const canvasVideoRendererRef = useRef(createCanvasVideoRenderer());
useEffect(() => {
const canvasVideoRenderer = canvasVideoRendererRef.current;
if (hasRemoteVideo) {
canvasVideoRenderer.setCanvas(remoteVideoRef);
canvasVideoRenderer.enable(getGroupCallVideoFrameSource(demuxId));
return () => {
canvasVideoRenderer.disable();
};
}
canvasVideoRenderer.disable();
return noop;
}, [hasRemoteVideo, getGroupCallVideoFrameSource, demuxId]);
// If our `width` and `height` props don't match the canvas's aspect ratio, we want to
// fill the container. This can happen when RingRTC gives us an inaccurate
// `videoAspectRatio`.
const canvasStyles: CSSProperties = {};
const canvasEl = remoteVideoRef.current;
if (hasRemoteVideo && canvasEl) {
if (canvasEl.width > canvasEl.height) {
canvasStyles.width = '100%';
} else {
canvasStyles.height = '100%';
}
}
return (
<div
className="module-ongoing-call__group-call-remote-participant"
style={{
position: 'absolute',
width,
height,
top,
left,
}}
>
{hasRemoteVideo ? (
<canvas
className="module-ongoing-call__group-call-remote-participant__remote-video"
style={canvasStyles}
ref={remoteVideoRef}
/>
) : (
<CallBackgroundBlur>
{/* TODO: Improve the styling here. See DESKTOP-894. */}
<span />
</CallBackgroundBlur>
)}
</div>
);
};

View file

@ -0,0 +1,242 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useMemo } from 'react';
import Measure from 'react-measure';
import { takeWhile, chunk, maxBy, flatten } from 'lodash';
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
import { GroupCallRemoteParticipantType } from '../state/ducks/calling';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
const MIN_RENDERED_HEIGHT = 10;
const PARTICIPANT_MARGIN = 10;
interface Dimensions {
width: number;
height: number;
}
interface GridArrangement {
rows: Array<Array<GroupCallRemoteParticipantType>>;
scalar: number;
}
interface PropsType {
createCanvasVideoRenderer: () => CanvasVideoRenderer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
}
// This component lays out group call remote participants. It uses a custom layout
// algorithm (in other words, nothing that the browser provides, like flexbox) in
// order to animate the boxes as they move around, and to figure out the right fits.
//
// It's worth looking at the UI (or a design of it) to get an idea of how it works. Some
// things to notice:
//
// * Participants are arranged in 0 or more rows.
// * Each row is the same height, but each participant may have a different width.
// * It's possible, on small screens with lots of participants, to have participants
// removed from the grid. This is because participants have a minimum rendered height.
//
// There should be more specific comments throughout, but the high-level steps are:
//
// 1. Figure out the maximum number of possible rows that could fit on the screen; this is
// `maxRowCount`.
// 2. Figure out how many participants should be visible if all participants were rendered
// at the minimum height. Most of the time, we'll be able to render all of them, but on
// full calls with lots of participants, there could be some lost.
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
// distribute participants across the rows at the minimum height. Then find the
// "scalar": how much can we scale these boxes up while still fitting them on the
// screen? The biggest scalar wins as the "best arrangement".
// 4. Lay out this arrangement on the screen.
export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
createCanvasVideoRenderer,
getGroupCallVideoFrameSource,
remoteParticipants,
}) => {
const [containerDimensions, setContainerDimensions] = useState<Dimensions>({
width: 0,
height: 0,
});
// 1. Figure out the maximum number of possible rows that could fit on the screen.
//
// We choose the smaller of these two options:
//
// - The number of participants, which means there'd be one participant per row.
// - The number of possible rows in the container, assuming all participants were
// rendered at minimum height. Doesn't rely on the number of participants—it's some
// simple division.
//
// Could be 0 if (a) there are no participants (b) the container's height is small.
const maxRowCount = Math.min(
remoteParticipants.length,
Math.floor(
containerDimensions.height / (MIN_RENDERED_HEIGHT + PARTICIPANT_MARGIN)
)
);
// 2. Figure out how many participants should be visible if all participants were
// rendered at the minimum height. Most of the time, we'll be able to render all of
// them, but on full calls with lots of participants, there could be some lost.
//
// This is primarily memoized for clarity, not performance. We only need the result,
// not any of the "intermediate" values.
const visibleParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => {
// Imagine that we laid out all of the rows end-to-end. That's the maximum total
// width. So if there were 5 rows and the container was 100px wide, then we can't
// possibly fit more than 500px of participants.
const maxTotalWidth = maxRowCount * containerDimensions.width;
// We do the same thing for participants, "laying them out end-to-end" until they
// exceed the maximum total width.
let totalWidth = 0;
return takeWhile(remoteParticipants, remoteParticipant => {
totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
return totalWidth < maxTotalWidth;
});
}, [maxRowCount, containerDimensions.width, remoteParticipants]);
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
// distribute participants across the rows at the minimum height. Then find the
// "scalar": how much can we scale these boxes up while still fitting them on the
// screen? The biggest scalar wins as the "best arrangement".
const gridArrangement: GridArrangement = useMemo(() => {
let bestArrangement: GridArrangement = {
scalar: -1,
rows: [],
};
if (!visibleParticipants.length) {
return bestArrangement;
}
for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) {
// We do something pretty naïve here and chunk the visible participants into rows.
// For example, if there were 12 visible participants and `rowCount === 3`, there
// would be 4 participants per row.
//
// This naïve chunking is suboptimal in terms of absolute best fit, but it is much
// faster and simpler than trying to do this perfectly. In practice, this works
// fine in the UI from our testing.
const numberOfParticipantsInRow = Math.ceil(
visibleParticipants.length / rowCount
);
const rows = chunk(visibleParticipants, numberOfParticipantsInRow);
// We need to find the scalar for this arrangement. Imagine that we have these
// participants at the minimum heights, and we want to scale everything up until
// it's about to overflow.
//
// We don't want it to overflow horizontally or vertically, so we calculate a
// "width scalar" and "height scalar" and choose the smaller of the two. (Choosing
// the LARGER of the two could cause overflow.)
const widestRow = maxBy(rows, totalRemoteParticipantWidthAtMinHeight);
if (!widestRow) {
window.log.error(
'Unable to find the widest row, which should be impossible'
);
continue;
}
const widthScalar =
(containerDimensions.width -
(widestRow.length + 1) * PARTICIPANT_MARGIN) /
totalRemoteParticipantWidthAtMinHeight(widestRow);
const heightScalar =
(containerDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
(rowCount * MIN_RENDERED_HEIGHT);
const scalar = Math.min(widthScalar, heightScalar);
// If this scalar is the best one so far, we use that.
if (scalar > bestArrangement.scalar) {
bestArrangement = { scalar, rows };
}
}
return bestArrangement;
}, [
visibleParticipants,
maxRowCount,
containerDimensions.width,
containerDimensions.height,
]);
// 4. Lay out this arrangement on the screen.
const gridParticipantHeight = gridArrangement.scalar * MIN_RENDERED_HEIGHT;
const gridParticipantHeightWithMargin =
gridParticipantHeight + PARTICIPANT_MARGIN;
const gridTotalRowHeightWithMargin =
gridParticipantHeightWithMargin * gridArrangement.rows.length;
const gridTopOffset = Math.floor(
(containerDimensions.height - gridTotalRowHeightWithMargin) / 2
);
const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
(remoteParticipantsInRow, index) => {
const top = gridTopOffset + index * gridParticipantHeightWithMargin;
const totalRowWidthWithoutMargins =
totalRemoteParticipantWidthAtMinHeight(remoteParticipantsInRow) *
gridArrangement.scalar;
const totalRowWidth =
totalRowWidthWithoutMargins +
PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1);
const leftOffset = (containerDimensions.width - totalRowWidth) / 2;
let rowWidthSoFar = 0;
return remoteParticipantsInRow.map(remoteParticipant => {
const renderedWidth =
remoteParticipant.videoAspectRatio * gridParticipantHeight;
const left = rowWidthSoFar + leftOffset;
rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN;
return (
<GroupCallRemoteParticipant
key={remoteParticipant.demuxId}
createCanvasVideoRenderer={createCanvasVideoRenderer}
demuxId={remoteParticipant.demuxId}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
hasRemoteVideo={remoteParticipant.hasRemoteVideo}
height={gridParticipantHeight}
left={left}
top={top}
width={renderedWidth}
/>
);
});
}
);
const remoteParticipantElements = flatten(rowElements);
return (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
window.log.error('We should be measuring the bounds');
return;
}
setContainerDimensions(bounds);
}}
>
{({ measureRef }) => (
<div className="module-ongoing-call__grid" ref={measureRef}>
{remoteParticipantElements}
</div>
)}
</Measure>
);
};
function totalRemoteParticipantWidthAtMinHeight(
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>
): number {
return remoteParticipants.reduce(
(result, { videoAspectRatio }) =>
result + videoAspectRatio * MIN_RENDERED_HEIGHT,
0
);
}

View file

@ -54,6 +54,7 @@ import {
ProtoBinaryType,
} from './textsecure.d';
import { GroupCredentialsType } from './textsecure/WebAPI';
import MessageSender from './textsecure/SendMessage';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
import { ConversationModel } from './models/conversations';
@ -360,6 +361,86 @@ export function deriveGroupFields(
};
}
async function makeRequestWithTemporalRetry<T>({
logId,
publicParams,
secretParams,
request,
}: {
logId: string;
publicParams: string;
secretParams: string;
request: (sender: MessageSender, options: GroupCredentialsType) => Promise<T>;
}): Promise<T> {
const data = window.storage.get(GROUP_CREDENTIALS_KEY);
if (!data) {
throw new Error(
`makeRequestWithTemporalRetry/${logId}: No group credentials!`
);
}
const groupCredentials = getCredentialsForToday(data);
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error(
`makeRequestWithTemporalRetry/${logId}: textsecure.messaging is not available!`
);
}
const todayOptions = getGroupCredentials({
authCredentialBase64: groupCredentials.today.credential,
groupPublicParamsBase64: publicParams,
groupSecretParamsBase64: secretParams,
serverPublicParamsBase64: window.getServerPublicParams(),
});
try {
return await request(sender, todayOptions);
} catch (todayError) {
if (todayError.code === TEMPORAL_AUTH_REJECTED_CODE) {
window.log.warn(
`makeRequestWithTemporalRetry/${logId}: Trying again with tomorrow's credentials`
);
const tomorrowOptions = getGroupCredentials({
authCredentialBase64: groupCredentials.tomorrow.credential,
groupPublicParamsBase64: publicParams,
groupSecretParamsBase64: secretParams,
serverPublicParamsBase64: window.getServerPublicParams(),
});
return request(sender, tomorrowOptions);
}
throw todayError;
}
}
export async function fetchMembershipProof({
publicParams,
secretParams,
}: {
publicParams: string;
secretParams: string;
}): Promise<string | undefined> {
// Ensure we have the credentials we need before attempting GroupsV2 operations
await maybeFetchNewCredentials();
if (!publicParams) {
throw new Error('fetchMembershipProof: group was missing publicParams!');
}
if (!secretParams) {
throw new Error('fetchMembershipProof: group was missing secretParams!');
}
const response = await makeRequestWithTemporalRetry({
logId: 'fetchMembershipProof',
publicParams,
secretParams,
request: (sender, options) => sender.getGroupMembershipToken(options),
});
return response.token;
}
// Fetching and applying group changes
type MaybeUpdatePropsType = {
@ -2603,3 +2684,29 @@ function decryptPendingMember(
return member;
}
export function getMembershipList(
conversationId: string
): Array<{ uuid: string; uuidCiphertext: ArrayBuffer }> {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('getMembershipList: cannot find conversation');
}
const secretParams = conversation.get('secretParams');
if (!secretParams) {
throw new Error('getMembershipList: no secretParams');
}
const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
return conversation.getMembers().map(member => {
const uuid = member.get('uuid');
if (!uuid) {
throw new Error('getMembershipList: member has no UUID');
}
const uuidCiphertext = encryptUuid(clientZkGroupCipher, uuid);
return { uuid, uuidCiphertext };
});
}

View file

@ -1156,6 +1156,7 @@ export class ConversationModel extends window.Backbone.Model<
draftText,
firstName: this.get('profileName')!,
groupVersion,
groupId: this.get('groupId'),
inboxPosition,
isArchived: this.get('isArchived')!,
isBlocked: this.isBlocked(),
@ -1181,6 +1182,8 @@ export class ConversationModel extends window.Backbone.Model<
name: this.get('name')!,
phoneNumber: this.getNumber()!,
profileName: this.getProfileName()!,
publicParams: this.get('publicParams'),
secretParams: this.get('secretParams'),
sharedGroupNames: this.get('sharedGroupNames')!,
shouldShowDraft,
timestamp,

View file

@ -12,19 +12,51 @@ import {
CallSettings,
CallState,
CanvasVideoRenderer,
ConnectionState,
JoinState,
HttpMethod,
DeviceId,
GroupCall,
GroupMemberInfo,
GumVideoCapturer,
HangupMessage,
HangupType,
OfferType,
OpaqueMessage,
RingRTC,
UserId,
VideoFrameSource,
} from 'ringrtc';
import { uniqBy, noop } from 'lodash';
import { ActionsType as UxActionsType } from '../state/ducks/calling';
import { getConversationCallMode } from '../state/ducks/conversations';
import { EnvelopeClass } from '../textsecure.d';
import { AudioDevice, MediaDeviceSettings } from '../types/Calling';
import {
CallMode,
AudioDevice,
MediaDeviceSettings,
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import { ConversationModel } from '../models/conversations';
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'],
]);
export {
CallState,
@ -45,7 +77,7 @@ export class CallingClass {
private deviceReselectionTimer?: NodeJS.Timeout;
private callsByConversation: { [conversationId: string]: Call };
private callsByConversation: { [conversationId: string]: Call | GroupCall };
constructor() {
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
@ -65,6 +97,8 @@ export class CallingClass {
this
);
RingRTC.handleLogMessage = this.handleLogMessage.bind(this);
RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this);
RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this);
}
async startCallingLobby(
@ -73,14 +107,37 @@ export class CallingClass {
): Promise<void> {
window.log.info('CallingClass.startCallingLobby()');
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);
}
if (!this.uxActions) {
window.log.error('Missing uxActions, new call not allowed.');
return;
}
const remoteUserId = this.getRemoteUserIdFromConversation(conversation);
if (!remoteUserId || !this.localDeviceId) {
window.log.error('Missing identifier, new call not allowed.');
if (!this.localDeviceId) {
window.log.error(
'Missing local device identifier, new call not allowed.'
);
return;
}
@ -90,22 +147,48 @@ export class CallingClass {
return;
}
window.log.info('CallingClass.startCallingLobby(): Getting call settings');
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
// 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.');
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 conversationProps = conversation.format();
window.log.info('CallingClass.startCallingLobby(): Starting lobby');
this.uxActions.showCallLobby({
conversationId: conversationProps.id,
isVideoCall,
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);
}
await this.startDeviceReselectionTimer();
if (isVideoCall) {
@ -113,18 +196,22 @@ export class CallingClass {
}
}
stopCallingLobby(): void {
stopCallingLobby(conversationId?: string): void {
this.disableLocalCamera();
this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined;
if (conversationId) {
this.getGroupCall(conversationId)?.disconnect();
}
}
async startOutgoingCall(
async startOutgoingDirectCall(
conversationId: string,
hasLocalAudio: boolean,
hasLocalVideo: boolean
): Promise<void> {
window.log.info('CallingClass.startCallingLobby()');
window.log.info('CallingClass.startOutgoingDirectCall()');
if (!this.uxActions) {
throw new Error('Redux actions not available');
@ -152,7 +239,9 @@ export class CallingClass {
return;
}
window.log.info('CallingClass.startOutgoingCall(): Getting call settings');
window.log.info(
'CallingClass.startOutgoingDirectCall(): Getting call settings'
);
const callSettings = await this.getCallSettings(conversation);
@ -163,7 +252,9 @@ export class CallingClass {
return;
}
window.log.info('CallingClass.startOutgoingCall(): Starting in RingRTC');
window.log.info(
'CallingClass.startOutgoingDirectCall(): Starting in RingRTC'
);
// We could make this faster by getting the call object
// from the RingRTC before we lookup the ICE servers.
@ -188,8 +279,255 @@ export class CallingClass {
await this.startDeviceReselectionTimer();
}
private getDirectCall(conversationId: string): undefined | Call {
const call = getOwn(this.callsByConversation, conversationId);
return call instanceof Call ? call : undefined;
}
private getGroupCall(conversationId: string): undefined | GroupCall {
const call = getOwn(this.callsByConversation, conversationId);
return call instanceof GroupCall ? call : undefined;
}
/**
* 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 {
return this.callsByConversation[conversationId]?.callId;
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),
});
}
async accept(conversationId: string, asVideoCall: boolean): Promise<void> {
@ -228,33 +566,54 @@ export class CallingClass {
hangup(conversationId: string): void {
window.log.info('CallingClass.hangup()');
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
window.log.warn('Trying to hang up a non-existent call');
return;
}
RingRTC.hangup(callId);
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);
}
}
setOutgoingAudio(conversationId: string, enabled: boolean): void {
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
window.log.warn('Trying to set outgoing audio for a non-existent call');
return;
}
RingRTC.setOutgoingAudio(callId, enabled);
if (call instanceof Call) {
RingRTC.setOutgoingAudio(call.callId, enabled);
} else if (call instanceof GroupCall) {
call.setOutgoingAudioMuted(!enabled);
} else {
throw missingCaseError(call);
}
}
setOutgoingVideo(conversationId: string, enabled: boolean): void {
const callId = this.getCallIdForConversation(conversationId);
if (!callId) {
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
window.log.warn('Trying to set outgoing video for a non-existent call');
return;
}
RingRTC.setOutgoingVideo(callId, enabled);
if (call instanceof Call) {
RingRTC.setOutgoingVideo(call.callId, enabled);
} else if (call instanceof GroupCall) {
call.setOutgoingVideoMuted(!enabled);
} else {
throw missingCaseError(call);
}
}
private async startDeviceReselectionTimer(): Promise<void> {
@ -554,13 +913,17 @@ export class CallingClass {
return;
}
const sourceUuid = envelope.sourceUuid
? uuidToArrayBuffer(envelope.sourceUuid)
: null;
const messageAgeSec = envelope.messageAgeSec ? envelope.messageAgeSec : 0;
window.log.info('CallingClass.handleCallingMessage(): Handling in RingRTC');
RingRTC.handleCallingMessage(
remoteUserId,
null,
sourceUuid,
remoteDeviceId,
this.localDeviceId,
messageAgeSec,
@ -639,6 +1002,21 @@ export class CallingClass {
return false;
}
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);
}
private async handleOutgoingSignaling(
remoteUserId: UserId,
message: CallingMessage
@ -797,6 +1175,48 @@ export class CallingClass {
}
}
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
);
}
private getRemoteUserIdFromConversation(
conversation: ConversationModel
): UserId | undefined | null {

View file

@ -5,14 +5,17 @@ import { ThunkAction } from 'redux-thunk';
import { CallEndedReason } from 'ringrtc';
import { has, omit } from 'lodash';
import { getOwn } from '../../util/getOwn';
import { missingCaseError } from '../../util/missingCaseError';
import { notify } from '../../services/notify';
import { calling } from '../../services/calling';
import { StateType as RootStateType } from '../reducer';
import { getActiveCall } from '../selectors/calling';
import {
CallingDeviceType,
CallMode,
CallState,
ChangeIODevicePayloadType,
GroupCallConnectionState,
GroupCallJoinState,
MediaDeviceSettings,
} from '../../types/Calling';
import { callingTones } from '../../util/callingTones';
@ -25,6 +28,7 @@ import {
// State
export interface DirectCallStateType {
callMode: CallMode.Direct;
conversationId: string;
callState?: CallState;
callEndedReason?: CallEndedReason;
@ -33,6 +37,22 @@ export interface DirectCallStateType {
hasRemoteVideo?: boolean;
}
export interface GroupCallRemoteParticipantType {
demuxId: number;
userId: string;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
videoAspectRatio: number;
}
export interface GroupCallStateType {
callMode: CallMode.Group;
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
}
export interface ActiveCallStateType {
conversationId: string;
joinedAt?: number;
@ -44,7 +64,9 @@ export interface ActiveCallStateType {
}
export type CallingStateType = MediaDeviceSettings & {
callsByConversation: { [conversationId: string]: DirectCallStateType };
callsByConversation: {
[conversationId: string]: DirectCallStateType | GroupCallStateType;
};
activeCallState?: ActiveCallStateType;
};
@ -63,10 +85,23 @@ export type CallStateChangeType = {
title: string;
};
export type CancelCallType = {
conversationId: string;
};
export type DeclineCallType = {
conversationId: string;
};
export type GroupCallStateChangeType = {
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
};
export type HangUpType = {
conversationId: string;
};
@ -76,11 +111,15 @@ export type IncomingCallType = {
isVideoCall: boolean;
};
export type StartCallType = {
interface StartDirectCallType {
conversationId: string;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
};
}
export interface StartCallType extends StartDirectCallType {
callMode: CallMode.Direct | CallMode.Group;
}
export type RemoteVideoChangeType = {
conversationId: string;
@ -95,9 +134,21 @@ export type SetLocalVideoType = {
enabled: boolean;
};
export type ShowCallLobbyType = {
export type ShowCallLobbyType =
| {
callMode: CallMode.Direct;
conversationId: string;
isVideoCall: boolean;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
}
| {
callMode: CallMode.Group;
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
};
export type SetLocalPreviewType = {
@ -110,6 +161,13 @@ export type SetRendererCanvasType = {
// Helpers
export const getActiveCall = ({
activeCallState,
callsByConversation,
}: CallingStateType): undefined | DirectCallStateType | GroupCallStateType =>
activeCallState &&
getOwn(callsByConversation, activeCallState.conversationId);
// Actions
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
@ -119,6 +177,7 @@ const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED';
const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
const DECLINE_CALL = 'calling/DECLINE_CALL';
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
const HANG_UP = 'calling/HANG_UP';
const INCOMING_CALL = 'calling/INCOMING_CALL';
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
@ -126,7 +185,7 @@ const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
const START_CALL = 'calling/START_CALL';
const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
@ -165,6 +224,11 @@ type DeclineCallActionType = {
payload: DeclineCallType;
};
type GroupCallStateChangeActionType = {
type: 'calling/GROUP_CALL_STATE_CHANGE';
payload: GroupCallStateChangeType;
};
type HangUpActionType = {
type: 'calling/HANG_UP';
payload: HangUpType;
@ -177,7 +241,7 @@ type IncomingCallActionType = {
type OutgoingCallActionType = {
type: 'calling/OUTGOING_CALL';
payload: StartCallType;
payload: StartDirectCallType;
};
type RefreshIODevicesActionType = {
@ -205,9 +269,9 @@ type ShowCallLobbyActionType = {
payload: ShowCallLobbyType;
};
type StartCallActionType = {
type: 'calling/START_CALL';
payload: StartCallType;
type StartDirectCallActionType = {
type: 'calling/START_DIRECT_CALL';
payload: StartDirectCallType;
};
type ToggleParticipantsActionType = {
@ -230,6 +294,7 @@ export type CallingActionType =
| ChangeIODeviceFulfilledActionType
| CloseNeedPermissionScreenActionType
| DeclineCallActionType
| GroupCallStateChangeActionType
| HangUpActionType
| IncomingCallActionType
| OutgoingCallActionType
@ -238,7 +303,7 @@ export type CallingActionType =
| SetLocalAudioActionType
| SetLocalVideoFulfilledActionType
| ShowCallLobbyActionType
| StartCallActionType
| StartDirectCallActionType
| ToggleParticipantsActionType
| TogglePipActionType
| ToggleSettingsActionType;
@ -346,8 +411,8 @@ function closeNeedPermissionScreen(): CloseNeedPermissionScreenActionType {
};
}
function cancelCall(): CancelCallActionType {
window.Signal.Services.calling.stopCallingLobby();
function cancelCall(payload: CancelCallType): CancelCallActionType {
calling.stopCallingLobby(payload.conversationId);
return {
type: CANCEL_CALL,
@ -363,6 +428,15 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType {
};
}
function groupCallStateChange(
payload: GroupCallStateChangeType
): GroupCallStateChangeActionType {
return {
type: GROUP_CALL_STATE_CHANGE,
payload,
};
}
function hangUp(payload: HangUpType): HangUpActionType {
calling.hangup(payload.conversationId);
@ -381,7 +455,7 @@ function receiveIncomingCall(
};
}
function outgoingCall(payload: StartCallType): OutgoingCallActionType {
function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
callingTones.playRingtone();
return {
@ -428,11 +502,14 @@ function setLocalAudio(
payload: SetLocalAudioType
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
return (dispatch, getState) => {
const { conversationId } = getActiveCall(getState().calling) || {};
if (conversationId) {
calling.setOutgoingAudio(conversationId, payload.enabled);
const activeCall = getActiveCall(getState().calling);
if (!activeCall) {
window.log.warn('Trying to set local audio when no call is active');
return;
}
calling.setOutgoingAudio(activeCall.conversationId, payload.enabled);
dispatch({
type: SET_LOCAL_AUDIO_FULFILLED,
payload,
@ -444,12 +521,19 @@ function setLocalVideo(
payload: SetLocalVideoType
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
return async (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall) {
window.log.warn('Trying to set local video when no call is active');
return;
}
let enabled: boolean;
if (await requestCameraPermissions()) {
const { conversationId, callState } =
getActiveCall(getState().calling) || {};
if (conversationId && callState) {
calling.setOutgoingVideo(conversationId, payload.enabled);
if (
activeCall.callMode === CallMode.Group ||
(activeCall.callMode === CallMode.Direct && activeCall.callState)
) {
calling.setOutgoingVideo(activeCall.conversationId, payload.enabled);
} else if (payload.enabled) {
calling.enableLocalCamera();
} else {
@ -477,16 +561,34 @@ function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType {
};
}
function startCall(payload: StartCallType): StartCallActionType {
calling.startOutgoingCall(
function startCall(
payload: StartCallType
): ThunkAction<void, RootStateType, unknown, StartDirectCallActionType> {
return dispatch => {
switch (payload.callMode) {
case CallMode.Direct:
calling.startOutgoingDirectCall(
payload.conversationId,
payload.hasLocalAudio,
payload.hasLocalVideo
);
return {
type: START_CALL,
dispatch({
type: START_DIRECT_CALL,
payload,
});
break;
case CallMode.Group:
calling.joinGroupCall(
payload.conversationId,
payload.hasLocalAudio,
payload.hasLocalVideo
);
// The calling service should already be wired up to Redux so we don't need to
// dispatch anything here.
break;
default:
throw missingCaseError(payload.callMode);
}
};
}
@ -515,6 +617,7 @@ export const actions = {
changeIODevice,
closeNeedPermissionScreen,
declineCall,
groupCallStateChange,
hangUp,
receiveIncomingCall,
outgoingCall,
@ -568,20 +671,41 @@ export function reducer(
const { callsByConversation } = state;
if (action.type === SHOW_CALL_LOBBY) {
let call: DirectCallStateType | GroupCallStateType;
switch (action.payload.callMode) {
case CallMode.Direct:
call = {
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
};
break;
case CallMode.Group:
// We expect to be in this state briefly. The Calling service should update the
// call state shortly.
call = {
callMode: CallMode.Group,
conversationId: action.payload.conversationId,
connectionState: action.payload.connectionState,
joinState: action.payload.joinState,
remoteParticipants: action.payload.remoteParticipants,
};
break;
default:
throw missingCaseError(action.payload);
}
return {
...state,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
conversationId: action.payload.conversationId,
isIncoming: false,
isVideoCall: action.payload.isVideoCall,
},
[action.payload.conversationId]: call,
},
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: true,
hasLocalVideo: action.payload.isVideoCall,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
participantsList: false,
pip: false,
settingsDialogOpen: false,
@ -589,12 +713,13 @@ export function reducer(
};
}
if (action.type === START_CALL) {
if (action.type === START_DIRECT_CALL) {
return {
...state,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: false,
@ -636,14 +761,19 @@ export function reducer(
action.type === HANG_UP ||
action.type === CLOSE_NEED_PERMISSION_SCREEN
) {
if (!state.activeCallState) {
const activeCall = getActiveCall(state);
if (!activeCall) {
window.log.warn('No active call to remove');
return state;
}
return removeConversationFromState(
state,
state.activeCallState.conversationId
);
switch (activeCall.callMode) {
case CallMode.Direct:
return removeConversationFromState(state, activeCall.conversationId);
case CallMode.Group:
return omit(state, 'activeCallState');
default:
throw missingCaseError(activeCall);
}
}
if (action.type === DECLINE_CALL) {
@ -656,6 +786,7 @@ export function reducer(
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: true,
@ -671,6 +802,7 @@ export function reducer(
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: false,
@ -703,8 +835,8 @@ export function reducer(
state.callsByConversation,
action.payload.conversationId
);
if (!call) {
window.log.warn('Cannot update state for non-existent call');
if (call?.callMode !== CallMode.Direct) {
window.log.warn('Cannot update state for a non-direct call');
return state;
}
@ -734,11 +866,55 @@ export function reducer(
};
}
if (action.type === GROUP_CALL_STATE_CHANGE) {
const {
conversationId,
connectionState,
joinState,
hasLocalAudio,
hasLocalVideo,
remoteParticipants,
} = action.payload;
if (connectionState === GroupCallConnectionState.NotConnected) {
return {
...state,
callsByConversation: omit(callsByConversation, conversationId),
activeCallState:
state.activeCallState?.conversationId === conversationId
? undefined
: state.activeCallState,
};
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: {
callMode: CallMode.Group,
conversationId,
connectionState,
joinState,
remoteParticipants,
},
},
activeCallState:
state.activeCallState?.conversationId === conversationId
? {
...state.activeCallState,
hasLocalAudio,
hasLocalVideo,
}
: state.activeCallState,
};
}
if (action.type === REMOTE_VIDEO_CHANGE) {
const { conversationId, hasVideo } = action.payload;
const call = getOwn(state.callsByConversation, conversationId);
if (!call) {
window.log.warn('Cannot update remote video for a non-existent call');
if (call?.callMode !== CallMode.Direct) {
window.log.warn('Cannot update remote video for a non-direct call');
return state;
}

View file

@ -18,6 +18,7 @@ import { NoopActionType } from './noop';
import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Colors';
import { BodyRangeType } from '../../types/Util';
import { CallMode } from '../../types/Calling';
// State
@ -91,9 +92,12 @@ export type ConversationType = {
sharedGroupNames?: Array<string>;
groupVersion?: 1 | 2;
groupId?: string;
isMissingMandatoryProfileSharing?: boolean;
messageRequestsEnabled?: boolean;
acceptedMessageRequest?: boolean;
secretParams?: string;
publicParams?: string;
};
export type ConversationLookupType = {
[key: string]: ConversationType;
@ -188,6 +192,31 @@ export type ConversationsStateType = {
messagesByConversation: MessagesByConversationType;
};
// Helpers
export const getConversationCallMode = (
conversation: ConversationType
): CallMode => {
if (
conversation.left ||
conversation.isBlocked ||
conversation.isMe ||
!conversation.acceptedMessageRequest
) {
return CallMode.None;
}
if (conversation.type === 'direct') {
return CallMode.Direct;
}
if (conversation.type === 'group' && conversation.groupVersion === 2) {
return CallMode.Group;
}
return CallMode.None;
};
// Actions
type ConversationAddedActionType = {

View file

@ -3,11 +3,8 @@
import { createSelector } from 'reselect';
import { CallingStateType } from '../ducks/calling';
import { CallState } from '../../types/Calling';
import { getOwn } from '../../util/getOwn';
const getActiveCallState = (state: CallingStateType) => state.activeCallState;
import { CallingStateType, DirectCallStateType } from '../ducks/calling';
import { CallMode, CallState } from '../../types/Calling';
const getCallsByConversation = (state: CallingStateType) =>
state.callsByConversation;
@ -16,18 +13,14 @@ const getCallsByConversation = (state: CallingStateType) =>
// UI are ready to handle this.
export const getIncomingCall = createSelector(
getCallsByConversation,
callsByConversation =>
Object.values(callsByConversation).find(
call => call.isIncoming && call.callState === CallState.Ringing
)
(callsByConversation): undefined | DirectCallStateType => {
const result = Object.values(callsByConversation).find(
call =>
call.callMode === CallMode.Direct &&
call.isIncoming &&
call.callState === CallState.Ringing
);
export const getActiveCall = createSelector(
getActiveCallState,
getCallsByConversation,
(activeCallState, callsByConversation) =>
activeCallState &&
getOwn(callsByConversation, activeCallState.conversationId)
// TypeScript needs a little help to be sure that this is a direct call.
return result?.callMode === CallMode.Direct ? result : undefined;
}
);
export const isCallActive = createSelector(getActiveCall, Boolean);

View file

@ -3,10 +3,13 @@
import React from 'react';
import { connect } from 'react-redux';
import { CanvasVideoRenderer } from 'ringrtc';
import { mapDispatchToProps } from '../actions';
import { CallManager } from '../../components/CallManager';
import { calling as callingService } from '../../services/calling';
import { getMe, getConversationSelector } from '../selectors/conversations';
import { getActiveCall, getIncomingCall } from '../selectors/calling';
import { getActiveCall } from '../ducks/calling';
import { getIncomingCall } from '../selectors/calling';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
@ -17,6 +20,12 @@ function renderDeviceSelection(): JSX.Element {
return <SmartCallingDeviceSelection />;
}
const createCanvasVideoRenderer = () => new CanvasVideoRenderer();
const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource.bind(
callingService
);
const mapStateToActiveCallProp = (state: StateType) => {
const { calling } = state;
const { activeCallState } = calling;
@ -69,6 +78,8 @@ const mapStateToIncomingCallProp = (state: StateType) => {
const mapStateToProps = (state: StateType) => ({
activeCall: mapStateToActiveCallProp(state),
availableCameras: state.calling.availableCameras,
createCanvasVideoRenderer,
getGroupCallVideoFrameSource,
i18n: getIntl(state),
incomingCall: mapStateToIncomingCallProp(state),
me: getMe(state),

View file

@ -6,7 +6,9 @@ import { pick } from 'lodash';
import { ConversationHeader } from '../../components/conversation/ConversationHeader';
import { getConversationSelector } from '../selectors/conversations';
import { StateType } from '../reducer';
import { isCallActive } from '../selectors/calling';
import { CallMode } from '../../types/Calling';
import { getConversationCallMode } from '../ducks/conversations';
import { getActiveCall } from '../ducks/calling';
import { getIntl } from '../selectors/user';
export interface OwnProps {
@ -36,6 +38,11 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
throw new Error('Could not find conversation');
}
const conversationCallMode = getConversationCallMode(conversation);
const conversationSupportsCalls =
conversationCallMode === CallMode.Direct ||
(conversationCallMode === CallMode.Group && window.GROUP_CALLING);
return {
...pick(conversation, [
'acceptedMessageRequest',
@ -59,10 +66,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
]),
i18n: getIntl(state),
showBackButton: state.conversations.selectedConversationPanelDepth > 0,
showCallButtons:
conversation.type === 'direct' &&
!conversation.isMe &&
!isCallActive(state.calling),
showCallButtons: conversationSupportsCalls && !getActiveCall(state.calling),
};
};

View file

@ -5,15 +5,27 @@ import { assert } from 'chai';
import * as sinon from 'sinon';
import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop';
import { actions, getEmptyState, reducer } from '../../../state/ducks/calling';
import {
CallingStateType,
actions,
getActiveCall,
getEmptyState,
reducer,
} from '../../../state/ducks/calling';
import { calling as callingService } from '../../../services/calling';
import { CallState } from '../../../types/Calling';
import {
CallMode,
CallState,
GroupCallConnectionState,
GroupCallJoinState,
} from '../../../types/Calling';
describe('calling duck', () => {
const stateWithDirectCall = {
const stateWithDirectCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
@ -23,7 +35,7 @@ describe('calling duck', () => {
},
};
const stateWithActiveDirectCall = {
const stateWithActiveDirectCall: CallingStateType = {
...stateWithDirectCall,
activeCallState: {
conversationId: 'fake-direct-call-conversation-id',
@ -35,10 +47,11 @@ describe('calling duck', () => {
},
};
const stateWithIncomingDirectCall = {
const stateWithIncomingDirectCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
@ -48,6 +61,39 @@ describe('calling duck', () => {
},
};
const stateWithGroupCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
'fake-group-call-conversation-id': {
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
remoteParticipants: [
{
demuxId: 123,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 4 / 3,
},
],
},
},
};
const stateWithActiveGroupCall: CallingStateType = {
...stateWithGroupCall,
activeCallState: {
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
};
const getEmptyRootState = () => rootReducer(undefined, noopAction());
beforeEach(function beforeEach() {
@ -141,6 +187,271 @@ describe('calling duck', () => {
});
});
describe('cancelCall', () => {
const { cancelCall } = actions;
beforeEach(function beforeEach() {
this.callingServiceStopCallingLobby = this.sandbox.stub(
callingService,
'stopCallingLobby'
);
});
it('stops the calling lobby for that conversation', function test() {
cancelCall({ conversationId: '123' });
sinon.assert.calledOnce(this.callingServiceStopCallingLobby);
sinon.assert.calledWith(this.callingServiceStopCallingLobby, '123');
});
it('completely removes an active direct call from the state', () => {
const result = reducer(
stateWithActiveDirectCall,
cancelCall({ conversationId: 'fake-direct-call-conversation-id' })
);
assert.notProperty(
result.callsByConversation,
'fake-direct-call-conversation-id'
);
assert.isUndefined(result.activeCallState);
});
it('removes the active group call, but leaves it in the state', () => {
const result = reducer(
stateWithActiveGroupCall,
cancelCall({ conversationId: 'fake-group-call-conversation-id' })
);
assert.property(
result.callsByConversation,
'fake-group-call-conversation-id'
);
assert.isUndefined(result.activeCallState);
});
});
describe('groupCallStateChange', () => {
const { groupCallStateChange } = actions;
it('ignores new calls that are not connected', () => {
const result = reducer(
getEmptyState(),
groupCallStateChange({
conversationId: 'abc123',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
remoteParticipants: [],
})
);
assert.deepEqual(result, getEmptyState());
});
it('removes the call from the map of conversations if the call is disconnected', () => {
const result = reducer(
stateWithGroupCall,
groupCallStateChange({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
remoteParticipants: [],
})
);
assert.notProperty(
result.callsByConversation,
'fake-group-call-conversation-id'
);
});
it('drops the active call if it is disconnected', () => {
const result = reducer(
stateWithActiveGroupCall,
groupCallStateChange({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
hasLocalAudio: false,
hasLocalVideo: false,
remoteParticipants: [],
})
);
assert.isUndefined(result.activeCallState);
});
it('saves a new call to the map of conversations', () => {
const result = reducer(
getEmptyState(),
groupCallStateChange({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joining,
hasLocalAudio: true,
hasLocalVideo: false,
remoteParticipants: [
{
demuxId: 123,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 4 / 3,
},
],
})
);
assert.deepEqual(
result.callsByConversation['fake-group-call-conversation-id'],
{
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joining,
remoteParticipants: [
{
demuxId: 123,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 4 / 3,
},
],
}
);
});
it('updates a call in the map of conversations', () => {
const result = reducer(
stateWithGroupCall,
groupCallStateChange({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: false,
remoteParticipants: [
{
demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false,
hasRemoteVideo: true,
videoAspectRatio: 16 / 9,
},
],
})
);
assert.deepEqual(
result.callsByConversation['fake-group-call-conversation-id'],
{
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
remoteParticipants: [
{
demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false,
hasRemoteVideo: true,
videoAspectRatio: 16 / 9,
},
],
}
);
});
it("if no call is active, doesn't touch the active call state", () => {
const result = reducer(
stateWithGroupCall,
groupCallStateChange({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: false,
remoteParticipants: [
{
demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false,
hasRemoteVideo: true,
videoAspectRatio: 16 / 9,
},
],
})
);
assert.isUndefined(result.activeCallState);
});
it("if the call is not active, doesn't touch the active call state", () => {
const result = reducer(
stateWithActiveGroupCall,
groupCallStateChange({
conversationId: 'another-fake-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: true,
remoteParticipants: [
{
demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false,
hasRemoteVideo: true,
videoAspectRatio: 16 / 9,
},
],
})
);
assert.deepEqual(result.activeCallState, {
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
});
});
it('if the call is active, updates the active call state', () => {
const result = reducer(
stateWithActiveGroupCall,
groupCallStateChange({
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
hasLocalAudio: true,
hasLocalVideo: true,
remoteParticipants: [
{
demuxId: 456,
userId: 'aead696f-4373-4e51-b9c2-1bb4d1adccf0',
hasRemoteAudio: false,
hasRemoteVideo: true,
videoAspectRatio: 16 / 9,
},
],
})
);
assert.strictEqual(
result.activeCallState?.conversationId,
'fake-group-call-conversation-id'
);
assert.isTrue(result.activeCallState?.hasLocalAudio);
assert.isTrue(result.activeCallState?.hasLocalVideo);
});
});
describe('setLocalAudio', () => {
const { setLocalAudio } = actions;
@ -154,7 +465,14 @@ describe('calling duck', () => {
it('dispatches a SET_LOCAL_AUDIO_FULFILLED action', () => {
const dispatch = sinon.spy();
setLocalAudio({ enabled: true })(dispatch, getEmptyRootState, null);
setLocalAudio({ enabled: true })(
dispatch,
() => ({
...getEmptyRootState(),
calling: stateWithActiveDirectCall,
}),
null
);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
@ -201,7 +519,14 @@ describe('calling duck', () => {
it('updates the local audio state with SET_LOCAL_AUDIO_FULFILLED', () => {
const dispatch = sinon.spy();
setLocalAudio({ enabled: false })(dispatch, getEmptyRootState, null);
setLocalAudio({ enabled: false })(
dispatch,
() => ({
...getEmptyRootState(),
calling: stateWithActiveDirectCall,
}),
null
);
const action = dispatch.getCall(0).args[0];
const result = reducer(stateWithActiveDirectCall, action);
@ -217,12 +542,15 @@ describe('calling duck', () => {
const result = reducer(
getEmptyState(),
showCallLobby({
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id',
isVideoCall: true,
hasLocalAudio: true,
hasLocalVideo: true,
})
);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id',
isIncoming: false,
isVideoCall: true,
@ -242,39 +570,65 @@ describe('calling duck', () => {
const { startCall } = actions;
beforeEach(function beforeEach() {
this.callingStartOutgoingCall = this.sandbox.stub(
this.callingStartOutgoingDirectCall = this.sandbox.stub(
callingService,
'startOutgoingCall'
'startOutgoingDirectCall'
);
this.callingJoinGroupCall = this.sandbox.stub(
callingService,
'joinGroupCall'
);
});
it('asks the calling service to start an outgoing call', function test() {
it('asks the calling service to start an outgoing direct call', function test() {
const dispatch = sinon.spy();
startCall({
callMode: CallMode.Direct,
conversationId: '123',
hasLocalAudio: true,
hasLocalVideo: false,
});
})(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(this.callingStartOutgoingCall);
sinon.assert.calledOnce(this.callingStartOutgoingDirectCall);
sinon.assert.calledWith(
this.callingStartOutgoingCall,
this.callingStartOutgoingDirectCall,
'123',
true,
false
);
sinon.assert.notCalled(this.callingJoinGroupCall);
});
it('saves the call and makes it active', () => {
const result = reducer(
getEmptyState(),
it('asks the calling service to join a group call', function test() {
const dispatch = sinon.spy();
startCall({
callMode: CallMode.Group,
conversationId: '123',
hasLocalAudio: true,
hasLocalVideo: false,
})(dispatch, getEmptyRootState, null);
sinon.assert.calledOnce(this.callingJoinGroupCall);
sinon.assert.calledWith(this.callingJoinGroupCall, '123', true, false);
sinon.assert.notCalled(this.callingStartOutgoingDirectCall);
});
it('saves direct calls and makes them active', () => {
const dispatch = sinon.spy();
startCall({
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: false,
})
);
})(dispatch, getEmptyRootState, null);
const action = dispatch.getCall(0).args[0];
const result = reducer(getEmptyState(), action);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
callMode: CallMode.Direct,
conversationId: 'fake-conversation-id',
callState: CallState.Prering,
isIncoming: false,
@ -289,6 +643,18 @@ describe('calling duck', () => {
settingsDialogOpen: false,
});
});
it("doesn't dispatch any actions for group calls", () => {
const dispatch = sinon.spy();
startCall({
callMode: CallMode.Group,
conversationId: '123',
hasLocalAudio: true,
hasLocalVideo: false,
})(dispatch, getEmptyRootState, null);
sinon.assert.notCalled(dispatch);
});
});
describe('toggleSettings', () => {
@ -342,4 +708,27 @@ describe('calling duck', () => {
});
});
});
describe('helpers', () => {
describe('getActiveCall', () => {
it('returns undefined if there are no calls', () => {
assert.isUndefined(getActiveCall(getEmptyState()));
});
it('returns undefined if there is no active call', () => {
assert.isUndefined(getActiveCall(stateWithDirectCall));
});
it('returns the active call', () => {
assert.deepEqual(getActiveCall(stateWithActiveDirectCall), {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
});
});
});
});
});

View file

@ -0,0 +1,118 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
getConversationCallMode,
ConversationType,
} from '../../../state/ducks/conversations';
import { CallMode } from '../../../types/Calling';
describe('conversations duck', () => {
describe('helpers', () => {
describe('getConversationCallMode', () => {
const fakeConversation: ConversationType = {
id: 'id1',
e164: '+18005551111',
activeAt: Date.now(),
name: 'No timestamp',
timestamp: 0,
inboxPosition: 0,
phoneNumber: 'notused',
isArchived: false,
markedUnread: false,
type: 'direct',
isMe: false,
lastUpdated: Date.now(),
title: 'No timestamp',
unreadCount: 1,
isSelected: false,
typingContact: {
name: 'Someone There',
color: 'blue',
phoneNumber: '+18005551111',
},
acceptedMessageRequest: true,
};
it("returns CallMode.None if you've left the conversation", () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
left: true,
}),
CallMode.None
);
});
it("returns CallMode.None if you've blocked the other person", () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
isBlocked: true,
}),
CallMode.None
);
});
it("returns CallMode.None if you haven't accepted message requests", () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
acceptedMessageRequest: false,
}),
CallMode.None
);
});
it('returns CallMode.None if the conversation is Note to Self', () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
isMe: true,
}),
CallMode.None
);
});
it('returns CallMode.None for v1 groups', () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
type: 'group',
groupVersion: 1,
}),
CallMode.None
);
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
type: 'group',
}),
CallMode.None
);
});
it('returns CallMode.Direct if the conversation is a normal direct conversation', () => {
assert.strictEqual(
getConversationCallMode(fakeConversation),
CallMode.Direct
);
});
it('returns CallMode.Group if the conversation is a v2 group', () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
type: 'group',
groupVersion: 2,
}),
CallMode.Group
);
});
});
});
});

View file

@ -2,19 +2,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { CallState } from '../../../types/Calling';
import {
getIncomingCall,
getActiveCall,
isCallActive,
} from '../../../state/selectors/calling';
import { getEmptyState } from '../../../state/ducks/calling';
import { CallMode, CallState } from '../../../types/Calling';
import { getIncomingCall } from '../../../state/selectors/calling';
import { getEmptyState, CallingStateType } from '../../../state/ducks/calling';
describe('state/selectors/calling', () => {
const stateWithDirectCall = {
const stateWithDirectCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
@ -24,7 +21,7 @@ describe('state/selectors/calling', () => {
},
};
const stateWithActiveDirectCall = {
const stateWithActiveDirectCall: CallingStateType = {
...stateWithDirectCall,
activeCallState: {
conversationId: 'fake-direct-call-conversation-id',
@ -36,10 +33,11 @@ describe('state/selectors/calling', () => {
},
};
const stateWithIncomingDirectCall = {
const stateWithIncomingDirectCall: CallingStateType = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
@ -61,6 +59,7 @@ describe('state/selectors/calling', () => {
it('returns the incoming call', () => {
assert.deepEqual(getIncomingCall(stateWithIncomingDirectCall), {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
@ -69,38 +68,4 @@ describe('state/selectors/calling', () => {
});
});
});
describe('getActiveCall', () => {
it('returns undefined if there are no calls', () => {
assert.isUndefined(getActiveCall(getEmptyState()));
});
it('returns undefined if there is no active call', () => {
assert.isUndefined(getActiveCall(stateWithDirectCall));
});
it('returns the active call', () => {
assert.deepEqual(getActiveCall(stateWithActiveDirectCall), {
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
});
});
});
describe('isCallActive', () => {
it('returns false if there are no calls', () => {
assert.isFalse(isCallActive(getEmptyState()));
});
it('returns false if there is no active call', () => {
assert.isFalse(isCallActive(stateWithDirectCall));
});
it('returns true if there is an active call', () => {
assert.isTrue(isCallActive(stateWithActiveDirectCall));
});
});
});

10
ts/textsecure.d.ts vendored
View file

@ -174,6 +174,7 @@ type GroupsProtobufTypes = {
GroupChange: typeof GroupChangeClass;
GroupChanges: typeof GroupChangesClass;
GroupAttributeBlob: typeof GroupAttributeBlobClass;
GroupExternalCredential: typeof GroupExternalCredentialClass;
};
type SignalServiceProtobufTypes = {
@ -439,6 +440,15 @@ export declare namespace GroupChangesClass {
}
}
export declare class GroupExternalCredentialClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => GroupExternalCredentialClass;
token?: string;
}
export declare class GroupAttributeBlobClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,

View file

@ -34,6 +34,7 @@ import {
DataMessageClass,
GroupChangeClass,
GroupClass,
GroupExternalCredentialClass,
StorageServiceCallOptionsType,
StorageServiceCredentials,
SyncMessageClass,
@ -1841,4 +1842,10 @@ export default class MessageSender {
): Promise<ArrayBuffer> {
return this.server.modifyStorageRecords(data, options);
}
async getGroupMembershipToken(
options: GroupCredentialsType
): Promise<GroupExternalCredentialClass> {
return this.server.getGroupExternalCredential(options);
}
}

View file

@ -50,6 +50,7 @@ import {
GroupChangeClass,
GroupChangesClass,
GroupClass,
GroupExternalCredentialClass,
StorageServiceCallOptionsType,
StorageServiceCredentials,
} from '../textsecure.d';
@ -625,6 +626,7 @@ const URL_CALLS = {
getStickerPackUpload: 'v1/sticker/pack/form',
groupLog: 'v1/groups/logs',
groups: 'v1/groups',
groupToken: 'v1/groups/token',
keys: 'v2/keys',
messages: 'v1/messages',
profile: 'v1/profile',
@ -726,6 +728,9 @@ export type WebAPIType = {
startDay: number,
endDay: number
) => Promise<Array<GroupCredentialType>>;
getGroupExternalCredential: (
options: GroupCredentialsType
) => Promise<GroupExternalCredentialClass>;
getGroupLog: (
startVersion: number,
options: GroupCredentialsType
@ -779,6 +784,12 @@ export type WebAPIType = {
targetUrl: string,
options?: ProxiedRequestOptionsType
) => Promise<any>;
makeSfuRequest: (
targetUrl: string,
type: HTTPCodeType,
headers: HeaderListType,
body: ArrayBuffer | undefined
) => Promise<ArrayBufferWithDetailsType>;
modifyGroup: (
changes: GroupChangeClass.Actions,
options: GroupCredentialsType
@ -940,6 +951,7 @@ export function initialize({
getGroup,
getGroupAvatar,
getGroupCredentials,
getGroupExternalCredential,
getGroupLog,
getIceServers,
getKeysForIdentifier,
@ -959,6 +971,7 @@ export function initialize({
fetchLinkPreviewMetadata,
fetchLinkPreviewImage,
makeProxiedRequest,
makeSfuRequest,
modifyGroup,
modifyStorageRecords,
putAttachment,
@ -1833,6 +1846,24 @@ export function initialize({
};
}
async function makeSfuRequest(
targetUrl: string,
type: HTTPCodeType,
headers: HeaderListType,
body: ArrayBuffer | undefined
): Promise<ArrayBufferWithDetailsType> {
return _outerAjax(targetUrl, {
certificateAuthority,
data: body,
headers,
proxyUrl,
responseType: 'arraybufferwithdetails',
timeout: 0,
type,
version,
});
}
// Groups
function generateGroupAuth(
@ -1860,6 +1891,28 @@ export function initialize({
return response.credentials;
}
async function getGroupExternalCredential(
options: GroupCredentialsType
): Promise<GroupExternalCredentialClass> {
const basicAuth = generateGroupAuth(
options.groupPublicParamsHex,
options.authCredentialPresentationHex
);
const response: ArrayBuffer = await _ajax({
basicAuth,
call: 'groupToken',
httpType: 'GET',
contentType: 'application/x-protobuf',
responseType: 'arraybuffer',
host: storageUrl,
});
return window.textsecure.protobuf.GroupExternalCredential.decode(
response
);
}
function verifyAttributes(attributes: AvatarUploadAttributesClass) {
const {
key,

View file

@ -1,6 +1,16 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export enum CallMode {
None,
Direct,
Group,
}
// Ideally, we would import many of these directly from RingRTC. But because Storybook
// cannot import RingRTC (as it runs in the browser), we have these copies. That also
// means we have to convert the "real" enum to our enum in some cases.
// Must be kept in sync with RingRTC.CallState
export enum CallState {
Prering = 'init',
@ -31,6 +41,36 @@ export enum CallEndedReason {
CallerIsNotMultiring = 'CallerIsNotMultiring',
}
// Must be kept in sync with RingRTC's ConnectionState
export enum GroupCallConnectionState {
NotConnected = 0,
Connecting = 1,
Connected = 2,
Reconnecting = 3,
}
// Must be kept in sync with RingRTC's JoinState
export enum GroupCallJoinState {
NotJoined = 0,
Joining = 1,
Joined = 2,
}
// Should match RingRTC's CanvasVideoRenderer
interface Ref<T> {
readonly current: T | null;
}
export interface CanvasVideoRenderer {
setCanvas(canvas: Ref<HTMLCanvasElement> | undefined): void;
enable(source: VideoFrameSource): void;
disable(): void;
}
// Should match RingRTC's VideoFrameSource
export interface VideoFrameSource {
receiveVideoFrame(buffer: ArrayBuffer): [number, number] | undefined;
}
// Must be kept in sync with RingRTC.AudioDevice
export interface AudioDevice {
// Device name.

View file

@ -14391,20 +14391,11 @@
"rule": "React-useRef",
"path": "ts/components/CallScreen.js",
"line": " const localVideoRef = react_1.useRef(null);",
"lineNumber": 35,
"lineNumber": 38,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T21:35:52.858Z",
"reasonDetail": "Used to get the local video element for rendering."
},
{
"rule": "React-useRef",
"path": "ts/components/CallScreen.js",
"line": " const remoteVideoRef = react_1.useRef(null);",
"lineNumber": 36,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T21:35:52.858Z",
"reasonDetail": "Used to get the remote video element for rendering."
},
{
"rule": "React-useRef",
"path": "ts/components/CallingLobby.js",
@ -14594,6 +14585,42 @@
"updated": "2020-10-26T23:56:13.482Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/DirectCallRemoteParticipant.js",
"line": " const remoteVideoRef = react_1.useRef(null);",
"lineNumber": 15,
"reasonCategory": "usageTrusted",
"updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Needed to render the remote video element."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupCallRemoteParticipant.js",
"line": " const remoteVideoRef = react_1.useRef(null);",
"lineNumber": 16,
"reasonCategory": "usageTrusted",
"updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Needed to render the remote video element."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupCallRemoteParticipant.js",
"line": " const canvasVideoRendererRef = react_1.useRef(createCanvasVideoRenderer());",
"lineNumber": 17,
"reasonCategory": "usageTrusted",
"updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Doesn't touch the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/GroupCallRemoteParticipant.tsx",
"line": " const canvasVideoRendererRef = useRef(createCanvasVideoRenderer());",
"lineNumber": 31,
"reasonCategory": "usageTrusted",
"updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Doesn't touch the DOM."
},
{
"rule": "jQuery-$(",
"path": "ts/components/Intl.js",
@ -15164,7 +15191,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.js",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
"lineNumber": 1233,
"lineNumber": 1260,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
},
@ -15172,7 +15199,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.ts",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
"lineNumber": 2105,
"lineNumber": 2158,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
}

3
ts/window.d.ts vendored
View file

@ -478,6 +478,9 @@ declare global {
hasSignalAccount: (number: string) => boolean;
getServerTrustRoot: () => WhatIsThis;
readyForUpdates: () => void;
// Flags
GROUP_CALLING: boolean;
}
interface Error {