Receive rings for group calls

This commit is contained in:
Evan Hahn 2021-08-20 11:06:15 -05:00 committed by GitHub
parent fe040a2873
commit 79c976668b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 2112 additions and 359 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -25,7 +25,7 @@
width: 100%;
}
&__contact {
&__conversation {
align-items: center;
display: flex;
min-width: 0;

View File

@ -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> = {}): 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> = {}): PropsType => ({
}),
uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541',
},
notifyForCall: action('notify-for-call'),
openSystemPreferencesAction: action('open-system-preferences-action'),
playRingtone: action('play-ringtone'),
renderDeviceSelection: () => <div />,
renderSafetyNumberViewer: (_: SafetyNumberViewerProps) => <div />,
setGroupCallVideoRequest: action('set-group-call-video-request'),
@ -97,6 +91,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): 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)', () => (
<CallManager
{...createProps({
incomingCall: {
call: getIncomingCallState(),
callMode: CallMode.Direct as const,
conversation: getConversation(),
isVideoCall: true,
},
})}
/>
));
story.add('Ringing (group call)', () => (
<CallManager
{...createProps({
incomingCall: {
callMode: CallMode.Group as const,
conversation: {
...getConversation(),
type: 'group',
title: 'Tahoe Trip',
},
otherMembersRung: [
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
],
ringer: { firstName: 'Rick', title: 'Rick Sanchez' },
},
})}
/>

View File

@ -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<Pick<ConversationType, 'firstName' | 'title'>>;
ringer: Pick<ConversationType, 'firstName' | 'title'>;
};
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<ActiveCallManagerPropsType> = ({
};
export const CallManager: React.FC<PropsType> = 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<PropsType> = props => {
return (
<IncomingCallBar
acceptCall={acceptCall}
bounceAppIconStart={bounceAppIconStart}
bounceAppIconStop={bounceAppIconStop}
declineCall={declineCall}
i18n={i18n}
call={incomingCall.call}
conversation={incomingCall.conversation}
notifyForCall={notifyForCall}
{...incomingCall}
/>
);
}
return null;
};
function getShouldRing({
activeCall,
incomingCall,
}: Readonly<Pick<PropsType, 'activeCall' | 'incomingCall'>>): 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);
}
}

View File

@ -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<PropsType> = ({
</div>
);
};
function getParticipantName(
participant: Readonly<Pick<ConversationType, 'firstName' | 'title'>>
): string {
return participant.firstName || participant.title;
}

View File

@ -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 (
<IncomingCallBar
{...defaultProps}
call={{
...defaultProps.call,
isVideoCall,
}}
conversation={{
...defaultProps.conversation,
color,
name,
}}
/>
);
})
.add('Incoming Call Bar (video)', () => <IncomingCallBar {...defaultProps} />)
.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)', () => (
<IncomingCallBar
{...defaultProps}
call={{ ...defaultProps.call, isVideoCall: false }}
{...commonProps}
conversation={directConversation}
callMode={CallMode.Direct}
isVideoCall
/>
))
.add('Incoming direct call (audio)', () => (
<IncomingCallBar
{...commonProps}
conversation={directConversation}
callMode={CallMode.Direct}
isVideoCall={false}
/>
))
.add('Incoming group call (only calling you)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 1 other)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[{ firstName: 'Morty', title: 'Morty Smith' }]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 2 others)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 3 others)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
{ firstName: 'Beth', title: 'Beth Smith' },
]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 4 others)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
{ firstName: 'Beth', title: 'Beth Sanchez' },
{ firstName: 'Jerry', title: 'Beth Smith' },
]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
));

View File

@ -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<Pick<ConversationType, 'firstName' | 'title'>>;
ringer: Pick<ConversationType, 'firstName' | 'title'>;
}
);
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<Pick<ConversationType, 'firstName' | 'title'>>;
ringer: Pick<ConversationType, 'firstName' | 'title'>;
}>): JSX.Element => {
// As an optimization, we only process the first two names.
const [first, second] = otherMembersRung
.slice(0, 2)
.map(member => <Emojify text={getParticipantName(member)} />);
const ringerNode = <Emojify text={getParticipantName(ringer)} />;
switch (otherMembersRung.length) {
case 0:
return (
<Intl
id="incomingGroupCall__ringing-you"
i18n={i18n}
components={{ ringer: ringerNode }}
/>
);
case 1:
return (
<Intl
id="incomingGroupCall__ringing-1-other"
i18n={i18n}
components={{
ringer: ringerNode,
otherMember: first,
}}
/>
);
case 2:
return (
<Intl
id="incomingGroupCall__ringing-2-others"
i18n={i18n}
components={{
ringer: ringerNode,
first,
second,
}}
/>
);
break;
case 3:
return (
<Intl
id="incomingGroupCall__ringing-3-others"
i18n={i18n}
components={{
ringer: ringerNode,
first,
second,
}}
/>
);
break;
default:
return (
<Intl
id="incomingGroupCall__ringing-many"
i18n={i18n}
components={{
ringer: ringerNode,
first,
second,
remaining: String(otherMembersRung.length - 2),
}}
/>
);
}
};
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 = (
<ContactName
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
i18n={i18n}
/>
);
messageNode = i18n(
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall'
);
break;
case CallMode.Group: {
const { otherMembersRung, ringer } = props;
isVideoCall = true;
headerNode = <Emojify text={title} />;
messageNode = (
<GroupCallMessage
i18n={i18n}
otherMembersRung={otherMembersRung}
ringer={ringer}
/>
);
break;
}
default:
throw missingCaseError(props);
}
// We don't want to re-notify if the title changes.
const initialTitleRef = useRef<string>(title);
useEffect(() => {
const initialTitle = initialTitleRef.current;
notifyForCall(initialTitle, isVideoCall);
}, [isVideoCall, notifyForCall]);
useEffect(() => {
bounceAppIconStart();
return () => {
bounceAppIconStop();
};
}, [bounceAppIconStart, bounceAppIconStop]);
return (
<div className="IncomingCallBar__container">
<div className="IncomingCallBar__bar">
<div className="IncomingCallBar__contact">
<div className="IncomingCallBar__contact--avatar">
<div className="IncomingCallBar__conversation">
<div className="IncomingCallBar__conversation--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
conversationType={conversationType}
i18n={i18n}
isMe={isMe}
name={name}
@ -103,18 +250,15 @@ export const IncomingCallBar = ({
size={52}
/>
</div>
<div className="IncomingCallBar__contact--name">
<div className="IncomingCallBar__contact--name-header">
<ContactName
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
i18n={i18n}
/>
<div className="IncomingCallBar__conversation--name">
<div className="IncomingCallBar__conversation--name-header">
{headerNode}
</div>
<div dir="auto" className="IncomingCallBar__contact--message-text">
{i18n(isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall')}
<div
dir="auto"
className="IncomingCallBar__conversation--message-text"
>
{messageNode}
</div>
</div>
</div>

View File

@ -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<void> {
window.log.info('CallingClass.accept()');
async acceptDirectCall(
conversationId: string,
asVideoCall: boolean
): Promise<void> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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();

View File

@ -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<void> {
return channels.deleteJob(id);
}
function processGroupCallRingRequest(
ringId: bigint
): Promise<ProcessGroupCallRingRequestResult> {
return channels.processGroupCallRingRequest(ringId);
}
function processGroupCallRingCancelation(ringId: bigint): Promise<void> {
return channels.processGroupCallRingCancelation(ringId);
}
async function cleanExpiredGroupCallRings(): Promise<void> {
await channels.cleanExpiredGroupCallRings();
}
async function updateAllConversationColors(
conversationColor?: ConversationColorType,
customColorData?: {

View File

@ -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<StoredJob>): Promise<void>;
deleteJob(id: string): Promise<void>;
processGroupCallRingRequest(
ringId: bigint
): Promise<ProcessGroupCallRingRequestResult>;
processGroupCallRingCancelation(ringId: bigint): Promise<void>;
cleanExpiredGroupCallRings(): Promise<void>;
updateAllConversationColors: (
conversationColor?: ConversationColorType,
customColorData?: {

View File

@ -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<Array<null | number | string>>;
type Query = { [key: string]: null | number | string | Buffer };
type ArrayQuery = Array<Array<null | number | bigint | string>>;
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<void> {
db.prepare<Query>('DELETE FROM jobs WHERE id = $id').run({ id });
}
async function processGroupCallRingRequest(
ringId: bigint
): Promise<ProcessGroupCallRingRequestResult> {
const db = getInstance();
return db.transaction(() => {
let result: ProcessGroupCallRingRequestResult;
const wasRingPreviouslyCanceled = Boolean(
db
.prepare<Query>(
`
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<EmptyQuery>(
`
SELECT 1 FROM groupCallRings
WHERE isActive = 1
LIMIT 1;
`
)
.pluck(true)
.get()
);
if (isThereAnotherActiveRing) {
result = ProcessGroupCallRingRequestResult.ThereIsAnotherActiveRing;
} else {
result = ProcessGroupCallRingRequestResult.ShouldRing;
}
db.prepare<Query>(
`
INSERT OR IGNORE INTO groupCallRings (ringId, isActive, createdAt)
VALUES ($ringId, 1, $createdAt);
`
);
}
return result;
})();
}
async function processGroupCallRingCancelation(ringId: bigint): Promise<void> {
const db = getInstance();
db.prepare<Query>(
`
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<void> {
const db = getInstance();
db.prepare<Query>(
`
DELETE FROM groupCallRings
WHERE createdAt < $expiredRingTime;
`
).run({
expiredRingTime: Date.now() - MAX_GROUP_CALL_RING_AGE,
});
}
async function getStatisticsForLogging(): Promise<Record<string, string>> {
const counts = await pProps({
messageCount: getMessageCount(),

View File

@ -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<GroupCallParticipantInfoType>;
};
} & 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<undefined | GroupCallStateType>
): 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<void, RootStateType, unknown, AcceptCallPendingActionType> {
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<void> {
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<CallingStateType>
): 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);
}

View File

@ -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);
}
});
}
);

View File

@ -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 <SmartCallingDeviceSelection />;
@ -38,6 +44,33 @@ const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource
callingService
);
async function notifyForCall(
title: string,
isVideoCall: boolean
): Promise<void> {
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,
});

View File

@ -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');
});
});

View File

@ -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', () => {

View File

@ -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
);
});
});

View File

@ -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
);
});
});
});

View File

@ -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,
}

View File

@ -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<Pick<ConversationType, 'firstName' | 'title'>>
): string {
return participant.firstName || participant.title;
}

View File

@ -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;
}
}

View File

@ -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<string>(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",

2
ts/window.d.ts vendored
View File

@ -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;
}

View File

@ -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"