Sync call link call history

This commit is contained in:
ayumi-signal 2024-04-25 10:09:05 -07:00 committed by GitHub
parent ce83195170
commit 2785501f82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 800 additions and 175 deletions

View file

@ -7048,6 +7048,10 @@
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When call was declined"
},
"icu:CallHistory__Description--Adhoc": {
"messageformat": "Call link",
"description": "Call History > Short description of call > When you joined a call link call"
},
"icu:TypingBubble__avatar--overflow-count": {
"messageformat": "{count, plural, one {# other is} other {# others are}} typing.",
"description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden."

View file

@ -613,8 +613,14 @@ message SyncMessage {
}
message CallLinkUpdate {
optional bytes rootKey = 1;
enum Type {
UPDATE = 0;
DELETE = 1;
}
optional bytes rootKey = 1;
optional bytes adminPasskey = 2;
optional Type type = 3; // defaults to UPDATE
}
message CallLogEvent {

View file

@ -199,6 +199,7 @@ import {
import { deriveStorageServiceKey } from './Crypto';
import { getThemeType } from './util/getThemeType';
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@ -681,6 +682,10 @@ export async function startApp(): Promise<void> {
'callEventSync',
queuedEventListener(onCallEventSync, false)
);
messageReceiver.addEventListener(
'callLinkUpdateSync',
queuedEventListener(onCallLinkUpdateSync, false)
);
messageReceiver.addEventListener(
'callLogEventSync',
queuedEventListener(onCallLogEventSync, false)

View file

@ -44,9 +44,11 @@ import { CallsNewCallButton } from './CallsNewCall';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme';
import type { CallingConversationType } from '../types/Calling';
import { CallMode } from '../types/Calling';
import type { CallLinkType } from '../types/CallLink';
import { callLinkToConversation } from '../util/callLinks';
import {
callLinkToConversation,
getPlaceholderCallLinkConversation,
} from '../util/callLinks';
function Timestamp({
i18n,
@ -299,6 +301,26 @@ export function CallsList({
[searchState]
);
const getConversationForItem = useCallback(
(item: CallHistoryGroup | null): CallingConversationType | null => {
if (!item) {
return null;
}
const isAdhoc = item?.type === CallType.Adhoc;
if (isAdhoc) {
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
if (callLink) {
return callLinkToConversation(callLink, i18n);
}
return getPlaceholderCallLinkConversation(item.peerId, i18n);
}
return getConversation(item.peerId) ?? null;
},
[getCallLink, getConversation, i18n]
);
const isRowLoaded = useCallback(
(props: Index) => {
return searchState.results?.items[props.index] != null;
@ -309,16 +331,11 @@ export function CallsList({
const rowRenderer = useCallback(
({ key, index, style }: ListRowProps) => {
const item = searchState.results?.items.at(index) ?? null;
const isAdhoc = item?.mode === CallMode.Adhoc;
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
const conversation: CallingConversationType | null =
// eslint-disable-next-line no-nested-ternary
item
? callLink
? callLinkToConversation(callLink, i18n)
: getConversation(item.peerId) ?? null
: null;
const conversation = getConversationForItem(item);
const isAdhoc = item?.type === CallType.Adhoc;
const isNewCallVisible = Boolean(
!isAdhoc || (isAdhoc && getCallLink(item.peerId))
);
if (
searchState.state === 'pending' ||
@ -354,7 +371,7 @@ export function CallsList({
let statusText;
if (wasMissed) {
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
} else if (callLink) {
} else if (isAdhoc) {
statusText = i18n('icu:CallsList__ItemCallInfo--CallLink');
} else if (item.type === CallType.Group) {
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
@ -393,21 +410,23 @@ export function CallsList({
/>
}
trailing={
<CallsNewCallButton
callType={item.type}
hasActiveCall={hasActiveCall}
onClick={() => {
if (callLink) {
startCallLinkLobbyByRoomId(callLink.roomId);
} else if (conversation) {
if (item.type === CallType.Audio) {
onOutgoingAudioCallInConversation(conversation.id);
} else {
onOutgoingVideoCallInConversation(conversation.id);
isNewCallVisible ? (
<CallsNewCallButton
callType={item.type}
hasActiveCall={hasActiveCall}
onClick={() => {
if (isAdhoc) {
startCallLinkLobbyByRoomId(item.peerId);
} else if (conversation) {
if (item.type === CallType.Audio) {
onOutgoingAudioCallInConversation(conversation.id);
} else {
onOutgoingVideoCallInConversation(conversation.id);
}
}
}
}}
/>
}}
/>
) : undefined
}
title={
<span
@ -426,10 +445,13 @@ export function CallsList({
</span>
}
onClick={() => {
if (callLink) {
if (isAdhoc) {
return;
}
if (conversation == null) {
return;
}
onSelectCallHistoryGroup(conversation.id, item);
}}
/>
@ -440,7 +462,7 @@ export function CallsList({
hasActiveCall,
searchState,
getCallLink,
getConversation,
getConversationForItem,
selectedCallHistoryGroup,
onSelectCallHistoryGroup,
onOutgoingAudioCallInConversation,

View file

@ -72,6 +72,10 @@ function describeCallHistory(
direction: CallDirection,
status: CallStatus
): string {
if (type === CallType.Adhoc) {
return i18n('icu:CallHistory__Description--Adhoc');
}
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
if (direction === CallDirection.Incoming) {
return i18n('icu:CallHistory__Description--Missed', { type });

View file

@ -585,7 +585,8 @@ async function getCallLinkPreview(
}
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
const callLinkState = await calling.readCallLink({ callLinkRootKey });
const readResult = await calling.readCallLink({ callLinkRootKey });
const { callLinkState } = readResult;
if (!callLinkState || callLinkState.revoked) {
return null;
}

View file

@ -6,7 +6,7 @@ import { ipcRenderer } from 'electron';
import type {
AudioDevice,
CallId,
CallLinkState,
CallLinkState as RingRTCCallLinkState,
DeviceId,
GroupCallObserver,
PeekInfo,
@ -46,7 +46,6 @@ import Long from 'long';
import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup';
import type {
ActionsType as CallingReduxActionsType,
CallLinkStateType,
GroupCallParticipantInfoType,
GroupCallPeekInfoType,
} from '../state/ducks/calling';
@ -120,7 +119,7 @@ import {
getCallIdFromRing,
getLocalCallEventFromRingUpdate,
convertJoinState,
updateLocalAdhocCallHistory,
updateAdhocCallHistory,
getCallIdFromEra,
getCallDetailsForAdhocCall,
} from '../util/callDisposition';
@ -134,6 +133,7 @@ import {
} from '../util/callLinks';
import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled';
import { conversationJobQueue } from '../jobs/conversationJobQueue';
import type { ReadCallLinkState } from '../types/CallLink';
const {
processGroupCallRingCancellation,
@ -173,6 +173,7 @@ type CallingReduxInterface = Pick<
| 'groupCallEnded'
| 'groupCallRaisedHandsChange'
| 'groupCallStateChange'
| 'joinedAdhocCall'
| 'outgoingCall'
| 'receiveGroupCallReactions'
| 'receiveIncomingDirectCall'
@ -538,7 +539,16 @@ export class CallingClass {
callLinkRootKey,
}: Readonly<{
callLinkRootKey: CallLinkRootKey;
}>): Promise<CallLinkState | undefined> {
}>): Promise<
| {
callLinkState: ReadCallLinkState;
errorStatusCode: undefined;
}
| {
callLinkState: undefined;
errorStatusCode: number;
}
> {
if (!this._sfuUrl) {
throw new Error('readCallLink() missing SFU URL; not handling call link');
}
@ -555,11 +565,17 @@ export class CallingClass {
);
if (!result.success) {
log.warn(`${logId}: failed`);
return;
return {
callLinkState: undefined,
errorStatusCode: result.errorStatusCode,
};
}
log.info(`${logId}: success`);
return result.value;
return {
callLinkState: this.formatCallLinkStateForRedux(result.value),
errorStatusCode: undefined,
};
}
async startCallLinkLobby({
@ -1050,9 +1066,13 @@ export class CallingClass {
eraId
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
if (callMode === CallMode.Group) {
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
}
drop(
this.onGroupCallJoined({
peerId: conversationId,
eraId,
callMode,
})
);
}
}
@ -1126,10 +1146,13 @@ export class CallingClass {
eraId
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
if (callMode === CallMode.Group) {
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
}
drop(
this.onGroupCallJoined({
peerId: conversationId,
eraId,
callMode,
})
);
}
// For adhoc calls, conversationId will be a roomId
@ -1168,6 +1191,22 @@ export class CallingClass {
};
}
private async onGroupCallJoined({
callMode,
peerId,
eraId,
}: {
callMode: CallMode.Group | CallMode.Adhoc;
peerId: string;
eraId: string;
}): Promise<void> {
if (callMode === CallMode.Group) {
drop(this.sendGroupCallUpdateMessage(peerId, eraId));
} else if (callMode === CallMode.Adhoc) {
this.reduxInterface?.joinedAdhocCall(peerId);
}
}
public async joinCallLinkCall({
roomId,
rootKey,
@ -1365,8 +1404,8 @@ export class CallingClass {
}
public formatCallLinkStateForRedux(
callLinkState: CallLinkState
): CallLinkStateType {
callLinkState: RingRTCCallLinkState
): ReadCallLinkState {
const { name, restrictions, expiration, revoked } = callLinkState;
return {
name,
@ -2658,7 +2697,7 @@ export class CallingClass {
LocalCallEvent.Accepted,
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged'
);
await updateLocalAdhocCallHistory(callEvent);
await updateAdhocCallHistory(callEvent);
} catch (error) {
log.error(
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state',

View file

@ -29,7 +29,7 @@ import type {
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink';
import type { CallLinkStateType, CallLinkType } from '../types/CallLink';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
export type AdjacentMessagesByConversationOptionsType = Readonly<{
@ -681,13 +681,15 @@ export type DataInterface = {
>;
callLinkExists(roomId: string): Promise<boolean>;
getAllCallLinks: () => Promise<ReadonlyArray<CallLinkType>>;
getCallLinkByRoomId: (roomId: string) => Promise<CallLinkType | undefined>;
insertCallLink(callLink: CallLinkType): Promise<void>;
updateCallLinkAdminKeyByRoomId(
roomId: string,
adminKey: string
): Promise<void>;
updateCallLinkState(
roomId: string,
name: string,
restrictions: CallLinkRestrictions,
expiration: number | null,
revoked: boolean
callLinkState: CallLinkStateType
): Promise<void>;
migrateConversationMessages: (
obsoleteId: string,

View file

@ -169,7 +169,9 @@ import {
import {
callLinkExists,
getAllCallLinks,
getCallLinkByRoomId,
insertCallLink,
updateCallLinkAdminKeyByRoomId,
updateCallLinkState,
} from './server/callLinks';
import { CallMode } from '../types/Calling';
@ -341,7 +343,9 @@ const dataInterface: ServerInterface = {
getRecentStaleRingsAndMarkOlderMissed,
callLinkExists,
getAllCallLinks,
getCallLinkByRoomId,
insertCallLink,
updateCallLinkAdminKeyByRoomId,
updateCallLinkState,
migrateConversationMessages,
getMessagesBetween,

View file

@ -3,12 +3,16 @@
import type { Database } from '@signalapp/better-sqlite3';
import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkRestrictions, CallLinkType } from '../../types/CallLink';
import type { CallLinkStateType, CallLinkType } from '../../types/CallLink';
import {
callLinkRestrictionsSchema,
callLinkRecordSchema,
} from '../../types/CallLink';
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks';
import {
callLinkToRecord,
callLinkFromRecord,
toAdminKeyBytes,
} from '../../util/callLinks';
import { getReadonlyInstance, getWritableInstance, prepare } from '../Server';
import { sql } from '../util';
import { strictAssert } from '../../util/assert';
@ -23,6 +27,23 @@ export async function callLinkExists(roomId: string): Promise<boolean> {
return db.prepare(query).pluck(true).get(params) === 1;
}
export async function getCallLinkByRoomId(
roomId: string
): Promise<CallLinkType | undefined> {
const db = getReadonlyInstance();
const row = prepare(db, 'SELECT * FROM callLinks WHERE roomId = $roomId').get(
{
roomId,
}
);
if (!row) {
return undefined;
}
return callLinkFromRecord(callLinkRecordSchema.parse(row));
}
export async function getAllCallLinks(): Promise<ReadonlyArray<CallLinkType>> {
const db = getReadonlyInstance();
const [query] = sql`
@ -70,11 +91,9 @@ export async function insertCallLink(callLink: CallLinkType): Promise<void> {
export async function updateCallLinkState(
roomId: string,
name: string,
restrictions: CallLinkRestrictions,
expiration: number,
revoked: boolean
callLinkState: CallLinkStateType
): Promise<void> {
const { name, restrictions, expiration, revoked } = callLinkState;
const db = await getWritableInstance();
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
const [query, params] = sql`
@ -89,6 +108,22 @@ export async function updateCallLinkState(
db.prepare(query).run(params);
}
export async function updateCallLinkAdminKeyByRoomId(
roomId: string,
adminKey: string
): Promise<void> {
const db = await getWritableInstance();
const adminKeyBytes = toAdminKeyBytes(adminKey);
prepare(
db,
`
UPDATE callLinks
SET adminKey = $adminKeyBytes
WHERE roomId = $roomId;
`
).run({ roomId, adminKeyBytes });
}
function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void {
const derivedRoomId = CallLinkRootKey.parse(rootKey)
.deriveRoomId()

View file

@ -7,7 +7,7 @@ import {
hasScreenCapturePermission,
openSystemPreferences,
} from 'mac-screen-capture-permissions';
import { omit } from 'lodash';
import { omit, pick } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
import {
CallLinkRootKey,
@ -32,7 +32,7 @@ import type {
PresentedSource,
PresentableSource,
} from '../../types/Calling';
import type { CallLinkRestrictions } from '../../types/CallLink';
import type { CallLinkStateType } from '../../types/CallLink';
import {
CALLING_REACTIONS_LIFETIME,
MAX_CALLING_REACTIONS,
@ -46,7 +46,11 @@ import {
} from '../../types/Calling';
import { callingTones } from '../../util/callingTones';
import { requestCameraPermissions } from '../../util/callingPermissions';
import { getRoomIdFromRootKey } from '../../util/callLinks';
import {
CALL_LINK_DEFAULT_STATE,
getRoomIdFromRootKey,
} from '../../util/callLinks';
import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync';
import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue';
import type { AciString } from '../../types/ServiceId';
@ -168,13 +172,6 @@ export type AdhocCallsType = {
[roomId: string]: GroupCallStateType;
};
export type CallLinkStateType = ReadonlyDeep<{
name: string;
restrictions: CallLinkRestrictions;
expiration: number | null;
revoked: boolean;
}>;
export type CallLinksByRoomIdStateType = ReadonlyDeep<
CallLinkStateType & {
rootKey: string;
@ -244,10 +241,20 @@ type GroupCallStateChangeActionPayloadType =
ourAci: AciString;
};
type HandleCallLinkUpdateActionPayloadType = ReadonlyDeep<{
roomId: string;
callLinkDetails: CallLinksByRoomIdStateType;
}>;
type HangUpActionPayloadType = ReadonlyDeep<{
conversationId: string;
}>;
type HandleCallLinkUpdateType = ReadonlyDeep<{
rootKey: string;
adminKey: string | null;
}>;
export type IncomingDirectCallType = ReadonlyDeep<{
conversationId: string;
isVideoCall: boolean;
@ -567,6 +574,7 @@ const GROUP_CALL_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED';
const GROUP_CALL_REACTIONS_EXPIRED = 'calling/GROUP_CALL_REACTIONS_EXPIRED';
const HANDLE_CALL_LINK_UPDATE = 'calling/HANDLE_CALL_LINK_UPDATE';
const HANG_UP = 'calling/HANG_UP';
const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL';
const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL';
@ -699,6 +707,11 @@ type GroupCallReactionsExpiredActionType = ReadonlyDeep<{
payload: GroupCallReactionsExpiredActionPayloadType;
}>;
type HandleCallLinkUpdateActionType = ReadonlyDeep<{
type: 'calling/HANDLE_CALL_LINK_UPDATE';
payload: HandleCallLinkUpdateActionPayloadType;
}>;
type HangUpActionType = ReadonlyDeep<{
type: 'calling/HANG_UP';
payload: HangUpActionPayloadType;
@ -838,6 +851,7 @@ export type CallingActionType =
| GroupCallStateChangeActionType
| GroupCallReactionsReceivedActionType
| GroupCallReactionsExpiredActionType
| HandleCallLinkUpdateActionType
| HangUpActionType
| IncomingDirectCallActionType
| IncomingGroupCallActionType
@ -1206,6 +1220,72 @@ function groupCallStateChange(
};
}
// From sync messages, to notify us that another device joined or changed a call link.
function handleCallLinkUpdate(
payload: HandleCallLinkUpdateType
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return async dispatch => {
const { rootKey, adminKey } = payload;
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const logId = `handleCallLinkUpdate(${roomId})`;
const readResult = await calling.readCallLink({
callLinkRootKey,
});
// Only give up when server confirms the call link is gone. If we fail to fetch
// state due to unexpected errors, continue to save rootKey and adminKey.
if (readResult.errorStatusCode === 404) {
log.info(`${logId}: Call link not found, ignoring`);
return;
}
const { callLinkState: freshCallLinkState } = readResult;
const existingCallLink = await dataInterface.getCallLinkByRoomId(roomId);
const existingCallLinkState = pick(existingCallLink, [
'name',
'restrictions',
'expiration',
'revoked',
]);
const callLinkDetails: CallLinksByRoomIdStateType = {
...CALL_LINK_DEFAULT_STATE,
...existingCallLinkState,
...freshCallLinkState,
rootKey,
adminKey,
};
if (existingCallLink) {
if (adminKey && adminKey !== existingCallLink.adminKey) {
await dataInterface.updateCallLinkAdminKeyByRoomId(roomId, adminKey);
log.info(`${logId}: Updated existing call link with new adminKey`);
}
if (freshCallLinkState) {
await dataInterface.updateCallLinkState(roomId, freshCallLinkState);
log.info(`${logId}: Updated existing call link state`);
}
} else {
await dataInterface.insertCallLink({
roomId,
...callLinkDetails,
});
log.info(`${logId}: Saved new call link`);
}
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: {
roomId,
callLinkDetails,
},
});
};
}
function hangUpActiveCall(
reason: string
): ThunkAction<void, RootStateType, unknown, HangUpActionType> {
@ -1327,6 +1407,21 @@ function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
};
}
function joinedAdhocCall(
roomId: string
): ThunkAction<void, RootStateType, unknown, never> {
return async (_dispatch, getState) => {
const state = getState();
const callLink = getOwn(state.calling.callLinks, roomId);
if (!callLink) {
log.warn(`joinedAdhocCall(${roomId}): call link not found`);
return;
}
drop(sendCallLinkUpdateSync(callLink));
};
}
function peekGroupCallForTheFirstTime(
conversationId: string
): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
@ -1712,7 +1807,8 @@ const _startCallLinkLobby = async ({
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const callLinkState = await calling.readCallLink({ callLinkRootKey });
const readResult = await calling.readCallLink({ callLinkRootKey });
const { callLinkState } = readResult;
if (!callLinkState) {
const i18n = getIntl(getState());
dispatch({
@ -1725,7 +1821,10 @@ const _startCallLinkLobby = async ({
});
return;
}
if (callLinkState.revoked || callLinkState.expiration < new Date()) {
if (
callLinkState.revoked ||
callLinkState.expiration < new Date().getTime()
) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
@ -1739,21 +1838,13 @@ const _startCallLinkLobby = async ({
}
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const formattedCallLinkState =
calling.formatCallLinkStateForRedux(callLinkState);
try {
const { name, restrictions, expiration, revoked } = formattedCallLinkState;
const callLinkExists = await dataInterface.callLinkExists(roomId);
if (callLinkExists) {
await dataInterface.updateCallLinkState(
roomId,
name,
restrictions,
expiration,
revoked
);
await dataInterface.updateCallLinkState(roomId, callLinkState);
log.info('startCallLinkLobby: Updated existing call link', roomId);
} else {
const { name, restrictions, expiration, revoked } = callLinkState;
await dataInterface.insertCallLink({
roomId,
rootKey,
@ -1790,7 +1881,7 @@ const _startCallLinkLobby = async ({
type: START_CALL_LINK_LOBBY,
payload: {
...callLobbyData,
callLinkState: formattedCallLinkState,
callLinkState,
callLinkRootKey: rootKey,
conversationId: roomId,
isConversationTooBigToRing: false,
@ -1983,6 +2074,8 @@ export const actions = {
groupCallRaisedHandsChange,
groupCallStateChange,
hangUpActiveCall,
handleCallLinkUpdate,
joinedAdhocCall,
onOutgoingVideoCallInConversation,
onOutgoingAudioCallInConversation,
openSystemPreferencesAction,
@ -3100,5 +3193,18 @@ export function reducer(
};
}
if (action.type === HANDLE_CALL_LINK_UPDATE) {
const { callLinks } = state;
const { roomId, callLinkDetails } = action.payload;
return {
...state,
callLinks: {
...callLinks,
[roomId]: callLinkDetails,
},
};
}
return state;
}

View file

@ -0,0 +1,26 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CallLinkType } from '../../types/CallLink';
import { CallLinkRestrictions } from '../../types/CallLink';
import { MONTH } from '../../util/durations/constants';
export const FAKE_CALL_LINK: CallLinkType = {
adminKey: null,
expiration: Date.now() + MONTH, // set me
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};
// Please set expiration
export const FAKE_CALL_LINK_WITH_ADMIN_KEY: CallLinkType = {
adminKey: 'xXPI77e6MoVHYREW8iKYmQ==',
expiration: Date.now() + MONTH, // set me
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};

View file

@ -4,29 +4,10 @@
import { assert } from 'chai';
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks';
import type { CallLinkType } from '../../types/CallLink';
import { CallLinkRestrictions } from '../../types/CallLink';
import { MONTH } from '../../util/durations/constants';
const CALL_LINK: CallLinkType = {
adminKey: null,
expiration: Date.now() + MONTH,
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};
const CALL_LINK_WITH_ADMIN_KEY: CallLinkType = {
adminKey: 'xXPI77e6MoVHYREW8iKYmQ==',
expiration: Date.now() + MONTH,
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};
import {
FAKE_CALL_LINK as CALL_LINK,
FAKE_CALL_LINK_WITH_ADMIN_KEY as CALL_LINK_WITH_ADMIN_KEY,
} from '../helpers/fakeCallLink';
describe('callLinks', () => {
it('callLinkToRecord() and callLinkFromRecord() can convert to record and back', () => {

View file

@ -321,21 +321,21 @@ describe('sql/getCallHistoryGroups', () => {
const now = Date.now();
const roomId = generateUuid();
function toCall(callId: string, timestamp: number, type: CallType) {
function toCall(callId: string, timestamp: number) {
return {
callId,
peerId: roomId,
ringerId: null,
mode: CallMode.Adhoc,
type,
type: CallType.Adhoc,
direction: CallDirection.Outgoing,
timestamp,
status: AdhocCallStatus.Joined,
};
}
const call1 = toCall('1', now - 10, CallType.Group);
const call2 = toCall('2', now, CallType.Group);
const call1 = toCall('1', now - 10);
const call2 = toCall('2', now);
await saveCallHistory(call1);
await saveCallHistory(call2);
@ -394,28 +394,23 @@ describe('sql/getCallHistoryGroupsCount', () => {
const roomId1 = generateUuid();
const roomId2 = generateUuid();
function toCall(
callId: string,
roomId: string,
timestamp: number,
type: CallType
) {
function toCall(callId: string, roomId: string, timestamp: number) {
return {
callId,
peerId: roomId,
ringerId: null,
mode: CallMode.Adhoc,
type,
type: CallType.Adhoc,
direction: CallDirection.Outgoing,
timestamp,
status: AdhocCallStatus.Joined,
};
}
const call1 = toCall('1', roomId1, now - 20, CallType.Group);
const call2 = toCall('2', roomId1, now - 10, CallType.Group);
const call3 = toCall('3', roomId1, now, CallType.Group);
const call4 = toCall('4', roomId2, now, CallType.Group);
const call1 = toCall('1', roomId1, now - 20);
const call2 = toCall('2', roomId1, now - 10);
const call3 = toCall('3', roomId1, now);
const call4 = toCall('4', roomId2, now);
await saveCallHistory(call1);
await saveCallHistory(call2);

View file

@ -36,6 +36,8 @@ import {
import { generateAci } from '../../../types/ServiceId';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import type { UnwrapPromise } from '../../../types/Util';
import { CallLinkRestrictions } from '../../../types/CallLink';
import { FAKE_CALL_LINK } from '../../../test-both/helpers/fakeCallLink';
const ACI_1 = generateAci();
const NOW = new Date('2020-01-23T04:56:00.000');
@ -1301,6 +1303,62 @@ describe('calling duck', () => {
});
});
describe('handleCallLinkUpdate', () => {
const { roomId, rootKey, expiration } = FAKE_CALL_LINK;
beforeEach(function (this: Mocha.Context) {
this.callingServiceReadCallLink = this.sandbox
.stub(callingService, 'readCallLink')
.resolves({
callLinkState: {
name: 'Signal Call',
restrictions: CallLinkRestrictions.None,
expiration,
revoked: false,
},
errorStatusCode: undefined,
});
});
it('reads the call link from calling service', async function (this: Mocha.Context) {
const { handleCallLinkUpdate } = actions;
const dispatch = sinon.spy();
await handleCallLinkUpdate({ rootKey, adminKey: null })(
dispatch,
getEmptyRootState,
null
);
sinon.assert.calledOnce(this.callingServiceReadCallLink);
});
it('dispatches HANDLE_CALL_LINK_UPDATE', async () => {
const { handleCallLinkUpdate } = actions;
const dispatch = sinon.spy();
await handleCallLinkUpdate({ rootKey, adminKey: null })(
dispatch,
getEmptyRootState,
null
);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/HANDLE_CALL_LINK_UPDATE',
payload: {
roomId,
callLinkDetails: {
name: 'Signal Call',
restrictions: CallLinkRestrictions.None,
expiration,
revoked: false,
rootKey,
adminKey: null,
},
},
});
});
});
describe('peekNotConnectedGroupCall', () => {
const { peekNotConnectedGroupCall } = actions;

View file

@ -129,6 +129,7 @@ import {
ContactSyncEvent,
StoryRecipientUpdateEvent,
CallLogEventSyncEvent,
CallLinkUpdateSyncEvent,
} from './messageReceiverEvents';
import * as log from '../logging/log';
import * as durations from '../util/durations';
@ -145,6 +146,7 @@ import { filterAndClean } from '../types/BodyRange';
import { getCallEventForProto } from '../util/callDisposition';
import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey';
import { CallLogEvent } from '../types/CallDisposition';
import { CallLinkUpdateSyncType } from '../types/CallLink';
const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000;
@ -674,6 +676,11 @@ export default class MessageReceiver
handler: (ev: CallEventSyncEvent) => void
): void;
public override addEventListener(
name: 'callLinkUpdateSync',
handler: (ev: CallLinkUpdateSyncEvent) => void
): void;
public override addEventListener(
name: 'callLogEventSync',
handler: (ev: CallLogEventSyncEvent) => void
@ -3152,6 +3159,9 @@ export default class MessageReceiver
if (syncMessage.callEvent) {
return this.handleCallEvent(envelope, syncMessage.callEvent);
}
if (syncMessage.callLinkUpdate) {
return this.handleCallLinkUpdate(envelope, syncMessage.callLinkUpdate);
}
if (syncMessage.callLogEvent) {
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
}
@ -3510,6 +3520,53 @@ export default class MessageReceiver
log.info('handleCallEvent: finished');
}
private async handleCallLinkUpdate(
envelope: ProcessedEnvelope,
callLinkUpdate: Proto.SyncMessage.ICallLinkUpdate
): Promise<void> {
const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleCallLinkUpdate', logId);
logUnexpectedUrgentValue(envelope, 'callLinkUpdateSync');
let callLinkUpdateSyncType: CallLinkUpdateSyncType;
if (callLinkUpdate.type == null) {
throw new Error('MessageReceiver.handleCallLinkUpdate: type was null');
} else if (
callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.UPDATE
) {
callLinkUpdateSyncType = CallLinkUpdateSyncType.Update;
} else if (
callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.DELETE
) {
callLinkUpdateSyncType = CallLinkUpdateSyncType.Delete;
} else {
throw new Error(
`MessageReceiver.handleCallLinkUpdate: unknown type ${callLinkUpdate.type}`
);
}
const rootKey = Bytes.isNotEmpty(callLinkUpdate.rootKey)
? callLinkUpdate.rootKey
: undefined;
const adminKey = Bytes.isNotEmpty(callLinkUpdate.adminPasskey)
? callLinkUpdate.adminPasskey
: undefined;
const ev = new CallLinkUpdateSyncEvent(
{
type: callLinkUpdateSyncType,
rootKey,
adminKey,
},
this.removeFromCache.bind(this, envelope)
);
await this.dispatchAndWait(logId, ev);
log.info('handleCallLinkUpdate: finished');
}
private async handleCallLogEvent(
envelope: ProcessedEnvelope,
callLogEvent: Proto.SyncMessage.ICallLogEvent

View file

@ -14,6 +14,7 @@ import type {
} from './Types.d';
import type { ContactDetailsWithAvatar } from './ContactsParser';
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
import type { CallLinkUpdateSyncType } from '../types/CallLink';
export class EmptyEvent extends Event {
constructor() {
@ -439,6 +440,21 @@ export class CallEventSyncEvent extends ConfirmableEvent {
}
}
export type CallLinkUpdateSyncEventData = Readonly<{
type: CallLinkUpdateSyncType;
rootKey: Uint8Array | undefined;
adminKey: Uint8Array | undefined;
}>;
export class CallLinkUpdateSyncEvent extends ConfirmableEvent {
constructor(
public readonly callLinkUpdate: CallLinkUpdateSyncEventData,
confirm: ConfirmCallback
) {
super('callLinkUpdateSync', confirm);
}
}
export type CallLogEventSyncEventData = Readonly<{
event: CallLogEvent;
timestamp: number;

View file

@ -9,11 +9,13 @@ import { aciSchema } from './ServiceId';
import { bytesToUuid } from '../util/uuidToBytes';
import { SignalService as Proto } from '../protobuf';
import * as Bytes from '../Bytes';
import { UUID_BYTE_SIZE } from './Crypto';
export enum CallType {
Audio = 'Audio',
Video = 'Video',
Group = 'Group',
Adhoc = 'Adhoc',
}
export enum CallDirection {
@ -45,30 +47,42 @@ export enum RemoteCallEvent {
export type CallEvent = LocalCallEvent | RemoteCallEvent;
export enum DirectCallStatus {
export enum CallStatusValue {
Pending = 'Pending',
Accepted = 'Accepted',
Missed = 'Missed',
Declined = 'Declined',
Deleted = 'Deleted',
}
export enum GroupCallStatus {
GenericGroupCall = 'GenericGroupCall',
OutgoingRing = 'OutgoingRing',
Ringing = 'Ringing',
Joined = 'Joined',
// keep these in sync with direct
Accepted = DirectCallStatus.Accepted,
Missed = DirectCallStatus.Missed,
Declined = DirectCallStatus.Declined,
Deleted = DirectCallStatus.Deleted,
JoinedAdhoc = 'JoinedAdhoc',
}
export enum DirectCallStatus {
Pending = CallStatusValue.Pending,
Accepted = CallStatusValue.Accepted,
Missed = CallStatusValue.Missed,
Declined = CallStatusValue.Declined,
Deleted = CallStatusValue.Deleted,
}
export enum GroupCallStatus {
GenericGroupCall = CallStatusValue.GenericGroupCall,
OutgoingRing = CallStatusValue.OutgoingRing,
Ringing = CallStatusValue.Ringing,
Joined = CallStatusValue.Joined,
Accepted = CallStatusValue.Accepted,
Missed = CallStatusValue.Missed,
Declined = CallStatusValue.Declined,
Deleted = CallStatusValue.Deleted,
}
export enum AdhocCallStatus {
Pending = DirectCallStatus.Pending,
Joined = GroupCallStatus.Joined,
Deleted = DirectCallStatus.Deleted,
Pending = CallStatusValue.Pending,
Joined = CallStatusValue.JoinedAdhoc,
Deleted = CallStatusValue.Deleted,
}
export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus;
@ -177,11 +191,15 @@ export const callHistoryGroupSchema = z.object({
}) satisfies z.ZodType<CallHistoryGroup>;
const peerIdInBytesSchema = z.instanceof(Uint8Array).transform(value => {
const uuid = bytesToUuid(value);
if (uuid != null) {
return uuid;
// direct conversationId
if (value.byteLength === UUID_BYTE_SIZE) {
const uuid = bytesToUuid(value);
if (uuid != null) {
return uuid;
}
}
// assuming groupId
// groupId or call link roomId
return Bytes.toBase64(value);
});

View file

@ -4,6 +4,16 @@ import type { ReadonlyDeep } from 'type-fest';
import { z } from 'zod';
import type { ConversationType } from '../state/ducks/conversations';
export enum CallLinkUpdateSyncType {
Update = 'Update',
Delete = 'Delete',
}
export type CallLinkUpdateData = Readonly<{
rootKey: Uint8Array;
adminKey: Uint8Array | undefined;
}>;
/**
* Restrictions
*/
@ -33,10 +43,25 @@ export type CallLinkType = Readonly<{
adminKey: string | null;
name: string;
restrictions: CallLinkRestrictions;
// Revocation is not supported currently but still returned by the server
revoked: boolean;
// Guaranteed from RingRTC readCallLink, but locally may be null immediately after
// CallLinkUpdate sync and before readCallLink
expiration: number | null;
}>;
export type CallLinkStateType = Pick<
CallLinkType,
'name' | 'restrictions' | 'revoked' | 'expiration'
>;
export type ReadCallLinkState = Readonly<{
name: string;
restrictions: CallLinkRestrictions;
revoked: boolean;
expiration: number;
}>;
// Ephemeral conversation-like type to satisfy components
export type CallLinkConversationType = ReadonlyDeep<
Omit<ConversationType, 'type'> & {
@ -65,6 +90,6 @@ export const callLinkRecordSchema = z.object({
// state
name: z.string(),
restrictions: callLinkRestrictionsSchema,
expiration: z.number().int(),
expiration: z.number().int().nullable(),
revoked: z.union([z.literal(1), z.literal(0)]),
}) satisfies z.ZodType<CallLinkRecord>;

View file

@ -59,13 +59,20 @@ import {
callHistoryDetailsSchema,
callDetailsSchema,
AdhocCallStatus,
CallStatusValue,
} from '../types/CallDisposition';
import type { ConversationType } from '../state/ducks/conversations';
import type { ConversationModel } from '../models/conversations';
import { drop } from './drop';
// utils
// -----
// call link roomIds are automatically redacted via redactCallLinkRoomIds()
export function peerIdToLog(peerId: string, mode: CallMode): string {
return mode === CallMode.Group ? `groupv2(${peerId})` : peerId;
}
export function formatCallEvent(callEvent: CallEventDetails): string {
const {
callId,
@ -78,14 +85,14 @@ export function formatCallEvent(callEvent: CallEventDetails): string {
ringerId,
timestamp,
} = callEvent;
const peerIdLog = mode === CallMode.Group ? `groupv2(${peerId})` : peerId;
const peerIdLog = peerIdToLog(peerId, mode);
return `CallEvent (${callId}, ${peerIdLog}, ${mode}, ${event}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId}, ${eventSource})`;
}
export function formatCallHistory(callHistory: CallHistoryDetails): string {
const { callId, peerId, direction, status, type, mode, timestamp, ringerId } =
callHistory;
const peerIdLog = mode === CallMode.Group ? `groupv2(${peerId})` : peerId;
const peerIdLog = peerIdToLog(peerId, mode);
return `CallHistory (${callId}, ${peerIdLog}, ${mode}, ${status}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId})`;
}
@ -189,6 +196,8 @@ export function getCallEventForProto(
type = CallType.Audio;
} else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL) {
type = CallType.Video;
} else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.AD_HOC_CALL) {
type = CallType.Adhoc;
} else {
throw new TypeError(`Unknown call type ${callEvent.type}`);
}
@ -196,6 +205,8 @@ export function getCallEventForProto(
let mode: CallMode;
if (type === CallType.Group) {
mode = CallMode.Group;
} else if (type === CallType.Adhoc) {
mode = CallMode.Adhoc;
} else {
mode = CallMode.Direct;
}
@ -246,21 +257,23 @@ const typeToProto = {
[CallType.Audio]: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL,
[CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL,
[CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL,
[CallType.Adhoc]: Proto.SyncMessage.CallEvent.Type.AD_HOC_CALL,
};
const statusToProto: Record<
CallStatus,
Proto.SyncMessage.CallEvent.Event | null
> = {
[DirectCallStatus.Accepted]: Proto.SyncMessage.CallEvent.Event.ACCEPTED, // and GroupCallStatus.Accepted
[DirectCallStatus.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED, // and GroupCallStatus.Declined
[DirectCallStatus.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE, // and GroupCallStatus.Deleted
[DirectCallStatus.Missed]: null, // and GroupCallStatus.Missed
[DirectCallStatus.Pending]: null,
[GroupCallStatus.GenericGroupCall]: null,
[GroupCallStatus.Joined]: null,
[GroupCallStatus.OutgoingRing]: null,
[GroupCallStatus.Ringing]: null,
[CallStatusValue.Accepted]: Proto.SyncMessage.CallEvent.Event.ACCEPTED,
[CallStatusValue.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED,
[CallStatusValue.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE,
[CallStatusValue.Missed]: null,
[CallStatusValue.Pending]: null,
[CallStatusValue.GenericGroupCall]: null,
[CallStatusValue.OutgoingRing]: null,
[CallStatusValue.Ringing]: null,
[CallStatusValue.Joined]: null,
[CallStatusValue.JoinedAdhoc]: Proto.SyncMessage.CallEvent.Event.ACCEPTED,
};
function shouldSyncStatus(callStatus: CallStatus) {
@ -279,7 +292,10 @@ function getProtoForCallHistory(
)}`
);
let peerId = uuidToBytes(callHistory.peerId);
let peerId =
callHistory.mode === CallMode.Adhoc
? Bytes.fromBase64(callHistory.peerId)
: uuidToBytes(callHistory.peerId);
if (peerId.length === 0) {
peerId = Bytes.fromBase64(callHistory.peerId);
}
@ -479,8 +495,10 @@ export function getCallDetailsForAdhocCall(
peerId,
ringerId: null,
mode: CallMode.Adhoc,
type: CallType.Group,
direction: CallDirection.Outgoing,
type: CallType.Adhoc,
// Direction is only outgoing when your action causes ringing for others.
// As Adhoc calls do not support ringing, this is always incoming for now
direction: CallDirection.Incoming,
timestamp: Date.now(),
});
}
@ -884,6 +902,17 @@ async function updateLocalCallHistory(
);
}
export async function updateAdhocCallHistory(
callEvent: CallEventDetails
): Promise<void> {
const callHistory = await updateLocalAdhocCallHistory(callEvent);
if (!callHistory) {
return;
}
drop(updateRemoteCallHistory(callHistory));
}
export async function updateLocalAdhocCallHistory(
callEvent: CallEventDetails
): Promise<CallHistoryDetails | null> {
@ -900,11 +929,11 @@ export async function updateLocalAdhocCallHistory(
if (prevCallHistory != null) {
log.info(
'updateLocalAdhocCallHistory: Found previous call history:',
'updateAdhocCallHistory: Found previous call history:',
formatCallHistory(prevCallHistory)
);
} else {
log.info('updateLocalAdhocCallHistory: No previous call history');
log.info('updateAdhocCallHistory: No previous call history');
}
let callHistory: CallHistoryDetails;
@ -912,7 +941,7 @@ export async function updateLocalAdhocCallHistory(
callHistory = transitionCallHistory(prevCallHistory, callEvent);
} catch (error) {
log.error(
"updateLocalAdhocCallHistory: Couldn't transition call history:",
"updateAdhocCallHistory: Couldn't transition call history:",
formatCallEvent(callEvent),
Errors.toLogFormat(error)
);
@ -923,17 +952,17 @@ export async function updateLocalAdhocCallHistory(
callHistory.status === AdhocCallStatus.Pending ||
callHistory.status === AdhocCallStatus.Joined ||
callHistory.status === AdhocCallStatus.Deleted,
`updateLocalAdhocCallHistory: callHistory must have adhoc status (was ${callHistory.status})`
`updateAdhocCallHistory: callHistory must have adhoc status (was ${callHistory.status})`
);
const isDeleted = callHistory.status === AdhocCallStatus.Deleted;
if (prevCallHistory != null && isEqual(prevCallHistory, callHistory)) {
log.info(
'updateLocalAdhocCallHistory: Next call history is identical, skipping save'
'updateAdhocCallHistory: Next call history is identical, skipping save'
);
} else {
log.info(
'updateLocalAdhocCallHistory: Saving call history:',
'updateAdhocCallHistory: Saving call history:',
formatCallHistory(callHistory)
);
await window.Signal.Data.saveCallHistory(callHistory);
@ -1126,7 +1155,11 @@ export async function updateCallHistoryFromRemoteEvent(
callEvent: CallEventDetails,
receivedAtCounter: number
): Promise<void> {
await updateLocalCallHistory(callEvent, receivedAtCounter);
if (callEvent.mode === CallMode.Direct || callEvent.mode === CallMode.Group) {
await updateLocalCallHistory(callEvent, receivedAtCounter);
} else if (callEvent.mode === CallMode.Adhoc) {
await updateLocalAdhocCallHistory(callEvent);
}
}
export async function updateCallHistoryFromLocalEvent(

View file

@ -18,10 +18,18 @@ import type {
} from '../types/CallLink';
import {
callLinkRecordSchema,
CallLinkRestrictions,
toCallLinkRestrictions,
} from '../types/CallLink';
import type { LocalizerType } from '../types/Util';
export const CALL_LINK_DEFAULT_STATE = {
name: '',
restrictions: CallLinkRestrictions.Unknown,
revoked: false,
expiration: null,
};
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
return rootKey.deriveRoomId().toString('hex');
}
@ -81,6 +89,37 @@ export function callLinkToConversation(
};
}
export function getPlaceholderCallLinkConversation(
roomId: string,
i18n: LocalizerType
): CallLinkConversationType {
return {
id: roomId,
type: 'callLink',
isMe: false,
title: i18n('icu:calling__call-link-default-title'),
sharedGroupNames: [],
acceptedMessageRequest: true,
badges: [],
};
}
export function toRootKeyBytes(rootKey: string): Uint8Array {
return CallLinkRootKey.parse(rootKey).bytes;
}
export function fromRootKeyBytes(rootKey: Uint8Array): string {
return CallLinkRootKey.fromBytes(rootKey as Buffer).toString();
}
export function toAdminKeyBytes(adminKey: string): Uint8Array {
return Bytes.fromBase64(adminKey);
}
export function fromAdminKeyBytes(adminKey: Uint8Array): string {
return Bytes.toBase64(adminKey);
}
/**
* DB record conversions
*/
@ -90,12 +129,9 @@ export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
throw new Error('CallLink.callLinkToRecord: rootKey is null');
}
// rootKey has a RingRTC parsing function, adminKey is just bytes
const rootKey = callLink.rootKey
? CallLinkRootKey.parse(callLink.rootKey).bytes
: null;
const rootKey = toRootKeyBytes(callLink.rootKey);
const adminKey = callLink.adminKey
? Bytes.fromBase64(callLink.adminKey)
? toAdminKeyBytes(callLink.adminKey)
: null;
return callLinkRecordSchema.parse({
roomId: callLink.roomId,
@ -109,11 +145,13 @@ export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
}
export function callLinkFromRecord(record: CallLinkRecord): CallLinkType {
if (record.rootKey == null) {
throw new Error('CallLink.callLinkFromRecord: rootKey is null');
}
// root keys in memory are strings for simplicity
const rootKey = CallLinkRootKey.fromBytes(
record.rootKey as Buffer
).toString();
const adminKey = record.adminKey ? Bytes.toBase64(record.adminKey) : null;
const rootKey = fromRootKeyBytes(record.rootKey);
const adminKey = record.adminKey ? fromAdminKeyBytes(record.adminKey) : null;
return {
roomId: record.roomId,
rootKey,

View file

@ -177,7 +177,7 @@ export function getCallingIcon(
}
return 'video';
}
if (callType === CallType.Group) {
if (callType === CallType.Group || callType === CallType.Adhoc) {
return 'video';
}
throw missingCaseError(callType);

View file

@ -65,6 +65,7 @@ export const sendTypesEnum = z.enum([
'viewOnceSync',
'viewSync',
'callEventSync',
'callLinkUpdateSync',
'callLogEventSync',
// No longer used, all non-urgent

View file

@ -3,22 +3,32 @@
import type { CallEventSyncEvent } from '../textsecure/messageReceiverEvents';
import * as log from '../logging/log';
import { updateCallHistoryFromRemoteEvent } from './callDisposition';
import {
peerIdToLog,
updateCallHistoryFromRemoteEvent,
} from './callDisposition';
import { CallMode } from '../types/Calling';
export async function onCallEventSync(
syncEvent: CallEventSyncEvent
): Promise<void> {
const { callEvent, confirm } = syncEvent;
const { callEventDetails, receivedAtCounter } = callEvent;
const { peerId } = callEventDetails;
const conversation = window.ConversationController.get(peerId);
if (
callEventDetails.mode === CallMode.Direct ||
callEventDetails.mode === CallMode.Group
) {
const { peerId } = callEventDetails;
const conversation = window.ConversationController.get(peerId);
if (!conversation) {
log.warn(
`onCallEventSync: No conversation found for conversationId ${peerId}`
);
return;
if (!conversation) {
const peerIdLog = peerIdToLog(peerId, callEventDetails.mode);
log.warn(
`onCallEventSync: No conversation found for conversationId ${peerIdLog}`
);
return;
}
}
await updateCallHistoryFromRemoteEvent(callEventDetails, receivedAtCounter);

View file

@ -0,0 +1,57 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkUpdateSyncEvent } from '../textsecure/messageReceiverEvents';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { fromAdminKeyBytes, getRoomIdFromRootKey } from './callLinks';
import { strictAssert } from './assert';
import { CallLinkUpdateSyncType } from '../types/CallLink';
export async function onCallLinkUpdateSync(
syncEvent: CallLinkUpdateSyncEvent
): Promise<void> {
const { callLinkUpdate, confirm } = syncEvent;
const { type, rootKey, adminKey } = callLinkUpdate;
if (!rootKey) {
log.warn('onCallLinkUpdateSync: Missing rootKey, invalid sync message');
return;
}
let callLinkRootKey: CallLinkRootKey;
let roomId: string;
try {
callLinkRootKey = CallLinkRootKey.fromBytes(rootKey as Buffer);
roomId = getRoomIdFromRootKey(callLinkRootKey);
strictAssert(
roomId,
'onCallLinkUpdateSync: roomId is required in sync message'
);
} catch (err) {
log.error('onCallLinkUpdateSync: Could not parse root key');
return;
}
const logId = `onCallLinkUpdateSync(${roomId}, ${type})`;
log.info(`${logId}: Processing`);
try {
if (type === CallLinkUpdateSyncType.Update) {
const rootKeyString = callLinkRootKey.toString();
const adminKeyString = adminKey ? fromAdminKeyBytes(adminKey) : null;
window.reduxActions.calling.handleCallLinkUpdate({
rootKey: rootKeyString,
adminKey: adminKeyString,
});
} else if (type === CallLinkUpdateSyncType.Delete) {
// TODO: DESKTOP-6951
log.warn(`${logId}: Deleting call links is not supported`);
}
} catch (err) {
log.error(`${logId}: Failed to process`, Errors.toLogFormat(err));
}
confirm();
}

View file

@ -0,0 +1,82 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Bytes from '../Bytes';
import { CallLinkUpdateSyncType } from '../types/CallLink';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { SignalService as Proto } from '../protobuf';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import MessageSender from '../textsecure/SendMessage';
import { toAdminKeyBytes, toRootKeyBytes } from './callLinks';
export type sendCallLinkUpdateSyncCallLinkType = {
rootKey: string;
adminKey: string | null;
};
export async function sendCallLinkUpdateSync(
callLink: sendCallLinkUpdateSyncCallLinkType
): Promise<void> {
return _sendCallLinkUpdateSync(callLink, CallLinkUpdateSyncType.Update);
}
/**
* Underlying sync message is CallLinkUpdate with type set to DELETE.
*/
export async function sendCallLinkDeleteSync(
callLink: sendCallLinkUpdateSyncCallLinkType
): Promise<void> {
return _sendCallLinkUpdateSync(callLink, CallLinkUpdateSyncType.Delete);
}
async function _sendCallLinkUpdateSync(
callLink: sendCallLinkUpdateSyncCallLinkType,
type: CallLinkUpdateSyncType
): Promise<void> {
let protoType: Proto.SyncMessage.CallLinkUpdate.Type;
if (type === CallLinkUpdateSyncType.Update) {
protoType = Proto.SyncMessage.CallLinkUpdate.Type.UPDATE;
} else if (type === CallLinkUpdateSyncType.Delete) {
protoType = Proto.SyncMessage.CallLinkUpdate.Type.DELETE;
} else {
throw new Error(`sendCallLinkUpdateSync: unknown type ${type}`);
}
log.info(`sendCallLinkUpdateSync: Sending CallLinkUpdate type=${type}`);
try {
const ourAci = window.textsecure.storage.user.getCheckedAci();
const callLinkUpdate = new Proto.SyncMessage.CallLinkUpdate({
type: protoType,
rootKey: toRootKeyBytes(callLink.rootKey),
adminPasskey: callLink.adminKey
? toAdminKeyBytes(callLink.adminKey)
: null,
});
const syncMessage = MessageSender.createSyncMessage();
syncMessage.callLinkUpdate = callLinkUpdate;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
await singleProtoJobQueue.add({
contentHint: ContentHint.RESENDABLE,
serviceId: ourAci,
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'callLinkUpdateSync',
urgent: false,
});
} catch (error) {
log.error(
'sendCallLinkUpdateSync: Failed to queue sync message:',
Errors.toLogFormat(error)
);
}
}