diff --git a/_locales/en/messages.json b/_locales/en/messages.json index de3a56f5e..5071cc862 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -988,7 +988,7 @@ "description": "Shown in iOS theme when you or someone quotes to a message which is not from you" }, "audioPermissionNeeded": { - "message": "To send audio messages, allow Signal Desktop to access your microphone.", + "message": "To send voice messages, allow Signal Desktop to access your microphone.", "description": "Shown if the user attempts to send an audio message without audio permissions turned on" }, "audioCallingPermissionNeeded": { @@ -1559,7 +1559,7 @@ "description": "when you block someone and cannot view their video" }, "calling__block-info": { - "message": "You won't receive their audio or video and they won't receive yours.", + "message": "You won't receive their voice or video and they won't receive yours.", "description": "Shown in the modal dialog to describe how blocking works in a group call" }, "calling__overflow__scroll-up": { @@ -2765,15 +2765,23 @@ "description": "Shown in the shortcuts guide" }, "Keyboard--accept-video-call": { - "message": "Accept call with video", - "description": "Shown in the calling keyboard shortcuts guide" + "message": "Answer call with video", + "description": "(deleted 2023/01/09) Shown in the calling keyboard shortcuts guide" }, "Keyboard--accept-audio-call": { - "message": "Accept call with audio", + "message": "Answer call with audio", + "description": "(deleted 2023/01/09) Shown in the calling keyboard shortcuts guide" + }, + "icu:Keyboard--accept-video-call": { + "messageformat": "Answer call with video (video calls only)", + "description": "Shown in the calling keyboard shortcuts guide" + }, + "icu:Keyboard--accept-call-without-video": { + "messageformat": "Answer call without video", "description": "Shown in the calling keyboard shortcuts guide" }, "Keyboard--start-audio-call": { - "message": "Start audio call", + "message": "Start voice call", "description": "Shown in the calling keyboard shortcuts guide" }, "Keyboard--start-video-call": { @@ -3189,11 +3197,11 @@ "description": "When a user has no common groups, show this warning" }, "acceptCall": { - "message": "Answer", + "message": "Answer call", "description": "Shown in tooltip for the button to accept a call (audio or video)" }, "acceptCallWithoutVideo": { - "message": "Answer without video", + "message": "Answer call without video", "description": "Shown in tooltip for the button to accept a video call without video" }, "declineCall": { @@ -3201,40 +3209,40 @@ "description": "Shown in tooltip for the button to decline a call (audio or video)" }, "declinedIncomingAudioCall": { - "message": "You declined an audio call", - "description": "Shown in conversation history when you declined an incoming audio call" + "message": "You declined a voice call", + "description": "Shown in conversation history when you declined an incoming voice call" }, "declinedIncomingVideoCall": { "message": "You declined a video call", "description": "Shown in conversation history when you declined an incoming video call" }, "acceptedIncomingAudioCall": { - "message": "Incoming audio call", - "description": "Shown in conversation history when you accepted an incoming audio call" + "message": "Incoming voice call", + "description": "Shown in conversation history when you accepted an incoming voice call" }, "acceptedIncomingVideoCall": { "message": "Incoming video call", "description": "Shown in conversation history when you accepted an incoming video call" }, "missedIncomingAudioCall": { - "message": "Missed audio call", - "description": "Shown in conversation history when you missed an incoming audio call" + "message": "Missed voice call", + "description": "Shown in conversation history when you missed an incoming voice call" }, "missedIncomingVideoCall": { "message": "Missed video call", "description": "Shown in conversation history when you missed an incoming video call" }, "acceptedOutgoingAudioCall": { - "message": "Outgoing audio call", - "description": "Shown in conversation history when you made an outgoing audio call" + "message": "Outgoing voice call", + "description": "Shown in conversation history when you made an outgoing voice call" }, "acceptedOutgoingVideoCall": { "message": "Outgoing video call", "description": "Shown in conversation history when you made an outgoing video call" }, "missedOrDeclinedOutgoingAudioCall": { - "message": "Unanswered audio call", - "description": "Shown in conversation history when your audio call is missed or declined" + "message": "Unanswered voice call", + "description": "Shown in conversation history when your voice call is missed or declined" }, "missedOrDeclinedOutgoingVideoCall": { "message": "Unanswered video call", @@ -3249,8 +3257,8 @@ "description": "Shown in a notification body when Signal is minimized to tray" }, "incomingAudioCall": { - "message": "Incoming audio call...", - "description": "Shown in both the incoming call bar and notification for an incoming audio call" + "message": "Incoming voice call...", + "description": "Shown in both the incoming call bar and notification for an incoming voice call" }, "incomingVideoCall": { "message": "Incoming video call...", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 2b83cfa76..51232e089 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -573,6 +573,33 @@ message SyncMessage { optional uint32 registrationId = 3; } + message CallEvent { + enum Type { + UNKNOWN = 0; + AUDIO_CALL = 1; + VIDEO_CALL = 2; + } + + enum Direction { + UNKNOWN = 0; + INCOMING = 1; + OUTGOING = 2; + } + + enum Event { + UNKNOWN = 0; + ACCEPTED = 1; + NOT_ACCEPTED = 2; + } + + optional bytes peerUuid = 1; + optional uint64 callId = 2; + optional uint64 timestamp = 3; + optional Type type = 4; + optional Direction direction = 5; + optional Event event = 6; + } + optional Sent sent = 1; optional Contacts contacts = 2; optional Groups groups = 3; @@ -591,6 +618,7 @@ message SyncMessage { repeated Viewed viewed = 16; optional PniIdentity pniIdentity = 17; optional PniChangeNumber pniChangeNumber = 18; + optional CallEvent callEvent = 19; } message AttachmentPointer { diff --git a/ts/background.ts b/ts/background.ts index 4e5989e30..ee93c784d 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -161,6 +161,7 @@ import { clearConversationDraftAttachments } from './util/clearConversationDraft import { removeLinkPreview } from './services/LinkPreview'; import { PanelType } from './types/Panels'; import { getQuotedMessageSelector } from './state/selectors/composer'; +import { onCallEventSync } from './util/onCallEventSync'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -411,6 +412,10 @@ export async function startApp(): Promise { 'storyRecipientUpdate', queuedEventListener(onStoryRecipientUpdate, false) ); + messageReceiver.addEventListener( + 'callEventSync', + queuedEventListener(onCallEventSync, false) + ); }); ourProfileKeyService.initialize(window.storage); diff --git a/ts/components/IncomingCallBar.tsx b/ts/components/IncomingCallBar.tsx index c42ac1160..9f70c3ce0 100644 --- a/ts/components/IncomingCallBar.tsx +++ b/ts/components/IncomingCallBar.tsx @@ -224,8 +224,10 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null { }, [bounceAppIconStart, bounceAppIconStop]); const acceptVideoCall = useCallback(() => { - acceptCall({ conversationId, asVideoCall: true }); - }, [acceptCall, conversationId]); + if (isVideoCall) { + acceptCall({ conversationId, asVideoCall: true }); + } + }, [isVideoCall, acceptCall, conversationId]); const acceptAudioCall = useCallback(() => { acceptCall({ conversationId, asVideoCall: false }); diff --git a/ts/components/ShortcutGuide.tsx b/ts/components/ShortcutGuide.tsx index 6d8654569..a436ddadc 100644 --- a/ts/components/ShortcutGuide.tsx +++ b/ts/components/ShortcutGuide.tsx @@ -205,11 +205,11 @@ const CALLING_SHORTCUTS: Array = [ keys: [['shift', 'V']], }, { - description: 'Keyboard--accept-video-call', + description: 'icu:Keyboard--accept-video-call', keys: [['ctrlOrAlt', 'shift', 'V']], }, { - description: 'Keyboard--accept-audio-call', + description: 'icu:Keyboard--accept-call-without-video', keys: [['ctrlOrAlt', 'shift', 'A']], }, { diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index bfa2ac86c..86314c15b 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -18,6 +18,7 @@ import { import { missingCaseError } from '../../util/missingCaseError'; import { Tooltip, TooltipPlacement } from '../Tooltip'; import * as log from '../../logging/log'; +import { assertDev } from '../../util/assert'; export type PropsActionsType = { returnToActiveCall: () => void; @@ -42,11 +43,14 @@ export const CallingNotification: React.FC = React.memo( let timestamp: number; let wasMissed = false; switch (props.callMode) { - case CallMode.Direct: - timestamp = props.acceptedTime || props.endedTime; + case CallMode.Direct: { + const resolvedTime = props.acceptedTime ?? props.endedTime; + assertDev(resolvedTime, 'Direct call must have accepted or ended time'); + timestamp = resolvedTime; wasMissed = props.wasIncoming && !props.acceptedTime && !props.wasDeclined; break; + } case CallMode.Group: timestamp = props.startedTime; break; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 74e653ad4..c788ef3b1 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -60,7 +60,7 @@ import type { } from '../types/Colors'; import type { MessageModel } from './messages'; import { getContact } from '../messages/helpers'; -import { strictAssert } from '../util/assert'; +import { assertDev, strictAssert } from '../util/assert'; import { isConversationMuted } from '../util/isConversationMuted'; import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; import { @@ -3226,8 +3226,11 @@ export class ConversationModel extends window.Backbone let detailsToSave: CallHistoryDetailsType; switch (callHistoryDetails.callMode) { - case CallMode.Direct: - timestamp = callHistoryDetails.endedTime; + case CallMode.Direct: { + const resolvedTime = + callHistoryDetails.acceptedTime ?? callHistoryDetails.endedTime; + assertDev(resolvedTime, 'Direct call must have accepted or ended time'); + timestamp = resolvedTime; unread = !callHistoryDetails.wasDeclined && !callHistoryDetails.acceptedTime; detailsToSave = { @@ -3235,6 +3238,7 @@ export class ConversationModel extends window.Backbone callMode: CallMode.Direct, }; break; + } case CallMode.Group: timestamp = callHistoryDetails.startedTime; unread = false; @@ -3257,9 +3261,20 @@ export class ConversationModel extends window.Backbone // TODO: DESKTOP-722 } as unknown as MessageAttributesType; + if (callHistoryDetails.callMode === CallMode.Direct) { + const messageId = await window.Signal.Data.getCallHistoryMessageByCallId( + this.id, + callHistoryDetails.callId + ); + if (messageId != null) { + message.id = messageId; + } + } + const id = await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), }); + const model = window.MessageController.register( id, new window.Whisper.Message({ diff --git a/ts/services/calling.ts b/ts/services/calling.ts index fb42e814c..ff1a406a2 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -36,6 +36,7 @@ import { BandwidthMode, } from '@signalapp/ringrtc'; import { uniqBy, noop } from 'lodash'; +import Long from 'long'; import type { ActionsType as CallingReduxActionsType, @@ -1549,11 +1550,15 @@ export class CallingClass { await this.handleOutgoingSignaling(remoteUserId, message); + const callId = callingMessage.offer.callId?.toString(); + assertDev(callId != null, 'Call ID missing from offer'); + const ProtoOfferType = Proto.CallingMessage.Offer.Type; - this.addCallHistoryForFailedIncomingCall( + await this.addCallHistoryForFailedIncomingCall( conversation, callingMessage.offer.type === ProtoOfferType.OFFER_VIDEO_CALL, - envelope.timestamp + envelope.timestamp, + callId ); return; @@ -1847,6 +1852,7 @@ export class CallingClass { return null; } + const callId = Long.fromValue(call.callId).toString(); try { // The peer must be 'trusted' before accepting a call from them. // This is mostly the safety number check, unverified meaning that they were @@ -1859,10 +1865,11 @@ export class CallingClass { log.info( `Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}` ); - this.addCallHistoryForFailedIncomingCall( + await this.addCallHistoryForFailedIncomingCall( conversation, call.isVideoCall, - Date.now() + Date.now(), + callId ); return null; } @@ -1879,17 +1886,18 @@ export class CallingClass { return await this.getCallSettings(conversation); } catch (err) { log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`); - this.addCallHistoryForFailedIncomingCall( + await this.addCallHistoryForFailedIncomingCall( conversation, call.isVideoCall, - Date.now() + Date.now(), + callId ); return null; } } - private handleAutoEndedIncomingCallRequest( - _callId: CallId, + private async handleAutoEndedIncomingCallRequest( + callId: CallId, remoteUserId: UserId, reason: CallEndedReason, ageInSeconds: number, @@ -1909,12 +1917,13 @@ export class CallingClass { : 0; const endedTime = Date.now() - ageInMilliseconds; - this.addCallHistoryForAutoEndedIncomingCall( + await this.addCallHistoryForAutoEndedIncomingCall( conversation, reason, endedTime, wasVideoCall, - receivedAtCounter + receivedAtCounter, + Long.fromValue(callId).toString() ); } @@ -1929,16 +1938,18 @@ export class CallingClass { let acceptedTime: number | undefined; // eslint-disable-next-line no-param-reassign - call.handleStateChanged = () => { + call.handleStateChanged = async () => { if (call.state === CallState.Accepted) { acceptedTime = acceptedTime || Date.now(); } else if (call.state === CallState.Ended) { - this.addCallHistoryForEndedCall(conversation, call, acceptedTime); + await this.addCallHistoryForEndedCall(conversation, call, acceptedTime); this.stopDeviceReselectionTimer(); this.lastMediaDeviceSettings = undefined; delete this.callsByConversation[conversation.id]; } reduxInterface.callStateChange({ + remoteUserId: call.remoteUserId, + callId: Long.fromValue(call.callId).toString(), conversationId: conversation.id, acceptedTime, callState: call.state, @@ -2088,7 +2099,7 @@ export class CallingClass { }; } - private addCallHistoryForEndedCall( + private async addCallHistoryForEndedCall( conversation: ConversationModel, call: Call, acceptedTimeParam: number | undefined @@ -2110,8 +2121,11 @@ export class CallingClass { acceptedTime = Date.now(); } - void conversation.addCallHistory( + const callId = Long.fromValue(call.callId).toString(); + + await conversation.addCallHistory( { + callId, callMode: CallMode.Direct, wasIncoming: call.isIncoming, wasVideoCall: call.isVideoCall, @@ -2123,12 +2137,13 @@ export class CallingClass { ); } - private addCallHistoryForFailedIncomingCall( + private async addCallHistoryForFailedIncomingCall( conversation: ConversationModel, wasVideoCall: boolean, - timestamp: number + timestamp: number, + callId: string ) { - void conversation.addCallHistory( + await conversation.addCallHistory( { callMode: CallMode.Direct, wasIncoming: true, @@ -2137,17 +2152,19 @@ export class CallingClass { wasDeclined: false, acceptedTime: undefined, endedTime: timestamp, + callId, }, undefined ); } - private addCallHistoryForAutoEndedIncomingCall( + private async addCallHistoryForAutoEndedIncomingCall( conversation: ConversationModel, reason: CallEndedReason, endedTime: number, wasVideoCall: boolean, - receivedAtCounter: number | undefined + receivedAtCounter: number | undefined, + callId: string ) { let wasDeclined = false; let acceptedTime; @@ -2159,8 +2176,9 @@ export class CallingClass { } // Otherwise it will show up as a missed call. - void conversation.addCallHistory( + await conversation.addCallHistory( { + callId, callMode: CallMode.Direct, wasIncoming: true, wasVideoCall, diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 94313bd47..f896174d9 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -546,6 +546,10 @@ export type DataInterface = { getLastConversationMessage(options: { conversationId: string; }): Promise; + getCallHistoryMessageByCallId( + conversationId: string, + callId: string + ): Promise; hasGroupCallHistoryMessage: ( conversationId: string, eraId: string diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 6f25502b1..8d5e817ed 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -256,6 +256,7 @@ const dataInterface: ServerInterface = { getConversationRangeCenteredOnMessage, getConversationMessageStats, getLastConversationMessage, + getCallHistoryMessageByCallId, hasGroupCallHistoryMessage, migrateConversationMessages, @@ -3009,6 +3010,32 @@ async function getConversationRangeCenteredOnMessage({ })(); } +async function getCallHistoryMessageByCallId( + conversationId: string, + callId: string +): Promise { + const db = getInstance(); + + const id: string | void = db + .prepare( + ` + SELECT id + FROM messages + WHERE conversationId = $conversationId + AND type = 'call-history' + AND callMode = 'Direct' + AND callId = $callId + ` + ) + .pluck() + .get({ + conversationId, + callId, + }); + + return id; +} + async function hasGroupCallHistoryMessage( conversationId: string, eraId: string diff --git a/ts/sql/migrations/72-optimize-call-id-message-lookup.ts b/ts/sql/migrations/72-optimize-call-id-message-lookup.ts new file mode 100644 index 000000000..4be96343f --- /dev/null +++ b/ts/sql/migrations/72-optimize-call-id-message-lookup.ts @@ -0,0 +1,38 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/better-sqlite3'; +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion72( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 72) { + return; + } + + db.transaction(() => { + db.exec( + ` + ALTER TABLE messages + ADD COLUMN callId TEXT + GENERATED ALWAYS AS ( + json_extract(json, '$.callHistoryDetails.callId') + ); + ALTER TABLE messages + ADD COLUMN callMode TEXT + GENERATED ALWAYS AS ( + json_extract(json, '$.callHistoryDetails.callMode') + ); + CREATE INDEX messages_call ON messages + (conversationId, type, callMode, callId); + ` + ); + + db.pragma('user_version = 72'); + })(); + + logger.info('updateToSchemaVersion72: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 7155e89ba..b85de24b9 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -47,6 +47,7 @@ import updateToSchemaVersion68 from './68-drop-deprecated-columns'; import updateToSchemaVersion69 from './69-group-call-ring-cancellations'; import updateToSchemaVersion70 from './70-story-reply-index'; import updateToSchemaVersion71 from './71-merge-notifications'; +import updateToSchemaVersion72 from './72-optimize-call-id-message-lookup'; function updateToSchemaVersion1( currentVersion: number, @@ -1963,6 +1964,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion70, updateToSchemaVersion71, + updateToSchemaVersion72, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index e58842b61..fd60cfe4d 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -52,6 +52,8 @@ import { isDirectConversation } from '../../util/whatTypeOfConversation'; import { SHOW_TOAST } from './toast'; import { ToastType } from '../../types/Toast'; import type { ShowToastActionType } from './toast'; +import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; +import MessageSender from '../../textsecure/SendMessage'; // State @@ -137,6 +139,8 @@ export type AcceptCallType = { }; export type CallStateChangeType = { + remoteUserId: string; // TODO: Remove + callId: string; // TODO: Remove conversationId: string; acceptedTime?: number; callState: CallState; @@ -688,12 +692,67 @@ function callStateChange( CallStateChangeFulfilledActionType > { return async dispatch => { - const { callState } = payload; + const { + callId, + callState, + isVideoCall, + isIncoming, + acceptedTime, + callEndedReason, + remoteUserId, + } = payload; + if (callState === CallState.Ended) { await callingTones.playEndCall(); ipcRenderer.send('close-screen-share-controller'); } + const isOutgoing = !isIncoming; + const wasAccepted = acceptedTime != null; + const isConnected = callState === CallState.Accepted; // "connected" + const isEnded = callState === CallState.Ended && callEndedReason != null; + + const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup; + const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup; + + const answered = isConnected && wasAccepted; + const notAnswered = isEnded && !wasAccepted; + + const isOutgoingRemoteAccept = isOutgoing && isConnected && answered; + const isIncomingLocalAccept = isIncoming && isConnected && answered; + const isOutgoingLocalHangup = isOutgoing && isLocalHangup && notAnswered; + const isIncomingLocalHangup = isIncoming && isLocalHangup && notAnswered; + const isOutgoingRemoteHangup = isOutgoing && isRemoteHangup && notAnswered; + const isIncomingRemoteHangup = isIncoming && isRemoteHangup && notAnswered; + + if (isIncomingRemoteHangup) { + // This is considered just another "missed" event + log.info('callStateChange: not syncing hangup from self'); + } else if ( + isOutgoingRemoteAccept || + isIncomingLocalAccept || + isOutgoingLocalHangup || + isIncomingLocalHangup || + isOutgoingRemoteHangup + ) { + try { + await singleProtoJobQueue.add( + MessageSender.getCallEventSync( + remoteUserId, + callId, + isVideoCall, + isIncoming, + acceptedTime != null + ) + ); + } catch (error) { + log.error( + 'callStateChange: Failed to queue sync message', + Errors.toLogFormat(error) + ); + } + } + dispatch({ type: CALL_STATE_CHANGE_FULFILLED, payload, diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 0424bef7b..1d7e850b2 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -37,7 +37,7 @@ import { SignedPreKeys, } from '../LibSignalStores'; import { verifySignature } from '../Curve'; -import { strictAssert } from '../util/assert'; +import { assertDev, strictAssert } from '../util/assert'; import type { BatcherType } from '../util/batcher'; import { createBatcher } from '../util/batcher'; import { drop } from '../util/drop'; @@ -111,6 +111,7 @@ import { GroupEvent, GroupSyncEvent, StoryRecipientUpdateEvent, + CallEventSyncEvent, } from './messageReceiverEvents'; import * as log from '../logging/log'; import * as durations from '../util/durations'; @@ -617,6 +618,11 @@ export default class MessageReceiver handler: (ev: StoryRecipientUpdateEvent) => void ): void; + public override addEventListener( + name: 'callEventSync', + handler: (ev: CallEventSyncEvent) => void + ): void; + public override addEventListener(name: string, handler: EventHandler): void { return super.addEventListener(name, handler); } @@ -2958,6 +2964,9 @@ export default class MessageReceiver if (syncMessage.viewed && syncMessage.viewed.length) { return this.handleViewed(envelope, syncMessage.viewed); } + if (syncMessage.callEvent) { + return this.handleCallEvent(envelope, syncMessage.callEvent); + } this.removeFromCache(envelope); log.warn( @@ -3219,6 +3228,64 @@ export default class MessageReceiver ); } + private async handleCallEvent( + envelope: ProcessedEnvelope, + callEvent: Proto.SyncMessage.ICallEvent + ): Promise { + const logId = getEnvelopeId(envelope); + log.info('MessageReceiver.handleCallEvent', logId); + const { peerUuid, callId } = callEvent; + + if (!peerUuid) { + throw new Error('MessageReceiver.handleCallEvent: missing peerUuid'); + } + + if (!callId) { + throw new Error('MessageReceiver.handleCallEvent: missing callId'); + } + + logUnexpectedUrgentValue(envelope, 'callEventSync'); + + const peerUuidStr = bytesToUuid(peerUuid); + + assertDev( + peerUuidStr != null, + 'MessageReceiver.handleCallEvent: invalid peerUuid' + ); + + const { receivedAtCounter, timestamp } = envelope; + + const wasIncoming = + callEvent.direction === Proto.SyncMessage.CallEvent.Direction.INCOMING; + const wasVideoCall = + callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL; + const wasAccepted = + callEvent.event === Proto.SyncMessage.CallEvent.Event.ACCEPTED; + const wasDeclined = + callEvent.event === Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED; + + const acceptedTime = wasAccepted ? timestamp : undefined; + const endedTime = wasDeclined ? timestamp : undefined; + + const callEventSync = new CallEventSyncEvent( + { + timestamp: envelope.timestamp, + peerUuid: peerUuidStr, + callId: callId.toString(), + wasIncoming, + wasVideoCall, + wasDeclined, + acceptedTime, + endedTime, + receivedAtCounter, + }, + this.removeFromCache.bind(this, envelope) + ); + await this.dispatchAndWait(logId, callEventSync); + + log.info('handleCallEvent: finished'); + } + private async handleContacts( envelope: ProcessedEnvelope, contacts: Proto.SyncMessage.IContacts diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index f7a4fa375..38c99ae4a 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1897,6 +1897,52 @@ export default class MessageSender { }; } + static getCallEventSync( + peerUuid: string, + callId: string, + isVideoCall: boolean, + isIncoming: boolean, + isAccepted: boolean + ): SingleProtoJobData { + const myUuid = window.textsecure.storage.user.getCheckedUuid(); + const syncMessage = MessageSender.createSyncMessage(); + + const type = isVideoCall + ? Proto.SyncMessage.CallEvent.Type.VIDEO_CALL + : Proto.SyncMessage.CallEvent.Type.AUDIO_CALL; + const direction = isIncoming + ? Proto.SyncMessage.CallEvent.Direction.INCOMING + : Proto.SyncMessage.CallEvent.Direction.OUTGOING; + const event = isAccepted + ? Proto.SyncMessage.CallEvent.Event.ACCEPTED + : Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED; + + syncMessage.callEvent = new Proto.SyncMessage.CallEvent({ + peerUuid: uuidToBytes(peerUuid), + callId: Long.fromString(callId), + type, + direction, + event, + timestamp: Long.fromNumber(Date.now()), + }); + + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; + + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + return { + contentHint: ContentHint.RESENDABLE, + identifier: myUuid.toString(), + isSyncMessage: true, + protoBase64: Bytes.toBase64( + Proto.Content.encode(contentMessage).finish() + ), + type: 'callEventSync', + urgent: false, + }; + } + static getVerificationSync( destinationE164: string | undefined, destinationUuid: string | undefined, diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 7b602fbe5..32eda7845 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -404,6 +404,27 @@ export class ViewSyncEvent extends ConfirmableEvent { } } +export type CallEventSyncEventData = Readonly<{ + timestamp: number; + peerUuid: string; + callId: string; + wasVideoCall: boolean; + wasIncoming: boolean; + wasDeclined: boolean; + acceptedTime: number | undefined; + endedTime: number | undefined; + receivedAtCounter: number; +}>; + +export class CallEventSyncEvent extends ConfirmableEvent { + constructor( + public readonly callEvent: CallEventSyncEventData, + confirm: ConfirmCallback + ) { + super('callEventSync', confirm); + } +} + export type StoryRecipientUpdateData = Readonly<{ destinationUuid: string; storyMessageRecipients: Array; diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 6916355ab..a79efa310 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -167,12 +167,13 @@ export type MediaDeviceSettings = AvailableIODevicesType & { }; type DirectCallHistoryDetailsType = { + callId: string; callMode: CallMode.Direct; wasIncoming: boolean; wasVideoCall: boolean; wasDeclined: boolean; acceptedTime?: number; - endedTime: number; + endedTime?: number; }; type GroupCallHistoryDetailsType = { diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index ac8636810..7227e74cb 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -13,7 +13,7 @@ type DirectCallNotificationType = { wasVideoCall: boolean; wasDeclined: boolean; acceptedTime?: number; - endedTime: number; + endedTime?: number; }; type GroupCallNotificationType = { diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index 603b83086..aea5920c6 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -65,6 +65,7 @@ export const sendTypesEnum = z.enum([ 'verificationSync', 'viewOnceSync', 'viewSync', + 'callEventSync', // No longer used, all non-urgent 'legacyGroupChange', diff --git a/ts/util/onCallEventSync.ts b/ts/util/onCallEventSync.ts new file mode 100644 index 000000000..ba41d62f8 --- /dev/null +++ b/ts/util/onCallEventSync.ts @@ -0,0 +1,46 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { CallEventSyncEvent } from '../textsecure/messageReceiverEvents'; +import * as log from '../logging/log'; +import { CallMode } from '../types/Calling'; + +export async function onCallEventSync( + syncEvent: CallEventSyncEvent +): Promise { + const { callEvent, confirm } = syncEvent; + const { + peerUuid, + callId, + wasIncoming, + wasVideoCall, + wasDeclined, + acceptedTime, + endedTime, + receivedAtCounter, + } = callEvent; + + const conversation = window.ConversationController.get(peerUuid); + + if (!conversation) { + log.warn(`onCallEventSync: No conversation found for peerUuid ${peerUuid}`); + return; + } + + await conversation.queueJob('onCallEventSync', async () => { + await conversation.addCallHistory( + { + callId, + callMode: CallMode.Direct, + wasDeclined, + wasIncoming, + wasVideoCall, + acceptedTime: acceptedTime ?? undefined, + endedTime: endedTime ?? undefined, + }, + receivedAtCounter + ); + + confirm(); + }); +}