diff --git a/patches/node-fetch+2.6.1.patch b/patches/node-fetch+2.6.1.patch index 63d29f02f4..5539ff2d8e 100644 --- a/patches/node-fetch+2.6.1.patch +++ b/patches/node-fetch+2.6.1.patch @@ -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 diff --git a/preload.js b/preload.js index 4dcf1b8c46..456396ab07 100644 --- a/preload.js +++ b/preload.js @@ -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'); diff --git a/protos/Groups.proto b/protos/Groups.proto index a9dd3183be..e7c29d93b4 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -151,3 +151,7 @@ message GroupAttributeBlob { uint32 disappearingMessagesDuration = 3; } } + +message GroupExternalCredential { + string token = 1; +} diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 450395915a..d28f64aa26 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -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 { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 3b5967fc1e..b2660b35e0 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; } + &__container { + &--direct { + .module-ongoing-call__header { + position: absolute; + } + .module-ongoing-call__footer { + position: absolute; + } + } + } + &__header { - position: absolute; 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 { diff --git a/test/crypto_test.js b/test/crypto_test.js index d08885a2db..e445640779 100644 --- a/test/crypto_test.js +++ b/test/crypto_test.js @@ -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) + ); + }); + }); }); diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 973639a6ec..5b60cf4635 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -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 { const uuids = []; for (let i = 0; i < arrayBuffer.byteLength; i += 16) { diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index ec0348e985..ca6204d864 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -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 }) => ( - <> -

{title}

- - - )); + return permutations.map( + ({ props, title }: { props: Partial; title: string }) => ( + <> +

{title}

+ + + ) + ); }); diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 616bb9552d..906b665576 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -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; - activeCallState: ActiveCallStateType; - conversation: { - id: string; - avatarPath?: string; - color?: ColorType; - title: string; - name?: string; - phoneNumber?: string; - profileName?: string; - }; - }; +interface ActiveCallType { + call: DirectCallStateType | GroupCallStateType; + activeCallState: ActiveCallStateType; + conversation: ConversationType; +} + +export interface PropsType { + activeCall?: ActiveCallType; availableCameras: Array; - 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 = ({ activeCall, availableCameras, cancelCall, closeNeedPermissionScreen, - declineCall, + createCanvasVideoRenderer, hangUp, i18n, - incomingCall, + getGroupCallVideoFrameSource, me, renderDeviceSelection, setLocalAudio, @@ -95,21 +100,46 @@ export const CallManager = ({ toggleParticipants, togglePip, toggleSettings, -}: PropsType): JSX.Element | null => { - if (activeCall) { - const { call, activeCallState, conversation } = activeCall; - const { callState, callEndedReason } = call; - const { - joinedAt, +}) => { + const { call, activeCallState, conversation } = activeCall; + const { + joinedAt, + hasLocalAudio, + hasLocalVideo, + settingsDialogOpen, + pip, + } = activeCallState; + + const cancelActiveCall = useCallback(() => { + cancelCall({ conversationId: conversation.id }); + }, [cancelCall, conversation.id]); + + const joinActiveCall = useCallback(() => { + startCall({ + callMode: call.callMode, + conversationId: conversation.id, hasLocalAudio, hasLocalVideo, - settingsDialogOpen, - pip, - } = activeCallState; + }); + }, [startCall, call.callMode, conversation.id, hasLocalAudio, hasLocalVideo]); - const ended = callState === CallState.Ended; - if (ended) { - if (callEndedReason === CallEndedReason.RemoteHangupNeedPermission) { + 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 && + callEndedReason === CallEndedReason.RemoteHangupNeedPermission + ) { return ( ); } + showCallLobby = !callState; + break; } - - if (!callState) { - return ( - <> - { - startCall({ - conversationId: conversation.id, - hasLocalAudio, - hasLocalVideo, - }); - }} - setLocalPreview={setLocalPreview} - setLocalAudio={setLocalAudio} - setLocalVideo={setLocalVideo} - toggleParticipants={toggleParticipants} - toggleSettings={toggleSettings} - /> - {settingsDialogOpen && renderDeviceSelection()} - - ); - } - - const hasRemoteVideo = Boolean(call.hasRemoteVideo); - - if (pip) { - return ( - - ); + case CallMode.Group: { + showCallLobby = call.joinState === GroupCallJoinState.NotJoined; + break; } + default: + throw missingCaseError(call); + } + if (showCallLobby) { return ( <> - {settingsDialogOpen && renderDeviceSelection()} @@ -191,6 +185,58 @@ 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); + + return ( + + ); + } + + return ( + <> + + {settingsDialogOpen && renderDeviceSelection()} + + ); +}; + +export const CallManager: React.FC = 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 ; + } + // In the future, we may want to show the incoming call bar when a call is active. if (incomingCall) { return ( diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 475177c415..afab65b0e0 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -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 => ({ +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 => ({ 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: { diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 66e011ad66..88c2122d0f 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -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 = ({ - callState, + call, conversation, + createCanvasVideoRenderer, + getGroupCallVideoFrameSource, hangUp, hasLocalAudio, hasLocalVideo, - hasRemoteVideo, i18n, joinedAt, me, @@ -84,15 +90,11 @@ export const CallScreen: React.FC = ({ const [showControls, setShowControls] = useState(true); const localVideoRef = useRef(null); - const remoteVideoRef = useRef(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 = ({ }; }, [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 = ( + + ); + break; + case CallMode.Group: + hasRemoteVideo = call.remoteParticipants.some( + remoteParticipant => remoteParticipant.hasRemoteVideo + ); + isConnected = call.connectionState === GroupCallConnectionState.Connected; + remoteParticipants = ( + + ); + break; + default: + throw missingCaseError(call); + } const videoButtonType = hasLocalVideo ? CallingButtonType.VIDEO_ON @@ -158,9 +185,23 @@ export const CallScreen: React.FC = ({ ? 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 (
{ setShowControls(true); }} @@ -176,7 +217,12 @@ export const CallScreen: React.FC = ({
{conversation.title}
- {renderHeaderMessage(i18n, callState, acceptedDuration)} + {call.callMode === CallMode.Direct && + renderHeaderMessage( + i18n, + call.callState || CallState.Prering, + acceptedDuration + )}
- {hasRemoteVideo ? ( - - ) : ( - renderAvatar(i18n, conversation) - )} + {remoteParticipants}
{/* This layout-only element is not ideal. See the comment in _modules.css for more. */} @@ -260,40 +302,17 @@ export const CallScreen: React.FC = ({ ); }; -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 ( -
- -
- ); } function renderHeaderMessage( diff --git a/ts/components/DirectCallRemoteParticipant.tsx b/ts/components/DirectCallRemoteParticipant.tsx new file mode 100644 index 0000000000..f89fe611ec --- /dev/null +++ b/ts/components/DirectCallRemoteParticipant.tsx @@ -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 = ({ + conversation, + hasRemoteVideo, + i18n, + setRendererCanvas, +}) => { + const remoteVideoRef = useRef(null); + + useEffect(() => { + setRendererCanvas({ element: remoteVideoRef }); + return () => { + setRendererCanvas({ element: undefined }); + }; + }, [setRendererCanvas]); + + return hasRemoteVideo ? ( + + ) : ( + 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 ( +
+ +
+ ); +} diff --git a/ts/components/GroupCallRemoteParticipant.tsx b/ts/components/GroupCallRemoteParticipant.tsx new file mode 100644 index 0000000000..b53295eac8 --- /dev/null +++ b/ts/components/GroupCallRemoteParticipant.tsx @@ -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 = ({ + createCanvasVideoRenderer, + demuxId, + getGroupCallVideoFrameSource, + hasRemoteVideo, + height, + left, + top, + width, +}) => { + const remoteVideoRef = useRef(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 ( +
+ {hasRemoteVideo ? ( + + ) : ( + + {/* TODO: Improve the styling here. See DESKTOP-894. */} + + + )} +
+ ); +}; diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx new file mode 100644 index 0000000000..0a0e71040c --- /dev/null +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -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>; + scalar: number; +} + +interface PropsType { + createCanvasVideoRenderer: () => CanvasVideoRenderer; + getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; + remoteParticipants: ReadonlyArray; +} + +// 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 = ({ + createCanvasVideoRenderer, + getGroupCallVideoFrameSource, + remoteParticipants, +}) => { + const [containerDimensions, setContainerDimensions] = useState({ + 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 = 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> = 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 ( + + ); + }); + } + ); + const remoteParticipantElements = flatten(rowElements); + + return ( + { + if (!bounds) { + window.log.error('We should be measuring the bounds'); + return; + } + setContainerDimensions(bounds); + }} + > + {({ measureRef }) => ( +
+ {remoteParticipantElements} +
+ )} +
+ ); +}; + +function totalRemoteParticipantWidthAtMinHeight( + remoteParticipants: ReadonlyArray +): number { + return remoteParticipants.reduce( + (result, { videoAspectRatio }) => + result + videoAspectRatio * MIN_RENDERED_HEIGHT, + 0 + ); +} diff --git a/ts/groups.ts b/ts/groups.ts index ec727a0742..469a4512cb 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -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({ + logId, + publicParams, + secretParams, + request, +}: { + logId: string; + publicParams: string; + secretParams: string; + request: (sender: MessageSender, options: GroupCredentialsType) => Promise; +}): Promise { + 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 { + // 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 }; + }); +} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index eb6c5d3063..08e4f98189 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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, diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 706069e77b..2e002778d8 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -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 { 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,21 +147,47 @@ export class CallingClass { return; } - window.log.info('CallingClass.startCallingLobby(): Getting call settings'); - - // 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.'); - return; - } - - const conversationProps = conversation.format(); - window.log.info('CallingClass.startCallingLobby(): Starting lobby'); - this.uxActions.showCallLobby({ - conversationId: conversationProps.id, - isVideoCall, - }); + + switch (callMode) { + case CallMode.Direct: + this.uxActions.showCallLobby({ + callMode: CallMode.Direct, + conversationId: conversationProps.id, + hasLocalAudio: true, + hasLocalVideo: isVideoCall, + }); + break; + case CallMode.Group: { + if ( + !conversationProps.groupId || + !conversationProps.publicParams || + !conversationProps.secretParams + ) { + window.log.error( + 'Conversation is missing required parameters. Cannot connect group call' + ); + return; + } + const groupCall = this.connectGroupCall(conversationProps.id, { + groupId: conversationProps.groupId, + publicParams: conversationProps.publicParams, + secretParams: conversationProps.secretParams, + }); + + groupCall.setOutgoingAudioMuted(false); + groupCall.setOutgoingVideoMuted(!isVideoCall); + + this.uxActions.showCallLobby({ + callMode: CallMode.Group, + conversationId: conversationProps.id, + ...this.formatGroupCallForRedux(groupCall), + }); + break; + } + default: + throw missingCaseError(callMode); + } await this.startDeviceReselectionTimer(); @@ -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 { - 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 { @@ -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 { @@ -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 { + 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 { diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index fa4ee3b703..81f1cdf51f 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -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; +} + 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; +}; + 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,10 +134,22 @@ export type SetLocalVideoType = { enabled: boolean; }; -export type ShowCallLobbyType = { - conversationId: string; - isVideoCall: boolean; -}; +export type ShowCallLobbyType = + | { + callMode: CallMode.Direct; + conversationId: string; + hasLocalAudio: boolean; + hasLocalVideo: boolean; + } + | { + callMode: CallMode.Group; + conversationId: string; + connectionState: GroupCallConnectionState; + joinState: GroupCallJoinState; + hasLocalAudio: boolean; + hasLocalVideo: boolean; + remoteParticipants: Array; + }; export type SetLocalPreviewType = { element: React.RefObject | undefined; @@ -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 { 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 { 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( - payload.conversationId, - payload.hasLocalAudio, - payload.hasLocalVideo - ); - - return { - type: START_CALL, - payload, +function startCall( + payload: StartCallType +): ThunkAction { + return dispatch => { + switch (payload.callMode) { + case CallMode.Direct: + calling.startOutgoingDirectCall( + payload.conversationId, + payload.hasLocalAudio, + payload.hasLocalVideo + ); + 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; } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 90d7cf2656..eb89b27dc5 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; 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 = { diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index 2594b334d6..8a9e7ed86d 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -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 + ); + // TypeScript needs a little help to be sure that this is a direct call. + return result?.callMode === CallMode.Direct ? result : undefined; + } ); - -export const getActiveCall = createSelector( - getActiveCallState, - getCallsByConversation, - (activeCallState, callsByConversation) => - activeCallState && - getOwn(callsByConversation, activeCallState.conversationId) -); - -export const isCallActive = createSelector(getActiveCall, Boolean); diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 627759d611..318898bc70 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -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 ; } +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), diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index cfc62bd2ee..59b36cc292 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -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), }; }; diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 6e492939a5..ca3ff97b87 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -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(), - startCall({ - conversationId: 'fake-conversation-id', - hasLocalAudio: true, - hasLocalVideo: false, - }) - ); + 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, + }); + }); + }); + }); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts new file mode 100644 index 0000000000..70abd25aad --- /dev/null +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -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 + ); + }); + }); + }); +}); diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index d859f5c00c..89117a2752 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -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)); - }); - }); }); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 9e308a9ca0..ab80c5ff35 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -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, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 4a3b876453..adc81e7bfe 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -34,6 +34,7 @@ import { DataMessageClass, GroupChangeClass, GroupClass, + GroupExternalCredentialClass, StorageServiceCallOptionsType, StorageServiceCredentials, SyncMessageClass, @@ -1841,4 +1842,10 @@ export default class MessageSender { ): Promise { return this.server.modifyStorageRecords(data, options); } + + async getGroupMembershipToken( + options: GroupCredentialsType + ): Promise { + return this.server.getGroupExternalCredential(options); + } } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index dc8108c539..8e48982828 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -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>; + getGroupExternalCredential: ( + options: GroupCredentialsType + ) => Promise; getGroupLog: ( startVersion: number, options: GroupCredentialsType @@ -779,6 +784,12 @@ export type WebAPIType = { targetUrl: string, options?: ProxiedRequestOptionsType ) => Promise; + makeSfuRequest: ( + targetUrl: string, + type: HTTPCodeType, + headers: HeaderListType, + body: ArrayBuffer | undefined + ) => Promise; 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 { + 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 { + 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, diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index ccbb8bd92c..cf0be4ce39 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -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 { + readonly current: T | null; +} +export interface CanvasVideoRenderer { + setCanvas(canvas: Ref | 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. diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index b86149e7e1..2875f9134b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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" } diff --git a/ts/window.d.ts b/ts/window.d.ts index ef2aeb1293..33d2f857b6 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -478,6 +478,9 @@ declare global { hasSignalAccount: (number: string) => boolean; getServerTrustRoot: () => WhatIsThis; readyForUpdates: () => void; + + // Flags + GROUP_CALLING: boolean; } interface Error {