From 688ddd49d1dc7f5c646b80523952d0420b9b0b94 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Thu, 25 May 2023 14:17:35 -0700 Subject: [PATCH] Validate and log transitions for call disposition --- ts/models/conversations.ts | 43 ++++++++----- ts/util/callHistoryDetails.ts | 114 ++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 ts/util/callHistoryDetails.ts diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index ab26f7b350f1..09137a01da3f 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -160,6 +160,7 @@ import { stripNewlinesForLeftPane } from '../util/stripNewlinesForLeftPane'; import { findAndFormatContact } from '../util/findAndFormatContact'; import { deriveProfileKeyVersion } from '../util/zkgroup'; import { incrementMessageCounter } from '../util/incrementMessageCounter'; +import { validateTransition } from '../util/callHistoryDetails'; const EMPTY_ARRAY: Readonly<[]> = []; const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {}; @@ -3396,21 +3397,9 @@ export class ConversationModel extends window.Backbone // awaited it would block on this forever. drop( this.queueJob('addCallHistory', async () => { - const message: MessageAttributesType = { - id: generateGuid(), - conversationId: this.id, - type: 'call-history', - sent_at: timestamp, - timestamp, - received_at: receivedAtCounter || incrementMessageCounter(), - received_at_ms: timestamp, - readStatus: unread ? ReadStatus.Unread : ReadStatus.Read, - seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable, - callHistoryDetails: detailsToSave, - }; - // Force save if we're adding a new call history message for a direct call let forceSave = true; + let previousMessage: MessageAttributesType | void; if (callHistoryDetails.callMode === CallMode.Direct) { const messageId = await window.Signal.Data.getCallHistoryMessageByCallId( @@ -3421,9 +3410,11 @@ export class ConversationModel extends window.Backbone log.info( `addCallHistory: Found existing call history message (Call ID: ${callHistoryDetails.callId}, Message ID: ${messageId})` ); - message.id = messageId; // We don't want to force save if we're updating an existing message forceSave = false; + previousMessage = await window.Signal.Data.getMessageById( + messageId + ); } else { log.info( `addCallHistory: No existing call history message found (Call ID: ${callHistoryDetails.callId})` @@ -3431,6 +3422,30 @@ export class ConversationModel extends window.Backbone } } + if ( + !validateTransition( + previousMessage?.callHistoryDetails, + callHistoryDetails, + log + ) + ) { + log.info("addCallHistory: Transition isn't valid, not saving"); + return; + } + + const message: MessageAttributesType = { + id: previousMessage?.id ?? generateGuid(), + conversationId: this.id, + type: 'call-history', + sent_at: timestamp, + timestamp, + received_at: receivedAtCounter || incrementMessageCounter(), + received_at_ms: timestamp, + readStatus: unread ? ReadStatus.Unread : ReadStatus.Read, + seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable, + callHistoryDetails, + }; + const id = await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), forceSave, diff --git a/ts/util/callHistoryDetails.ts b/ts/util/callHistoryDetails.ts new file mode 100644 index 000000000000..29a0ed8208e6 --- /dev/null +++ b/ts/util/callHistoryDetails.ts @@ -0,0 +1,114 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { CallHistoryDetailsFromDiskType } from '../types/Calling'; +import { CallMode } from '../types/Calling'; +import type { LoggerType } from '../types/Logging'; +import { strictAssert } from './assert'; +import { missingCaseError } from './missingCaseError'; + +enum CallHistoryStatus { + Pending = 'Pending', + Missed = 'Missed', + Accepted = 'Accepted', + NotAccepted = 'NotAccepted', +} + +function getCallHistoryStatus( + callHistoryDetails: CallHistoryDetailsFromDiskType +): CallHistoryStatus { + strictAssert( + callHistoryDetails.callMode === CallMode.Direct, + "Can't get call history status for group call (unimplemented)" + ); + if (callHistoryDetails.acceptedTime != null) { + return CallHistoryStatus.Accepted; + } + if (callHistoryDetails.wasDeclined) { + return CallHistoryStatus.NotAccepted; + } + if (callHistoryDetails.endedTime != null) { + return CallHistoryStatus.Missed; + } + return CallHistoryStatus.Pending; +} + +function isAllowedTransition( + from: CallHistoryStatus, + to: CallHistoryStatus, + log: LoggerType +): boolean { + if (from === CallHistoryStatus.Pending) { + log.info('callHistoryDetails: Can go from pending to anything.'); + return true; + } + if (to === CallHistoryStatus.Pending) { + log.info("callHistoryDetails: Can't go to pending once out of it."); + return false; + } + if (from === CallHistoryStatus.Missed) { + log.info( + "callHistoryDetails: A missed call on this device might've been picked up or explicitly declined on a linked device." + ); + return true; + } + if (from === CallHistoryStatus.Accepted) { + log.info( + 'callHistoryDetails: If we accept anywhere that beats everything.' + ); + return false; + } + if ( + from === CallHistoryStatus.NotAccepted && + to === CallHistoryStatus.Accepted + ) { + log.info( + 'callHistoryDetails: If we declined on this device but picked up on another device, that counts as accepted.' + ); + return true; + } + + if (from === CallHistoryStatus.NotAccepted) { + log.info( + "callHistoryDetails: Can't transition from NotAccepted to anything else" + ); + return false; + } + + throw missingCaseError(from); +} + +export function validateTransition( + prev: CallHistoryDetailsFromDiskType | void, + next: CallHistoryDetailsFromDiskType, + log: LoggerType +): boolean { + // Only validating Direct calls for now + if (next.callMode !== CallMode.Direct) { + return true; + } + if (prev == null) { + return true; + } + + strictAssert( + prev.callMode === CallMode.Direct && next.callMode === CallMode.Direct, + "Call mode must be 'Direct'" + ); + strictAssert(prev.callId === next.callId, 'Call ID must not change'); + strictAssert( + prev.wasIncoming === next.wasIncoming, + 'wasIncoming must not change' + ); + strictAssert( + prev.wasVideoCall === next.wasVideoCall, + 'wasVideoCall must not change' + ); + + const before = getCallHistoryStatus(prev); + const after = getCallHistoryStatus(next); + log.info( + `callHistoryDetails: Checking transition (Call ID: ${next.callId}, Before: ${before}, After: ${after})` + ); + return isAllowedTransition(before, after, log); +}