Fix legacy call-history messages without a callId
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
6f0401b847
commit
ef0a3de636
17 changed files with 831 additions and 426 deletions
|
@ -47,6 +47,9 @@ export type PropsType = CallingNotificationType &
|
||||||
export const CallingNotification: React.FC<PropsType> = React.memo(
|
export const CallingNotification: React.FC<PropsType> = React.memo(
|
||||||
function CallingNotificationInner(props) {
|
function CallingNotificationInner(props) {
|
||||||
const { i18n } = props;
|
const { i18n } = props;
|
||||||
|
if (props.callHistory == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const { type, direction, status, timestamp } = props.callHistory;
|
const { type, direction, status, timestamp } = props.callHistory;
|
||||||
const icon = getCallingIcon(type, direction, status);
|
const icon = getCallingIcon(type, direction, status);
|
||||||
return (
|
return (
|
||||||
|
@ -96,6 +99,10 @@ function renderCallingNotificationButton(
|
||||||
let disabledTooltipText: undefined | string;
|
let disabledTooltipText: undefined | string;
|
||||||
let onClick: () => void;
|
let onClick: () => void;
|
||||||
|
|
||||||
|
if (props.callHistory == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
switch (props.callHistory.mode) {
|
switch (props.callHistory.mode) {
|
||||||
case CallMode.Direct: {
|
case CallMode.Direct: {
|
||||||
const { direction, type } = props.callHistory;
|
const { direction, type } = props.callHistory;
|
||||||
|
@ -149,10 +156,6 @@ function renderCallingNotificationButton(
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case CallMode.None: {
|
|
||||||
log.error('renderCallingNotificationButton: Call mode cant be none');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
log.error(missingCaseError(props.callHistory.mode));
|
log.error(missingCaseError(props.callHistory.mode));
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -721,9 +721,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
conversationSelector: getConversationSelector(state),
|
conversationSelector: getConversationSelector(state),
|
||||||
});
|
});
|
||||||
if (callingNotification) {
|
if (callingNotification) {
|
||||||
return {
|
const text = getCallingNotificationText(
|
||||||
text: getCallingNotificationText(callingNotification, window.i18n),
|
callingNotification,
|
||||||
};
|
window.i18n
|
||||||
|
);
|
||||||
|
if (text != null) {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.error("This call history message doesn't have valid call history");
|
log.error("This call history message doesn't have valid call history");
|
||||||
|
|
|
@ -416,7 +416,7 @@ export class CallingClass {
|
||||||
|
|
||||||
const callMode = getConversationCallMode(conversation);
|
const callMode = getConversationCallMode(conversation);
|
||||||
switch (callMode) {
|
switch (callMode) {
|
||||||
case CallMode.None:
|
case null:
|
||||||
log.error('Conversation does not support calls, new call not allowed.');
|
log.error('Conversation does not support calls, new call not allowed.');
|
||||||
return;
|
return;
|
||||||
case CallMode.Direct: {
|
case CallMode.Direct: {
|
||||||
|
|
|
@ -1953,7 +1953,6 @@ function saveMessageSync(
|
||||||
sourceServiceId,
|
sourceServiceId,
|
||||||
sourceDevice,
|
sourceDevice,
|
||||||
storyId,
|
storyId,
|
||||||
callId,
|
|
||||||
type,
|
type,
|
||||||
readStatus,
|
readStatus,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
|
@ -2011,7 +2010,6 @@ function saveMessageSync(
|
||||||
sourceServiceId: sourceServiceId || null,
|
sourceServiceId: sourceServiceId || null,
|
||||||
sourceDevice: sourceDevice || null,
|
sourceDevice: sourceDevice || null,
|
||||||
storyId: storyId || null,
|
storyId: storyId || null,
|
||||||
callId: callId || null,
|
|
||||||
type: type || null,
|
type: type || null,
|
||||||
readStatus: readStatus ?? null,
|
readStatus: readStatus ?? null,
|
||||||
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
|
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
|
||||||
|
@ -2044,7 +2042,6 @@ function saveMessageSync(
|
||||||
sourceServiceId = $sourceServiceId,
|
sourceServiceId = $sourceServiceId,
|
||||||
sourceDevice = $sourceDevice,
|
sourceDevice = $sourceDevice,
|
||||||
storyId = $storyId,
|
storyId = $storyId,
|
||||||
callId = $callId,
|
|
||||||
type = $type,
|
type = $type,
|
||||||
readStatus = $readStatus,
|
readStatus = $readStatus,
|
||||||
seenStatus = $seenStatus
|
seenStatus = $seenStatus
|
||||||
|
@ -2090,7 +2087,6 @@ function saveMessageSync(
|
||||||
sourceServiceId,
|
sourceServiceId,
|
||||||
sourceDevice,
|
sourceDevice,
|
||||||
storyId,
|
storyId,
|
||||||
callId,
|
|
||||||
type,
|
type,
|
||||||
readStatus,
|
readStatus,
|
||||||
seenStatus
|
seenStatus
|
||||||
|
@ -2117,7 +2113,6 @@ function saveMessageSync(
|
||||||
$sourceServiceId,
|
$sourceServiceId,
|
||||||
$sourceDevice,
|
$sourceDevice,
|
||||||
$storyId,
|
$storyId,
|
||||||
$callId,
|
|
||||||
$type,
|
$type,
|
||||||
$readStatus,
|
$readStatus,
|
||||||
$seenStatus
|
$seenStatus
|
||||||
|
|
|
@ -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!');
|
|
||||||
}
|
|
369
ts/sql/migrations/89-call-history.ts
Normal file
369
ts/sql/migrations/89-call-history.ts
Normal 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!');
|
||||||
|
}
|
|
@ -62,8 +62,8 @@ import updateToSchemaVersion83 from './83-mentions';
|
||||||
import updateToSchemaVersion84 from './84-all-mentions';
|
import updateToSchemaVersion84 from './84-all-mentions';
|
||||||
import updateToSchemaVersion85 from './85-add-kyber-keys';
|
import updateToSchemaVersion85 from './85-add-kyber-keys';
|
||||||
import updateToSchemaVersion86 from './86-story-replies-index';
|
import updateToSchemaVersion86 from './86-story-replies-index';
|
||||||
import updateToSchemaVersion87 from './87-calls-history-table';
|
|
||||||
import updateToSchemaVersion88 from './88-service-ids';
|
import updateToSchemaVersion88 from './88-service-ids';
|
||||||
|
import updateToSchemaVersion89 from './89-call-history';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -1996,8 +1996,9 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion84,
|
updateToSchemaVersion84,
|
||||||
updateToSchemaVersion85,
|
updateToSchemaVersion85,
|
||||||
updateToSchemaVersion86,
|
updateToSchemaVersion86,
|
||||||
updateToSchemaVersion87,
|
(_v: number, _i: Database, _l: LoggerType): void => undefined, // version 87 was dropped
|
||||||
updateToSchemaVersion88,
|
updateToSchemaVersion88,
|
||||||
|
updateToSchemaVersion89,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||||
|
|
|
@ -512,14 +512,14 @@ export type ConversationsStateType = Readonly<{
|
||||||
|
|
||||||
export const getConversationCallMode = (
|
export const getConversationCallMode = (
|
||||||
conversation: ConversationType
|
conversation: ConversationType
|
||||||
): CallMode => {
|
): CallMode | null => {
|
||||||
if (
|
if (
|
||||||
conversation.left ||
|
conversation.left ||
|
||||||
conversation.isBlocked ||
|
conversation.isBlocked ||
|
||||||
conversation.isMe ||
|
conversation.isMe ||
|
||||||
!conversation.acceptedMessageRequest
|
!conversation.acceptedMessageRequest
|
||||||
) {
|
) {
|
||||||
return CallMode.None;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conversation.type === 'direct') {
|
if (conversation.type === 'direct') {
|
||||||
|
@ -530,7 +530,7 @@ export const getConversationCallMode = (
|
||||||
return CallMode.Group;
|
return CallMode.Group;
|
||||||
}
|
}
|
||||||
|
|
||||||
return CallMode.None;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
|
@ -1317,6 +1317,14 @@ export type GetPropsForCallHistoryOptions = Pick<
|
||||||
| 'ourConversationId'
|
| 'ourConversationId'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const emptyCallNotification = {
|
||||||
|
callHistory: null,
|
||||||
|
callCreator: null,
|
||||||
|
callExternalState: CallExternalState.Ended,
|
||||||
|
maxDevices: Infinity,
|
||||||
|
deviceCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
export function getPropsForCallHistory(
|
export function getPropsForCallHistory(
|
||||||
message: MessageWithUIFieldsType,
|
message: MessageWithUIFieldsType,
|
||||||
{
|
{
|
||||||
|
@ -1328,17 +1336,15 @@ export function getPropsForCallHistory(
|
||||||
}: GetPropsForCallHistoryOptions
|
}: GetPropsForCallHistoryOptions
|
||||||
): CallingNotificationType {
|
): CallingNotificationType {
|
||||||
const { callId } = message;
|
const { callId } = message;
|
||||||
if (callId == null && 'callHistoryDetails' in message) {
|
if (callId == null) {
|
||||||
log.error(
|
log.error('getPropsForCallHistory: Missing callId');
|
||||||
'getPropsForCallHistory: Found callHistoryDetails, but no callId'
|
return emptyCallNotification;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
strictAssert(callId != null, 'getPropsForCallHistory: Missing callId');
|
|
||||||
const callHistory = callHistorySelector(callId);
|
const callHistory = callHistorySelector(callId);
|
||||||
strictAssert(
|
if (callHistory == null) {
|
||||||
callHistory != null,
|
log.error('getPropsForCallHistory: Missing callHistory');
|
||||||
'getPropsForCallHistory: Missing callHistory'
|
return emptyCallNotification;
|
||||||
);
|
}
|
||||||
|
|
||||||
const conversation = conversationSelector(callHistory.peerId);
|
const conversation = conversationSelector(callHistory.peerId);
|
||||||
strictAssert(
|
strictAssert(
|
||||||
|
|
|
@ -54,7 +54,7 @@ const getOutgoingCallButtonStyle = (
|
||||||
|
|
||||||
const conversationCallMode = getConversationCallMode(conversation);
|
const conversationCallMode = getConversationCallMode(conversation);
|
||||||
switch (conversationCallMode) {
|
switch (conversationCallMode) {
|
||||||
case CallMode.None:
|
case null:
|
||||||
return OutgoingCallButtonStyle.None;
|
return OutgoingCallButtonStyle.None;
|
||||||
case CallMode.Direct:
|
case CallMode.Direct:
|
||||||
return OutgoingCallButtonStyle.Both;
|
return OutgoingCallButtonStyle.Both;
|
||||||
|
|
|
@ -136,53 +136,53 @@ describe('both/state/ducks/conversations', () => {
|
||||||
const fakeConversation: ConversationType = getDefaultConversation();
|
const fakeConversation: ConversationType = getDefaultConversation();
|
||||||
const fakeGroup: ConversationType = getDefaultGroup();
|
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(
|
assert.strictEqual(
|
||||||
getConversationCallMode({
|
getConversationCallMode({
|
||||||
...fakeConversation,
|
...fakeConversation,
|
||||||
left: true,
|
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(
|
assert.strictEqual(
|
||||||
getConversationCallMode({
|
getConversationCallMode({
|
||||||
...fakeConversation,
|
...fakeConversation,
|
||||||
isBlocked: true,
|
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(
|
assert.strictEqual(
|
||||||
getConversationCallMode({
|
getConversationCallMode({
|
||||||
...fakeConversation,
|
...fakeConversation,
|
||||||
acceptedMessageRequest: false,
|
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(
|
assert.strictEqual(
|
||||||
getConversationCallMode({
|
getConversationCallMode({
|
||||||
...fakeConversation,
|
...fakeConversation,
|
||||||
isMe: true,
|
isMe: true,
|
||||||
}),
|
}),
|
||||||
CallMode.None
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns CallMode.None for v1 groups', () => {
|
it('returns null for v1 groups', () => {
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
getConversationCallMode({
|
getConversationCallMode({
|
||||||
...fakeGroup,
|
...fakeGroup,
|
||||||
groupVersion: 1,
|
groupVersion: 1,
|
||||||
}),
|
}),
|
||||||
CallMode.None
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
|
@ -190,7 +190,7 @@ describe('both/state/ducks/conversations', () => {
|
||||||
...fakeGroup,
|
...fakeGroup,
|
||||||
groupVersion: undefined,
|
groupVersion: undefined,
|
||||||
}),
|
}),
|
||||||
CallMode.None
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ describe('SQL/updateToSchemaVersion88', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
db = new SQL(':memory:');
|
db = new SQL(':memory:');
|
||||||
updateToVersion(db, 87);
|
updateToVersion(db, 86);
|
||||||
|
|
||||||
insertData(db, 'items', [
|
insertData(db, 'items', [
|
||||||
{
|
{
|
||||||
|
|
404
ts/test-node/sql/migration_89_test.ts
Normal file
404
ts/test-node/sql/migration_89_test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -17,9 +17,6 @@ import { objectToJSON, sql, sqlJoin } from '../../sql/util';
|
||||||
import { BodyRange } from '../../types/BodyRange';
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
import type { AciString } from '../../types/ServiceId';
|
import type { AciString } from '../../types/ServiceId';
|
||||||
import { generateAci } 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';
|
import { updateToVersion } from './helpers';
|
||||||
|
|
||||||
const OUR_UUID = generateGuid();
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,6 @@ import type { ServiceIdString } from './ServiceId';
|
||||||
|
|
||||||
// These are strings (1) for the database (2) for Storybook.
|
// These are strings (1) for the database (2) for Storybook.
|
||||||
export enum CallMode {
|
export enum CallMode {
|
||||||
None = 'None',
|
|
||||||
Direct = 'Direct',
|
Direct = 'Direct',
|
||||||
Group = 'Group',
|
Group = 'Group',
|
||||||
}
|
}
|
||||||
|
|
|
@ -500,8 +500,6 @@ export function transitionCallHistory(
|
||||||
event,
|
event,
|
||||||
direction
|
direction
|
||||||
);
|
);
|
||||||
} else if (mode === CallMode.None) {
|
|
||||||
throw new TypeError('Call mode must not be none');
|
|
||||||
} else {
|
} else {
|
||||||
throw missingCaseError(mode);
|
throw missingCaseError(mode);
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,8 @@ export enum CallExternalState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallingNotificationType = Readonly<{
|
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;
|
callCreator: ConversationType | null;
|
||||||
callExternalState: CallExternalState;
|
callExternalState: CallExternalState;
|
||||||
deviceCount: number;
|
deviceCount: number;
|
||||||
|
@ -110,8 +111,12 @@ function getGroupCallNotificationText(
|
||||||
export function getCallingNotificationText(
|
export function getCallingNotificationText(
|
||||||
callingNotification: CallingNotificationType,
|
callingNotification: CallingNotificationType,
|
||||||
i18n: LocalizerType
|
i18n: LocalizerType
|
||||||
): string {
|
): string | null {
|
||||||
const { callHistory, callCreator, callExternalState } = callingNotification;
|
const { callHistory, callCreator, callExternalState } = callingNotification;
|
||||||
|
if (callHistory == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (callHistory.mode === CallMode.Direct) {
|
if (callHistory.mode === CallMode.Direct) {
|
||||||
return getDirectCallNotificationText(
|
return getDirectCallNotificationText(
|
||||||
callHistory.direction,
|
callHistory.direction,
|
||||||
|
@ -123,11 +128,6 @@ export function getCallingNotificationText(
|
||||||
if (callHistory.mode === CallMode.Group) {
|
if (callHistory.mode === CallMode.Group) {
|
||||||
return getGroupCallNotificationText(callExternalState, callCreator, i18n);
|
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);
|
throw missingCaseError(callHistory.mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue