Fix legacy call-history messages without a callId

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Jamie Kyle 2023-08-16 17:11:09 -07:00 committed by Jamie Kyle
parent 6f0401b847
commit ef0a3de636
17 changed files with 831 additions and 426 deletions

View file

@ -47,6 +47,9 @@ export type PropsType = CallingNotificationType &
export const CallingNotification: React.FC<PropsType> = React.memo(
function CallingNotificationInner(props) {
const { i18n } = props;
if (props.callHistory == null) {
return null;
}
const { type, direction, status, timestamp } = props.callHistory;
const icon = getCallingIcon(type, direction, status);
return (
@ -96,6 +99,10 @@ function renderCallingNotificationButton(
let disabledTooltipText: undefined | string;
let onClick: () => void;
if (props.callHistory == null) {
return null;
}
switch (props.callHistory.mode) {
case CallMode.Direct: {
const { direction, type } = props.callHistory;
@ -149,10 +156,6 @@ function renderCallingNotificationButton(
}
break;
}
case CallMode.None: {
log.error('renderCallingNotificationButton: Call mode cant be none');
return null;
}
default:
log.error(missingCaseError(props.callHistory.mode));
return null;

View file

@ -721,9 +721,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
conversationSelector: getConversationSelector(state),
});
if (callingNotification) {
return {
text: getCallingNotificationText(callingNotification, window.i18n),
};
const text = getCallingNotificationText(
callingNotification,
window.i18n
);
if (text != null) {
return {
text,
};
}
}
log.error("This call history message doesn't have valid call history");

View file

@ -416,7 +416,7 @@ export class CallingClass {
const callMode = getConversationCallMode(conversation);
switch (callMode) {
case CallMode.None:
case null:
log.error('Conversation does not support calls, new call not allowed.');
return;
case CallMode.Direct: {

View file

@ -1953,7 +1953,6 @@ function saveMessageSync(
sourceServiceId,
sourceDevice,
storyId,
callId,
type,
readStatus,
expireTimer,
@ -2011,7 +2010,6 @@ function saveMessageSync(
sourceServiceId: sourceServiceId || null,
sourceDevice: sourceDevice || null,
storyId: storyId || null,
callId: callId || null,
type: type || null,
readStatus: readStatus ?? null,
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
@ -2044,7 +2042,6 @@ function saveMessageSync(
sourceServiceId = $sourceServiceId,
sourceDevice = $sourceDevice,
storyId = $storyId,
callId = $callId,
type = $type,
readStatus = $readStatus,
seenStatus = $seenStatus
@ -2090,7 +2087,6 @@ function saveMessageSync(
sourceServiceId,
sourceDevice,
storyId,
callId,
type,
readStatus,
seenStatus
@ -2117,7 +2113,6 @@ function saveMessageSync(
$sourceServiceId,
$sourceDevice,
$storyId,
$callId,
$type,
$readStatus,
$seenStatus

View file

@ -1,215 +0,0 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import { callIdFromEra } from '@signalapp/ringrtc';
import Long from 'long';
import type { LoggerType } from '../../types/Logging';
import { 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';
export default function updateToSchemaVersion87(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 87) {
return;
}
db.transaction(() => {
const ourUuid = getOurUuid(db);
const [createTable] = sql`
DROP TABLE IF EXISTS callsHistory;
CREATE TABLE 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
);
CREATE INDEX callsHistory_order on callsHistory (timestamp DESC);
CREATE INDEX callsHistory_byConversation ON callsHistory (peerId);
-- For 'getCallHistoryGroupData':
-- This index should target the subqueries for 'possible_parent' and 'possible_children'
CREATE INDEX callsHistory_callAndGroupInfo_optimize on callsHistory (
direction,
peerId,
timestamp DESC,
status
);
`;
db.exec(createTable);
const [selectQuery] = sql`
SELECT * FROM messages
WHERE type = 'call-history'
AND callId IS NOT NULL; -- Older messages don't have callId
`;
const rows = db.prepare(selectQuery).all();
const uniqueConstraint = new Set();
for (const row of rows) {
const json = JSON.parse(row.json);
const details = json.callHistoryDetails;
const { conversationId: peerId } = row;
const { callMode } = details;
let callId: string;
let type: CallType;
let direction: CallDirection;
let status: GroupCallStatus | DirectCallStatus;
let timestamp: number;
let ringerId: string | null = null;
if (details.callMode === CallMode.Direct) {
callId = details.callId;
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.endedTime ?? details.acceptedTime ?? null;
} else if (details.callMode === 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;
ringerId = details.creatorUuid;
} else {
logger.error(
`updateToSchemaVersion87: unknown callMode: ${details.callMode}`
);
continue;
}
if (callId == null) {
logger.error(
"updateToSchemaVersion87: callId doesn't exist, too old, skipping"
);
continue;
}
const callHistory: CallHistoryDetails = {
callId,
peerId,
ringerId,
mode: callMode,
type,
direction,
status,
timestamp,
};
const result = callHistoryDetailsSchema.safeParse(callHistory);
if (!result.success) {
logger.error(
`updateToSchemaVersion87: invalid callHistoryDetails (error: ${JSON.stringify(
result.error.format()
)}, input: ${JSON.stringify(json)}, output: ${JSON.stringify(
callHistory
)}))`
);
continue;
}
// We need to ensure a call with the same callId and peerId doesn't get
// inserted twice because of the unique constraint on the table.
const uniqueKey = `${callId} -> ${peerId}`;
if (uniqueConstraint.has(uniqueKey)) {
logger.error(
`updateToSchemaVersion87: duplicate callId/peerId pair (${uniqueKey})`
);
continue;
}
uniqueConstraint.add(uniqueKey);
const [insertQuery, insertParams] = sql`
INSERT 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 [updateQuery, updateParams] = sql`
UPDATE messages
SET json = JSON_PATCH(json, ${JSON.stringify({
callHistoryDetails: null, // delete
callId,
})})
WHERE id = ${row.id}
`;
db.prepare(updateQuery).run(updateParams);
}
const [updateMessagesTable] = sql`
DROP INDEX IF EXISTS messages_call;
ALTER TABLE messages
DROP COLUMN callId;
ALTER TABLE messages
ADD COLUMN callId TEXT;
ALTER TABLE messages
DROP COLUMN callMode;
`;
db.exec(updateMessagesTable);
db.pragma('user_version = 87');
})();
logger.info('updateToSchemaVersion87: success!');
}

View file

@ -0,0 +1,369 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import { callIdFromEra } from '@signalapp/ringrtc';
import Long from 'long';
import { v4 as generateUuid } from 'uuid';
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 { MessageType, ConversationType } from '../Interface';
import { strictAssert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import type { ServiceIdString } from '../../types/ServiceId';
import { isAciString } from '../../types/ServiceId';
// 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;
};
export type CallHistoryDetailsType =
| DirectCallHistoryDetailsType
| GroupCallHistoryDetailsType;
export type CallHistoryDetailsFromDiskType =
// old messages weren't set with a callMode
| SetOptional<DirectCallHistoryDetailsType, 'callMode'>
| SetOptional<GroupCallHistoryDetailsType, 'callMode'>;
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: Pick<ConversationType, 'type' | 'serviceId' | 'groupId'>
): string {
if (conversation.type === 'private') {
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
): 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 (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 ?? message.timestamp;
} 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;
ringerId = details.creatorUuid;
} else {
throw missingCaseError(mode);
}
const callHistory: CallHistoryDetails = {
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp,
};
return callHistoryDetailsSchema.parse(callHistory);
}
export default function updateToSchemaVersion89(
currentVersion: number,
db: Database,
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.type AS conversationType,
conversations.serviceId AS conversationServiceId,
conversations.groupId AS conversationGroupId
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;
conversationType: ConversationType['type'];
conversationServiceId: ServiceIdString | undefined;
conversationGroupId: string | undefined;
};
const rows: Array<CallHistoryRow> = db.prepare(selectQuery).all();
for (const row of rows) {
const {
messageJson,
conversationType,
conversationServiceId,
conversationGroupId,
} = row;
const message = jsonToObject<MessageWithCallHistoryDetails>(messageJson);
const details = message.callHistoryDetails;
const peerId = getPeerIdFromConversation({
type: conversationType,
serviceId: conversationServiceId,
groupId: conversationGroupId,
});
const callHistory = convertLegacyCallDetails(
ourUuid,
peerId,
message,
details
);
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!');
}

View file

@ -62,8 +62,8 @@ import updateToSchemaVersion83 from './83-mentions';
import updateToSchemaVersion84 from './84-all-mentions';
import updateToSchemaVersion85 from './85-add-kyber-keys';
import updateToSchemaVersion86 from './86-story-replies-index';
import updateToSchemaVersion87 from './87-calls-history-table';
import updateToSchemaVersion88 from './88-service-ids';
import updateToSchemaVersion89 from './89-call-history';
function updateToSchemaVersion1(
currentVersion: number,
@ -1996,8 +1996,9 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion84,
updateToSchemaVersion85,
updateToSchemaVersion86,
updateToSchemaVersion87,
(_v: number, _i: Database, _l: LoggerType): void => undefined, // version 87 was dropped
updateToSchemaVersion88,
updateToSchemaVersion89,
];
export function updateSchema(db: Database, logger: LoggerType): void {

View file

@ -512,14 +512,14 @@ export type ConversationsStateType = Readonly<{
export const getConversationCallMode = (
conversation: ConversationType
): CallMode => {
): CallMode | null => {
if (
conversation.left ||
conversation.isBlocked ||
conversation.isMe ||
!conversation.acceptedMessageRequest
) {
return CallMode.None;
return null;
}
if (conversation.type === 'direct') {
@ -530,7 +530,7 @@ export const getConversationCallMode = (
return CallMode.Group;
}
return CallMode.None;
return null;
};
// Actions

View file

@ -1317,6 +1317,14 @@ export type GetPropsForCallHistoryOptions = Pick<
| 'ourConversationId'
>;
const emptyCallNotification = {
callHistory: null,
callCreator: null,
callExternalState: CallExternalState.Ended,
maxDevices: Infinity,
deviceCount: 0,
};
export function getPropsForCallHistory(
message: MessageWithUIFieldsType,
{
@ -1328,17 +1336,15 @@ export function getPropsForCallHistory(
}: GetPropsForCallHistoryOptions
): CallingNotificationType {
const { callId } = message;
if (callId == null && 'callHistoryDetails' in message) {
log.error(
'getPropsForCallHistory: Found callHistoryDetails, but no callId'
);
if (callId == null) {
log.error('getPropsForCallHistory: Missing callId');
return emptyCallNotification;
}
strictAssert(callId != null, 'getPropsForCallHistory: Missing callId');
const callHistory = callHistorySelector(callId);
strictAssert(
callHistory != null,
'getPropsForCallHistory: Missing callHistory'
);
if (callHistory == null) {
log.error('getPropsForCallHistory: Missing callHistory');
return emptyCallNotification;
}
const conversation = conversationSelector(callHistory.peerId);
strictAssert(

View file

@ -54,7 +54,7 @@ const getOutgoingCallButtonStyle = (
const conversationCallMode = getConversationCallMode(conversation);
switch (conversationCallMode) {
case CallMode.None:
case null:
return OutgoingCallButtonStyle.None;
case CallMode.Direct:
return OutgoingCallButtonStyle.Both;

View file

@ -136,53 +136,53 @@ describe('both/state/ducks/conversations', () => {
const fakeConversation: ConversationType = getDefaultConversation();
const fakeGroup: ConversationType = getDefaultGroup();
it("returns CallMode.None if you've left the conversation", () => {
it("returns null if you've left the conversation", () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
left: true,
}),
CallMode.None
null
);
});
it("returns CallMode.None if you've blocked the other person", () => {
it("returns null if you've blocked the other person", () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
isBlocked: true,
}),
CallMode.None
null
);
});
it("returns CallMode.None if you haven't accepted message requests", () => {
it("returns null if you haven't accepted message requests", () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
acceptedMessageRequest: false,
}),
CallMode.None
null
);
});
it('returns CallMode.None if the conversation is Note to Self', () => {
it('returns null if the conversation is Note to Self', () => {
assert.strictEqual(
getConversationCallMode({
...fakeConversation,
isMe: true,
}),
CallMode.None
null
);
});
it('returns CallMode.None for v1 groups', () => {
it('returns null for v1 groups', () => {
assert.strictEqual(
getConversationCallMode({
...fakeGroup,
groupVersion: 1,
}),
CallMode.None
null
);
assert.strictEqual(
@ -190,7 +190,7 @@ describe('both/state/ducks/conversations', () => {
...fakeGroup,
groupVersion: undefined,
}),
CallMode.None
null
);
});

View file

@ -21,7 +21,7 @@ describe('SQL/updateToSchemaVersion88', () => {
beforeEach(() => {
db = new SQL(':memory:');
updateToVersion(db, 87);
updateToVersion(db, 86);
insertData(db, 'items', [
{

View file

@ -0,0 +1,404 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import type { Database } from '@signalapp/better-sqlite3';
import SQL from '@signalapp/better-sqlite3';
import { v4 as generateGuid } from 'uuid';
import { jsonToObject, sql } from '../../sql/util';
import { CallMode } from '../../types/Calling';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import {
CallDirection,
CallType,
DirectCallStatus,
GroupCallStatus,
callHistoryDetailsSchema,
} from '../../types/CallDisposition';
import type {
CallHistoryDetailsFromDiskType,
MessageWithCallHistoryDetails,
} from '../../sql/migrations/89-call-history';
import { getCallIdFromEra } from '../../util/callDisposition';
import { isValidUuid } from '../../util/isValidUuid';
import { updateToVersion } from './helpers';
import type { MessageType } from '../../sql/Interface';
describe('SQL/updateToSchemaVersion89', () => {
let db: Database;
beforeEach(() => {
db = new SQL(':memory:');
updateToVersion(db, 88);
});
afterEach(() => {
db.close();
});
function getDirectCallHistoryDetails(options: {
callId: string | null;
noCallMode?: boolean;
wasDeclined?: boolean;
}): CallHistoryDetailsFromDiskType {
return {
callId: options.callId ?? undefined,
callMode: options.noCallMode ? undefined : CallMode.Direct,
wasDeclined: options.wasDeclined ?? false,
wasIncoming: false,
wasVideoCall: false,
acceptedTime: undefined,
endedTime: undefined,
};
}
function getGroupCallHistoryDetails(options: {
eraId: string;
noCallMode?: boolean;
}): CallHistoryDetailsFromDiskType {
return {
eraId: options.eraId,
callMode: options.noCallMode ? undefined : CallMode.Group,
creatorUuid: generateGuid(),
startedTime: Date.now(),
};
}
function createCallHistoryMessage(options: {
messageId: string;
conversationId: string;
callHistoryDetails: CallHistoryDetailsFromDiskType;
}): MessageWithCallHistoryDetails {
const message: MessageWithCallHistoryDetails = {
id: options.messageId,
type: 'call-history',
conversationId: options.conversationId,
sent_at: Date.now() - 10,
received_at: Date.now() - 10,
timestamp: Date.now() - 10,
callHistoryDetails: options.callHistoryDetails,
};
const json = JSON.stringify(message);
const [query, params] = sql`
INSERT INTO messages
(id, conversationId, type, json)
VALUES
(
${message.id},
${message.conversationId},
${message.type},
${json}
)
`;
db.prepare(query).run(params);
return message;
}
function createConversation(type: 'private' | 'group') {
const id = generateGuid();
const serviceId = type === 'private' ? generateGuid() : null;
const groupId = type === 'group' ? generateGuid() : null;
const [query, params] = sql`
INSERT INTO conversations
(id, type, serviceId, groupId)
VALUES
(${id}, ${type}, ${serviceId}, ${groupId});
`;
db.prepare(query).run(params);
return { id, serviceId, groupId };
}
function getAllCallHistory() {
const [selectHistoryQuery] = sql`
SELECT * FROM callsHistory;
`;
return db
.prepare(selectHistoryQuery)
.all()
.map(row => {
return callHistoryDetailsSchema.parse(row);
});
}
it('pulls out call history messages into the new table', () => {
updateToVersion(db, 88);
const conversation1 = createConversation('private');
const conversation2 = createConversation('group');
const callId1 = '123';
const eraId2 = 'abc';
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation1.id,
callHistoryDetails: getDirectCallHistoryDetails({
callId: callId1,
}),
});
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation2.id,
callHistoryDetails: getGroupCallHistoryDetails({
eraId: eraId2,
}),
});
updateToVersion(db, 89);
const callHistory = getAllCallHistory();
assert.strictEqual(callHistory.length, 2);
assert.strictEqual(callHistory[0].callId, callId1);
assert.strictEqual(callHistory[1].callId, getCallIdFromEra(eraId2));
});
it('migrates older messages without a callId', () => {
updateToVersion(db, 88);
const conversation = createConversation('private');
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation.id,
callHistoryDetails: getDirectCallHistoryDetails({
callId: null, // no id
}),
});
updateToVersion(db, 89);
const callHistory = getAllCallHistory();
assert.strictEqual(callHistory.length, 1);
assert.isTrue(isValidUuid(callHistory[0].callId));
});
it('migrates older messages without a callMode', () => {
updateToVersion(db, 88);
const conversation1 = createConversation('private');
const conversation2 = createConversation('group');
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation1.id,
callHistoryDetails: getDirectCallHistoryDetails({
callId: null, // no id
noCallMode: true,
}),
});
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation2.id,
callHistoryDetails: getGroupCallHistoryDetails({
eraId: 'abc',
noCallMode: true,
}),
});
updateToVersion(db, 89);
const callHistory = getAllCallHistory();
assert.strictEqual(callHistory.length, 2);
assert.strictEqual(callHistory[0].mode, CallMode.Direct);
assert.strictEqual(callHistory[1].mode, CallMode.Group);
});
it('handles unique constraint violations', () => {
updateToVersion(db, 88);
const conversation = createConversation('private');
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation.id, // same conversation
callHistoryDetails: getDirectCallHistoryDetails({
callId: '123', // same callId
}),
});
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation.id, // same conversation
callHistoryDetails: getDirectCallHistoryDetails({
callId: '123', // same callId
}),
});
updateToVersion(db, 89);
const callHistory = getAllCallHistory();
assert.strictEqual(callHistory.length, 1);
});
it('normalizes peerId to conversation.serviceId or conversation.groupId', () => {
updateToVersion(db, 88);
const conversation1 = createConversation('private');
const conversation2 = createConversation('group');
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation1.id,
callHistoryDetails: getDirectCallHistoryDetails({
callId: '123',
}),
});
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation2.id,
callHistoryDetails: getGroupCallHistoryDetails({
eraId: 'abc',
}),
});
updateToVersion(db, 89);
const callHistory = getAllCallHistory();
assert.strictEqual(callHistory.length, 2);
assert.strictEqual(callHistory[0].peerId, conversation1.serviceId);
assert.strictEqual(callHistory[1].peerId, conversation2.groupId);
});
describe('clients with schema version 87', () => {
function createCallHistoryTable() {
const [query] = sql`
CREATE TABLE 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
);
`;
db.exec(query);
}
function insertCallHistory(callHistory: CallHistoryDetails) {
const [query, params] = sql`
INSERT 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(query).run(params);
}
function getMessages() {
const [query] = sql`
SELECT json FROM messages;
`;
return db
.prepare(query)
.all()
.map(row => {
return jsonToObject<MessageType>(row.json);
});
}
it('migrates existing peerId to conversation.serviceId or conversation.groupId', () => {
updateToVersion(db, 88);
createCallHistoryTable();
const conversation1 = createConversation('private');
const conversation2 = createConversation('group');
insertCallHistory({
callId: '123',
peerId: conversation1.id,
ringerId: null,
mode: CallMode.Direct,
type: CallType.Audio,
direction: CallDirection.Incoming,
status: DirectCallStatus.Accepted,
timestamp: Date.now(),
});
insertCallHistory({
callId: 'abc',
peerId: conversation2.id,
ringerId: null,
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Incoming,
status: GroupCallStatus.Accepted,
timestamp: Date.now(),
});
updateToVersion(db, 89);
const callHistory = getAllCallHistory();
assert.strictEqual(callHistory.length, 2);
assert.strictEqual(callHistory[0].peerId, conversation1.serviceId);
assert.strictEqual(callHistory[1].peerId, conversation2.groupId);
});
it('migrates duplicate call history where the first was already migrated', () => {
updateToVersion(db, 88);
createCallHistoryTable();
const conversation = createConversation('private');
insertCallHistory({
callId: '123',
peerId: conversation.id,
ringerId: null,
mode: CallMode.Direct,
type: CallType.Audio,
direction: CallDirection.Incoming,
status: DirectCallStatus.Pending,
timestamp: Date.now() - 1000,
});
createCallHistoryMessage({
messageId: generateGuid(),
conversationId: conversation.id,
callHistoryDetails: getDirectCallHistoryDetails({
callId: '123',
wasDeclined: true,
}),
});
updateToVersion(db, 89);
const callHistory = getAllCallHistory();
assert.strictEqual(callHistory.length, 1);
assert.strictEqual(callHistory[0].status, DirectCallStatus.Declined);
const messages = getMessages();
assert.strictEqual(messages.length, 1);
assert.strictEqual(messages[0].type, 'call-history');
assert.strictEqual(messages[0].callId, '123');
assert.notProperty(messages[0], 'callHistoryDetails');
});
});
});

View file

@ -17,9 +17,6 @@ import { objectToJSON, sql, sqlJoin } from '../../sql/util';
import { BodyRange } from '../../types/BodyRange';
import type { AciString } from '../../types/ServiceId';
import { generateAci } from '../../types/ServiceId';
import { callHistoryDetailsSchema } from '../../types/CallDisposition';
import { CallMode } from '../../types/Calling';
import type { MessageAttributesType } from '../../model-types.d';
import { updateToVersion } from './helpers';
const OUR_UUID = generateGuid();
@ -3565,162 +3562,4 @@ describe('SQL migrations test', () => {
);
});
});
describe('updateToSchemaVersion87', () => {
it('pulls out call history messages into the new table', () => {
updateToVersion(db, 86);
const message1Id = generateGuid();
const message2Id = generateGuid();
const conversationId = generateGuid();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types
const message1: MessageAttributesType & { callHistoryDetails: any } = {
id: message1Id,
type: 'call-history',
conversationId,
sent_at: Date.now() - 10,
received_at: Date.now() - 10,
timestamp: Date.now() - 10,
callHistoryDetails: {
callId: '123',
callMode: CallMode.Direct,
wasDeclined: false,
wasDeleted: false,
wasIncoming: false,
wasVideoCall: false,
acceptedTime: Date.now(),
endedTime: undefined,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types
const message2: MessageAttributesType & { callHistoryDetails: any } = {
id: message2Id,
type: 'call-history',
conversationId,
sent_at: Date.now(),
received_at: Date.now(),
timestamp: Date.now(),
callHistoryDetails: {
callMode: CallMode.Group,
creatorUuid: generateGuid(),
eraId: (0x123).toString(16),
startedTime: Date.now(),
},
};
const [insertQuery, insertParams] = sql`
INSERT INTO messages (
id,
conversationId,
type,
json
)
VALUES
(
${message1Id},
${conversationId},
${message1.type},
${JSON.stringify(message1)}
),
(
${message2Id},
${conversationId},
${message2.type},
${JSON.stringify(message2)}
);
`;
db.prepare(insertQuery).run(insertParams);
updateToVersion(db, 87);
const [selectHistoryQuery] = sql`
SELECT * FROM callsHistory;
`;
const rows = db.prepare(selectHistoryQuery).all();
for (const row of rows) {
callHistoryDetailsSchema.parse(row);
}
});
it('handles unique constraint violations', () => {
updateToVersion(db, 86);
const message1Id = generateGuid();
const message2Id = generateGuid();
const conversationId = generateGuid();
const callHistoryDetails = {
callId: '123',
callMode: CallMode.Direct,
wasDeclined: false,
wasDeleted: false,
wasIncoming: false,
wasVideoCall: false,
acceptedTime: Date.now(),
endedTime: undefined,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types
const message1: MessageAttributesType & { callHistoryDetails: any } = {
id: message1Id,
type: 'call-history',
conversationId,
sent_at: Date.now() - 10,
received_at: Date.now() - 10,
timestamp: Date.now() - 10,
callHistoryDetails,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types
const message2: MessageAttributesType & { callHistoryDetails: any } = {
id: message2Id,
type: 'call-history',
conversationId,
sent_at: Date.now(),
received_at: Date.now(),
timestamp: Date.now(),
callHistoryDetails,
};
const [insertQuery, insertParams] = sql`
INSERT INTO messages (
id,
conversationId,
type,
json
)
VALUES
(
${message1Id},
${conversationId},
${message1.type},
${JSON.stringify(message1)}
),
(
${message2Id},
${conversationId},
${message2.type},
${JSON.stringify(message2)}
);
`;
db.prepare(insertQuery).run(insertParams);
updateToVersion(db, 87);
const [selectHistoryQuery] = sql`
SELECT * FROM callsHistory;
`;
const rows = db.prepare(selectHistoryQuery).all();
for (const row of rows) {
callHistoryDetailsSchema.parse(row);
}
assert.strictEqual(rows.length, 1);
});
});
});

View file

@ -7,7 +7,6 @@ import type { ServiceIdString } from './ServiceId';
// These are strings (1) for the database (2) for Storybook.
export enum CallMode {
None = 'None',
Direct = 'Direct',
Group = 'Group',
}

View file

@ -500,8 +500,6 @@ export function transitionCallHistory(
event,
direction
);
} else if (mode === CallMode.None) {
throw new TypeError('Call mode must not be none');
} else {
throw missingCaseError(mode);
}

View file

@ -22,7 +22,8 @@ export enum CallExternalState {
}
export type CallingNotificationType = Readonly<{
callHistory: CallHistoryDetails;
// In some older calls, we don't have a call id, this hardens against that.
callHistory: CallHistoryDetails | null;
callCreator: ConversationType | null;
callExternalState: CallExternalState;
deviceCount: number;
@ -110,8 +111,12 @@ function getGroupCallNotificationText(
export function getCallingNotificationText(
callingNotification: CallingNotificationType,
i18n: LocalizerType
): string {
): string | null {
const { callHistory, callCreator, callExternalState } = callingNotification;
if (callHistory == null) {
return null;
}
if (callHistory.mode === CallMode.Direct) {
return getDirectCallNotificationText(
callHistory.direction,
@ -123,11 +128,6 @@ export function getCallingNotificationText(
if (callHistory.mode === CallMode.Group) {
return getGroupCallNotificationText(callExternalState, callCreator, i18n);
}
if (callHistory.mode === CallMode.None) {
throw new Error(
'getCallingNotificationText: Cannot render call history details with mode = None'
);
}
throw missingCaseError(callHistory.mode);
}