// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { callIdFromEra } from '@signalapp/ringrtc'; import Long from 'long'; import { v4 as generateUuid } from 'uuid'; import { isObject } from 'lodash'; import type { SetOptional } from 'type-fest'; import type { LoggerType } from '../../types/Logging'; import { jsonToObject, sql } from '../util'; import { getOurUuid } from './41-uuid-keys'; import type { CallHistoryDetails } from '../../types/CallDisposition'; import { DirectCallStatus, CallDirection, CallType, GroupCallStatus, callHistoryDetailsSchema, } from '../../types/CallDisposition'; import { CallMode } from '../../types/Calling'; import type { WritableDB, MessageType, ConversationType } from '../Interface'; import { strictAssert } from '../../util/assert'; import { missingCaseError } from '../../util/missingCaseError'; import { isAciString } from '../../util/isAciString'; // Legacy type for calls that never had a call id type DirectCallHistoryDetailsType = { callId?: string; callMode: CallMode.Direct; wasIncoming: boolean; wasVideoCall: boolean; wasDeclined: boolean; acceptedTime?: number; endedTime?: number; }; type GroupCallHistoryDetailsType = { callMode: CallMode.Group; creatorUuid: string; eraId: string; startedTime?: number; // Treat this as optional, some calls may be missing it }; export type CallHistoryDetailsType = | DirectCallHistoryDetailsType | GroupCallHistoryDetailsType; export type CallHistoryDetailsFromDiskType = // old messages weren't set with a callMode | SetOptional | SetOptional; export type MessageWithCallHistoryDetails = MessageType & { callHistoryDetails: CallHistoryDetailsFromDiskType; }; function upcastCallHistoryDetailsFromDiskType( callDetails: CallHistoryDetailsFromDiskType ): CallHistoryDetailsType { if (callDetails.callMode === CallMode.Direct) { return callDetails as DirectCallHistoryDetailsType; } if (callDetails.callMode === CallMode.Group) { return callDetails as GroupCallHistoryDetailsType; } // Some very old calls don't have a callMode, so we need to infer it from the // other fields. This is a best effort attempt to make sure we at least have // enough data to form the call history entry correctly. if ( Object.hasOwn(callDetails, 'wasIncoming') && Object.hasOwn(callDetails, 'wasVideoCall') ) { return { callMode: CallMode.Direct, ...callDetails, } as DirectCallHistoryDetailsType; } if ( Object.hasOwn(callDetails, 'eraId') && Object.hasOwn(callDetails, 'startedTime') ) { return { callMode: CallMode.Group, ...callDetails, } as GroupCallHistoryDetailsType; } throw new Error('Could not determine call mode'); } function getPeerIdFromConversation( conversation: ConversationType, logger: LoggerType ): string { if (conversation.type === 'private') { if (conversation.serviceId == null) { logger.warn( `updateToSchemaVersion89: Private conversation (${conversation.id}) was missing serviceId (discoveredUnregisteredAt: ${conversation.discoveredUnregisteredAt})` ); return conversation.id; } strictAssert( isAciString(conversation.serviceId), 'ACI must exist for direct chat' ); return conversation.serviceId; } strictAssert( conversation.groupId != null, 'groupId must exist for group chat' ); return conversation.groupId; } function convertLegacyCallDetails( ourUuid: string | undefined, peerId: string, message: MessageType, partialDetails: CallHistoryDetailsFromDiskType, logger: LoggerType ): CallHistoryDetails { const details = upcastCallHistoryDetailsFromDiskType(partialDetails); const { callMode: mode } = details; let callId: string; let type: CallType; let direction: CallDirection; let status: GroupCallStatus | DirectCallStatus; let timestamp: number; let ringerId: string | null = null; strictAssert(mode != null, 'mode must exist'); // If we cannot find any timestamp on the message, we'll use 0 const fallbackTimestamp = message.timestamp ?? message.sent_at ?? message.received_at_ms ?? 0; if (mode === CallMode.Direct) { // We don't have a callId for older calls, generating a uuid instead callId = details.callId ?? generateUuid(); type = details.wasVideoCall ? CallType.Video : CallType.Audio; direction = details.wasIncoming ? CallDirection.Incoming : CallDirection.Outgoing; if (details.acceptedTime != null) { status = DirectCallStatus.Accepted; } else { status = details.wasDeclined ? DirectCallStatus.Declined : DirectCallStatus.Missed; } timestamp = details.acceptedTime ?? details.endedTime ?? fallbackTimestamp; } else if (mode === CallMode.Group) { callId = Long.fromValue(callIdFromEra(details.eraId)).toString(); type = CallType.Group; direction = details.creatorUuid === ourUuid ? CallDirection.Outgoing : CallDirection.Incoming; status = GroupCallStatus.GenericGroupCall; timestamp = details.startedTime ?? fallbackTimestamp; ringerId = details.creatorUuid; } else { throw missingCaseError(mode); } const callHistory: CallHistoryDetails = { callId, peerId, ringerId, mode, type, direction, status, timestamp, }; const result = callHistoryDetailsSchema.safeParse(callHistory); if (result.success) { return result.data; } logger.error( `convertLegacyCallDetails: Could not convert ${mode} call`, result.error.toString() ); throw new Error(`Failed to convert legacy ${mode} call details`); } export default function updateToSchemaVersion89( currentVersion: number, db: WritableDB, logger: LoggerType ): void { if (currentVersion >= 89) { return; } db.transaction(() => { const ourUuid = getOurUuid(db); const [createTable] = sql` -- This table may have already existed from migration 87 CREATE TABLE IF NOT EXISTS callsHistory ( callId TEXT PRIMARY KEY, peerId TEXT NOT NULL, -- conversation id (legacy) | uuid | groupId | roomId ringerId TEXT DEFAULT NULL, -- ringer uuid mode TEXT NOT NULL, -- enum "Direct" | "Group" type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group" direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing -- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted" -- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted" status TEXT NOT NULL, timestamp INTEGER NOT NULL, UNIQUE (callId, peerId) ON CONFLICT FAIL ); -- Update peerId to be uuid or groupId UPDATE callsHistory SET peerId = ( SELECT CASE WHEN conversations.type = 'private' THEN conversations.serviceId WHEN conversations.type = 'group' THEN conversations.groupId END FROM conversations WHERE callsHistory.peerId IS conversations.id AND callsHistory.peerId IS NOT conversations.serviceId ) WHERE EXISTS ( SELECT 1 FROM conversations WHERE callsHistory.peerId IS conversations.id AND callsHistory.peerId IS NOT conversations.serviceId ); CREATE INDEX IF NOT EXISTS callsHistory_order on callsHistory (timestamp DESC); CREATE INDEX IF NOT EXISTS callsHistory_byConversation ON callsHistory (peerId); -- For 'getCallHistoryGroupData': -- This index should target the subqueries for 'possible_parent' and 'possible_children' CREATE INDEX IF NOT EXISTS callsHistory_callAndGroupInfo_optimize on callsHistory ( direction, peerId, timestamp DESC, status ); `; db.exec(createTable); const [selectQuery] = sql` SELECT messages.json AS messageJson, conversations.id AS conversationId, conversations.json AS conversationJson FROM messages LEFT JOIN conversations ON conversations.id = messages.conversationId WHERE messages.type = 'call-history' -- Some of these messages were already migrated AND messages.json->'callHistoryDetails' IS NOT NULL -- Sort from oldest to newest, so that newer messages can overwrite older ORDER BY messages.received_at ASC, messages.sent_at ASC; `; // Must match query above type CallHistoryRow = { messageJson: string; conversationId: string; conversationJson: string; }; const rows: Array = db.prepare(selectQuery).all(); for (const row of rows) { const { messageJson, conversationId, conversationJson } = row; const message = jsonToObject(messageJson); const conversation = jsonToObject(conversationJson); if (!isObject(conversation)) { logger.warn( `updateToSchemaVersion89: Private conversation (${conversationId}) ` + 'has non-object json column' ); continue; } const details = message.callHistoryDetails; const peerId = getPeerIdFromConversation(conversation, logger); const callHistory = convertLegacyCallDetails( ourUuid, peerId, message, details, logger ); const [insertQuery, insertParams] = sql` -- Using 'OR REPLACE' because in some earlier versions of call history -- we had a bug where we would insert duplicate call history entries -- for the same callId and peerId. -- We're assuming here that the latest call history entry is the most -- accurate. INSERT OR REPLACE INTO callsHistory ( callId, peerId, ringerId, mode, type, direction, status, timestamp ) VALUES ( ${callHistory.callId}, ${callHistory.peerId}, ${callHistory.ringerId}, ${callHistory.mode}, ${callHistory.type}, ${callHistory.direction}, ${callHistory.status}, ${callHistory.timestamp} ) `; db.prepare(insertQuery).run(insertParams); const messageId = message.id; strictAssert(messageId != null, 'message.id must exist'); const [updateQuery, updateParams] = sql` UPDATE messages SET json = JSON_PATCH(json, ${JSON.stringify({ callHistoryDetails: null, // delete callId: callHistory.callId, })}) WHERE id = ${messageId} `; db.prepare(updateQuery).run(updateParams); } const [dropIndex] = sql` DROP INDEX IF EXISTS messages_call; `; db.exec(dropIndex); try { const [dropColumnQuery] = sql` ALTER TABLE messages DROP COLUMN callMode; `; db.exec(dropColumnQuery); } catch (error) { if (!error.message.includes('no such column: "callMode"')) { throw error; } } try { const [dropColumnQuery] = sql` ALTER TABLE messages DROP COLUMN callId; `; db.exec(dropColumnQuery); } catch (error) { if (!error.message.includes('no such column: "callId"')) { throw error; } } const [optimizeMessages] = sql` ALTER TABLE messages ADD COLUMN callId TEXT GENERATED ALWAYS AS ( json_extract(json, '$.callId') ); -- Optimize getCallHistoryMessageByCallId CREATE INDEX messages_call ON messages (conversationId, type, callId); CREATE INDEX messages_callHistory_readStatus ON messages (type, readStatus) WHERE type IS 'call-history'; `; db.exec(optimizeMessages); db.pragma('user_version = 89'); })(); logger.info('updateToSchemaVersion89: success!'); }