Backup: Support for calls

This commit is contained in:
Scott Nonnenberg 2024-08-14 04:39:04 +10:00 committed by GitHub
parent 3a631a587f
commit 63e14a7df6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1047 additions and 116 deletions

View file

@ -276,7 +276,7 @@ message AdHocCall {
} }
uint64 callId = 1; uint64 callId = 1;
// Refers to a `CallLink` recipient. // Refers to a Recipient with the `callLink` field set
uint64 recipientId = 2; uint64 recipientId = 2;
State state = 3; State state = 3;
uint64 callTimestamp = 4; uint64 callTimestamp = 4;
@ -703,6 +703,7 @@ message IndividualCall {
Direction direction = 3; Direction direction = 3;
State state = 4; State state = 4;
uint64 startedCallTimestamp = 5; uint64 startedCallTimestamp = 5;
bool read = 6;
} }
message GroupCall { message GroupCall {
@ -734,6 +735,7 @@ message GroupCall {
uint64 startedCallTimestamp = 5; uint64 startedCallTimestamp = 5;
// The time the call ended. 0 indicates an unknown time. // The time the call ended. 0 indicates an unknown time.
uint64 endedCallTimestamp = 6; uint64 endedCallTimestamp = 6;
bool read = 7;
} }
message SimpleChatUpdate { message SimpleChatUpdate {

View file

@ -7,6 +7,8 @@ import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import pMap from 'p-map'; import pMap from 'p-map';
import pTimeout from 'p-timeout'; import pTimeout from 'p-timeout';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { isNumber } from 'lodash';
import { CallLinkRootKey } from '@signalapp/ringrtc';
import { Backups, SignalService } from '../../protobuf'; import { Backups, SignalService } from '../../protobuf';
import { import {
@ -89,7 +91,18 @@ import { BACKUP_VERSION } from './constants';
import { getMessageIdForLogging } from '../../util/idForLogging'; import { getMessageIdForLogging } from '../../util/idForLogging';
import { getCallsHistoryForRedux } from '../callHistoryLoader'; import { getCallsHistoryForRedux } from '../callHistoryLoader';
import { makeLookup } from '../../util/makeLookup'; import { makeLookup } from '../../util/makeLookup';
import type { CallHistoryDetails } from '../../types/CallDisposition'; import type {
CallHistoryDetails,
CallStatus,
} from '../../types/CallDisposition';
import {
CallMode,
CallDirection,
CallType,
DirectCallStatus,
GroupCallStatus,
AdhocCallStatus,
} from '../../types/CallDisposition';
import { isAciString } from '../../util/isAciString'; import { isAciString } from '../../util/isAciString';
import { hslToRGB } from '../../util/hslToRGB'; import { hslToRGB } from '../../util/hslToRGB';
import type { AboutMe, LocalChatStyle } from './types'; import type { AboutMe, LocalChatStyle } from './types';
@ -113,6 +126,10 @@ import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager';
import { getBackupCdnInfo } from './util/mediaId'; import { getBackupCdnInfo } from './util/mediaId';
import { calculateExpirationTimestamp } from '../../util/expirationTimer'; import { calculateExpirationTimestamp } from '../../util/expirationTimer';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import { CallLinkRestrictions } from '../../types/CallLink';
import { toAdminKeyBytes } from '../../util/callLinks';
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
import { SeenStatus } from '../../MessageSeenStatus';
const MAX_CONCURRENCY = 10; const MAX_CONCURRENCY = 10;
@ -180,6 +197,7 @@ export class BackupExportStream extends Readable {
private readonly backupTimeMs = getSafeLongFromTimestamp(this.now); private readonly backupTimeMs = getSafeLongFromTimestamp(this.now);
private readonly convoIdToRecipientId = new Map<string, number>(); private readonly convoIdToRecipientId = new Map<string, number>();
private readonly roomIdToRecipientId = new Map<string, number>();
private attachmentBackupJobs: Array<CoreAttachmentBackupJobType> = []; private attachmentBackupJobs: Array<CoreAttachmentBackupJobType> = [];
private buffers = new Array<Uint8Array>(); private buffers = new Array<Uint8Array>();
private nextRecipientId = 0; private nextRecipientId = 0;
@ -230,6 +248,8 @@ export class BackupExportStream extends Readable {
await this.flush(); await this.flush();
const stats = { const stats = {
adHocCalls: 0,
callLinks: 0,
conversations: 0, conversations: 0,
chats: 0, chats: 0,
distributionLists: 0, distributionLists: 0,
@ -283,7 +303,7 @@ export class BackupExportStream extends Readable {
this.pushFrame({ this.pushFrame({
recipient: { recipient: {
id: this.getDistributionListRecipientId(), id: Long.fromNumber(this.getNextRecipientId()),
distributionList: { distributionList: {
distributionId: uuidToBytes(list.id), distributionId: uuidToBytes(list.id),
deletionTimestamp: list.deletedAtTimestamp deletionTimestamp: list.deletedAtTimestamp
@ -309,6 +329,48 @@ export class BackupExportStream extends Readable {
stats.distributionLists += 1; stats.distributionLists += 1;
} }
const callLinks = await DataReader.getAllCallLinks();
for (const link of callLinks) {
const {
rootKey: rootKeyString,
adminKey,
name,
restrictions,
revoked,
expiration,
} = link;
if (revoked) {
continue;
}
const id = this.getNextRecipientId();
const rootKey = CallLinkRootKey.parse(rootKeyString);
const roomId = getRoomIdFromRootKey(rootKey);
this.roomIdToRecipientId.set(roomId, id);
this.pushFrame({
recipient: {
id: Long.fromNumber(id),
callLink: {
rootKey: rootKey.bytes,
adminKey: adminKey ? toAdminKeyBytes(adminKey) : null,
name,
restrictions: toCallLinkRestrictionsProto(restrictions),
expirationMs: isNumber(expiration)
? Long.fromNumber(expiration)
: null,
},
},
});
// eslint-disable-next-line no-await-in-loop
await this.flush();
stats.callLinks += 1;
}
const stickerPacks = await DataReader.getInstalledStickerPacks(); const stickerPacks = await DataReader.getInstalledStickerPacks();
for (const { id, key } of stickerPacks) { for (const { id, key } of stickerPacks) {
@ -376,6 +438,37 @@ export class BackupExportStream extends Readable {
stats.chats += 1; stats.chats += 1;
} }
const allCallHistoryItems = await DataReader.getAllCallHistory();
for (const item of allCallHistoryItems) {
const { callId, type, peerId: roomId, status, timestamp } = item;
if (type !== CallType.Adhoc) {
continue;
}
const recipientId = this.roomIdToRecipientId.get(roomId);
if (!recipientId) {
log.warn(
`backups: Dropping ad-hoc call; recipientId for roomId ${roomId.slice(-2)} not found`
);
continue;
}
this.pushFrame({
adHocCall: {
callId: Long.fromString(callId),
recipientId: Long.fromNumber(recipientId),
state: toAdHocCallStateProto(status),
callTimestamp: Long.fromNumber(timestamp),
},
});
// eslint-disable-next-line no-await-in-loop
await this.flush();
stats.adHocCalls += 1;
}
let cursor: PageMessagesCursorType | undefined; let cursor: PageMessagesCursorType | undefined;
const callHistory = getCallsHistoryForRedux(); const callHistory = getCallsHistoryForRedux();
@ -640,11 +733,11 @@ export class BackupExportStream extends Readable {
return result; return result;
} }
private getDistributionListRecipientId(): Long { private getNextRecipientId(): number {
const recipientId = this.nextRecipientId; const recipientId = this.nextRecipientId;
this.nextRecipientId += 1; this.nextRecipientId += 1;
return Long.fromNumber(recipientId); return recipientId;
} }
private toRecipient( private toRecipient(
@ -1082,7 +1175,7 @@ export class BackupExportStream extends Readable {
async toChatItemUpdate( async toChatItemUpdate(
options: NonBubbleOptionsType options: NonBubbleOptionsType
): Promise<NonBubbleResultType> { ): Promise<NonBubbleResultType> {
const { authorId, message } = options; const { authorId, callHistoryByCallId, message } = options;
const logId = `toChatItemUpdate(${getMessageIdForLogging(message)})`; const logId = `toChatItemUpdate(${getMessageIdForLogging(message)})`;
const updateMessage = new Backups.ChatUpdateMessage(); const updateMessage = new Backups.ChatUpdateMessage();
@ -1092,97 +1185,83 @@ export class BackupExportStream extends Readable {
}; };
if (isCallHistory(message)) { if (isCallHistory(message)) {
// TODO (DESKTOP-6964) const conversation = window.ConversationController.get(
// const callingMessage = new Backups.CallChatUpdate(); message.conversationId
// const { callId } = message; );
// if (!callId) {
// throw new Error( if (!conversation) {
// `${logId}: Message was callHistory, but missing callId!` throw new Error(
// ); `${logId}: callHistory message had unknown conversationId!`
// } );
// const callHistory = callHistoryByCallId[callId]; }
// if (!callHistory) {
// throw new Error( const { callId } = message;
// `${logId}: Message had callId, but no call history details were found!` if (!callId) {
// ); throw new Error(`${logId}: callHistory message was missing callId!`);
// } }
// callingMessage.callId = Long.fromString(callId);
// if (callHistory.mode === CallMode.Group) { const callHistory = callHistoryByCallId[callId];
// const groupCall = new Backups.GroupCallChatUpdate(); if (!callHistory) {
// const { ringerId } = callHistory; throw new Error(
// if (!ringerId) { `${logId}: callHistory message had callId, but no call history details were found!`
// throw new Error( );
// `${logId}: Message had missing ringerId for a group call!` }
// );
// } if (isGroup(conversation.attributes)) {
// groupCall.startedCallAci = this.aciToBytes(ringerId); const groupCall = new Backups.GroupCall();
// groupCall.startedCallTimestamp = Long.fromNumber(callHistory.timestamp);
// // Note: we don't store inCallACIs, instead relying on RingRTC in-memory state strictAssert(
// callingMessage.groupCall = groupCall; callHistory.mode === CallMode.Group,
// } else { 'in group, should be group call'
// const callMessage = new Backups.IndividualCallChatUpdate(); );
// const { direction, type, status } = callHistory;
// if ( if (callHistory.status === GroupCallStatus.Deleted) {
// status === DirectCallStatus.Accepted || return { kind: NonBubbleResultKind.Drop };
// status === DirectCallStatus.Pending }
// ) {
// if (type === CallType.Audio) { const { ringerId } = callHistory;
// callMessage.type = if (ringerId) {
// direction === CallDirection.Incoming const ringerConversation =
// ? Backups.IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL window.ConversationController.get(ringerId);
// : Backups.IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL; if (!ringerConversation) {
// } else if (type === CallType.Video) { throw new Error(
// callMessage.type = 'toChatItemUpdate/callHistory: ringerId conversation not found!'
// direction === CallDirection.Incoming );
// ? Backups.IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL }
// : Backups.IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL;
// } else { const recipientId = this.getRecipientId(
// throw new Error( ringerConversation.attributes
// `${logId}: Message direct status '${status}' call had type ${type}` );
// ); groupCall.ringerRecipientId = recipientId;
// } groupCall.startedCallRecipientId = recipientId;
// } else if (status === DirectCallStatus.Declined) { }
// if (direction === CallDirection.Incoming) {
// // question: do we really not call declined calls things that we decline? groupCall.callId = Long.fromString(callId);
// throw new Error( groupCall.state = toGroupCallStateProto(callHistory.status);
// `${logId}: Message direct call was declined but incoming` groupCall.startedCallTimestamp = Long.fromNumber(callHistory.timestamp);
// ); groupCall.endedCallTimestamp = Long.fromNumber(0);
// } groupCall.read = message.seenStatus === SeenStatus.Seen;
// if (type === CallType.Audio) {
// callMessage.type = updateMessage.groupCall = groupCall;
// Backups.IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_AUDIO_CALL; return { kind: NonBubbleResultKind.Directionless, patch };
// } else if (type === CallType.Video) { }
// callMessage.type =
// Backups.IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_VIDEO_CALL; const individualCall = new Backups.IndividualCall();
// } else { const { direction, type, status, timestamp } = callHistory;
// throw new Error(
// `${logId}: Message direct status '${status}' call had type ${type}` if (status === GroupCallStatus.Deleted) {
// ); return { kind: NonBubbleResultKind.Drop };
// } }
// } else if (status === DirectCallStatus.Missed) {
// if (direction === CallDirection.Outgoing) { individualCall.callId = Long.fromString(callId);
// throw new Error( individualCall.type = toIndividualCallTypeProto(type);
// `${logId}: Message direct call was missed but outgoing` individualCall.direction = toIndividualCallDirectionProto(direction);
// ); individualCall.state = toIndividualCallStateProto(status);
// } individualCall.startedCallTimestamp = Long.fromNumber(timestamp);
// if (type === CallType.Audio) { individualCall.read = message.seenStatus === SeenStatus.Seen;
// callMessage.type =
// Backups.IndividualCallChatUpdate.Type.MISSED_INCOMING_AUDIO_CALL; updateMessage.individualCall = individualCall;
// } else if (type === CallType.Video) { return { kind: NonBubbleResultKind.Directionless, patch };
// callMessage.type =
// Backups.IndividualCallChatUpdate.Type.MISSED_INCOMING_VIDEO_CALL;
// } else {
// throw new Error(
// `${logId}: Message direct status '${status}' call had type ${type}`
// );
// }
// } else {
// throw new Error(`${logId}: Message direct call had status ${status}`);
// }
// callingMessage.callMessage = callMessage;
// }
// updateMessage.callingMessage = callingMessage;
// return chatItem;
} }
if (isExpirationTimerUpdate(message)) { if (isExpirationTimerUpdate(message)) {
@ -2484,3 +2563,127 @@ function hslToRGBInt(hue: number, saturation: number): number {
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0; return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0;
} }
function toGroupCallStateProto(state: CallStatus): Backups.GroupCall.State {
const values = Backups.GroupCall.State;
if (state === GroupCallStatus.GenericGroupCall) {
return values.GENERIC;
}
if (state === GroupCallStatus.OutgoingRing) {
return values.OUTGOING_RING;
}
if (state === GroupCallStatus.Ringing) {
return values.RINGING;
}
if (state === GroupCallStatus.Joined) {
return values.JOINED;
}
if (state === GroupCallStatus.Accepted) {
return values.ACCEPTED;
}
if (state === GroupCallStatus.Missed) {
return values.MISSED;
}
if (state === GroupCallStatus.MissedNotificationProfile) {
return values.MISSED_NOTIFICATION_PROFILE;
}
if (state === GroupCallStatus.Declined) {
return values.DECLINED;
}
if (state === GroupCallStatus.Deleted) {
throw new Error(
'groupCallStatusToGroupCallState: Never back up deleted items!'
);
}
return values.UNKNOWN_STATE;
}
function toIndividualCallDirectionProto(
direction: CallDirection
): Backups.IndividualCall.Direction {
const values = Backups.IndividualCall.Direction;
if (direction === CallDirection.Incoming) {
return values.INCOMING;
}
if (direction === CallDirection.Outgoing) {
return values.OUTGOING;
}
return values.UNKNOWN_DIRECTION;
}
function toIndividualCallTypeProto(
type: CallType
): Backups.IndividualCall.Type {
const values = Backups.IndividualCall.Type;
if (type === CallType.Audio) {
return values.AUDIO_CALL;
}
if (type === CallType.Video) {
return values.VIDEO_CALL;
}
return values.UNKNOWN_TYPE;
}
function toIndividualCallStateProto(
status: CallStatus
): Backups.IndividualCall.State {
const values = Backups.IndividualCall.State;
if (status === DirectCallStatus.Accepted) {
return values.ACCEPTED;
}
if (status === DirectCallStatus.Declined) {
return values.NOT_ACCEPTED;
}
if (status === DirectCallStatus.Missed) {
return values.MISSED;
}
if (status === DirectCallStatus.MissedNotificationProfile) {
return values.MISSED_NOTIFICATION_PROFILE;
}
if (status === DirectCallStatus.Deleted) {
throw new Error(
'statusToIndividualCallProtoEnum: Never back up deleted items!'
);
}
return values.UNKNOWN_STATE;
}
function toAdHocCallStateProto(status: CallStatus): Backups.AdHocCall.State {
const values = Backups.AdHocCall.State;
if (status === AdhocCallStatus.Generic) {
return values.GENERIC;
}
if (status === AdhocCallStatus.Joined) {
return values.GENERIC;
}
if (status === AdhocCallStatus.Pending) {
return values.GENERIC;
}
return values.UNKNOWN_STATE;
}
function toCallLinkRestrictionsProto(
restrictions: CallLinkRestrictions
): Backups.CallLink.Restrictions {
const values = Backups.CallLink.Restrictions;
if (restrictions === CallLinkRestrictions.None) {
return values.NONE;
}
if (restrictions === CallLinkRestrictions.AdminApproval) {
return values.ADMIN_APPROVAL;
}
return values.UNKNOWN;
}

View file

@ -7,6 +7,7 @@ import { v4 as generateUuid } from 'uuid';
import pMap from 'p-map'; import pMap from 'p-map';
import { Writable } from 'stream'; import { Writable } from 'stream';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { CallLinkRootKey } from '@signalapp/ringrtc';
import { Backups, SignalService } from '../../protobuf'; import { Backups, SignalService } from '../../protobuf';
import { DataWriter } from '../../sql/Client'; import { DataWriter } from '../../sql/Client';
@ -84,6 +85,20 @@ import { filterAndClean } from '../../types/BodyRange';
import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME'; import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME';
import { copyFromQuotedMessage } from '../../messages/copyQuote'; import { copyFromQuotedMessage } from '../../messages/copyQuote';
import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue'; import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue';
import {
AdhocCallStatus,
CallDirection,
CallMode,
CallType,
DirectCallStatus,
GroupCallStatus,
} from '../../types/CallDisposition';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import { CallLinkRestrictions } from '../../types/CallLink';
import type { CallLinkType } from '../../types/CallLink';
import { fromAdminKeyBytes } from '../../util/callLinks';
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
const MAX_CONCURRENCY = 10; const MAX_CONCURRENCY = 10;
@ -231,6 +246,7 @@ export class BackupImportStream extends Writable {
number, number,
ConversationAttributesType ConversationAttributesType
>(); >();
private readonly recipientIdToCallLink = new Map<number, CallLinkType>();
private readonly chatIdToConvo = new Map< private readonly chatIdToConvo = new Map<
number, number,
ConversationAttributesType ConversationAttributesType
@ -382,6 +398,8 @@ export class BackupImportStream extends Writable {
if (frame.recipient) { if (frame.recipient) {
const { recipient } = frame; const { recipient } = frame;
strictAssert(recipient.id != null, 'Recipient must have an id'); strictAssert(recipient.id != null, 'Recipient must have an id');
const recipientId = recipient.id.toNumber();
let convo: ConversationAttributesType; let convo: ConversationAttributesType;
if (recipient.contact) { if (recipient.contact) {
convo = await this.fromContact(recipient.contact); convo = await this.fromContact(recipient.contact);
@ -402,6 +420,11 @@ export class BackupImportStream extends Writable {
} else if (recipient.distributionList) { } else if (recipient.distributionList) {
await this.fromDistributionList(recipient.distributionList); await this.fromDistributionList(recipient.distributionList);
// Not a conversation
return;
} else if (recipient.callLink) {
await this.fromCallLink(recipientId, recipient.callLink);
// Not a conversation // Not a conversation
return; return;
} else { } else {
@ -413,7 +436,7 @@ export class BackupImportStream extends Writable {
this.saveConversation(convo); this.saveConversation(convo);
} }
this.recipientIdToConvo.set(recipient.id.toNumber(), convo); this.recipientIdToConvo.set(recipientId, convo);
} else if (frame.chat) { } else if (frame.chat) {
await this.fromChat(frame.chat); await this.fromChat(frame.chat);
} else if (frame.chatItem) { } else if (frame.chatItem) {
@ -426,6 +449,8 @@ export class BackupImportStream extends Writable {
await this.fromChatItem(frame.chatItem, { aboutMe }); await this.fromChatItem(frame.chatItem, { aboutMe });
} else if (frame.stickerPack) { } else if (frame.stickerPack) {
await this.fromStickerPack(frame.stickerPack); await this.fromStickerPack(frame.stickerPack);
} else if (frame.adHocCall) {
await this.fromAdHocCall(frame.adHocCall);
} else { } else {
log.warn(`${this.logId}: unsupported frame item ${frame.item}`); log.warn(`${this.logId}: unsupported frame item ${frame.item}`);
} }
@ -463,6 +488,12 @@ export class BackupImportStream extends Writable {
this.saveMessageBatcher.add(attributes); this.saveMessageBatcher.add(attributes);
} }
private async saveCallHistory(
callHistory: CallHistoryDetails
): Promise<void> {
await DataWriter.saveCallHistory(callHistory);
}
private async fromAccount({ private async fromAccount({
profileKey, profileKey,
username, username,
@ -987,6 +1018,38 @@ export class BackupImportStream extends Writable {
await DataWriter.createNewStoryDistribution(result); await DataWriter.createNewStoryDistribution(result);
} }
private async fromCallLink(
recipientId: number,
callLinkProto: Backups.ICallLink
): Promise<void> {
const {
rootKey: rootKeyBytes,
adminKey,
name,
restrictions,
expirationMs,
} = callLinkProto;
strictAssert(rootKeyBytes?.length, 'fromCallLink: rootKey is required');
strictAssert(name, 'fromCallLink: name is required');
const rootKey = CallLinkRootKey.fromBytes(Buffer.from(rootKeyBytes));
const callLink: CallLinkType = {
roomId: getRoomIdFromRootKey(rootKey),
rootKey: rootKey.toString(),
adminKey: adminKey?.length ? fromAdminKeyBytes(adminKey) : null,
name,
restrictions: fromCallLinkRestrictionsProto(restrictions),
revoked: false,
expiration: expirationMs?.toNumber() || null,
};
this.recipientIdToCallLink.set(recipientId, callLink);
await DataWriter.insertCallLink(callLink);
}
private async fromChat(chat: Backups.IChat): Promise<void> { private async fromChat(chat: Backups.IChat): Promise<void> {
strictAssert(chat.id != null, 'chat must have an id'); strictAssert(chat.id != null, 'chat must have an id');
strictAssert(chat.recipientId != null, 'chat must have a recipientId'); strictAssert(chat.recipientId != null, 'chat must have a recipientId');
@ -1329,7 +1392,12 @@ export class BackupImportStream extends Writable {
} }
strictAssert(directionless, 'Absent direction state'); strictAssert(directionless, 'Absent direction state');
return { patch: {} }; return {
patch: {
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
},
};
} }
private async fromStandardMessage( private async fromStandardMessage(
@ -1757,7 +1825,7 @@ export class BackupImportStream extends Writable {
timestamp: number; timestamp: number;
} }
): Promise<ChatItemParseResult | undefined> { ): Promise<ChatItemParseResult | undefined> {
const { aboutMe, author } = options; const { aboutMe, author, conversation } = options;
if (updateMessage.groupChange) { if (updateMessage.groupChange) {
return this.fromGroupUpdateMessage(updateMessage.groupChange, options); return this.fromGroupUpdateMessage(updateMessage.groupChange, options);
@ -1870,8 +1938,117 @@ export class BackupImportStream extends Writable {
}; };
} }
// TODO (DESKTOP-6964): check these fields if (updateMessage.groupCall) {
// updateMessage.callingMessage const { groupId } = conversation;
if (!isGroup(conversation)) {
throw new Error('groupCall: Conversation is not a group!');
}
if (!groupId) {
throw new Error('groupCall: No groupId available on conversation');
}
const {
callId: callIdLong,
state,
ringerRecipientId,
startedCallRecipientId,
startedCallTimestamp,
read,
} = updateMessage.groupCall;
const initiator =
ringerRecipientId?.toNumber() || startedCallRecipientId?.toNumber();
const ringer = isNumber(initiator)
? this.recipientIdToConvo.get(initiator)
: undefined;
if (!callIdLong) {
throw new Error('groupCall: callId is required!');
}
if (!startedCallTimestamp) {
throw new Error('groupCall: startedCallTimestamp is required!');
}
const isRingerMe = ringer?.serviceId === aboutMe.aci;
const callId = callIdLong.toString();
const callHistory: CallHistoryDetails = {
callId,
status: fromGroupCallStateProto(state),
mode: CallMode.Group,
type: CallType.Group,
ringerId: ringer?.serviceId ?? null,
peerId: groupId,
direction: isRingerMe ? CallDirection.Outgoing : CallDirection.Incoming,
timestamp: startedCallTimestamp.toNumber(),
};
await this.saveCallHistory(callHistory);
return {
message: {
type: 'call-history',
callId,
sourceServiceId: undefined,
readStatus: ReadStatus.Read,
seenStatus: read ? SeenStatus.Seen : SeenStatus.Unseen,
},
additionalMessages: [],
};
}
if (updateMessage.individualCall) {
const {
callId: callIdLong,
type,
direction: protoDirection,
state,
startedCallTimestamp,
read,
} = updateMessage.individualCall;
if (!callIdLong) {
throw new Error('individualCall: callId is required!');
}
if (!startedCallTimestamp) {
throw new Error('individualCall: startedCallTimestamp is required!');
}
const peerId = conversation.serviceId || conversation.e164;
strictAssert(peerId, 'individualCall: no peerId found for call');
const callId = callIdLong.toString();
const direction = fromIndividualCallDirectionProto(protoDirection);
const ringerId =
direction === CallDirection.Outgoing
? aboutMe.aci
: conversation.serviceId;
strictAssert(ringerId, 'individualCall: no ringerId found');
const callHistory: CallHistoryDetails = {
callId,
status: fromIndividualCallStateProto(state),
mode: CallMode.Direct,
type: fromIndividualCallTypeProto(type),
ringerId,
peerId,
direction,
timestamp: startedCallTimestamp.toNumber(),
};
await this.saveCallHistory(callHistory);
return {
message: {
type: 'call-history',
callId,
sourceServiceId: undefined,
readStatus: ReadStatus.Read,
seenStatus: read ? SeenStatus.Seen : SeenStatus.Unseen,
},
additionalMessages: [],
};
}
return undefined; return undefined;
} }
@ -2572,6 +2749,44 @@ export class BackupImportStream extends Writable {
); );
} }
private async fromAdHocCall({
callId: callIdLong,
recipientId: recipientIdLong,
state,
callTimestamp,
}: Backups.IAdHocCall): Promise<void> {
strictAssert(callIdLong, 'AdHocCall must have a callId');
const callId = callIdLong.toString();
const logId = `fromAdhocCall(${callId.slice(-2)})`;
strictAssert(callTimestamp, `${logId}: must have a valid timestamp`);
strictAssert(recipientIdLong, 'AdHocCall must have a recipientIdLong');
const recipientId = recipientIdLong.toNumber();
const callLink = this.recipientIdToCallLink.get(recipientId);
if (!callLink) {
log.warn(
`${logId}: Dropping ad-hoc call, Call Link for recipientId ${recipientId} not found`
);
return;
}
const callHistory: CallHistoryDetails = {
callId,
peerId: callLink.roomId,
ringerId: null,
mode: CallMode.Adhoc,
type: CallType.Adhoc,
direction: CallDirection.Unknown,
timestamp: callTimestamp.toNumber(),
status: fromAdHocCallStateProto(state),
};
await this.saveCallHistory(callHistory);
}
private async fromCustomChatColors( private async fromCustomChatColors(
customChatColors: customChatColors:
| ReadonlyArray<Backups.ChatStyle.ICustomChatColor> | ReadonlyArray<Backups.ChatStyle.ICustomChatColor>
@ -2766,3 +2981,133 @@ function rgbIntToHSL(intValue: number): { hue: number; saturation: number } {
return { hue, saturation }; return { hue, saturation };
} }
function fromGroupCallStateProto(
state: Backups.GroupCall.State | undefined | null
): GroupCallStatus {
const values = Backups.GroupCall.State;
if (state == null) {
return GroupCallStatus.GenericGroupCall;
}
if (state === values.GENERIC) {
return GroupCallStatus.GenericGroupCall;
}
if (state === values.OUTGOING_RING) {
return GroupCallStatus.OutgoingRing;
}
if (state === values.RINGING) {
return GroupCallStatus.Ringing;
}
if (state === values.JOINED) {
return GroupCallStatus.Joined;
}
if (state === values.ACCEPTED) {
return GroupCallStatus.Accepted;
}
if (state === values.MISSED) {
return GroupCallStatus.Missed;
}
if (state === values.MISSED_NOTIFICATION_PROFILE) {
return GroupCallStatus.MissedNotificationProfile;
}
if (state === values.DECLINED) {
return GroupCallStatus.Declined;
}
return GroupCallStatus.GenericGroupCall;
}
function fromIndividualCallDirectionProto(
direction: Backups.IndividualCall.Direction | undefined | null
): CallDirection {
const values = Backups.IndividualCall.Direction;
if (direction == null) {
return CallDirection.Unknown;
}
if (direction === values.INCOMING) {
return CallDirection.Incoming;
}
if (direction === values.OUTGOING) {
return CallDirection.Outgoing;
}
return CallDirection.Unknown;
}
function fromIndividualCallTypeProto(
type: Backups.IndividualCall.Type | undefined | null
): CallType {
const values = Backups.IndividualCall.Type;
if (type == null) {
return CallType.Unknown;
}
if (type === values.AUDIO_CALL) {
return CallType.Audio;
}
if (type === values.VIDEO_CALL) {
return CallType.Video;
}
return CallType.Unknown;
}
function fromIndividualCallStateProto(
status: Backups.IndividualCall.State | undefined | null
): DirectCallStatus {
const values = Backups.IndividualCall.State;
if (status == null) {
return DirectCallStatus.Unknown;
}
if (status === values.ACCEPTED) {
return DirectCallStatus.Accepted;
}
if (status === values.NOT_ACCEPTED) {
return DirectCallStatus.Declined;
}
if (status === values.MISSED) {
return DirectCallStatus.Missed;
}
if (status === values.MISSED_NOTIFICATION_PROFILE) {
return DirectCallStatus.MissedNotificationProfile;
}
return DirectCallStatus.Unknown;
}
function fromAdHocCallStateProto(
status: Backups.AdHocCall.State | undefined | null
): AdhocCallStatus {
const values = Backups.AdHocCall.State;
if (status == null) {
return AdhocCallStatus.Unknown;
}
if (status === values.GENERIC) {
return AdhocCallStatus.Generic;
}
return AdhocCallStatus.Unknown;
}
function fromCallLinkRestrictionsProto(
restrictions: Backups.CallLink.Restrictions | undefined | null
): CallLinkRestrictions {
const values = Backups.CallLink.Restrictions;
if (restrictions == null) {
return CallLinkRestrictions.Unknown;
}
if (restrictions === values.NONE) {
return CallLinkRestrictions.None;
}
if (restrictions === values.ADMIN_APPROVAL) {
return CallLinkRestrictions.AdminApproval;
}
return CallLinkRestrictions.Unknown;
}

View file

@ -779,6 +779,7 @@ type WritableInterface = {
_removeAllMessages: () => void; _removeAllMessages: () => void;
clearCallHistory: (target: CallLogEventTarget) => ReadonlyArray<string>; clearCallHistory: (target: CallLogEventTarget) => ReadonlyArray<string>;
_removeAllCallHistory: () => void;
markCallHistoryDeleted: (callId: string) => void; markCallHistoryDeleted: (callId: string) => void;
cleanupCallHistoryMessages: () => void; cleanupCallHistoryMessages: () => void;
markCallHistoryRead(callId: string): void; markCallHistoryRead(callId: string): void;
@ -796,6 +797,7 @@ type WritableInterface = {
beginDeleteAllCallLinks(): void; beginDeleteAllCallLinks(): void;
beginDeleteCallLink(roomId: string): void; beginDeleteCallLink(roomId: string): void;
finalizeDeleteCallLink(roomId: string): void; finalizeDeleteCallLink(roomId: string): void;
_removeAllCallLinks(): void;
deleteCallLinkFromSync(roomId: string): void; deleteCallLinkFromSync(roomId: string): void;
migrateConversationMessages: (obsoleteId: string, currentId: string) => void; migrateConversationMessages: (obsoleteId: string, currentId: string) => void;
saveEditedMessage: ( saveEditedMessage: (

View file

@ -182,6 +182,7 @@ import {
finalizeDeleteCallLink, finalizeDeleteCallLink,
beginDeleteCallLink, beginDeleteCallLink,
deleteCallLinkFromSync, deleteCallLinkFromSync,
_removeAllCallLinks,
} from './server/callLinks'; } from './server/callLinks';
import { import {
replaceAllEndorsementsForGroup, replaceAllEndorsementsForGroup,
@ -425,6 +426,7 @@ export const DataWriter: ServerWritableInterface = {
_removeAllMessages, _removeAllMessages,
getUnreadEditedMessagesAndMarkRead, getUnreadEditedMessagesAndMarkRead,
clearCallHistory, clearCallHistory,
_removeAllCallHistory,
markCallHistoryDeleted, markCallHistoryDeleted,
cleanupCallHistoryMessages, cleanupCallHistoryMessages,
markCallHistoryRead, markCallHistoryRead,
@ -438,6 +440,7 @@ export const DataWriter: ServerWritableInterface = {
beginDeleteAllCallLinks, beginDeleteAllCallLinks,
beginDeleteCallLink, beginDeleteCallLink,
finalizeDeleteCallLink, finalizeDeleteCallLink,
_removeAllCallLinks,
deleteCallLinkFromSync, deleteCallLinkFromSync,
migrateConversationMessages, migrateConversationMessages,
saveEditedMessage, saveEditedMessage,
@ -3472,6 +3475,13 @@ function getAllCallHistory(db: ReadableDB): ReadonlyArray<CallHistoryDetails> {
return db.prepare(query).all(); return db.prepare(query).all();
} }
function _removeAllCallHistory(db: WritableDB): void {
const [query, params] = sql`
DELETE FROM callsHistory;
`;
db.prepare(query).run(params);
}
function clearCallHistory( function clearCallHistory(
db: WritableDB, db: WritableDB,
target: CallLogEventTarget target: CallLogEventTarget

View file

@ -237,3 +237,10 @@ export function finalizeDeleteCallLink(db: WritableDB, roomId: string): void {
`; `;
db.prepare(query).run(params); db.prepare(query).run(params);
} }
export function _removeAllCallLinks(db: WritableDB): void {
const [query, params] = sql`
DELETE FROM callLinks;
`;
db.prepare(query).run(params);
}

View file

@ -39,9 +39,9 @@ describe('backup/attachments', () => {
let contactA: ConversationModel; let contactA: ConversationModel;
beforeEach(async () => { beforeEach(async () => {
await DataWriter._removeAllMessages(); await DataWriter.removeAll();
await DataWriter._removeAllConversations();
window.storage.reset(); window.storage.reset();
window.ConversationController.reset();
await setupBasics(); await setupBasics();
@ -69,7 +69,9 @@ describe('backup/attachments', () => {
}); });
}); });
afterEach(() => { afterEach(async () => {
await DataWriter.removeAll();
sandbox.restore(); sandbox.restore();
}); });

View file

@ -22,6 +22,8 @@ import {
asymmetricRoundtripHarness, asymmetricRoundtripHarness,
symmetricRoundtripHarness, symmetricRoundtripHarness,
} from './helpers'; } from './helpers';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
// Note: this should be kept up to date with GroupV2Change.stories.tsx, to // Note: this should be kept up to date with GroupV2Change.stories.tsx, to
// maintain the comprehensive set of GroupV2 notifications we need to handle // maintain the comprehensive set of GroupV2 notifications we need to handle
@ -66,6 +68,8 @@ function createMessage(
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
type: 'group-v2-change', type: 'group-v2-change',
sourceServiceId, sourceServiceId,
}; };
@ -73,8 +77,8 @@ function createMessage(
describe('backup/groupv2/notifications', () => { describe('backup/groupv2/notifications', () => {
beforeEach(async () => { beforeEach(async () => {
await DataWriter._removeAllMessages(); await DataWriter.removeAll();
await DataWriter._removeAllConversations(); window.ConversationController.reset();
window.storage.reset(); window.storage.reset();
await setupBasics(); await setupBasics();
@ -112,6 +116,9 @@ describe('backup/groupv2/notifications', () => {
await loadCallsHistory(); await loadCallsHistory();
}); });
afterEach(async () => {
await DataWriter.removeAll();
});
describe('roundtrips given groupv2 notifications with', () => { describe('roundtrips given groupv2 notifications with', () => {
it('Multiple items', async () => { it('Multiple items', async () => {
@ -2031,6 +2038,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}; };
@ -2047,6 +2056,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}; };
@ -2076,6 +2087,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
}; };
counter += 1; counter += 1;
@ -2092,6 +2105,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
}; };
const messages: Array<MessageAttributesType> = [ const messages: Array<MessageAttributesType> = [
@ -2124,6 +2139,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}; };
@ -2140,6 +2157,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}; };
@ -2156,6 +2175,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}; };
@ -2188,6 +2209,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}; };
const legacyAfter = { const legacyAfter = {
@ -2202,6 +2225,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}; };
@ -2221,6 +2246,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}; };
const allDataAfter = { const allDataAfter = {
@ -2235,6 +2262,8 @@ describe('backup/groupv2/notifications', () => {
received_at: counter, received_at: counter,
sent_at: counter, sent_at: counter,
timestamp: counter, timestamp: counter,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}; };

View file

@ -0,0 +1,266 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import assert from 'assert';
import { v4 as generateGuid } from 'uuid';
import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { ConversationModel } from '../../models/conversations';
import type { MessageAttributesType } from '../../model-types';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import type { CallLinkType } from '../../types/CallLink';
import * as Bytes from '../../Bytes';
import { getRandomBytes } from '../../Crypto';
import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import { loadCallsHistory } from '../../services/callHistoryLoader';
import { setupBasics, symmetricRoundtripHarness } from './helpers';
import {
AdhocCallStatus,
CallDirection,
CallMode,
CallType,
DirectCallStatus,
GroupCallStatus,
} from '../../types/CallDisposition';
import { CallLinkRestrictions } from '../../types/CallLink';
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
import { fromAdminKeyBytes } from '../../util/callLinks';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
import { deriveGroupID, deriveGroupSecretParams } from '../../util/zkgroup';
const CONTACT_A = generateAci();
const GROUP_MASTER_KEY = getRandomBytes(32);
const GROUP_SECRET_PARAMS = deriveGroupSecretParams(GROUP_MASTER_KEY);
const GROUP_ID_STRING = Bytes.toBase64(deriveGroupID(GROUP_SECRET_PARAMS));
describe('backup/calling', () => {
let contactA: ConversationModel;
let groupA: ConversationModel;
let callLink: CallLinkType;
beforeEach(async () => {
await DataWriter.removeAll();
window.ConversationController.reset();
window.storage.reset();
await setupBasics();
contactA = await window.ConversationController.getOrCreateAndWait(
CONTACT_A,
'private',
{ systemGivenName: 'CONTACT_A' }
);
groupA = await window.ConversationController.getOrCreateAndWait(
GROUP_ID_STRING,
'group',
{
groupVersion: 2,
masterKey: Bytes.toBase64(GROUP_MASTER_KEY),
name: 'Rock Enthusiasts',
}
);
const rootKey = CallLinkRootKey.generate();
const adminKey = CallLinkRootKey.generateAdminPassKey();
callLink = {
rootKey: rootKey.toString(),
roomId: getRoomIdFromRootKey(rootKey),
// TODO: DESKTOP-7511
adminKey: fromAdminKeyBytes(Buffer.concat([adminKey, adminKey])),
name: "Let's Talk Rocks",
restrictions: CallLinkRestrictions.AdminApproval,
revoked: false,
expiration: null,
};
await DataWriter.insertCallLink(callLink);
await loadCallsHistory();
});
after(async () => {
await DataWriter.removeAll();
});
describe('Direct calls', () => {
it('roundtrips with a missed call', async () => {
const now = Date.now();
const callId = '11111';
const callHistory: CallHistoryDetails = {
callId,
peerId: CONTACT_A,
ringerId: CONTACT_A,
mode: CallMode.Direct,
type: CallType.Audio,
status: DirectCallStatus.Missed,
direction: CallDirection.Incoming,
timestamp: now,
};
await DataWriter.saveCallHistory(callHistory);
await loadCallsHistory();
const messageUnseen: MessageAttributesType = {
id: generateGuid(),
type: 'call-history',
sent_at: now,
received_at: now,
timestamp: now,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
conversationId: contactA.id,
callId,
};
const messageSeen: MessageAttributesType = {
id: generateGuid(),
type: 'call-history',
sent_at: now + 1,
received_at: now + 1,
timestamp: now + 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
conversationId: contactA.id,
callId,
};
await symmetricRoundtripHarness([messageUnseen, messageSeen]);
const allCallHistory = await DataReader.getAllCallHistory();
assert.strictEqual(allCallHistory.length, 1);
assert.deepEqual(callHistory, allCallHistory[0]);
});
});
describe('Group calls', () => {
it('roundtrips with a missed call', async () => {
const now = Date.now();
const callId = '22222';
const callHistory: CallHistoryDetails = {
callId,
peerId: GROUP_ID_STRING,
ringerId: CONTACT_A,
mode: CallMode.Group,
type: CallType.Group,
status: GroupCallStatus.Declined,
direction: CallDirection.Incoming,
timestamp: now,
};
await DataWriter.saveCallHistory(callHistory);
await loadCallsHistory();
const messageUnseen: MessageAttributesType = {
id: generateGuid(),
type: 'call-history',
sent_at: now,
received_at: now,
timestamp: now,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
conversationId: groupA.id,
callId,
};
const messageSeen: MessageAttributesType = {
id: generateGuid(),
type: 'call-history',
sent_at: now + 1,
received_at: now + 1,
timestamp: now + 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
conversationId: groupA.id,
callId,
};
await symmetricRoundtripHarness([messageUnseen, messageSeen]);
const allCallHistory = await DataReader.getAllCallHistory();
assert.strictEqual(allCallHistory.length, 1);
assert.deepEqual(callHistory, allCallHistory[0]);
});
});
describe('Call Links', () => {
it('roundtrips with a link with admin details', async () => {
const allCallLinksBefore = await DataReader.getAllCallLinks();
assert.strictEqual(allCallLinksBefore.length, 1);
await symmetricRoundtripHarness([]);
const allCallLinks = await DataReader.getAllCallLinks();
assert.strictEqual(allCallLinks.length, 1);
assert.deepEqual(callLink, allCallLinks[0]);
});
it('roundtrips with a link without admin details', async () => {
await DataWriter._removeAllCallLinks();
const rootKey = CallLinkRootKey.generate();
const callLinkNoAdmin = {
rootKey: rootKey.toString(),
roomId: getRoomIdFromRootKey(rootKey),
adminKey: null,
name: "Let's Talk Rocks #2",
restrictions: CallLinkRestrictions.AdminApproval,
revoked: false,
expiration: null,
};
await DataWriter.insertCallLink(callLinkNoAdmin);
const allCallLinksBefore = await DataReader.getAllCallLinks();
assert.strictEqual(allCallLinksBefore.length, 1);
await symmetricRoundtripHarness([]);
const allCallLinks = await DataReader.getAllCallLinks();
assert.strictEqual(allCallLinks.length, 1);
assert.deepEqual(callLinkNoAdmin, allCallLinks[0]);
});
});
describe('Adhoc calls', () => {
it('roundtrips with a joined call', async () => {
const now = Date.now();
const callId = '333333';
const callHistory: CallHistoryDetails = {
callId,
peerId: callLink.roomId,
ringerId: null,
mode: CallMode.Adhoc,
type: CallType.Adhoc,
status: AdhocCallStatus.Generic,
direction: CallDirection.Unknown,
timestamp: now,
};
await DataWriter.saveCallHistory(callHistory);
await loadCallsHistory();
await symmetricRoundtripHarness([]);
const allCallHistory = await DataReader.getAllCallHistory();
assert.strictEqual(allCallHistory.length, 1);
assert.deepEqual(callHistory, allCallHistory[0]);
});
it('does not roundtrip call with missing call link', async () => {
const now = Date.now();
const callId = '44444';
const callHistory: CallHistoryDetails = {
callId,
peerId: 'nonexistent',
ringerId: null,
mode: CallMode.Adhoc,
type: CallType.Adhoc,
status: AdhocCallStatus.Generic,
direction: CallDirection.Unknown,
timestamp: now,
};
await DataWriter.saveCallHistory(callHistory);
await loadCallsHistory();
await symmetricRoundtripHarness([]);
const allCallHistory = await DataReader.getAllCallHistory();
assert.strictEqual(allCallHistory.length, 0);
});
});
});

View file

@ -199,9 +199,7 @@ export async function asymmetricRoundtripHarness(
} }
async function clearData() { async function clearData() {
await DataWriter._removeAllMessages(); await DataWriter.removeAll();
await DataWriter._removeAllConversations();
await DataWriter.removeAllItems();
window.storage.reset(); window.storage.reset();
window.ConversationController.reset(); window.ConversationController.reset();

View file

@ -87,6 +87,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: OUR_ACI, sourceServiceId: OUR_ACI,
}, },
]); ]);
@ -101,6 +103,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}, },
]); ]);
@ -116,6 +120,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}, },
]); ]);
@ -132,6 +138,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}, },
]); ]);
@ -148,6 +156,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}, },
]); ]);
@ -162,6 +172,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}, },
]); ]);
@ -176,6 +188,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}, },
]); ]);
@ -190,6 +204,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
}, },
]); ]);
@ -399,6 +415,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
sourceDevice: 1, sourceDevice: 1,
@ -419,6 +437,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
changedId: contactA.id, changedId: contactA.id,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
profileChange: { profileChange: {
@ -439,6 +459,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
titleTransition: { titleTransition: {
renderInfo: { renderInfo: {
@ -460,6 +482,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
conversationMerge: { conversationMerge: {
renderInfo: { renderInfo: {
type: 'private', type: 'private',
@ -479,6 +503,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
phoneNumberDiscovery: { phoneNumberDiscovery: {
e164: '+12125551234', e164: '+12125551234',
@ -499,8 +525,8 @@ describe('backup/non-bubble messages', () => {
sourceDevice: 1, sourceDevice: 1,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
readStatus: ReadStatus.Unread, readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen, seenStatus: SeenStatus.Seen,
unidentifiedDeliveryReceived: true, unidentifiedDeliveryReceived: true,
supportedVersionAtReceive: 5, supportedVersionAtReceive: 5,
requiredProtocolVersion: 6, requiredProtocolVersion: 6,
@ -537,6 +563,8 @@ describe('backup/non-bubble messages', () => {
}, },
received_at: 1, received_at: 1,
sent_at: 1, sent_at: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
timestamp: 1, timestamp: 1,
}, },
@ -553,6 +581,8 @@ describe('backup/non-bubble messages', () => {
received_at: 1, received_at: 1,
sourceServiceId: CONTACT_A, sourceServiceId: CONTACT_A,
sourceDevice: 1, sourceDevice: 1,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
sent_at: 1, sent_at: 1,
timestamp: 1, timestamp: 1,
messageRequestResponseEvent: MessageRequestResponseEvent.SPAM, messageRequestResponseEvent: MessageRequestResponseEvent.SPAM,

View file

@ -70,6 +70,9 @@ describe('sql/getCallHistoryGroups', () => {
beforeEach(async () => { beforeEach(async () => {
await removeAll(); await removeAll();
}); });
after(async () => {
await removeAll();
});
it('should merge related items in order', async () => { it('should merge related items in order', async () => {
const now = Date.now(); const now = Date.now();

View file

@ -10,7 +10,7 @@ import { SignalService as Proto } from '../protobuf';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { UUID_BYTE_SIZE } from './Crypto'; import { UUID_BYTE_SIZE } from './Crypto';
// These are strings (1) for the database (2) for Storybook. // These are strings (1) for the backup (2) for Storybook.
export enum CallMode { export enum CallMode {
Direct = 'Direct', Direct = 'Direct',
Group = 'Group', Group = 'Group',
@ -22,11 +22,15 @@ export enum CallType {
Video = 'Video', Video = 'Video',
Group = 'Group', Group = 'Group',
Adhoc = 'Adhoc', Adhoc = 'Adhoc',
// Only used for backup roundtripping
Unknown = 'Unknown',
} }
export enum CallDirection { export enum CallDirection {
Incoming = 'Incoming', Incoming = 'Incoming',
Outgoing = 'Outgoing', Outgoing = 'Outgoing',
// Only used for backup roundtripping
Unknown = 'Unknown',
} }
export enum CallLogEvent { export enum CallLogEvent {
@ -59,6 +63,8 @@ export enum CallStatusValue {
Pending = 'Pending', Pending = 'Pending',
Accepted = 'Accepted', Accepted = 'Accepted',
Missed = 'Missed', Missed = 'Missed',
// TODO: DESKTOP-3483 - not generated locally
MissedNotificationProfile = 'MissedNotificationProfile',
Declined = 'Declined', Declined = 'Declined',
Deleted = 'Deleted', Deleted = 'Deleted',
GenericGroupCall = 'GenericGroupCall', GenericGroupCall = 'GenericGroupCall',
@ -67,14 +73,20 @@ export enum CallStatusValue {
Ringing = 'Ringing', Ringing = 'Ringing',
Joined = 'Joined', Joined = 'Joined',
JoinedAdhoc = 'JoinedAdhoc', JoinedAdhoc = 'JoinedAdhoc',
// Only used for backup roundtripping
Unknown = 'Unknown',
} }
export enum DirectCallStatus { export enum DirectCallStatus {
Pending = CallStatusValue.Pending, Pending = CallStatusValue.Pending,
Accepted = CallStatusValue.Accepted, Accepted = CallStatusValue.Accepted,
Missed = CallStatusValue.Missed, Missed = CallStatusValue.Missed,
// TODO: DESKTOP-3483 - not generated locally
MissedNotificationProfile = CallStatusValue.MissedNotificationProfile,
Declined = CallStatusValue.Declined, Declined = CallStatusValue.Declined,
Deleted = CallStatusValue.Deleted, Deleted = CallStatusValue.Deleted,
// Only used for backup roundtripping
Unknown = CallStatusValue.Unknown,
} }
export enum GroupCallStatus { export enum GroupCallStatus {
@ -84,6 +96,8 @@ export enum GroupCallStatus {
Joined = CallStatusValue.Joined, Joined = CallStatusValue.Joined,
Accepted = CallStatusValue.Accepted, Accepted = CallStatusValue.Accepted,
Missed = CallStatusValue.Missed, Missed = CallStatusValue.Missed,
// TODO: DESKTOP-3483 - not generated locally
MissedNotificationProfile = CallStatusValue.MissedNotificationProfile,
Declined = CallStatusValue.Declined, Declined = CallStatusValue.Declined,
Deleted = CallStatusValue.Deleted, Deleted = CallStatusValue.Deleted,
} }
@ -93,6 +107,8 @@ export enum AdhocCallStatus {
Pending = CallStatusValue.Pending, Pending = CallStatusValue.Pending,
Joined = CallStatusValue.JoinedAdhoc, Joined = CallStatusValue.JoinedAdhoc,
Deleted = CallStatusValue.Deleted, Deleted = CallStatusValue.Deleted,
// Only used for backup roundtripping
Unknown = CallStatusValue.Unknown,
} }
export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus; export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus;

View file

@ -284,6 +284,7 @@ export function getCallLogEventForProto(
const directionToProto = { const directionToProto = {
[CallDirection.Incoming]: Proto.SyncMessage.CallEvent.Direction.INCOMING, [CallDirection.Incoming]: Proto.SyncMessage.CallEvent.Direction.INCOMING,
[CallDirection.Outgoing]: Proto.SyncMessage.CallEvent.Direction.OUTGOING, [CallDirection.Outgoing]: Proto.SyncMessage.CallEvent.Direction.OUTGOING,
[CallDirection.Unknown]: Proto.SyncMessage.CallEvent.Direction.UNKNOWN,
}; };
const typeToProto = { const typeToProto = {
@ -291,6 +292,7 @@ const typeToProto = {
[CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL, [CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL,
[CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL, [CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL,
[CallType.Adhoc]: Proto.SyncMessage.CallEvent.Type.AD_HOC_CALL, [CallType.Adhoc]: Proto.SyncMessage.CallEvent.Type.AD_HOC_CALL,
[CallType.Unknown]: Proto.SyncMessage.CallEvent.Type.UNKNOWN,
}; };
const statusToProto: Record< const statusToProto: Record<
@ -301,6 +303,7 @@ const statusToProto: Record<
[CallStatusValue.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED, [CallStatusValue.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED,
[CallStatusValue.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE, [CallStatusValue.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE,
[CallStatusValue.Missed]: null, [CallStatusValue.Missed]: null,
[CallStatusValue.MissedNotificationProfile]: null,
[CallStatusValue.Pending]: null, [CallStatusValue.Pending]: null,
[CallStatusValue.GenericGroupCall]: null, [CallStatusValue.GenericGroupCall]: null,
[CallStatusValue.GenericAdhocCall]: [CallStatusValue.GenericAdhocCall]:
@ -309,6 +312,7 @@ const statusToProto: Record<
[CallStatusValue.Ringing]: null, [CallStatusValue.Ringing]: null,
[CallStatusValue.Joined]: null, [CallStatusValue.Joined]: null,
[CallStatusValue.JoinedAdhoc]: Proto.SyncMessage.CallEvent.Event.ACCEPTED, [CallStatusValue.JoinedAdhoc]: Proto.SyncMessage.CallEvent.Event.ACCEPTED,
[CallStatusValue.Unknown]: Proto.SyncMessage.CallEvent.Event.UNKNOWN,
}; };
function shouldSyncStatus(callStatus: CallStatus) { function shouldSyncStatus(callStatus: CallStatus) {
@ -681,12 +685,16 @@ function transitionTimestamp(
// We don't care about holding onto timestamps that were from these states // We don't care about holding onto timestamps that were from these states
if ( if (
callHistory.status === DirectCallStatus.Pending || callHistory.status === DirectCallStatus.Pending ||
callHistory.status === DirectCallStatus.Unknown ||
callHistory.status === GroupCallStatus.GenericGroupCall || callHistory.status === GroupCallStatus.GenericGroupCall ||
callHistory.status === GroupCallStatus.OutgoingRing || callHistory.status === GroupCallStatus.OutgoingRing ||
callHistory.status === GroupCallStatus.Ringing || callHistory.status === GroupCallStatus.Ringing ||
callHistory.status === DirectCallStatus.Missed || callHistory.status === DirectCallStatus.Missed ||
callHistory.status === DirectCallStatus.MissedNotificationProfile ||
callHistory.status === GroupCallStatus.Missed || callHistory.status === GroupCallStatus.Missed ||
callHistory.status === AdhocCallStatus.Pending callHistory.status === GroupCallStatus.MissedNotificationProfile ||
callHistory.status === AdhocCallStatus.Pending ||
callHistory.status === AdhocCallStatus.Unknown
) { ) {
return latestTimestamp; return latestTimestamp;
} }
@ -801,6 +809,7 @@ function transitionGroupCallStatus(
} }
case GroupCallStatus.Ringing: case GroupCallStatus.Ringing:
case GroupCallStatus.Missed: case GroupCallStatus.Missed:
case GroupCallStatus.MissedNotificationProfile:
case GroupCallStatus.Declined: { case GroupCallStatus.Declined: {
return GroupCallStatus.Accepted; return GroupCallStatus.Accepted;
} }

View file

@ -35,7 +35,10 @@ export function getDirectCallNotificationText(
callStatus: DirectCallStatus, callStatus: DirectCallStatus,
i18n: LocalizerType i18n: LocalizerType
): string { ): string {
if (callStatus === DirectCallStatus.Pending) { if (
callStatus === DirectCallStatus.Pending ||
callStatus === DirectCallStatus.Unknown
) {
if (callDirection === CallDirection.Incoming) { if (callDirection === CallDirection.Incoming) {
return callType === CallType.Video return callType === CallType.Video
? i18n('icu:incomingVideoCall') ? i18n('icu:incomingVideoCall')
@ -68,7 +71,10 @@ export function getDirectCallNotificationText(
: i18n('icu:missedOrDeclinedOutgoingAudioCall'); : i18n('icu:missedOrDeclinedOutgoingAudioCall');
} }
if (callStatus === DirectCallStatus.Missed) { if (
callStatus === DirectCallStatus.Missed ||
callStatus === DirectCallStatus.MissedNotificationProfile
) {
if (callDirection === CallDirection.Incoming) { if (callDirection === CallDirection.Incoming) {
return callType === CallType.Video return callType === CallType.Video
? i18n('icu:missedIncomingVideoCall') ? i18n('icu:missedIncomingVideoCall')
@ -219,5 +225,8 @@ export function getCallingIcon(
if (callType === CallType.Group || callType === CallType.Adhoc) { if (callType === CallType.Group || callType === CallType.Adhoc) {
return 'video'; return 'video';
} }
if (callType === CallType.Unknown) {
return 'video';
}
throw missingCaseError(callType); throw missingCaseError(callType);
} }