diff --git a/protos/Backups.proto b/protos/Backups.proto index f2ca9e719e..6093a585e3 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -276,7 +276,7 @@ message AdHocCall { } uint64 callId = 1; - // Refers to a `CallLink` recipient. + // Refers to a Recipient with the `callLink` field set uint64 recipientId = 2; State state = 3; uint64 callTimestamp = 4; @@ -703,6 +703,7 @@ message IndividualCall { Direction direction = 3; State state = 4; uint64 startedCallTimestamp = 5; + bool read = 6; } message GroupCall { @@ -734,6 +735,7 @@ message GroupCall { uint64 startedCallTimestamp = 5; // The time the call ended. 0 indicates an unknown time. uint64 endedCallTimestamp = 6; + bool read = 7; } message SimpleChatUpdate { diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index af3c0f04c3..638f72ec65 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -7,6 +7,8 @@ import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; import pMap from 'p-map'; import pTimeout from 'p-timeout'; import { Readable } from 'stream'; +import { isNumber } from 'lodash'; +import { CallLinkRootKey } from '@signalapp/ringrtc'; import { Backups, SignalService } from '../../protobuf'; import { @@ -89,7 +91,18 @@ import { BACKUP_VERSION } from './constants'; import { getMessageIdForLogging } from '../../util/idForLogging'; import { getCallsHistoryForRedux } from '../callHistoryLoader'; 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 { hslToRGB } from '../../util/hslToRGB'; import type { AboutMe, LocalChatStyle } from './types'; @@ -113,6 +126,10 @@ import { AttachmentBackupManager } from '../../jobs/AttachmentBackupManager'; import { getBackupCdnInfo } from './util/mediaId'; import { calculateExpirationTimestamp } from '../../util/expirationTimer'; 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; @@ -180,6 +197,7 @@ export class BackupExportStream extends Readable { private readonly backupTimeMs = getSafeLongFromTimestamp(this.now); private readonly convoIdToRecipientId = new Map(); + private readonly roomIdToRecipientId = new Map(); private attachmentBackupJobs: Array = []; private buffers = new Array(); private nextRecipientId = 0; @@ -230,6 +248,8 @@ export class BackupExportStream extends Readable { await this.flush(); const stats = { + adHocCalls: 0, + callLinks: 0, conversations: 0, chats: 0, distributionLists: 0, @@ -283,7 +303,7 @@ export class BackupExportStream extends Readable { this.pushFrame({ recipient: { - id: this.getDistributionListRecipientId(), + id: Long.fromNumber(this.getNextRecipientId()), distributionList: { distributionId: uuidToBytes(list.id), deletionTimestamp: list.deletedAtTimestamp @@ -309,6 +329,48 @@ export class BackupExportStream extends Readable { 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(); for (const { id, key } of stickerPacks) { @@ -376,6 +438,37 @@ export class BackupExportStream extends Readable { 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; const callHistory = getCallsHistoryForRedux(); @@ -640,11 +733,11 @@ export class BackupExportStream extends Readable { return result; } - private getDistributionListRecipientId(): Long { + private getNextRecipientId(): number { const recipientId = this.nextRecipientId; this.nextRecipientId += 1; - return Long.fromNumber(recipientId); + return recipientId; } private toRecipient( @@ -1082,7 +1175,7 @@ export class BackupExportStream extends Readable { async toChatItemUpdate( options: NonBubbleOptionsType ): Promise { - const { authorId, message } = options; + const { authorId, callHistoryByCallId, message } = options; const logId = `toChatItemUpdate(${getMessageIdForLogging(message)})`; const updateMessage = new Backups.ChatUpdateMessage(); @@ -1092,97 +1185,83 @@ export class BackupExportStream extends Readable { }; if (isCallHistory(message)) { - // TODO (DESKTOP-6964) - // const callingMessage = new Backups.CallChatUpdate(); - // const { callId } = message; - // if (!callId) { - // throw new Error( - // `${logId}: Message was callHistory, but missing callId!` - // ); - // } - // const callHistory = callHistoryByCallId[callId]; - // if (!callHistory) { - // throw new Error( - // `${logId}: Message had callId, but no call history details were found!` - // ); - // } - // callingMessage.callId = Long.fromString(callId); - // if (callHistory.mode === CallMode.Group) { - // const groupCall = new Backups.GroupCallChatUpdate(); - // const { ringerId } = callHistory; - // if (!ringerId) { - // throw new Error( - // `${logId}: Message had missing ringerId for a group call!` - // ); - // } - // groupCall.startedCallAci = this.aciToBytes(ringerId); - // groupCall.startedCallTimestamp = Long.fromNumber(callHistory.timestamp); - // // Note: we don't store inCallACIs, instead relying on RingRTC in-memory state - // callingMessage.groupCall = groupCall; - // } else { - // const callMessage = new Backups.IndividualCallChatUpdate(); - // const { direction, type, status } = callHistory; - // if ( - // status === DirectCallStatus.Accepted || - // status === DirectCallStatus.Pending - // ) { - // if (type === CallType.Audio) { - // callMessage.type = - // direction === CallDirection.Incoming - // ? Backups.IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL - // : Backups.IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL; - // } else if (type === CallType.Video) { - // callMessage.type = - // direction === CallDirection.Incoming - // ? Backups.IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL - // : Backups.IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL; - // } else { - // throw new Error( - // `${logId}: Message direct status '${status}' call had type ${type}` - // ); - // } - // } else if (status === DirectCallStatus.Declined) { - // if (direction === CallDirection.Incoming) { - // // question: do we really not call declined calls things that we decline? - // throw new Error( - // `${logId}: Message direct call was declined but incoming` - // ); - // } - // if (type === CallType.Audio) { - // callMessage.type = - // Backups.IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_AUDIO_CALL; - // } else if (type === CallType.Video) { - // callMessage.type = - // Backups.IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_VIDEO_CALL; - // } else { - // throw new Error( - // `${logId}: Message direct status '${status}' call had type ${type}` - // ); - // } - // } else if (status === DirectCallStatus.Missed) { - // if (direction === CallDirection.Outgoing) { - // throw new Error( - // `${logId}: Message direct call was missed but outgoing` - // ); - // } - // if (type === CallType.Audio) { - // callMessage.type = - // Backups.IndividualCallChatUpdate.Type.MISSED_INCOMING_AUDIO_CALL; - // } else if (type === CallType.Video) { - // 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; + const conversation = window.ConversationController.get( + message.conversationId + ); + + if (!conversation) { + throw new Error( + `${logId}: callHistory message had unknown conversationId!` + ); + } + + const { callId } = message; + if (!callId) { + throw new Error(`${logId}: callHistory message was missing callId!`); + } + + const callHistory = callHistoryByCallId[callId]; + if (!callHistory) { + throw new Error( + `${logId}: callHistory message had callId, but no call history details were found!` + ); + } + + if (isGroup(conversation.attributes)) { + const groupCall = new Backups.GroupCall(); + + strictAssert( + callHistory.mode === CallMode.Group, + 'in group, should be group call' + ); + + if (callHistory.status === GroupCallStatus.Deleted) { + return { kind: NonBubbleResultKind.Drop }; + } + + const { ringerId } = callHistory; + if (ringerId) { + const ringerConversation = + window.ConversationController.get(ringerId); + if (!ringerConversation) { + throw new Error( + 'toChatItemUpdate/callHistory: ringerId conversation not found!' + ); + } + + const recipientId = this.getRecipientId( + ringerConversation.attributes + ); + groupCall.ringerRecipientId = recipientId; + groupCall.startedCallRecipientId = recipientId; + } + + groupCall.callId = Long.fromString(callId); + groupCall.state = toGroupCallStateProto(callHistory.status); + groupCall.startedCallTimestamp = Long.fromNumber(callHistory.timestamp); + groupCall.endedCallTimestamp = Long.fromNumber(0); + groupCall.read = message.seenStatus === SeenStatus.Seen; + + updateMessage.groupCall = groupCall; + return { kind: NonBubbleResultKind.Directionless, patch }; + } + + const individualCall = new Backups.IndividualCall(); + const { direction, type, status, timestamp } = callHistory; + + if (status === GroupCallStatus.Deleted) { + return { kind: NonBubbleResultKind.Drop }; + } + + individualCall.callId = Long.fromString(callId); + individualCall.type = toIndividualCallTypeProto(type); + individualCall.direction = toIndividualCallDirectionProto(direction); + individualCall.state = toIndividualCallStateProto(status); + individualCall.startedCallTimestamp = Long.fromNumber(timestamp); + individualCall.read = message.seenStatus === SeenStatus.Seen; + + updateMessage.individualCall = individualCall; + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isExpirationTimerUpdate(message)) { @@ -2484,3 +2563,127 @@ function hslToRGBInt(hue: number, saturation: number): number { // eslint-disable-next-line no-bitwise 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; +} diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 4ac04d2cb1..c79ebd6264 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -7,6 +7,7 @@ import { v4 as generateUuid } from 'uuid'; import pMap from 'p-map'; import { Writable } from 'stream'; import { isNumber } from 'lodash'; +import { CallLinkRootKey } from '@signalapp/ringrtc'; import { Backups, SignalService } from '../../protobuf'; import { DataWriter } from '../../sql/Client'; @@ -84,6 +85,20 @@ import { filterAndClean } from '../../types/BodyRange'; import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME'; import { copyFromQuotedMessage } from '../../messages/copyQuote'; 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; @@ -231,6 +246,7 @@ export class BackupImportStream extends Writable { number, ConversationAttributesType >(); + private readonly recipientIdToCallLink = new Map(); private readonly chatIdToConvo = new Map< number, ConversationAttributesType @@ -382,6 +398,8 @@ export class BackupImportStream extends Writable { if (frame.recipient) { const { recipient } = frame; strictAssert(recipient.id != null, 'Recipient must have an id'); + const recipientId = recipient.id.toNumber(); + let convo: ConversationAttributesType; if (recipient.contact) { convo = await this.fromContact(recipient.contact); @@ -402,6 +420,11 @@ export class BackupImportStream extends Writable { } else if (recipient.distributionList) { await this.fromDistributionList(recipient.distributionList); + // Not a conversation + return; + } else if (recipient.callLink) { + await this.fromCallLink(recipientId, recipient.callLink); + // Not a conversation return; } else { @@ -413,7 +436,7 @@ export class BackupImportStream extends Writable { this.saveConversation(convo); } - this.recipientIdToConvo.set(recipient.id.toNumber(), convo); + this.recipientIdToConvo.set(recipientId, convo); } else if (frame.chat) { await this.fromChat(frame.chat); } else if (frame.chatItem) { @@ -426,6 +449,8 @@ export class BackupImportStream extends Writable { await this.fromChatItem(frame.chatItem, { aboutMe }); } else if (frame.stickerPack) { await this.fromStickerPack(frame.stickerPack); + } else if (frame.adHocCall) { + await this.fromAdHocCall(frame.adHocCall); } else { log.warn(`${this.logId}: unsupported frame item ${frame.item}`); } @@ -463,6 +488,12 @@ export class BackupImportStream extends Writable { this.saveMessageBatcher.add(attributes); } + private async saveCallHistory( + callHistory: CallHistoryDetails + ): Promise { + await DataWriter.saveCallHistory(callHistory); + } + private async fromAccount({ profileKey, username, @@ -987,6 +1018,38 @@ export class BackupImportStream extends Writable { await DataWriter.createNewStoryDistribution(result); } + private async fromCallLink( + recipientId: number, + callLinkProto: Backups.ICallLink + ): Promise { + 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 { strictAssert(chat.id != null, 'chat must have an id'); strictAssert(chat.recipientId != null, 'chat must have a recipientId'); @@ -1329,7 +1392,12 @@ export class BackupImportStream extends Writable { } strictAssert(directionless, 'Absent direction state'); - return { patch: {} }; + return { + patch: { + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, + }, + }; } private async fromStandardMessage( @@ -1757,7 +1825,7 @@ export class BackupImportStream extends Writable { timestamp: number; } ): Promise { - const { aboutMe, author } = options; + const { aboutMe, author, conversation } = options; if (updateMessage.groupChange) { return this.fromGroupUpdateMessage(updateMessage.groupChange, options); @@ -1870,8 +1938,117 @@ export class BackupImportStream extends Writable { }; } - // TODO (DESKTOP-6964): check these fields - // updateMessage.callingMessage + if (updateMessage.groupCall) { + 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; } @@ -2572,6 +2749,44 @@ export class BackupImportStream extends Writable { ); } + private async fromAdHocCall({ + callId: callIdLong, + recipientId: recipientIdLong, + state, + callTimestamp, + }: Backups.IAdHocCall): Promise { + 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( customChatColors: | ReadonlyArray @@ -2766,3 +2981,133 @@ function rgbIntToHSL(intValue: number): { hue: number; saturation: number } { 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; +} diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 04732060f3..df391134d4 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -779,6 +779,7 @@ type WritableInterface = { _removeAllMessages: () => void; clearCallHistory: (target: CallLogEventTarget) => ReadonlyArray; + _removeAllCallHistory: () => void; markCallHistoryDeleted: (callId: string) => void; cleanupCallHistoryMessages: () => void; markCallHistoryRead(callId: string): void; @@ -796,6 +797,7 @@ type WritableInterface = { beginDeleteAllCallLinks(): void; beginDeleteCallLink(roomId: string): void; finalizeDeleteCallLink(roomId: string): void; + _removeAllCallLinks(): void; deleteCallLinkFromSync(roomId: string): void; migrateConversationMessages: (obsoleteId: string, currentId: string) => void; saveEditedMessage: ( diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index dace9a7e85..6fd9310100 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -182,6 +182,7 @@ import { finalizeDeleteCallLink, beginDeleteCallLink, deleteCallLinkFromSync, + _removeAllCallLinks, } from './server/callLinks'; import { replaceAllEndorsementsForGroup, @@ -425,6 +426,7 @@ export const DataWriter: ServerWritableInterface = { _removeAllMessages, getUnreadEditedMessagesAndMarkRead, clearCallHistory, + _removeAllCallHistory, markCallHistoryDeleted, cleanupCallHistoryMessages, markCallHistoryRead, @@ -438,6 +440,7 @@ export const DataWriter: ServerWritableInterface = { beginDeleteAllCallLinks, beginDeleteCallLink, finalizeDeleteCallLink, + _removeAllCallLinks, deleteCallLinkFromSync, migrateConversationMessages, saveEditedMessage, @@ -3472,6 +3475,13 @@ function getAllCallHistory(db: ReadableDB): ReadonlyArray { return db.prepare(query).all(); } +function _removeAllCallHistory(db: WritableDB): void { + const [query, params] = sql` + DELETE FROM callsHistory; + `; + db.prepare(query).run(params); +} + function clearCallHistory( db: WritableDB, target: CallLogEventTarget diff --git a/ts/sql/server/callLinks.ts b/ts/sql/server/callLinks.ts index 2736035c31..444f63be74 100644 --- a/ts/sql/server/callLinks.ts +++ b/ts/sql/server/callLinks.ts @@ -237,3 +237,10 @@ export function finalizeDeleteCallLink(db: WritableDB, roomId: string): void { `; db.prepare(query).run(params); } + +export function _removeAllCallLinks(db: WritableDB): void { + const [query, params] = sql` + DELETE FROM callLinks; + `; + db.prepare(query).run(params); +} diff --git a/ts/test-electron/backup/attachments_test.ts b/ts/test-electron/backup/attachments_test.ts index 25a0bb5b19..541e627411 100644 --- a/ts/test-electron/backup/attachments_test.ts +++ b/ts/test-electron/backup/attachments_test.ts @@ -39,9 +39,9 @@ describe('backup/attachments', () => { let contactA: ConversationModel; beforeEach(async () => { - await DataWriter._removeAllMessages(); - await DataWriter._removeAllConversations(); + await DataWriter.removeAll(); window.storage.reset(); + window.ConversationController.reset(); await setupBasics(); @@ -69,7 +69,9 @@ describe('backup/attachments', () => { }); }); - afterEach(() => { + afterEach(async () => { + await DataWriter.removeAll(); + sandbox.restore(); }); diff --git a/ts/test-electron/backup/backup_groupv2_notifications_test.ts b/ts/test-electron/backup/backup_groupv2_notifications_test.ts index 0dbcd2b36c..711f5cb895 100644 --- a/ts/test-electron/backup/backup_groupv2_notifications_test.ts +++ b/ts/test-electron/backup/backup_groupv2_notifications_test.ts @@ -22,6 +22,8 @@ import { asymmetricRoundtripHarness, symmetricRoundtripHarness, } from './helpers'; +import { ReadStatus } from '../../messages/MessageReadStatus'; +import { SeenStatus } from '../../MessageSeenStatus'; // Note: this should be kept up to date with GroupV2Change.stories.tsx, to // maintain the comprehensive set of GroupV2 notifications we need to handle @@ -66,6 +68,8 @@ function createMessage( received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, type: 'group-v2-change', sourceServiceId, }; @@ -73,8 +77,8 @@ function createMessage( describe('backup/groupv2/notifications', () => { beforeEach(async () => { - await DataWriter._removeAllMessages(); - await DataWriter._removeAllConversations(); + await DataWriter.removeAll(); + window.ConversationController.reset(); window.storage.reset(); await setupBasics(); @@ -112,6 +116,9 @@ describe('backup/groupv2/notifications', () => { await loadCallsHistory(); }); + afterEach(async () => { + await DataWriter.removeAll(); + }); describe('roundtrips given groupv2 notifications with', () => { it('Multiple items', async () => { @@ -2031,6 +2038,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }; @@ -2047,6 +2056,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }; @@ -2076,6 +2087,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, }; counter += 1; @@ -2092,6 +2105,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, }; const messages: Array = [ @@ -2124,6 +2139,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }; @@ -2140,6 +2157,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }; @@ -2156,6 +2175,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }; @@ -2188,6 +2209,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }; const legacyAfter = { @@ -2202,6 +2225,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }; @@ -2221,6 +2246,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }; const allDataAfter = { @@ -2235,6 +2262,8 @@ describe('backup/groupv2/notifications', () => { received_at: counter, sent_at: counter, timestamp: counter, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }; diff --git a/ts/test-electron/backup/calling_test.ts b/ts/test-electron/backup/calling_test.ts new file mode 100644 index 0000000000..fe89a9d45a --- /dev/null +++ b/ts/test-electron/backup/calling_test.ts @@ -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); + }); + }); +}); diff --git a/ts/test-electron/backup/helpers.ts b/ts/test-electron/backup/helpers.ts index f29a6ed240..a791e931c1 100644 --- a/ts/test-electron/backup/helpers.ts +++ b/ts/test-electron/backup/helpers.ts @@ -199,9 +199,7 @@ export async function asymmetricRoundtripHarness( } async function clearData() { - await DataWriter._removeAllMessages(); - await DataWriter._removeAllConversations(); - await DataWriter.removeAllItems(); + await DataWriter.removeAll(); window.storage.reset(); window.ConversationController.reset(); diff --git a/ts/test-electron/backup/non_bubble_test.ts b/ts/test-electron/backup/non_bubble_test.ts index 5242be2f4a..35a676ecce 100644 --- a/ts/test-electron/backup/non_bubble_test.ts +++ b/ts/test-electron/backup/non_bubble_test.ts @@ -87,6 +87,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: OUR_ACI, }, ]); @@ -101,6 +103,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }, ]); @@ -116,6 +120,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }, ]); @@ -132,6 +138,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }, ]); @@ -148,6 +156,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }, ]); @@ -162,6 +172,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }, ]); @@ -176,6 +188,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }, ]); @@ -190,6 +204,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, }, ]); @@ -399,6 +415,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, sourceServiceId: CONTACT_A, sourceDevice: 1, @@ -419,6 +437,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, changedId: contactA.id, sourceServiceId: CONTACT_A, profileChange: { @@ -439,6 +459,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, titleTransition: { renderInfo: { @@ -460,6 +482,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, conversationMerge: { renderInfo: { type: 'private', @@ -479,6 +503,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sent_at: 1, timestamp: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, phoneNumberDiscovery: { e164: '+12125551234', @@ -499,8 +525,8 @@ describe('backup/non-bubble messages', () => { sourceDevice: 1, sent_at: 1, timestamp: 1, - readStatus: ReadStatus.Unread, - seenStatus: SeenStatus.Unseen, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, unidentifiedDeliveryReceived: true, supportedVersionAtReceive: 5, requiredProtocolVersion: 6, @@ -537,6 +563,8 @@ describe('backup/non-bubble messages', () => { }, received_at: 1, sent_at: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sourceServiceId: CONTACT_A, timestamp: 1, }, @@ -553,6 +581,8 @@ describe('backup/non-bubble messages', () => { received_at: 1, sourceServiceId: CONTACT_A, sourceDevice: 1, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, sent_at: 1, timestamp: 1, messageRequestResponseEvent: MessageRequestResponseEvent.SPAM, diff --git a/ts/test-electron/sql/getCallHistoryGroups_test.ts b/ts/test-electron/sql/getCallHistoryGroups_test.ts index 2c74b941e2..782a32c89d 100644 --- a/ts/test-electron/sql/getCallHistoryGroups_test.ts +++ b/ts/test-electron/sql/getCallHistoryGroups_test.ts @@ -70,6 +70,9 @@ describe('sql/getCallHistoryGroups', () => { beforeEach(async () => { await removeAll(); }); + after(async () => { + await removeAll(); + }); it('should merge related items in order', async () => { const now = Date.now(); diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts index d642840895..ae7af5eb89 100644 --- a/ts/types/CallDisposition.ts +++ b/ts/types/CallDisposition.ts @@ -10,7 +10,7 @@ import { SignalService as Proto } from '../protobuf'; import * as Bytes from '../Bytes'; 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 { Direct = 'Direct', Group = 'Group', @@ -22,11 +22,15 @@ export enum CallType { Video = 'Video', Group = 'Group', Adhoc = 'Adhoc', + // Only used for backup roundtripping + Unknown = 'Unknown', } export enum CallDirection { Incoming = 'Incoming', Outgoing = 'Outgoing', + // Only used for backup roundtripping + Unknown = 'Unknown', } export enum CallLogEvent { @@ -59,6 +63,8 @@ export enum CallStatusValue { Pending = 'Pending', Accepted = 'Accepted', Missed = 'Missed', + // TODO: DESKTOP-3483 - not generated locally + MissedNotificationProfile = 'MissedNotificationProfile', Declined = 'Declined', Deleted = 'Deleted', GenericGroupCall = 'GenericGroupCall', @@ -67,14 +73,20 @@ export enum CallStatusValue { Ringing = 'Ringing', Joined = 'Joined', JoinedAdhoc = 'JoinedAdhoc', + // Only used for backup roundtripping + Unknown = 'Unknown', } export enum DirectCallStatus { Pending = CallStatusValue.Pending, Accepted = CallStatusValue.Accepted, Missed = CallStatusValue.Missed, + // TODO: DESKTOP-3483 - not generated locally + MissedNotificationProfile = CallStatusValue.MissedNotificationProfile, Declined = CallStatusValue.Declined, Deleted = CallStatusValue.Deleted, + // Only used for backup roundtripping + Unknown = CallStatusValue.Unknown, } export enum GroupCallStatus { @@ -84,6 +96,8 @@ export enum GroupCallStatus { Joined = CallStatusValue.Joined, Accepted = CallStatusValue.Accepted, Missed = CallStatusValue.Missed, + // TODO: DESKTOP-3483 - not generated locally + MissedNotificationProfile = CallStatusValue.MissedNotificationProfile, Declined = CallStatusValue.Declined, Deleted = CallStatusValue.Deleted, } @@ -93,6 +107,8 @@ export enum AdhocCallStatus { Pending = CallStatusValue.Pending, Joined = CallStatusValue.JoinedAdhoc, Deleted = CallStatusValue.Deleted, + // Only used for backup roundtripping + Unknown = CallStatusValue.Unknown, } export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus; diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 99e5b9c27e..4bfb44616b 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -284,6 +284,7 @@ export function getCallLogEventForProto( const directionToProto = { [CallDirection.Incoming]: Proto.SyncMessage.CallEvent.Direction.INCOMING, [CallDirection.Outgoing]: Proto.SyncMessage.CallEvent.Direction.OUTGOING, + [CallDirection.Unknown]: Proto.SyncMessage.CallEvent.Direction.UNKNOWN, }; const typeToProto = { @@ -291,6 +292,7 @@ const typeToProto = { [CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL, [CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL, [CallType.Adhoc]: Proto.SyncMessage.CallEvent.Type.AD_HOC_CALL, + [CallType.Unknown]: Proto.SyncMessage.CallEvent.Type.UNKNOWN, }; const statusToProto: Record< @@ -301,6 +303,7 @@ const statusToProto: Record< [CallStatusValue.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED, [CallStatusValue.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE, [CallStatusValue.Missed]: null, + [CallStatusValue.MissedNotificationProfile]: null, [CallStatusValue.Pending]: null, [CallStatusValue.GenericGroupCall]: null, [CallStatusValue.GenericAdhocCall]: @@ -309,6 +312,7 @@ const statusToProto: Record< [CallStatusValue.Ringing]: null, [CallStatusValue.Joined]: null, [CallStatusValue.JoinedAdhoc]: Proto.SyncMessage.CallEvent.Event.ACCEPTED, + [CallStatusValue.Unknown]: Proto.SyncMessage.CallEvent.Event.UNKNOWN, }; function shouldSyncStatus(callStatus: CallStatus) { @@ -681,12 +685,16 @@ function transitionTimestamp( // We don't care about holding onto timestamps that were from these states if ( callHistory.status === DirectCallStatus.Pending || + callHistory.status === DirectCallStatus.Unknown || callHistory.status === GroupCallStatus.GenericGroupCall || callHistory.status === GroupCallStatus.OutgoingRing || callHistory.status === GroupCallStatus.Ringing || callHistory.status === DirectCallStatus.Missed || + callHistory.status === DirectCallStatus.MissedNotificationProfile || callHistory.status === GroupCallStatus.Missed || - callHistory.status === AdhocCallStatus.Pending + callHistory.status === GroupCallStatus.MissedNotificationProfile || + callHistory.status === AdhocCallStatus.Pending || + callHistory.status === AdhocCallStatus.Unknown ) { return latestTimestamp; } @@ -801,6 +809,7 @@ function transitionGroupCallStatus( } case GroupCallStatus.Ringing: case GroupCallStatus.Missed: + case GroupCallStatus.MissedNotificationProfile: case GroupCallStatus.Declined: { return GroupCallStatus.Accepted; } diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index 508110bd43..a63e0c42ed 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -35,7 +35,10 @@ export function getDirectCallNotificationText( callStatus: DirectCallStatus, i18n: LocalizerType ): string { - if (callStatus === DirectCallStatus.Pending) { + if ( + callStatus === DirectCallStatus.Pending || + callStatus === DirectCallStatus.Unknown + ) { if (callDirection === CallDirection.Incoming) { return callType === CallType.Video ? i18n('icu:incomingVideoCall') @@ -68,7 +71,10 @@ export function getDirectCallNotificationText( : i18n('icu:missedOrDeclinedOutgoingAudioCall'); } - if (callStatus === DirectCallStatus.Missed) { + if ( + callStatus === DirectCallStatus.Missed || + callStatus === DirectCallStatus.MissedNotificationProfile + ) { if (callDirection === CallDirection.Incoming) { return callType === CallType.Video ? i18n('icu:missedIncomingVideoCall') @@ -219,5 +225,8 @@ export function getCallingIcon( if (callType === CallType.Group || callType === CallType.Adhoc) { return 'video'; } + if (callType === CallType.Unknown) { + return 'video'; + } throw missingCaseError(callType); }