From 79c976668b360cf798e382d1dba80f5b517b0f5d Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Fri, 20 Aug 2021 11:06:15 -0500 Subject: [PATCH] Receive rings for group calls --- _locales/en/messages.json | 82 +++ package.json | 2 +- preload.js | 2 + protos/SignalService.proto | 8 +- stylesheets/components/IncomingCallBar.scss | 2 +- ts/components/CallManager.stories.tsx | 40 +- ts/components/CallManager.tsx | 82 ++- ts/components/CallingPreCallInfo.tsx | 7 +- ts/components/IncomingCallBar.stories.tsx | 122 +++- ts/components/IncomingCallBar.tsx | 196 ++++- ts/services/calling.ts | 297 +++++--- ts/sql/Client.ts | 19 + ts/sql/Interface.ts | 7 + ts/sql/Server.ts | 116 ++- ts/state/ducks/calling.ts | 336 ++++++--- ts/state/selectors/calling.ts | 44 +- ts/state/smart/CallManager.tsx | 71 +- .../util/callingGetParticipantName_test.ts | 23 + ts/test-electron/state/ducks/calling_test.ts | 689 ++++++++++++++++-- .../state/selectors/calling_test.ts | 91 ++- .../util/callingMessageToProto_test.ts | 89 +++ ts/types/Calling.ts | 6 + ts/util/callingGetParticipantName.ts | 10 + ts/util/callingMessageToProto.ts | 106 +++ ts/util/lint/exceptions.json | 16 + ts/window.d.ts | 2 + yarn.lock | 6 +- 27 files changed, 2112 insertions(+), 359 deletions(-) create mode 100644 ts/test-both/util/callingGetParticipantName_test.ts create mode 100644 ts/test-node/util/callingMessageToProto_test.ts create mode 100644 ts/util/callingGetParticipantName.ts create mode 100644 ts/util/callingMessageToProto.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 03007fa35..bb952d06e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3408,6 +3408,88 @@ "message": "Incoming video call...", "description": "Shown in both the incoming call bar and notification for an incoming video call" }, + "incomingGroupCall__ringing-you": { + "message": "$ringer$ is calling you", + "description": "Shown in the incoming call bar when someone is ringing you for a group call", + "placeholders": { + "ringer": { + "content": "$1", + "example": "Alice" + } + } + }, + "incomingGroupCall__ringing-1-other": { + "message": "$ringer$ is calling you and $otherMember$", + "description": "Shown in the incoming call bar when someone is ringing you for a group call", + "placeholders": { + "ringer": { + "content": "$1", + "example": "Alice" + }, + "otherMember": { + "content": "$2", + "example": "Bob" + } + } + }, + "incomingGroupCall__ringing-2-others": { + "message": "$ringer$ is calling you, $first$, and $second$", + "description": "Shown in the incoming call bar when someone is ringing you for a group call", + "placeholders": { + "ringer": { + "content": "$1", + "example": "Alice" + }, + "first": { + "content": "$2", + "example": "Bob" + }, + "second": { + "content": "$3", + "example": "Charlie" + } + } + }, + "incomingGroupCall__ringing-3-others": { + "message": "$ringer$ is calling you, $first$, $second$, and 1 other", + "description": "Shown in the incoming call bar when someone is ringing you for a group call", + "placeholders": { + "ringer": { + "content": "$1", + "example": "Alice" + }, + "first": { + "content": "$2", + "example": "Bob" + }, + "second": { + "content": "$3", + "example": "Charlie" + } + } + }, + "incomingGroupCall__ringing-many": { + "message": "$ringer$ is calling you, $first$, $second$, and $remaining$ others", + "description": "Shown in the incoming call bar when someone is ringing you for a group call", + "placeholders": { + "ringer": { + "content": "$1", + "example": "Alice" + }, + "first": { + "content": "$2", + "example": "Bob" + }, + "second": { + "content": "$3", + "example": "Charlie" + }, + "remaining": { + "content": "$4", + "example": "5" + } + } + }, "outgoingCallPrering": { "message": "Calling...", "description": "Shown in the call screen when placing an outgoing call that isn't ringing yet" diff --git a/package.json b/package.json index 39dda6846..66ac67617 100644 --- a/package.json +++ b/package.json @@ -153,7 +153,7 @@ "redux-ts-utils": "3.2.2", "reselect": "4.0.0", "rimraf": "2.6.2", - "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#800b31c5d43a1436bcea8b7b3f82a4baf4771bfb", + "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#031abcc7564e769648a8d8f8bda935fad0d877b9", "rotating-file-stream": "2.1.5", "sanitize-filename": "1.6.3", "sanitize.css": "11.0.0", diff --git a/preload.js b/preload.js index cf631655c..2efec5111 100644 --- a/preload.js +++ b/preload.js @@ -49,6 +49,8 @@ try { window.GV2_MIGRATION_DISABLE_ADD = false; window.GV2_MIGRATION_DISABLE_INVITE = false; + window.RING_WHEN_JOINING_GROUP_CALLS = false; + window.RETRY_DELAY = false; window.platform = process.platform; diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 8cde091b3..db462c842 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -98,7 +98,13 @@ message CallingMessage { } message Opaque { - optional bytes data = 1; + enum Urgency { + DROPPABLE = 0; + HANDLE_IMMEDIATELY = 1; + } + + optional bytes data = 1; + optional Urgency urgency = 2; } optional Offer offer = 1; diff --git a/stylesheets/components/IncomingCallBar.scss b/stylesheets/components/IncomingCallBar.scss index 41576bb99..7d48e12c0 100644 --- a/stylesheets/components/IncomingCallBar.scss +++ b/stylesheets/components/IncomingCallBar.scss @@ -25,7 +25,7 @@ width: 100%; } - &__contact { + &__conversation { align-items: center; display: flex; min-width: 0; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 7a8975670..3d0a60c2c 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -53,20 +53,12 @@ const getCommonActiveCallData = () => ({ showParticipantsList: boolean('showParticipantsList', false), }); -const getIncomingCallState = (extraProps = {}) => ({ - ...extraProps, - callMode: CallMode.Direct as CallMode.Direct, - conversationId: '3051234567', - callState: CallState.Ringing, - isIncoming: true, - isVideoCall: boolean('isVideoCall', true), - hasRemoteVideo: true, -}); - const createProps = (storyProps: Partial = {}): PropsType => ({ ...storyProps, availableCameras: [], acceptCall: action('accept-call'), + bounceAppIconStart: action('bounce-app-icon-start'), + bounceAppIconStop: action('bounce-app-icon-stop'), cancelCall: action('cancel-call'), closeNeedPermissionScreen: action('close-need-permission-screen'), declineCall: action('decline-call'), @@ -87,7 +79,9 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ }), uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541', }, + notifyForCall: action('notify-for-call'), openSystemPreferencesAction: action('open-system-preferences-action'), + playRingtone: action('play-ringtone'), renderDeviceSelection: () =>
, renderSafetyNumberViewer: (_: SafetyNumberViewerProps) =>
, setGroupCallVideoRequest: action('set-group-call-video-request'), @@ -97,6 +91,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), startCall: action('start-call'), + stopRingtone: action('stop-ringtone'), toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), toggleScreenRecordingPermissionsDialog: action( @@ -145,12 +140,33 @@ story.add('Ongoing Group Call', () => ( /> )); -story.add('Ringing', () => ( +story.add('Ringing (direct call)', () => ( +)); + +story.add('Ringing (group call)', () => ( + diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 5322e09e9..622766c95 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -1,7 +1,8 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; +import { noop } from 'lodash'; import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; import { CallScreen } from './CallScreen'; import { CallingLobby } from './CallingLobby'; @@ -28,7 +29,6 @@ import { AcceptCallType, CancelCallType, DeclineCallType, - DirectCallStateType, HangUpType, KeyChangeOkType, SetGroupCallVideoRequestType, @@ -55,26 +55,39 @@ export type PropsType = { demuxId: number ) => VideoFrameSource; getPresentingSources: () => void; - incomingCall?: { - call: DirectCallStateType; - conversation: ConversationType; - }; + incomingCall?: + | { + callMode: CallMode.Direct; + conversation: ConversationType; + isVideoCall: boolean; + } + | { + callMode: CallMode.Group; + conversation: ConversationType; + otherMembersRung: Array>; + ringer: Pick; + }; keyChangeOk: (_: KeyChangeOkType) => void; renderDeviceSelection: () => JSX.Element; renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element; startCall: (payload: StartCallType) => void; toggleParticipants: () => void; acceptCall: (_: AcceptCallType) => void; + bounceAppIconStart: () => unknown; + bounceAppIconStop: () => unknown; declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; me: MeType; + notifyForCall: (title: string, isVideoCall: boolean) => unknown; openSystemPreferencesAction: () => unknown; + playRingtone: () => unknown; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; + stopRingtone: () => unknown; hangUp: (_: HangUpType) => void; togglePip: () => void; toggleScreenRecordingPermissionsDialog: () => unknown; @@ -330,7 +343,31 @@ const ActiveCallManager: React.FC = ({ }; export const CallManager: React.FC = props => { - const { activeCall, incomingCall, acceptCall, declineCall, i18n } = props; + const { + acceptCall, + activeCall, + bounceAppIconStart, + bounceAppIconStop, + declineCall, + i18n, + incomingCall, + notifyForCall, + playRingtone, + stopRingtone, + } = props; + + const shouldRing = getShouldRing(props); + useEffect(() => { + if (shouldRing) { + playRingtone(); + return () => { + stopRingtone(); + }; + } + + stopRingtone(); + return noop; + }, [shouldRing, playRingtone, stopRingtone]); if (activeCall) { // `props` should logically have an `activeCall` at this point, but TypeScript can't @@ -343,13 +380,40 @@ export const CallManager: React.FC = props => { return ( ); } return null; }; + +function getShouldRing({ + activeCall, + incomingCall, +}: Readonly>): boolean { + if (incomingCall) { + return !activeCall; + } + + if (!activeCall) { + return false; + } + + switch (activeCall.callMode) { + case CallMode.Direct: + return ( + activeCall.callState === CallState.Prering || + activeCall.callState === CallState.Ringing + ); + case CallMode.Group: + return false; + default: + throw missingCaseError(activeCall); + } +} diff --git a/ts/components/CallingPreCallInfo.tsx b/ts/components/CallingPreCallInfo.tsx index 1049a6202..8a2d8c128 100644 --- a/ts/components/CallingPreCallInfo.tsx +++ b/ts/components/CallingPreCallInfo.tsx @@ -6,6 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; import { Avatar, AvatarSize } from './Avatar'; import { Emojify } from './conversation/Emojify'; +import { getParticipantName } from '../util/callingGetParticipantName'; import { missingCaseError } from '../util/missingCaseError'; type PropsType = { @@ -151,9 +152,3 @@ export const CallingPreCallInfo: FunctionComponent = ({
); }; - -function getParticipantName( - participant: Readonly> -): string { - return participant.firstName || participant.title; -} diff --git a/ts/components/IncomingCallBar.stories.tsx b/ts/components/IncomingCallBar.stories.tsx index aa9f9457b..5efb6446c 100644 --- a/ts/components/IncomingCallBar.stories.tsx +++ b/ts/components/IncomingCallBar.stories.tsx @@ -3,20 +3,20 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; -import { boolean, select, text } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; import { IncomingCallBar } from './IncomingCallBar'; -import { AvatarColors } from '../types/Colors'; +import { CallMode } from '../types/Calling'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; -import { getRandomColor } from '../test-both/helpers/getRandomColor'; const i18n = setupI18n('en', enMessages); -const defaultProps = { +const commonProps = { acceptCall: action('accept-call'), + bounceAppIconStart: action('bounceAppIconStart'), + bounceAppIconStop: action('bounceAppIconStop'), call: { conversationId: 'fake-conversation-id', callId: 0, @@ -33,36 +33,96 @@ const defaultProps = { }), declineCall: action('decline-call'), i18n, + notifyForCall: action('notify-for-call'), }; -storiesOf('Components/IncomingCallBar', module) - .add('Knobs Playground', () => { - const color = select('color', AvatarColors, getRandomColor()); - const isVideoCall = boolean('isVideoCall', false); - const name = text( - 'name', - 'Rick Sanchez Foo Bar Baz Spool Cool Mango Fango Wand Mars Venus Jupiter Spark Mirage Water Loop Branch Zeus Element Sail Bananas Cars Horticulture Turtle Lion Zebra Micro Music Garage Iguana Ohio Retro Joy Entertainment Logo Understanding Diary' - ); +const directConversation = getDefaultConversation({ + id: '3051234567', + avatarPath: undefined, + name: 'Rick Sanchez', + phoneNumber: '3051234567', + profileName: 'Rick Sanchez', + title: 'Rick Sanchez', +}); - return ( - - ); - }) - .add('Incoming Call Bar (video)', () => ) - .add('Incoming Call Bar (audio)', () => ( +const groupConversation = getDefaultConversation({ + avatarPath: undefined, + name: 'Tahoe Trip', + title: 'Tahoe Trip', + type: 'group', +}); + +storiesOf('Components/IncomingCallBar', module) + .add('Incoming direct call (video)', () => ( + )) + .add('Incoming direct call (audio)', () => ( + + )) + .add('Incoming group call (only calling you)', () => ( + + )) + .add('Incoming group call (calling you and 1 other)', () => ( + + )) + .add('Incoming group call (calling you and 2 others)', () => ( + + )) + .add('Incoming group call (calling you and 3 others)', () => ( + + )) + .add('Incoming group call (calling you and 4 others)', () => ( + )); diff --git a/ts/components/IncomingCallBar.tsx b/ts/components/IncomingCallBar.tsx index 48002cd2a..ccc3b609c 100644 --- a/ts/components/IncomingCallBar.tsx +++ b/ts/components/IncomingCallBar.tsx @@ -1,23 +1,25 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useEffect, useRef, ReactChild } from 'react'; import { Avatar } from './Avatar'; import { Tooltip } from './Tooltip'; +import { Intl } from './Intl'; import { Theme } from '../util/theme'; +import { getParticipantName } from '../util/callingGetParticipantName'; import { ContactName } from './conversation/ContactName'; +import { Emojify } from './conversation/Emojify'; import { LocalizerType } from '../types/Util'; import { AvatarColors } from '../types/Colors'; +import { CallMode } from '../types/Calling'; import { ConversationType } from '../state/ducks/conversations'; import { AcceptCallType, DeclineCallType } from '../state/ducks/calling'; +import { missingCaseError } from '../util/missingCaseError'; export type PropsType = { acceptCall: (_: AcceptCallType) => void; declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; - call: { - isVideoCall: boolean; - }; conversation: Pick< ConversationType, | 'acceptedMessageRequest' @@ -30,8 +32,22 @@ export type PropsType = { | 'profileName' | 'sharedGroupNames' | 'title' + | 'type' >; -}; + bounceAppIconStart(): unknown; + bounceAppIconStop(): unknown; + notifyForCall(conversationTitle: string, isVideoCall: boolean): unknown; +} & ( + | { + callMode: CallMode.Direct; + isVideoCall: boolean; + } + | { + callMode: CallMode.Group; + otherMembersRung: Array>; + ringer: Pick; + } +); type CallButtonProps = { classSuffix: string; @@ -61,14 +77,93 @@ const CallButton = ({ ); }; -export const IncomingCallBar = ({ - acceptCall, - declineCall, +const GroupCallMessage = ({ i18n, - call, - conversation, -}: PropsType): JSX.Element | null => { - const { isVideoCall } = call; + otherMembersRung, + ringer, +}: Readonly<{ + i18n: LocalizerType; + otherMembersRung: Array>; + ringer: Pick; +}>): JSX.Element => { + // As an optimization, we only process the first two names. + const [first, second] = otherMembersRung + .slice(0, 2) + .map(member => ); + const ringerNode = ; + + switch (otherMembersRung.length) { + case 0: + return ( + + ); + case 1: + return ( + + ); + case 2: + return ( + + ); + break; + case 3: + return ( + + ); + break; + default: + return ( + + ); + } +}; + +export const IncomingCallBar = (props: PropsType): JSX.Element | null => { + const { + acceptCall, + bounceAppIconStart, + bounceAppIconStop, + conversation, + declineCall, + i18n, + notifyForCall, + } = props; const { id: conversationId, acceptedMessageRequest, @@ -80,19 +175,71 @@ export const IncomingCallBar = ({ profileName, sharedGroupNames, title, + type: conversationType, } = conversation; + let isVideoCall: boolean; + let headerNode: ReactChild; + let messageNode: ReactChild; + + switch (props.callMode) { + case CallMode.Direct: + ({ isVideoCall } = props); + headerNode = ( + + ); + messageNode = i18n( + isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall' + ); + break; + case CallMode.Group: { + const { otherMembersRung, ringer } = props; + isVideoCall = true; + headerNode = ; + messageNode = ( + + ); + break; + } + default: + throw missingCaseError(props); + } + + // We don't want to re-notify if the title changes. + const initialTitleRef = useRef(title); + useEffect(() => { + const initialTitle = initialTitleRef.current; + notifyForCall(initialTitle, isVideoCall); + }, [isVideoCall, notifyForCall]); + + useEffect(() => { + bounceAppIconStart(); + return () => { + bounceAppIconStop(); + }; + }, [bounceAppIconStart, bounceAppIconStop]); + return (
-
-
+
+
-
-
- +
+
+ {headerNode}
-
- {i18n(isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall')} +
+ {messageNode}
diff --git a/ts/services/calling.ts b/ts/services/calling.ts index cd851ee3f..9c0cbb9ca 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -10,6 +10,7 @@ import { CallId, CallingMessage, CallLogLevel, + CallMessageUrgency, CallSettings, CallState, CanvasVideoRenderer, @@ -24,13 +25,16 @@ import { HangupType, OpaqueMessage, PeekInfo, + RingCancelReason, RingRTC, + RingUpdate, UserId, VideoFrameSource, VideoRequest, BandwidthMode, } from 'ringrtc'; import { uniqBy, noop } from 'lodash'; +import * as moment from 'moment'; import { ActionsType as UxActionsType, @@ -46,6 +50,7 @@ import { MediaDeviceSettings, PresentableSource, PresentedSource, + ProcessGroupCallRingRequestResult, } from '../types/Calling'; import { LocalizerType } from '../types/Util'; import { ConversationModel } from '../models/conversations'; @@ -72,12 +77,17 @@ import { REQUESTED_VIDEO_HEIGHT, REQUESTED_VIDEO_FRAMERATE, } from '../calling/constants'; +import { callingMessageToProto } from '../util/callingMessageToProto'; import { notify } from './notify'; import { getSendOptions } from '../util/getSendOptions'; import { SignalService as Proto } from '../protobuf'; +import dataInterface from '../sql/Client'; -// TODO: remove once we move away from ArrayBuffers -const FIXMEU8 = Uint8Array; +const { + processGroupCallRingRequest, + processGroupCallRingCancelation, + cleanExpiredGroupCallRings, +} = dataInterface; const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map< HttpMethod, @@ -89,6 +99,10 @@ const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map< [HttpMethod.Delete, 'DELETE'], ]); +const CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL = moment + .duration(10, 'minutes') + .asMilliseconds(); + // We send group call update messages to tell other clients to peek, which triggers // notifications, timeline messages, big green "Join" buttons, and so on. This enum // represents the three possible states we can be in. This helps ensure that we don't @@ -185,76 +199,6 @@ function protoToCallingMessage({ }; } -function bufferToProto( - value: Buffer | { toArrayBuffer(): ArrayBuffer } | undefined -): Uint8Array | undefined { - if (!value) { - return undefined; - } - if (value instanceof Uint8Array) { - return value; - } - - return new FIXMEU8(value.toArrayBuffer()); -} - -function callingMessageToProto({ - offer, - answer, - iceCandidates, - legacyHangup, - busy, - hangup, - supportsMultiRing, - destinationDeviceId, - opaque, -}: CallingMessage): Proto.ICallingMessage { - return { - offer: offer - ? { - ...offer, - type: offer.type as number, - opaque: bufferToProto(offer.opaque), - } - : undefined, - answer: answer - ? { - ...answer, - opaque: bufferToProto(answer.opaque), - } - : undefined, - iceCandidates: iceCandidates - ? iceCandidates.map(candidate => { - return { - ...candidate, - opaque: bufferToProto(candidate.opaque), - }; - }) - : undefined, - legacyHangup: legacyHangup - ? { - ...legacyHangup, - type: legacyHangup.type as number, - } - : undefined, - busy, - hangup: hangup - ? { - ...hangup, - type: hangup.type as number, - } - : undefined, - supportsMultiRing, - destinationDeviceId, - opaque: opaque - ? { - ...opaque, - data: bufferToProto(opaque.data), - } - : undefined, - }; -} - export class CallingClass { readonly videoCapturer: GumVideoCapturer; @@ -272,6 +216,8 @@ export class CallingClass { private hadLocalVideoBeforePresenting?: boolean; + private hasGivenOurUuidToRingRtc = false; + constructor() { this.videoCapturer = new GumVideoCapturer({ maxWidth: REQUESTED_VIDEO_WIDTH, @@ -299,10 +245,35 @@ export class CallingClass { RingRTC.handleLogMessage = this.handleLogMessage.bind(this); RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this); RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this); + RingRTC.handleSendCallMessageToGroup = this.handleSendCallMessageToGroup.bind( + this + ); + RingRTC.handleGroupCallRingUpdate = this.handleGroupCallRingUpdate.bind( + this + ); + + this.attemptToGiveOurUuidToRingRtc(); ipcRenderer.on('stop-screen-share', () => { uxActions.setPresenting(); }); + + this.cleanExpiredGroupCallRingsAndLoop(); + } + + private attemptToGiveOurUuidToRingRtc(): void { + if (this.hasGivenOurUuidToRingRtc) { + return; + } + + const ourUuid = window.textsecure.storage.user.getUuid(); + if (!ourUuid) { + // This can happen if we're not linked. It's okay if we hit this case. + return; + } + + RingRTC.setSelfUuid(Buffer.from(uuidToArrayBuffer(ourUuid))); + this.hasGivenOurUuidToRingRtc = true; } async startCallingLobby( @@ -715,6 +686,8 @@ export class CallingClass { hasLocalAudio: boolean, hasLocalVideo: boolean ): void { + this.attemptToGiveOurUuidToRingRtc(); + const conversation = window.ConversationController.get( conversationId )?.format(); @@ -744,6 +717,13 @@ export class CallingClass { groupCall.setOutgoingVideoMuted(!hasLocalVideo); this.videoCapturer.enableCaptureAndSend(groupCall); + // This is a temporary flag to help all client teams (Desktop, iOS, and Android) + // debug. Soon, this will be exposed in the UI (see DESKTOP-2113). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (window.RING_WHEN_JOINING_GROUP_CALLS) { + groupCall.ringAll(); + } + groupCall.join(); } @@ -959,8 +939,11 @@ export class CallingClass { }); } - async accept(conversationId: string, asVideoCall: boolean): Promise { - window.log.info('CallingClass.accept()'); + async acceptDirectCall( + conversationId: string, + asVideoCall: boolean + ): Promise { + window.log.info('CallingClass.acceptDirectCall()'); const callId = this.getCallIdForConversation(conversationId); if (!callId) { @@ -980,18 +963,43 @@ export class CallingClass { } } - decline(conversationId: string): void { - window.log.info('CallingClass.decline()'); + declineDirectCall(conversationId: string): void { + window.log.info('CallingClass.declineDirectCall()'); const callId = this.getCallIdForConversation(conversationId); if (!callId) { - window.log.warn('Trying to decline a non-existent call'); + window.log.warn( + 'declineDirectCall: Trying to decline a non-existent call' + ); return; } RingRTC.decline(callId); } + declineGroupCall(conversationId: string, ringId: bigint): void { + window.log.info('CallingClass.declineGroupCall()'); + + this.attemptToGiveOurUuidToRingRtc(); + + const groupId = window.ConversationController.get(conversationId)?.get( + 'groupId' + ); + if (!groupId) { + window.log.error( + 'declineGroupCall: could not find the group ID for that conversation' + ); + return; + } + const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId)); + + RingRTC.cancelGroupRing( + groupIdBuffer, + ringId, + RingCancelReason.DeclinedByUser + ); + } + hangup(conversationId: string): void { window.log.info('CallingClass.hangup()'); @@ -1548,7 +1556,8 @@ export class CallingClass { private async handleSendCallMessage( recipient: Uint8Array, - data: Uint8Array + data: Uint8Array, + urgency: CallMessageUrgency ): Promise { const userId = arrayBufferToUuid(typedArrayToArrayBuffer(recipient)); if (!userId) { @@ -1558,12 +1567,123 @@ export class CallingClass { const message = new CallingMessage(); message.opaque = new OpaqueMessage(); message.opaque.data = Buffer.from(data); - return this.handleOutgoingSignaling(userId, message); + return this.handleOutgoingSignaling(userId, message, urgency); + } + + private async handleSendCallMessageToGroup( + groupIdBytes: Buffer, + data: Buffer, + urgency: CallMessageUrgency + ): Promise { + this.attemptToGiveOurUuidToRingRtc(); + + const groupId = groupIdBytes.toString('base64'); + const conversation = window.ConversationController.get(groupId); + if (!conversation) { + window.log.error( + 'handleSendCallMessageToGroup(): could not find conversation' + ); + return; + } + + const timestamp = Date.now(); + + const callingMessage = new CallingMessage(); + callingMessage.opaque = new OpaqueMessage(); + callingMessage.opaque.data = data; + const contentMessage = new Proto.Content(); + contentMessage.callingMessage = callingMessageToProto( + callingMessage, + urgency + ); + + // We "fire and forget" because sending this message is non-essential. + // We also don't sync this message. + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + await handleMessageSend( + window.Signal.Util.sendContentMessageToGroup({ + contentHint: ContentHint.DEFAULT, + contentMessage, + conversation, + isPartialSend: false, + messageId: undefined, + recipients: conversation.getRecipients(), + sendOptions: await getSendOptions(conversation.attributes), + sendType: 'callingMessage', + timestamp, + }), + { messageIds: [], sendType: 'callingMessage' } + ); + } + + private async handleGroupCallRingUpdate( + groupIdBytes: Buffer, + ringId: bigint, + ringerBytes: Buffer, + update: RingUpdate + ): Promise { + window.log.info(`handleGroupCallRingUpdate(): got ring update ${update}`); + + this.attemptToGiveOurUuidToRingRtc(); + + const groupId = groupIdBytes.toString('base64'); + + const ringerUuid = arrayBufferToUuid(typedArrayToArrayBuffer(ringerBytes)); + if (!ringerUuid) { + window.log.error('handleGroupCallRingUpdate(): ringerUuid was invalid'); + return; + } + + const conversation = window.ConversationController.get(groupId); + if (!conversation) { + window.log.error( + 'handleGroupCallRingUpdate(): could not find conversation' + ); + return; + } + const conversationId = conversation.id; + + let shouldRing = false; + + if (update === RingUpdate.Requested) { + const processResult = await processGroupCallRingRequest(ringId); + switch (processResult) { + case ProcessGroupCallRingRequestResult.ShouldRing: + shouldRing = true; + break; + case ProcessGroupCallRingRequestResult.RingWasPreviouslyCanceled: + RingRTC.cancelGroupRing(groupIdBytes, ringId, null); + break; + case ProcessGroupCallRingRequestResult.ThereIsAnotherActiveRing: + RingRTC.cancelGroupRing(groupIdBytes, ringId, RingCancelReason.Busy); + break; + default: + throw missingCaseError(processResult); + } + } else { + await processGroupCallRingCancelation(ringId); + } + + if (shouldRing) { + window.log.info('handleGroupCallRingUpdate: ringing'); + this.uxActions?.receiveIncomingGroupCall({ + conversationId, + ringId, + ringerUuid, + }); + } else { + window.log.info('handleGroupCallRingUpdate: canceling any existing ring'); + this.uxActions?.cancelIncomingGroupCallRing({ + conversationId, + ringId, + }); + } } private async handleOutgoingSignaling( remoteUserId: UserId, - message: CallingMessage + message: CallingMessage, + urgency?: CallMessageUrgency ): Promise { const conversation = window.ConversationController.get(remoteUserId); const sendOptions = conversation @@ -1579,7 +1699,7 @@ export class CallingClass { const result = await handleMessageSend( window.textsecure.messaging.sendCallingMessage( remoteUserId, - callingMessageToProto(message), + callingMessageToProto(message, urgency), sendOptions ), { messageIds: [], sendType: 'callingMessage' } @@ -1640,7 +1760,7 @@ export class CallingClass { this.attachToCall(conversation, call); - this.uxActions.receiveIncomingCall({ + this.uxActions.receiveIncomingDirectCall({ conversationId: conversation.id, isVideoCall: call.isVideoCall, }); @@ -1926,6 +2046,19 @@ export class CallingClass { conversation.updateCallHistoryForGroupCall(peekInfo.eraId, creatorUuid); } + + private async cleanExpiredGroupCallRingsAndLoop(): Promise { + try { + await cleanExpiredGroupCallRings(); + } catch (err: unknown) { + // These errors are ignored here. They should be logged elsewhere and it's okay if + // we don't do a cleanup this time. + } + + setTimeout(() => { + this.cleanExpiredGroupCallRingsAndLoop(); + }, CLEAN_EXPIRED_GROUP_CALL_RINGS_INTERVAL); + } } export const calling = new CallingClass(); diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 0dc7dfa4a..82b7f0bcb 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -32,6 +32,7 @@ import { assert } from '../util/assert'; import { cleanDataForIpc } from './cleanDataForIpc'; import { ReactionType } from '../types/Reactions'; import { ConversationColorType, CustomColorType } from '../types/Colors'; +import type { ProcessGroupCallRingRequestResult } from '../types/Calling'; import { ConversationModelCollectionType, @@ -262,6 +263,10 @@ const dataInterface: ClientInterface = { insertJob, deleteJob, + processGroupCallRingRequest, + processGroupCallRingCancelation, + cleanExpiredGroupCallRings, + getStatisticsForLogging, // Test-only @@ -1611,6 +1616,20 @@ function deleteJob(id: string): Promise { return channels.deleteJob(id); } +function processGroupCallRingRequest( + ringId: bigint +): Promise { + return channels.processGroupCallRingRequest(ringId); +} + +function processGroupCallRingCancelation(ringId: bigint): Promise { + return channels.processGroupCallRingCancelation(ringId); +} + +async function cleanExpiredGroupCallRings(): Promise { + await channels.cleanExpiredGroupCallRings(); +} + async function updateAllConversationColors( conversationColor?: ConversationColorType, customColorData?: { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 89522c95b..34cfe572b 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -15,6 +15,7 @@ import type { ConversationModel } from '../models/conversations'; import type { StoredJob } from '../jobs/types'; import type { ReactionType } from '../types/Reactions'; import type { ConversationColorType, CustomColorType } from '../types/Colors'; +import type { ProcessGroupCallRingRequestResult } from '../types/Calling'; import { StorageAccessType } from '../types/Storage.d'; import type { AttachmentType } from '../types/Attachment'; import { BodyRangesType } from '../types/Util'; @@ -441,6 +442,12 @@ export type DataInterface = { insertJob(job: Readonly): Promise; deleteJob(id: string): Promise; + processGroupCallRingRequest( + ringId: bigint + ): Promise; + processGroupCallRingCancelation(ringId: bigint): Promise; + cleanExpiredGroupCallRings(): Promise; + updateAllConversationColors: ( conversationColor?: ConversationColorType, customColorData?: { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 10ab5b7e1..ac489ae60 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -13,6 +13,7 @@ import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import SQL, { Database, Statement } from 'better-sqlite3'; import pProps from 'p-props'; +import * as moment from 'moment'; import { v4 as generateUUID } from 'uuid'; import { @@ -42,6 +43,7 @@ import { isNotNil } from '../util/isNotNil'; import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { formatCountForLogging } from '../logging/formatCountForLogging'; import { ConversationColorType, CustomColorType } from '../types/Colors'; +import { ProcessGroupCallRingRequestResult } from '../types/Calling'; import { AllItemsType, @@ -93,8 +95,8 @@ type StickerRow = Readonly<{ }>; type EmptyQuery = []; -type ArrayQuery = Array>; -type Query = { [key: string]: null | number | string | Buffer }; +type ArrayQuery = Array>; +type Query = { [key: string]: null | number | bigint | string | Buffer }; // This value needs to be below SQLITE_MAX_VARIABLE_NUMBER. const MAX_VARIABLE_COUNT = 100; @@ -251,6 +253,10 @@ const dataInterface: ServerInterface = { insertJob, deleteJob, + processGroupCallRingRequest, + processGroupCallRingCancelation, + cleanExpiredGroupCallRings, + getStatisticsForLogging, // Server-only @@ -2089,6 +2095,27 @@ function updateToSchemaVersion39(currentVersion: number, db: Database) { console.log('updateToSchemaVersion39: success!'); } +function updateToSchemaVersion40(currentVersion: number, db: Database) { + if (currentVersion >= 40) { + return; + } + + db.transaction(() => { + db.exec( + ` + CREATE TABLE groupCallRings( + ringId INTEGER PRIMARY KEY, + isActive INTEGER NOT NULL, + createdAt INTEGER NOT NULL + ); + ` + ); + + db.pragma('user_version = 40'); + })(); + console.log('updateToSchemaVersion40: success!'); +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -2129,6 +2156,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion37, updateToSchemaVersion38, updateToSchemaVersion39, + updateToSchemaVersion40, ]; function updateSchema(db: Database): void { @@ -5868,6 +5896,90 @@ async function deleteJob(id: string): Promise { db.prepare('DELETE FROM jobs WHERE id = $id').run({ id }); } +async function processGroupCallRingRequest( + ringId: bigint +): Promise { + const db = getInstance(); + + return db.transaction(() => { + let result: ProcessGroupCallRingRequestResult; + + const wasRingPreviouslyCanceled = Boolean( + db + .prepare( + ` + SELECT 1 FROM groupCallRings + WHERE ringId = $ringId AND isActive = 0 + LIMIT 1; + ` + ) + .pluck(true) + .get({ ringId }) + ); + + if (wasRingPreviouslyCanceled) { + result = ProcessGroupCallRingRequestResult.RingWasPreviouslyCanceled; + } else { + const isThereAnotherActiveRing = Boolean( + db + .prepare( + ` + SELECT 1 FROM groupCallRings + WHERE isActive = 1 + LIMIT 1; + ` + ) + .pluck(true) + .get() + ); + if (isThereAnotherActiveRing) { + result = ProcessGroupCallRingRequestResult.ThereIsAnotherActiveRing; + } else { + result = ProcessGroupCallRingRequestResult.ShouldRing; + } + + db.prepare( + ` + INSERT OR IGNORE INTO groupCallRings (ringId, isActive, createdAt) + VALUES ($ringId, 1, $createdAt); + ` + ); + } + + return result; + })(); +} + +async function processGroupCallRingCancelation(ringId: bigint): Promise { + const db = getInstance(); + + db.prepare( + ` + INSERT INTO groupCallRings (ringId, isActive, createdAt) + VALUES ($ringId, 0, $createdAt) + ON CONFLICT (ringId) DO + UPDATE SET isActive = 0; + ` + ).run({ ringId, createdAt: Date.now() }); +} + +// This age, in milliseconds, should be longer than any group call ring duration. Beyond +// that, it doesn't really matter what the value is. +const MAX_GROUP_CALL_RING_AGE = moment.duration(30, 'minutes').asMilliseconds(); + +async function cleanExpiredGroupCallRings(): Promise { + const db = getInstance(); + + db.prepare( + ` + DELETE FROM groupCallRings + WHERE createdAt < $expiredRingTime; + ` + ).run({ + expiredRingTime: Date.now() - MAX_GROUP_CALL_RING_AGE, + }); +} + async function getStatisticsForLogging(): Promise> { const counts = await pProps({ messageCount: getMessageCount(), diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index aae0110f0..3a5a70bbf 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -12,7 +12,6 @@ import { has, omit } from 'lodash'; import { getOwn } from '../../util/getOwn'; import { getPlatform } from '../selectors/user'; import { missingCaseError } from '../../util/missingCaseError'; -import { notify } from '../../services/notify'; import { calling } from '../../services/calling'; import { StateType as RootStateType } from '../reducer'; import { @@ -29,10 +28,6 @@ import { } from '../../types/Calling'; import { callingTones } from '../../util/callingTones'; import { requestCameraPermissions } from '../../util/callingPermissions'; -import { - bounceAppIconStart, - bounceAppIconStop, -} from '../../shims/bounceAppIcon'; import { sleep } from '../../util/sleep'; import { LatestQueue } from '../../util/LatestQueue'; @@ -68,6 +63,16 @@ export type DirectCallStateType = { hasRemoteVideo?: boolean; }; +type GroupCallRingStateType = + | { + ringId?: undefined; + ringerUuid?: undefined; + } + | { + ringId: bigint; + ringerUuid: string; + }; + export type GroupCallStateType = { callMode: CallMode.Group; conversationId: string; @@ -75,7 +80,7 @@ export type GroupCallStateType = { joinState: GroupCallJoinState; peekInfo: GroupCallPeekInfoType; remoteParticipants: Array; -}; +} & GroupCallRingStateType; export type ActiveCallStateType = { conversationId: string; @@ -120,6 +125,11 @@ export type CancelCallType = { conversationId: string; }; +type CancelIncomingGroupCallRingType = { + conversationId: string; + ringId: bigint; +}; + export type DeclineCallType = { conversationId: string; }; @@ -150,11 +160,17 @@ export type KeyChangeOkType = { conversationId: string; }; -export type IncomingCallType = { +export type IncomingDirectCallType = { conversationId: string; isVideoCall: boolean; }; +type IncomingGroupCallType = { + conversationId: string; + ringId: bigint; + ringerUuid: string; +}; + type PeekNotConnectedGroupCallType = { conversationId: string; }; @@ -237,18 +253,28 @@ export const isAnybodyElseInGroupCall = ( ourUuid: string ): boolean => uuids.some(id => id !== ourUuid); +const getGroupCallRingState = ( + call: Readonly +): GroupCallRingStateType => + call?.ringId === undefined + ? {} + : { ringId: call.ringId, ringerUuid: call.ringerUuid }; + // Actions const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING'; const CANCEL_CALL = 'calling/CANCEL_CALL'; +const CANCEL_INCOMING_GROUP_CALL_RING = + 'calling/CANCEL_INCOMING_GROUP_CALL_RING'; const SHOW_CALL_LOBBY = 'calling/SHOW_CALL_LOBBY'; 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 DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL'; const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; const HANG_UP = 'calling/HANG_UP'; -const INCOMING_CALL = 'calling/INCOMING_CALL'; +const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL'; +const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL'; const MARK_CALL_TRUSTED = 'calling/MARK_CALL_TRUSTED'; const MARK_CALL_UNTRUSTED = 'calling/MARK_CALL_UNTRUSTED'; const OUTGOING_CALL = 'calling/OUTGOING_CALL'; @@ -279,6 +305,11 @@ type CancelCallActionType = { type: 'calling/CANCEL_CALL'; }; +type CancelIncomingGroupCallRingActionType = { + type: 'calling/CANCEL_INCOMING_GROUP_CALL_RING'; + payload: CancelIncomingGroupCallRingType; +}; + type CallLobbyActionType = { type: 'calling/SHOW_CALL_LOBBY'; payload: ShowCallLobbyType; @@ -300,7 +331,7 @@ type CloseNeedPermissionScreenActionType = { }; type DeclineCallActionType = { - type: 'calling/DECLINE_CALL'; + type: 'calling/DECLINE_DIRECT_CALL'; payload: DeclineCallType; }; @@ -314,9 +345,14 @@ type HangUpActionType = { payload: HangUpType; }; -type IncomingCallActionType = { - type: 'calling/INCOMING_CALL'; - payload: IncomingCallType; +type IncomingDirectCallActionType = { + type: 'calling/INCOMING_DIRECT_CALL'; + payload: IncomingDirectCallType; +}; + +type IncomingGroupCallActionType = { + type: 'calling/INCOMING_GROUP_CALL'; + payload: IncomingGroupCallType; }; type KeyChangedActionType = { @@ -417,6 +453,7 @@ type ToggleSpeakerViewActionType = { export type CallingActionType = | AcceptCallPendingActionType | CancelCallActionType + | CancelIncomingGroupCallRingActionType | CallLobbyActionType | CallStateChangeFulfilledActionType | ChangeIODeviceFulfilledActionType @@ -424,7 +461,8 @@ export type CallingActionType = | DeclineCallActionType | GroupCallStateChangeActionType | HangUpActionType - | IncomingCallActionType + | IncomingDirectCallActionType + | IncomingGroupCallActionType | KeyChangedActionType | KeyChangeOkActionType | OutgoingCallActionType @@ -450,17 +488,30 @@ export type CallingActionType = function acceptCall( payload: AcceptCallType ): ThunkAction { - return async dispatch => { + return async (dispatch, getState) => { + const { conversationId, asVideoCall } = payload; + + const call = getOwn(getState().calling.callsByConversation, conversationId); + if (!call) { + window.log.error('Trying to accept a non-existent call'); + return; + } + + switch (call.callMode) { + case CallMode.Direct: + await calling.acceptDirectCall(conversationId, asVideoCall); + break; + case CallMode.Group: + calling.joinGroupCall(conversationId, true, asVideoCall); + break; + default: + throw missingCaseError(call); + } + dispatch({ type: ACCEPT_CALL_PENDING, payload, }); - - try { - await calling.accept(payload.conversationId, payload.asVideoCall); - } catch (err) { - window.log.error(`Failed to acceptCall: ${err.stack}`); - } }; } @@ -473,16 +524,7 @@ function callStateChange( CallStateChangeFulfilledActionType > { return async dispatch => { - const { callState, isIncoming, title, isVideoCall } = payload; - if (callState === CallState.Ringing && isIncoming) { - await callingTones.playRingtone(); - await showCallNotification(title, isVideoCall); - bounceAppIconStart(); - } - if (callState !== CallState.Ringing) { - await callingTones.stopRingtone(); - bounceAppIconStop(); - } + const { callState } = payload; if (callState === CallState.Ended) { await callingTones.playEndCall(); ipcRenderer.send('close-screen-share-controller'); @@ -519,30 +561,6 @@ function changeIODevice( }; } -async function showCallNotification( - title: string, - isVideoCall: boolean -): Promise { - const shouldNotify = - !window.isActive() && window.Events.getCallSystemNotification(); - if (!shouldNotify) { - return; - } - notify({ - title, - icon: isVideoCall - ? 'images/icons/v2/video-solid-24.svg' - : 'images/icons/v2/phone-right-solid-24.svg', - message: window.i18n( - isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall' - ), - onNotificationClick: () => { - window.showWindow(); - }, - silent: false, - }); -} - function closeNeedPermissionScreen(): CloseNeedPermissionScreenActionType { return { type: CLOSE_NEED_PERMISSION_SCREEN, @@ -558,15 +576,59 @@ function cancelCall(payload: CancelCallType): CancelCallActionType { }; } -function declineCall(payload: DeclineCallType): DeclineCallActionType { - calling.decline(payload.conversationId); - +function cancelIncomingGroupCallRing( + payload: CancelIncomingGroupCallRingType +): CancelIncomingGroupCallRingActionType { return { - type: DECLINE_CALL, + type: CANCEL_INCOMING_GROUP_CALL_RING, payload, }; } +function declineCall( + payload: DeclineCallType +): ThunkAction< + void, + RootStateType, + unknown, + CancelIncomingGroupCallRingActionType | DeclineCallActionType +> { + return (dispatch, getState) => { + const { conversationId } = payload; + + const call = getOwn(getState().calling.callsByConversation, conversationId); + if (!call) { + window.log.error('Trying to decline a non-existent call'); + return; + } + + switch (call.callMode) { + case CallMode.Direct: + calling.declineDirectCall(conversationId); + dispatch({ + type: DECLINE_DIRECT_CALL, + payload, + }); + break; + case CallMode.Group: { + const { ringId } = call; + if (ringId === undefined) { + window.log.error('Trying to decline a group call without a ring ID'); + } else { + calling.declineGroupCall(conversationId, ringId); + dispatch({ + type: CANCEL_INCOMING_GROUP_CALL_RING, + payload: { conversationId, ringId }, + }); + } + break; + } + default: + throw missingCaseError(call); + } + }; +} + function getPresentingSources(): ThunkAction< void, RootStateType, @@ -697,11 +759,20 @@ function keyChangeOk( }; } -function receiveIncomingCall( - payload: IncomingCallType -): IncomingCallActionType { +function receiveIncomingDirectCall( + payload: IncomingDirectCallType +): IncomingDirectCallActionType { return { - type: INCOMING_CALL, + type: INCOMING_DIRECT_CALL, + payload, + }; +} + +function receiveIncomingGroupCall( + payload: IncomingGroupCallType +): IncomingGroupCallActionType { + return { + type: INCOMING_GROUP_CALL, payload, }; } @@ -718,8 +789,6 @@ function openSystemPreferencesAction(): ThunkAction< } function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType { - callingTones.playRingtone(); - return { type: OUTGOING_CALL, payload, @@ -1033,6 +1102,7 @@ export const actions = { acceptCall, callStateChange, cancelCall, + cancelIncomingGroupCallRing, changeIODevice, closeNeedPermissionScreen, declineCall, @@ -1044,7 +1114,8 @@ export const actions = { openSystemPreferencesAction, outgoingCall, peekNotConnectedGroupCall, - receiveIncomingCall, + receiveIncomingDirectCall, + receiveIncomingGroupCall, refreshIODevices, remoteSharingScreenChange, remoteVideoChange, @@ -1083,14 +1154,12 @@ export function getEmptyState(): CallingStateType { }; } -function getExistingPeekInfo( +function getGroupCall( conversationId: string, - state: CallingStateType -): undefined | GroupCallPeekInfoType { - const existingCall = getOwn(state.callsByConversation, conversationId); - return existingCall?.callMode === CallMode.Group - ? existingCall.peekInfo - : undefined; + state: Readonly +): undefined | GroupCallStateType { + const call = getOwn(state.callsByConversation, conversationId); + return call?.callMode === CallMode.Group ? call : undefined; } function removeConversationFromState( @@ -1112,33 +1181,38 @@ export function reducer( const { callsByConversation } = state; if (action.type === SHOW_CALL_LOBBY) { + const { conversationId } = action.payload; + let call: DirectCallStateType | GroupCallStateType; switch (action.payload.callMode) { case CallMode.Direct: call = { callMode: CallMode.Direct, - conversationId: action.payload.conversationId, + conversationId, isIncoming: false, isVideoCall: action.payload.hasLocalVideo, }; break; - case CallMode.Group: + case CallMode.Group: { // We expect to be in this state briefly. The Calling service should update the // call state shortly. + const existingCall = getGroupCall(conversationId, state); call = { callMode: CallMode.Group, - conversationId: action.payload.conversationId, + conversationId, connectionState: action.payload.connectionState, joinState: action.payload.joinState, peekInfo: action.payload.peekInfo || - getExistingPeekInfo(action.payload.conversationId, state) || { + existingCall?.peekInfo || { uuids: action.payload.remoteParticipants.map(({ uuid }) => uuid), maxDevices: Infinity, deviceCount: action.payload.remoteParticipants.length, }, remoteParticipants: action.payload.remoteParticipants, + ...getGroupCallRingState(existingCall), }; break; + } default: throw missingCaseError(action.payload); } @@ -1229,11 +1303,32 @@ export function reducer( } } - if (action.type === DECLINE_CALL) { + if (action.type === CANCEL_INCOMING_GROUP_CALL_RING) { + const { conversationId, ringId } = action.payload; + + const groupCall = getGroupCall(conversationId, state); + if (!groupCall || groupCall.ringId !== ringId) { + return state; + } + + if (groupCall.connectionState === GroupCallConnectionState.NotConnected) { + return removeConversationFromState(state, conversationId); + } + + return { + ...state, + callsByConversation: { + ...callsByConversation, + [conversationId]: omit(groupCall, ['ringId', 'ringerUuid']), + }, + }; + } + + if (action.type === DECLINE_DIRECT_CALL) { return removeConversationFromState(state, action.payload.conversationId); } - if (action.type === INCOMING_CALL) { + if (action.type === INCOMING_DIRECT_CALL) { return { ...state, callsByConversation: { @@ -1249,6 +1344,52 @@ export function reducer( }; } + if (action.type === INCOMING_GROUP_CALL) { + const { conversationId, ringId, ringerUuid } = action.payload; + + let groupCall: GroupCallStateType; + const existingGroupCall = getGroupCall(conversationId, state); + if (existingGroupCall) { + if (existingGroupCall.ringerUuid) { + window.log.info('Group call was already ringing'); + return state; + } + if (existingGroupCall.joinState !== GroupCallJoinState.NotJoined) { + window.log.info("Got a ring for a call we're already in"); + return state; + } + + groupCall = { + ...existingGroupCall, + ringId, + ringerUuid, + }; + } else { + groupCall = { + callMode: CallMode.Group, + conversationId, + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: { + uuids: [], + maxDevices: Infinity, + deviceCount: 0, + }, + remoteParticipants: [], + ringId, + ringerUuid, + }; + } + + return { + ...state, + callsByConversation: { + ...callsByConversation, + [conversationId]: groupCall, + }, + }; + } + if (action.type === OUTGOING_CALL) { return { ...state, @@ -1333,8 +1474,11 @@ export function reducer( remoteParticipants, } = action.payload; + const existingCall = getGroupCall(conversationId, state); + const existingRingState = getGroupCallRingState(existingCall); + const newPeekInfo = peekInfo || - getExistingPeekInfo(conversationId, state) || { + existingCall?.peekInfo || { uuids: remoteParticipants.map(({ uuid }) => uuid), maxDevices: Infinity, deviceCount: remoteParticipants.length, @@ -1348,7 +1492,10 @@ export function reducer( ? undefined : state.activeCallState; - if (!isAnybodyElseInGroupCall(newPeekInfo, ourUuid)) { + if ( + !isAnybodyElseInGroupCall(newPeekInfo, ourUuid) && + (!existingCall || !existingCall.ringerUuid) + ) { return { ...state, callsByConversation: omit(callsByConversation, conversationId), @@ -1366,6 +1513,13 @@ export function reducer( : state.activeCallState; } + let newRingState: GroupCallRingStateType; + if (joinState === GroupCallJoinState.NotJoined) { + newRingState = existingRingState; + } else { + newRingState = {}; + } + return { ...state, callsByConversation: { @@ -1377,6 +1531,7 @@ export function reducer( joinState, peekInfo: newPeekInfo, remoteParticipants, + ...newRingState, }, }, activeCallState: newActiveCallState, @@ -1386,26 +1541,22 @@ export function reducer( if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) { const { conversationId, peekInfo, ourConversationId } = action.payload; - const existingCall = getOwn(state.callsByConversation, conversationId) || { + const existingCall: GroupCallStateType = getGroupCall( + conversationId, + state + ) || { callMode: CallMode.Group, conversationId, connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, peekInfo: { - conversationIds: [], + uuids: [], maxDevices: Infinity, deviceCount: 0, }, remoteParticipants: [], }; - if (existingCall.callMode !== CallMode.Group) { - window.log.error( - 'Unexpected state: trying to update a non-group call. Doing nothing' - ); - return state; - } - // This action should only update non-connected group calls. It's not necessarily a // mistake if this action is dispatched "over" a connected call. Here's a valid // sequence of events: @@ -1419,7 +1570,10 @@ export function reducer( return state; } - if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) { + if ( + !isAnybodyElseInGroupCall(peekInfo, ourConversationId) || + !existingCall.ringerUuid + ) { return removeConversationFromState(state, conversationId); } diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index 8a16183db..ba85e379e 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { createSelector } from 'reselect'; @@ -9,9 +9,16 @@ import { CallsByConversationType, DirectCallStateType, GroupCallStateType, + isAnybodyElseInGroupCall, } from '../ducks/calling'; -import { CallMode, CallState } from '../../types/Calling'; +import { + CallMode, + CallState, + GroupCallConnectionState, +} from '../../types/Calling'; +import { getUserUuid } from './user'; import { getOwn } from '../../util/getOwn'; +import { missingCaseError } from '../../util/missingCaseError'; export type CallStateType = DirectCallStateType | GroupCallStateType; @@ -55,20 +62,29 @@ export const isInCall = createSelector( (call: CallStateType | undefined): boolean => Boolean(call) ); -// In theory, there could be multiple incoming calls. In practice, neither RingRTC nor the -// UI are ready to handle this. +// In theory, there could be multiple incoming calls, or an incoming call while there's +// an active call. In practice, the UI is not ready for this, and RingRTC doesn't +// support it for direct calls. export const getIncomingCall = createSelector( getCallsByConversation, + getUserUuid, ( - callsByConversation: CallsByConversationType - ): 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; + callsByConversation: CallsByConversationType, + ourUuid: string + ): undefined | DirectCallStateType | GroupCallStateType => { + return Object.values(callsByConversation).find(call => { + switch (call.callMode) { + case CallMode.Direct: + return call.isIncoming && call.callState === CallState.Ringing; + case CallMode.Group: + return ( + call.ringerUuid && + call.connectionState === GroupCallConnectionState.NotConnected && + isAnybodyElseInGroupCall(call.peekInfo, ourUuid) + ); + default: + throw missingCaseError(call); + } + }); } ); diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 274a85097..9daad679b 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -25,6 +25,12 @@ import { SmartSafetyNumberViewer, Props as SafetyNumberViewerProps, } from './SafetyNumberViewer'; +import { notify } from '../../services/notify'; +import { callingTones } from '../../util/callingTones'; +import { + bounceAppIconStart, + bounceAppIconStop, +} from '../../shims/bounceAppIcon'; function renderDeviceSelection(): JSX.Element { return ; @@ -38,6 +44,33 @@ const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource callingService ); +async function notifyForCall( + title: string, + isVideoCall: boolean +): Promise { + const shouldNotify = + !window.isActive() && window.Events.getCallSystemNotification(); + if (!shouldNotify) { + return; + } + notify({ + title, + icon: isVideoCall + ? 'images/icons/v2/video-solid-24.svg' + : 'images/icons/v2/phone-right-solid-24.svg', + message: window.i18n( + isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall' + ), + onNotificationClick: () => { + window.showWindow(); + }, + silent: false, + }); +} + +const playRingtone = callingTones.playRingtone.bind(callingTones); +const stopRingtone = callingTones.stopRingtone.bind(callingTones); + const mapStateToActiveCallProp = ( state: StateType ): undefined | ActiveCallType => { @@ -221,14 +254,41 @@ const mapStateToIncomingCallProp = (state: StateType) => { return undefined; } - return { - call, - conversation, - }; + switch (call.callMode) { + case CallMode.Direct: + return { + callMode: CallMode.Direct as const, + conversation, + isVideoCall: call.isVideoCall, + }; + case CallMode.Group: { + if (!call.ringerUuid) { + window.log.error('The incoming group call has no ring state'); + return undefined; + } + + const conversationSelector = getConversationSelector(state); + const ringer = conversationSelector(call.ringerUuid); + const otherMembersRung = (conversation.sortedGroupMembers ?? []).filter( + c => c.id !== ringer.id && !c.isMe + ); + + return { + callMode: CallMode.Group as const, + conversation, + otherMembersRung, + ringer, + }; + } + default: + throw missingCaseError(call); + } }; const mapStateToProps = (state: StateType) => ({ activeCall: mapStateToActiveCallProp(state), + bounceAppIconStart, + bounceAppIconStop, availableCameras: state.calling.availableCameras, getGroupCallVideoFrameSource, i18n: getIntl(state), @@ -239,6 +299,9 @@ const mapStateToProps = (state: StateType) => ({ // according to the type. This ensures one is set. uuid: getUserUuid(state), }, + notifyForCall, + playRingtone, + stopRingtone, renderDeviceSelection, renderSafetyNumberViewer, }); diff --git a/ts/test-both/util/callingGetParticipantName_test.ts b/ts/test-both/util/callingGetParticipantName_test.ts new file mode 100644 index 000000000..33027f339 --- /dev/null +++ b/ts/test-both/util/callingGetParticipantName_test.ts @@ -0,0 +1,23 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { getParticipantName } from '../../util/callingGetParticipantName'; + +describe('getParticipantName', () => { + it('returns the first name if available', () => { + const participant = { + firstName: 'Foo', + title: 'Foo Bar', + }; + + assert.strictEqual(getParticipantName(participant), 'Foo'); + }); + + it('returns the title if the first name is unavailable', () => { + const participant = { title: 'Foo Bar' }; + + assert.strictEqual(getParticipantName(participant), 'Foo Bar'); + }); +}); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 67304de06..f9ceaf8ca 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -95,6 +95,20 @@ describe('calling duck', () => { }, }; + const stateWithIncomingGroupCall = { + ...stateWithGroupCall, + callsByConversation: { + ...stateWithGroupCall.callsByConversation, + 'fake-group-call-conversation-id': { + ...stateWithGroupCall.callsByConversation[ + 'fake-group-call-conversation-id' + ], + ringId: BigInt(123), + ringerUuid: '789', + }, + }, + }; + const stateWithActiveGroupCall = { ...stateWithGroupCall, activeCallState: { @@ -318,81 +332,191 @@ describe('calling duck', () => { beforeEach(function beforeEach() { this.callingServiceAccept = this.sandbox - .stub(callingService, 'accept') + .stub(callingService, 'acceptDirectCall') .resolves(); + this.callingServiceJoin = this.sandbox.stub( + callingService, + 'joinGroupCall' + ); }); - it('dispatches an ACCEPT_CALL_PENDING action', async () => { - const dispatch = sinon.spy(); + describe('accepting a direct call', () => { + const getState = () => ({ + ...getEmptyRootState(), + calling: stateWithIncomingDirectCall, + }); - await acceptCall({ - conversationId: '123', - asVideoCall: true, - })(dispatch, getEmptyRootState, null); + it('dispatches an ACCEPT_CALL_PENDING action', async () => { + const dispatch = sinon.spy(); - sinon.assert.calledOnce(dispatch); - sinon.assert.calledWith(dispatch, { - type: 'calling/ACCEPT_CALL_PENDING', - payload: { - conversationId: '123', + await acceptCall({ + conversationId: 'fake-direct-call-conversation-id', asVideoCall: true, - }, - }); + })(dispatch, getState, null); - await acceptCall({ - conversationId: '456', - asVideoCall: false, - })(dispatch, getEmptyRootState, null); + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/ACCEPT_CALL_PENDING', + payload: { + conversationId: 'fake-direct-call-conversation-id', + asVideoCall: true, + }, + }); - sinon.assert.calledTwice(dispatch); - sinon.assert.calledWith(dispatch, { - type: 'calling/ACCEPT_CALL_PENDING', - payload: { - conversationId: '456', + await acceptCall({ + conversationId: 'fake-direct-call-conversation-id', asVideoCall: false, - }, + })(dispatch, getState, null); + + sinon.assert.calledTwice(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/ACCEPT_CALL_PENDING', + payload: { + conversationId: 'fake-direct-call-conversation-id', + asVideoCall: false, + }, + }); + }); + + it('asks the calling service to accept the call', async function test() { + const dispatch = sinon.spy(); + + await acceptCall({ + conversationId: 'fake-direct-call-conversation-id', + asVideoCall: true, + })(dispatch, getState, null); + + sinon.assert.calledOnce(this.callingServiceAccept); + sinon.assert.calledWith( + this.callingServiceAccept, + 'fake-direct-call-conversation-id', + true + ); + + await acceptCall({ + conversationId: 'fake-direct-call-conversation-id', + asVideoCall: false, + })(dispatch, getState, null); + + sinon.assert.calledTwice(this.callingServiceAccept); + sinon.assert.calledWith( + this.callingServiceAccept, + 'fake-direct-call-conversation-id', + false + ); + }); + + it('updates the active call state with ACCEPT_CALL_PENDING', async () => { + const dispatch = sinon.spy(); + await acceptCall({ + conversationId: 'fake-direct-call-conversation-id', + asVideoCall: true, + })(dispatch, getState, null); + const action = dispatch.getCall(0).args[0]; + + const result = reducer(stateWithIncomingDirectCall, action); + + assert.deepEqual(result.activeCallState, { + conversationId: 'fake-direct-call-conversation-id', + hasLocalAudio: true, + hasLocalVideo: true, + isInSpeakerView: false, + showParticipantsList: false, + safetyNumberChangedUuids: [], + pip: false, + settingsDialogOpen: false, + }); }); }); - it('asks the calling service to accept the call', async function test() { - const dispatch = sinon.spy(); + describe('accepting a group call', () => { + const getState = () => ({ + ...getEmptyRootState(), + calling: stateWithIncomingGroupCall, + }); - await acceptCall({ - conversationId: '123', - asVideoCall: true, - })(dispatch, getEmptyRootState, null); + it('dispatches an ACCEPT_CALL_PENDING action', async () => { + const dispatch = sinon.spy(); - sinon.assert.calledOnce(this.callingServiceAccept); - sinon.assert.calledWith(this.callingServiceAccept, '123', true); + await acceptCall({ + conversationId: 'fake-group-call-conversation-id', + asVideoCall: true, + })(dispatch, getState, null); - await acceptCall({ - conversationId: '456', - asVideoCall: false, - })(dispatch, getEmptyRootState, null); + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/ACCEPT_CALL_PENDING', + payload: { + conversationId: 'fake-group-call-conversation-id', + asVideoCall: true, + }, + }); - sinon.assert.calledTwice(this.callingServiceAccept); - sinon.assert.calledWith(this.callingServiceAccept, '456', false); - }); + await acceptCall({ + conversationId: 'fake-group-call-conversation-id', + asVideoCall: false, + })(dispatch, getState, null); - it('updates the active call state with ACCEPT_CALL_PENDING', async () => { - const dispatch = sinon.spy(); - await acceptCall({ - conversationId: 'fake-direct-call-conversation-id', - asVideoCall: true, - })(dispatch, getEmptyRootState, null); - const action = dispatch.getCall(0).args[0]; + sinon.assert.calledTwice(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/ACCEPT_CALL_PENDING', + payload: { + conversationId: 'fake-group-call-conversation-id', + asVideoCall: false, + }, + }); + }); - const result = reducer(stateWithIncomingDirectCall, action); + it('asks the calling service to join the call', async function test() { + const dispatch = sinon.spy(); - assert.deepEqual(result.activeCallState, { - conversationId: 'fake-direct-call-conversation-id', - hasLocalAudio: true, - hasLocalVideo: true, - isInSpeakerView: false, - showParticipantsList: false, - safetyNumberChangedUuids: [], - pip: false, - settingsDialogOpen: false, + await acceptCall({ + conversationId: 'fake-group-call-conversation-id', + asVideoCall: true, + })(dispatch, getState, null); + + sinon.assert.calledOnce(this.callingServiceJoin); + sinon.assert.calledWith( + this.callingServiceJoin, + 'fake-group-call-conversation-id', + true, + true + ); + + await acceptCall({ + conversationId: 'fake-group-call-conversation-id', + asVideoCall: false, + })(dispatch, getState, null); + + sinon.assert.calledTwice(this.callingServiceJoin); + sinon.assert.calledWith( + this.callingServiceJoin, + 'fake-group-call-conversation-id', + true, + false + ); + }); + + it('updates the active call state with ACCEPT_CALL_PENDING', async () => { + const dispatch = sinon.spy(); + await acceptCall({ + conversationId: 'fake-group-call-conversation-id', + asVideoCall: true, + })(dispatch, getState, null); + const action = dispatch.getCall(0).args[0]; + + const result = reducer(stateWithIncomingGroupCall, action); + + assert.deepEqual(result.activeCallState, { + conversationId: 'fake-group-call-conversation-id', + hasLocalAudio: true, + hasLocalVideo: true, + isInSpeakerView: false, + showParticipantsList: false, + safetyNumberChangedUuids: [], + pip: false, + settingsDialogOpen: false, + }); }); }); }); @@ -441,6 +565,201 @@ describe('calling duck', () => { }); }); + describe('cancelIncomingGroupCallRing', () => { + const { cancelIncomingGroupCallRing } = actions; + + it('does nothing if there is no associated group call', () => { + const state = getEmptyState(); + const action = cancelIncomingGroupCallRing({ + conversationId: 'garbage', + ringId: BigInt(1), + }); + + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it("does nothing if the ring to cancel isn't the same one", () => { + const action = cancelIncomingGroupCallRing({ + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(999), + }); + + const result = reducer(stateWithIncomingGroupCall, action); + + assert.strictEqual(result, stateWithIncomingGroupCall); + }); + + it("removes the call from the state if it's not connected", () => { + const state = { + ...stateWithGroupCall, + callsByConversation: { + ...stateWithGroupCall.callsByConversation, + 'fake-group-call-conversation-id': { + ...stateWithGroupCall.callsByConversation[ + 'fake-group-call-conversation-id' + ], + connectionState: GroupCallConnectionState.NotConnected, + ringId: BigInt(123), + ringerUuid: '789', + }, + }, + }; + const action = cancelIncomingGroupCallRing({ + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(123), + }); + + const result = reducer(state, action); + + assert.notProperty( + result.callsByConversation, + 'fake-group-call-conversation-id' + ); + }); + + it("removes the ring state, but not the call, if it's connected", () => { + const action = cancelIncomingGroupCallRing({ + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(123), + }); + + const result = reducer(stateWithIncomingGroupCall, action); + const call = + result.callsByConversation['fake-group-call-conversation-id']; + // It'd be nice to do this with an assert, but Chai doesn't understand it. + if (call?.callMode !== CallMode.Group) { + throw new Error('Expected to find a group call'); + } + + assert.isUndefined(call.ringId); + assert.isUndefined(call.ringerUuid); + }); + }); + + describe('declineCall', () => { + const { declineCall } = actions; + + let declineDirectCall: sinon.SinonStub; + let declineGroupCall: sinon.SinonStub; + + beforeEach(function beforeEach() { + declineDirectCall = this.sandbox.stub( + callingService, + 'declineDirectCall' + ); + declineGroupCall = this.sandbox.stub( + callingService, + 'declineGroupCall' + ); + }); + + describe('declining a direct call', () => { + const getState = () => ({ + ...getEmptyRootState(), + calling: stateWithIncomingDirectCall, + }); + + it('dispatches a DECLINE_DIRECT_CALL action', () => { + const dispatch = sinon.spy(); + + declineCall({ conversationId: 'fake-direct-call-conversation-id' })( + dispatch, + getState, + null + ); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/DECLINE_DIRECT_CALL', + payload: { + conversationId: 'fake-direct-call-conversation-id', + }, + }); + }); + + it('asks the calling service to decline the call', () => { + const dispatch = sinon.spy(); + + declineCall({ conversationId: 'fake-direct-call-conversation-id' })( + dispatch, + getState, + null + ); + + sinon.assert.calledOnce(declineDirectCall); + sinon.assert.calledWith( + declineDirectCall, + 'fake-direct-call-conversation-id' + ); + }); + + it('removes the call from the state', () => { + const dispatch = sinon.spy(); + declineCall({ conversationId: 'fake-direct-call-conversation-id' })( + dispatch, + getState, + null + ); + const action = dispatch.getCall(0).args[0]; + + const result = reducer(stateWithIncomingGroupCall, action); + + assert.notProperty( + result.callsByConversation, + 'fake-direct-call-conversation-id' + ); + }); + }); + + describe('declining a group call', () => { + const getState = () => ({ + ...getEmptyRootState(), + calling: stateWithIncomingGroupCall, + }); + + it('dispatches a CANCEL_INCOMING_GROUP_CALL_RING action', () => { + const dispatch = sinon.spy(); + + declineCall({ conversationId: 'fake-group-call-conversation-id' })( + dispatch, + getState, + null + ); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/CANCEL_INCOMING_GROUP_CALL_RING', + payload: { + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(123), + }, + }); + }); + + it('asks the calling service to decline the call', () => { + const dispatch = sinon.spy(); + + declineCall({ conversationId: 'fake-group-call-conversation-id' })( + dispatch, + getState, + null + ); + + sinon.assert.calledOnce(declineGroupCall); + sinon.assert.calledWith( + declineGroupCall, + 'fake-group-call-conversation-id', + BigInt(123) + ); + }); + + // NOTE: The state effects of this action are tested with + // `cancelIncomingGroupCallRing`. + }); + }); + describe('groupCallStateChange', () => { const { groupCallStateChange } = actions; @@ -475,7 +794,7 @@ describe('calling duck', () => { assert.deepEqual(result, getEmptyState()); }); - it('removes the call from the map of conversations if the call is not connected and has no peeked participants', () => { + it('removes the call from the map of conversations if the call is not connected and has no peeked participants or ringer', () => { const result = reducer( stateWithGroupCall, getAction({ @@ -659,6 +978,29 @@ describe('calling duck', () => { ); }); + it('saves a call to the map of conversations if the call had a ringer, even if it was otherwise ignorable', () => { + const result = reducer( + stateWithIncomingGroupCall, + getAction({ + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + hasLocalAudio: false, + hasLocalVideo: false, + peekInfo: { + uuids: [], + maxDevices: 16, + deviceCount: 0, + }, + remoteParticipants: [], + }) + ); + + assert.isDefined( + result.callsByConversation['fake-group-call-conversation-id'] + ); + }); + it('updates a call in the map of conversations', () => { const result = reducer( stateWithGroupCall, @@ -714,6 +1056,108 @@ describe('calling duck', () => { ); }); + it("keeps the existing ring state if you haven't joined the call", () => { + const state = { + ...stateWithGroupCall, + callsByConversation: { + ...stateWithGroupCall.callsByConversation, + 'fake-group-call-conversation-id': { + ...stateWithGroupCall.callsByConversation[ + 'fake-group-call-conversation-id' + ], + ringId: BigInt(456), + ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', + }, + }, + }; + const result = reducer( + state, + getAction({ + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.Connected, + joinState: GroupCallJoinState.NotJoined, + hasLocalAudio: true, + hasLocalVideo: false, + peekInfo: { + uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, + remoteParticipants: [ + { + uuid: '123', + demuxId: 456, + hasRemoteAudio: false, + hasRemoteVideo: true, + presenting: false, + sharingScreen: false, + videoAspectRatio: 16 / 9, + }, + ], + }) + ); + + assert.include( + result.callsByConversation['fake-group-call-conversation-id'], + { + callMode: CallMode.Group, + ringId: BigInt(456), + ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', + } + ); + }); + + it("removes the ring state if you've joined the call", () => { + const state = { + ...stateWithGroupCall, + callsByConversation: { + ...stateWithGroupCall.callsByConversation, + 'fake-group-call-conversation-id': { + ...stateWithGroupCall.callsByConversation[ + 'fake-group-call-conversation-id' + ], + ringId: BigInt(456), + ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', + }, + }, + }; + const result = reducer( + state, + getAction({ + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.Connected, + joinState: GroupCallJoinState.Joined, + hasLocalAudio: true, + hasLocalVideo: false, + peekInfo: { + uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'], + maxDevices: 16, + deviceCount: 1, + }, + remoteParticipants: [ + { + uuid: '123', + demuxId: 456, + hasRemoteAudio: false, + hasRemoteVideo: true, + presenting: false, + sharingScreen: false, + videoAspectRatio: 16 / 9, + }, + ], + }) + ); + + assert.notProperty( + result.callsByConversation['fake-group-call-conversation-id'], + 'ringId' + ); + assert.notProperty( + result.callsByConversation['fake-group-call-conversation-id'], + 'ringerUuid' + ); + }); + it("if no call is active, doesn't touch the active call state", () => { const result = reducer( stateWithGroupCall, @@ -910,6 +1354,88 @@ describe('calling duck', () => { }); }); + describe('receiveIncomingGroupCall', () => { + const { receiveIncomingGroupCall } = actions; + + it('does nothing if the call was already ringing', () => { + const action = receiveIncomingGroupCall({ + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(456), + ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + }); + const result = reducer(stateWithIncomingGroupCall, action); + + assert.strictEqual(result, stateWithIncomingGroupCall); + }); + + it('does nothing if the call was already joined', () => { + const state = { + ...stateWithGroupCall, + callsByConversation: { + ...stateWithGroupCall.callsByConversation, + 'fake-group-call-conversation-id': { + ...stateWithGroupCall.callsByConversation[ + 'fake-group-call-conversation-id' + ], + joinState: GroupCallJoinState.Joined, + }, + }, + }; + const action = receiveIncomingGroupCall({ + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(456), + ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + }); + const result = reducer(state, action); + + assert.strictEqual(result, state); + }); + + it('creates a new group call if one did not exist', () => { + const action = receiveIncomingGroupCall({ + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(456), + ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + }); + const result = reducer(getEmptyState(), action); + + assert.deepEqual( + result.callsByConversation['fake-group-call-conversation-id'], + { + callMode: CallMode.Group, + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: { + uuids: [], + maxDevices: Infinity, + deviceCount: 0, + }, + remoteParticipants: [], + ringId: BigInt(456), + ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + } + ); + }); + + it('attaches ring state to an existing call', () => { + const action = receiveIncomingGroupCall({ + conversationId: 'fake-group-call-conversation-id', + ringId: BigInt(456), + ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + }); + const result = reducer(stateWithGroupCall, action); + + assert.include( + result.callsByConversation['fake-group-call-conversation-id'], + { + ringId: BigInt(456), + ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + } + ); + }); + }); + describe('setLocalAudio', () => { const { setLocalAudio } = actions; @@ -1187,6 +1713,55 @@ describe('calling duck', () => { deviceCount: 1, }); }); + + it("doesn't overwrite an existing group call's ring state if it was set previously", () => { + const result = reducer( + { + ...stateWithGroupCall, + callsByConversation: { + 'fake-group-call-conversation-id': { + ...stateWithGroupCall.callsByConversation[ + 'fake-group-call-conversation-id' + ], + ringId: BigInt(987), + ringerUuid: 'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8', + }, + }, + }, + showCallLobby({ + callMode: CallMode.Group, + conversationId: 'fake-group-call-conversation-id', + hasLocalAudio: true, + hasLocalVideo: true, + connectionState: GroupCallConnectionState.Connected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: undefined, + remoteParticipants: [ + { + uuid: '123', + demuxId: 123, + hasRemoteAudio: true, + hasRemoteVideo: true, + presenting: false, + sharingScreen: false, + videoAspectRatio: 4 / 3, + }, + ], + }) + ); + const call = + result.callsByConversation['fake-group-call-conversation-id']; + // It'd be nice to do this with an assert, but Chai doesn't understand it. + if (call?.callMode !== CallMode.Group) { + throw new Error('Expected to find a group call'); + } + + assert.strictEqual(call.ringId, BigInt(987)); + assert.strictEqual( + call.ringerUuid, + 'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8' + ); + }); }); describe('startCall', () => { diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 4e5ecb4bf..e397f1e95 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -4,14 +4,24 @@ import { assert } from 'chai'; import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; -import { CallMode, CallState } from '../../../types/Calling'; +import { + CallMode, + CallState, + GroupCallConnectionState, + GroupCallJoinState, +} from '../../../types/Calling'; import { getCallsByConversation, getCallSelector, getIncomingCall, isInCall, } from '../../../state/selectors/calling'; -import { getEmptyState, CallingStateType } from '../../../state/ducks/calling'; +import { + getEmptyState, + CallingStateType, + DirectCallStateType, + GroupCallStateType, +} from '../../../state/ducks/calling'; describe('state/selectors/calling', () => { const getEmptyRootState = () => rootReducer(undefined, noopAction()); @@ -49,17 +59,42 @@ describe('state/selectors/calling', () => { }, }; + const incomingDirectCall: DirectCallStateType = { + callMode: CallMode.Direct, + conversationId: 'fake-direct-call-conversation-id', + callState: CallState.Ringing, + isIncoming: true, + isVideoCall: false, + hasRemoteVideo: false, + }; + const stateWithIncomingDirectCall: CallingStateType = { ...getEmptyState(), callsByConversation: { - 'fake-direct-call-conversation-id': { - callMode: CallMode.Direct, - conversationId: 'fake-direct-call-conversation-id', - callState: CallState.Ringing, - isIncoming: true, - isVideoCall: false, - hasRemoteVideo: false, - }, + 'fake-direct-call-conversation-id': incomingDirectCall, + }, + }; + + const incomingGroupCall: GroupCallStateType = { + callMode: CallMode.Group, + conversationId: 'fake-group-call-conversation-id', + connectionState: GroupCallConnectionState.NotConnected, + joinState: GroupCallJoinState.NotJoined, + peekInfo: { + uuids: ['c75b51da-d484-4674-9b2c-cc11de00e227'], + creatorUuid: 'c75b51da-d484-4674-9b2c-cc11de00e227', + maxDevices: Infinity, + deviceCount: 1, + }, + remoteParticipants: [], + ringId: BigInt(123), + ringerUuid: 'c75b51da-d484-4674-9b2c-cc11de00e227', + }; + + const stateWithIncomingGroupCall: CallingStateType = { + ...getEmptyState(), + callsByConversation: { + 'fake-group-call-conversation-id': incomingGroupCall, }, }; @@ -119,17 +154,35 @@ describe('state/selectors/calling', () => { ); }); - it('returns the incoming call', () => { + it('returns undefined if there is a group call with no peeked participants', () => { + const state = { + ...stateWithIncomingGroupCall, + callsByConversation: { + 'fake-group-call-conversation-id': { + ...incomingGroupCall, + peekInfo: { + uuids: [], + maxDevices: Infinity, + deviceCount: 1, + }, + }, + }, + }; + + assert.isUndefined(getIncomingCall(getCallingState(state))); + }); + + it('returns an incoming direct call', () => { assert.deepEqual( getIncomingCall(getCallingState(stateWithIncomingDirectCall)), - { - callMode: CallMode.Direct, - conversationId: 'fake-direct-call-conversation-id', - callState: CallState.Ringing, - isIncoming: true, - isVideoCall: false, - hasRemoteVideo: false, - } + incomingDirectCall + ); + }); + + it('returns an incoming group call', () => { + assert.deepEqual( + getIncomingCall(getCallingState(stateWithIncomingGroupCall)), + incomingGroupCall ); }); }); diff --git a/ts/test-node/util/callingMessageToProto_test.ts b/ts/test-node/util/callingMessageToProto_test.ts new file mode 100644 index 000000000..48949378d --- /dev/null +++ b/ts/test-node/util/callingMessageToProto_test.ts @@ -0,0 +1,89 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { + CallMessageUrgency, + CallingMessage, + HangupMessage, + HangupType, + OpaqueMessage, +} from 'ringrtc'; +import { SignalService as Proto } from '../../protobuf'; + +import { callingMessageToProto } from '../../util/callingMessageToProto'; + +describe('callingMessageToProto', () => { + // NOTE: These tests are incomplete. + + describe('hangup field', () => { + it('leaves the field unset if `hangup` is not provided', () => { + const result = callingMessageToProto(new CallingMessage()); + assert.isUndefined(result.hangup); + }); + + it('attaches the type if provided', () => { + const callingMessage = new CallingMessage(); + callingMessage.hangup = new HangupMessage(); + callingMessage.hangup.type = HangupType.Busy; + + const result = callingMessageToProto(callingMessage); + + assert.strictEqual(result.hangup?.type, 3); + }); + }); + + describe('opaque field', () => { + it('leaves the field unset if neither `opaque` nor urgency are provided', () => { + const result = callingMessageToProto(new CallingMessage()); + assert.isUndefined(result.opaque); + }); + + it('attaches opaque data', () => { + const callingMessage = new CallingMessage(); + callingMessage.opaque = new OpaqueMessage(); + callingMessage.opaque.data = Buffer.from([1, 2, 3]); + + const result = callingMessageToProto(callingMessage); + + assert.deepEqual(result.opaque?.data, new Uint8Array([1, 2, 3])); + }); + + it('attaches urgency if provided', () => { + const droppableResult = callingMessageToProto( + new CallingMessage(), + CallMessageUrgency.Droppable + ); + assert.deepEqual( + droppableResult.opaque?.urgency, + Proto.CallingMessage.Opaque.Urgency.DROPPABLE + ); + + const urgentResult = callingMessageToProto( + new CallingMessage(), + CallMessageUrgency.HandleImmediately + ); + assert.deepEqual( + urgentResult.opaque?.urgency, + Proto.CallingMessage.Opaque.Urgency.HANDLE_IMMEDIATELY + ); + }); + + it('attaches urgency and opaque data if both are provided', () => { + const callingMessage = new CallingMessage(); + callingMessage.opaque = new OpaqueMessage(); + callingMessage.opaque.data = Buffer.from([1, 2, 3]); + + const result = callingMessageToProto( + callingMessage, + CallMessageUrgency.HandleImmediately + ); + + assert.deepEqual(result.opaque?.data, new Uint8Array([1, 2, 3])); + assert.deepEqual( + result.opaque?.urgency, + Proto.CallingMessage.Opaque.Urgency.HANDLE_IMMEDIATELY + ); + }); + }); +}); diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 1f1840125..325b2fb60 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -198,3 +198,9 @@ export type ChangeIODevicePayloadType = | { type: CallingDeviceType.CAMERA; selectedDevice: string } | { type: CallingDeviceType.MICROPHONE; selectedDevice: AudioDevice } | { type: CallingDeviceType.SPEAKER; selectedDevice: AudioDevice }; + +export enum ProcessGroupCallRingRequestResult { + ShouldRing, + RingWasPreviouslyCanceled, + ThereIsAnotherActiveRing, +} diff --git a/ts/util/callingGetParticipantName.ts b/ts/util/callingGetParticipantName.ts new file mode 100644 index 000000000..df296ff15 --- /dev/null +++ b/ts/util/callingGetParticipantName.ts @@ -0,0 +1,10 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConversationType } from '../state/ducks/conversations'; + +export function getParticipantName( + participant: Readonly> +): string { + return participant.firstName || participant.title; +} diff --git a/ts/util/callingMessageToProto.ts b/ts/util/callingMessageToProto.ts new file mode 100644 index 000000000..3f67175eb --- /dev/null +++ b/ts/util/callingMessageToProto.ts @@ -0,0 +1,106 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { CallingMessage, CallMessageUrgency } from 'ringrtc'; +import { SignalService as Proto } from '../protobuf'; +import * as log from '../logging/log'; +import { missingCaseError } from './missingCaseError'; + +// TODO: remove once we move away from ArrayBuffers +const FIXMEU8 = Uint8Array; + +export function callingMessageToProto( + { + offer, + answer, + iceCandidates, + legacyHangup, + busy, + hangup, + supportsMultiRing, + destinationDeviceId, + opaque, + }: CallingMessage, + urgency?: CallMessageUrgency +): Proto.ICallingMessage { + let opaqueField: undefined | Proto.CallingMessage.IOpaque; + if (opaque) { + opaqueField = { + ...opaque, + data: bufferToProto(opaque.data), + }; + } + if (urgency !== undefined) { + opaqueField = { + ...(opaqueField ?? {}), + urgency: urgencyToProto(urgency), + }; + } + + return { + offer: offer + ? { + ...offer, + type: offer.type as number, + opaque: bufferToProto(offer.opaque), + } + : undefined, + answer: answer + ? { + ...answer, + opaque: bufferToProto(answer.opaque), + } + : undefined, + iceCandidates: iceCandidates + ? iceCandidates.map(candidate => { + return { + ...candidate, + opaque: bufferToProto(candidate.opaque), + }; + }) + : undefined, + legacyHangup: legacyHangup + ? { + ...legacyHangup, + type: legacyHangup.type as number, + } + : undefined, + busy, + hangup: hangup + ? { + ...hangup, + type: hangup.type as number, + } + : undefined, + supportsMultiRing, + destinationDeviceId, + opaque: opaqueField, + }; +} + +function bufferToProto( + value: Buffer | { toArrayBuffer(): ArrayBuffer } | undefined +): Uint8Array | undefined { + if (!value) { + return undefined; + } + if (value instanceof Uint8Array) { + return value; + } + + return new FIXMEU8(value.toArrayBuffer()); +} + +function urgencyToProto( + urgency: CallMessageUrgency +): Proto.CallingMessage.Opaque.Urgency { + switch (urgency) { + case CallMessageUrgency.Droppable: + return Proto.CallingMessage.Opaque.Urgency.DROPPABLE; + case CallMessageUrgency.HandleImmediately: + return Proto.CallingMessage.Opaque.Urgency.HANDLE_IMMEDIATELY; + default: + log.error(missingCaseError(urgency)); + return Proto.CallingMessage.Opaque.Urgency.DROPPABLE; + } +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1ae15d875..c6e0f170b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -13465,6 +13465,22 @@ "reasonCategory": "usageTrusted", "updated": "2021-07-30T16:57:33.618Z" }, + { + "rule": "React-useRef", + "path": "ts/components/IncomingCallBar.js", + "line": " const initialTitleRef = react_1.useRef(title);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-16T20:52:11.043Z", + "reasonDetail": "Doesn't interact with the DOM." + }, + { + "rule": "React-useRef", + "path": "ts/components/IncomingCallBar.tsx", + "line": " const initialTitleRef = useRef(title);", + "reasonCategory": "usageTrusted", + "updated": "2021-08-16T20:52:11.043Z", + "reasonDetail": "Doesn't interact with the DOM." + }, { "rule": "React-useRef", "path": "ts/components/Input.js", diff --git a/ts/window.d.ts b/ts/window.d.ts index f1b0215e2..14e544628 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -513,6 +513,8 @@ declare global { GV2_ENABLE_STATE_PROCESSING: boolean; GV2_MIGRATION_DISABLE_ADD: boolean; GV2_MIGRATION_DISABLE_INVITE: boolean; + RING_WHEN_JOINING_GROUP_CALLS: boolean; + RETRY_DELAY: boolean; } diff --git a/yarn.lock b/yarn.lock index 6ec99738d..c177f2d7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15710,9 +15710,9 @@ rimraf@^3.0.0, rimraf@^3.0.2, rimraf@~3.0.2: dependencies: glob "^7.1.3" -"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#800b31c5d43a1436bcea8b7b3f82a4baf4771bfb": - version "2.11.0" - resolved "https://github.com/signalapp/signal-ringrtc-node.git#800b31c5d43a1436bcea8b7b3f82a4baf4771bfb" +"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#031abcc7564e769648a8d8f8bda935fad0d877b9": + version "2.11.1" + resolved "https://github.com/signalapp/signal-ringrtc-node.git#031abcc7564e769648a8d8f8bda935fad0d877b9" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.1"