// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client'; import { BackupLevel, ReceiptCredentialPresentation, } from '@signalapp/libsignal-client/zkgroup.js'; import { v7 as generateUuid } from 'uuid'; import pMap from 'p-map'; import { Writable } from 'node:stream'; import lodash from 'lodash'; import { CallLinkRootKey } from '@signalapp/ringrtc'; import type Long from 'long'; import { Backups, SignalService } from '../../protobuf/index.std.js'; import { DataReader, DataWriter } from '../../sql/Client.preload.js'; import { AttachmentDownloadSource, type StoryDistributionWithMembersType, type IdentityKeyType, } from '../../sql/Interface.std.js'; import { createLogger } from '../../logging/log.std.js'; import { GiftBadgeStates } from '../../types/GiftBadgeStates.std.js'; import { StorySendMode, MY_STORY_ID } from '../../types/Stories.std.js'; import type { AciString, ServiceIdString } from '../../types/ServiceId.std.js'; import * as LinkPreview from '../../types/LinkPreview.std.js'; import { fromAciObject, fromPniObject, fromServiceIdObject, } from '../../types/ServiceId.std.js'; import { isStoryDistributionId } from '../../types/StoryDistributionId.std.js'; import * as Errors from '../../types/errors.std.js'; import { PaymentEventKind } from '../../types/Payment.std.js'; import { MessageRequestResponseEvent } from '../../types/MessageRequestResponseEvent.std.js'; import { ContactFormType, AddressType as ContactAddressType, } from '../../types/EmbeddedContact.std.js'; import { STICKERPACK_ID_BYTE_LEN, STICKERPACK_KEY_BYTE_LEN, createPacksFromBackup, type StickerPackPointerType, } from '../../types/Stickers.preload.js'; import type { ConversationColorType, CustomColorsItemType, CustomColorType, CustomColorDataType, } from '../../types/Colors.std.js'; import { SEALED_SENDER } from '../../types/SealedSender.std.js'; import type { ConversationAttributesType, CustomError, MessageAttributesType, MessageReactionType, EditHistoryType, QuotedMessageType, } from '../../model-types.d.ts'; import { assertDev, strictAssert } from '../../util/assert.std.js'; import { upgradeMessageSchema } from '../../util/migrations.preload.js'; import { getCheckedTimestampFromLong, getCheckedTimestampOrUndefinedFromLong, getTimestampOrUndefinedFromLong, } from '../../util/timestampLongUtils.std.js'; import { MAX_SAFE_DATE } from '../../util/timestamp.std.js'; import { DurationInSeconds, SECOND } from '../../util/durations/index.std.js'; import { calculateExpirationTimestamp } from '../../util/expirationTimer.std.js'; import { dropNull } from '../../util/dropNull.std.js'; import { deriveGroupID, deriveGroupSecretParams, deriveGroupPublicParams, deriveAccessKeyFromProfileKey, } from '../../util/zkgroup.node.js'; import { incrementMessageCounter } from '../../util/incrementMessageCounter.preload.js'; import { generateMessageId } from '../../util/generateMessageId.node.js'; import { isAciString } from '../../util/isAciString.std.js'; import { PhoneNumberDiscoverability } from '../../util/phoneNumberDiscoverability.std.js'; import { PhoneNumberSharingMode } from '../../types/PhoneNumberSharingMode.std.js'; import { bytesToUuid } from '../../util/uuidToBytes.std.js'; import { missingCaseError } from '../../util/missingCaseError.std.js'; import { ReadStatus } from '../../messages/MessageReadStatus.std.js'; import { SendStatus } from '../../messages/MessageSendState.std.js'; import type { SendStateByConversationId } from '../../messages/MessageSendState.std.js'; import { SeenStatus } from '../../MessageSeenStatus.std.js'; import { constantTimeEqual } from '../../Crypto.node.js'; import { signalProtocolStore } from '../../SignalProtocolStore.preload.js'; import * as Bytes from '../../Bytes.std.js'; import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants.std.js'; import { UnsupportedBackupVersion } from './errors.std.js'; import type { AboutMe, BackupImportOptions, LocalChatStyle, } from './types.std.js'; import { getBackupMediaRootKey } from './crypto.preload.js'; import type { GroupV2ChangeDetailType } from '../../types/groups.std.js'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads.preload.js'; import { isNotNil } from '../../util/isNotNil.std.js'; import { isGroup } from '../../util/whatTypeOfConversation.dom.js'; import { rgbIntToHSL } from '../../util/rgbToHSL.std.js'; import { convertBackupMessageAttachmentToAttachment, convertFilePointerToAttachment, } from './util/filePointers.preload.js'; import { trimMessageWhitespace } from '../../types/BodyRange.std.js'; import { filterAndClean } from '../../util/BodyRange.node.js'; import { APPLICATION_OCTET_STREAM, stringToMIMEType, } from '../../types/MIME.std.js'; import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue.preload.js'; import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager.preload.js'; import { AdhocCallStatus, CallDirection, CallMode, CallType, DirectCallStatus, GroupCallStatus, } from '../../types/CallDisposition.std.js'; import type { CallHistoryDetails } from '../../types/CallDisposition.std.js'; import { CallLinkRestrictions, isCallLinkAdmin, } from '../../types/CallLink.std.js'; import type { CallLinkType } from '../../types/CallLink.std.js'; import type { RawBodyRange } from '../../types/BodyRange.std.js'; import { fromAdminKeyBytes, toCallHistoryFromUnusedCallLink, } from '../../util/callLinks.std.js'; import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc.node.js'; import { loadAllAndReinitializeRedux } from '../allLoaders.preload.js'; import { startBackupMediaDownload, resetBackupMediaDownloadStats, } from '../../util/backupMediaDownload.preload.js'; import { getEnvironment, isTestEnvironment, isTestOrMockEnvironment, } from '../../environment.std.js'; import { hasAttachmentDownloads } from '../../util/hasAttachmentDownloads.std.js'; import { isAdhoc, isNightly } from '../../util/version.std.js'; import { ToastType } from '../../types/Toast.dom.js'; import { isConversationAccepted } from '../../util/isConversationAccepted.preload.js'; import { saveBackupsSubscriberData } from '../../util/backupSubscriptionData.preload.js'; import { postSaveUpdates } from '../../util/cleanup.preload.js'; import type { LinkPreviewType } from '../../types/message/LinkPreviews.std.js'; import { MessageModel } from '../../models/messages.preload.js'; import { DEFAULT_PROFILE_COLOR, fromDayOfWeekArray, type NotificationProfileType, } from '../../types/NotificationProfile.std.js'; import { normalizeNotificationProfileId } from '../../types/NotificationProfile-node.node.js'; import { updateBackupMediaDownloadProgress } from '../../util/updateBackupMediaDownloadProgress.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; import { ChatFolderType } from '../../types/ChatFolder.std.js'; import type { ChatFolderId, ChatFolder } from '../../types/ChatFolder.std.js'; import { expiresTooSoonForBackup } from './util/expiration.std.js'; import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js'; import type { PinnedMessageParams } from '../../types/PinnedMessage.std.js'; import type { ThemeType } from '../../util/preload.preload.js'; const { isNumber } = lodash; const log = createLogger('import'); const MAX_CONCURRENCY = 10; const SAVE_MESSAGE_BATCH_SIZE = 10000; type ChatItemParseResult = { message: Partial; additionalMessages: Array>; }; const SKIP = 'SKIP' as const; function phoneToContactFormType( type: Backups.ContactAttachment.Phone.Type | null | undefined ): ContactFormType { const { Type } = Backups.ContactAttachment.Phone; switch (type) { case Type.HOME: return ContactFormType.HOME; case Type.MOBILE: return ContactFormType.MOBILE; case Type.WORK: return ContactFormType.WORK; case Type.CUSTOM: return ContactFormType.CUSTOM; case undefined: case null: case Type.UNKNOWN: return ContactFormType.HOME; default: throw missingCaseError(type); } } function emailToContactFormType( type: Backups.ContactAttachment.Email.Type | null | undefined ): ContactFormType { const { Type } = Backups.ContactAttachment.Email; switch (type) { case Type.HOME: return ContactFormType.HOME; case Type.MOBILE: return ContactFormType.MOBILE; case Type.WORK: return ContactFormType.WORK; case Type.CUSTOM: return ContactFormType.CUSTOM; case undefined: case null: case Type.UNKNOWN: return ContactFormType.HOME; default: throw missingCaseError(type); } } function addressToContactAddressType( type: Backups.ContactAttachment.PostalAddress.Type | null | undefined ): ContactAddressType { const { Type } = Backups.ContactAttachment.PostalAddress; switch (type) { case Type.HOME: return ContactAddressType.HOME; case Type.WORK: return ContactAddressType.WORK; case Type.CUSTOM: return ContactAddressType.CUSTOM; case undefined: case null: case Type.UNKNOWN: return ContactAddressType.HOME; default: throw missingCaseError(type); } } export class BackupImportStream extends Writable { #now = Date.now(); #parsedBackupInfo = false; #logId = 'BackupImportStream(unknown)'; #aboutMe: AboutMe | undefined; readonly #recipientIdToConvo = new Map(); readonly #recipientIdToCallLink = new Map(); readonly #adminCallLinksToHasCall = new Map(); readonly #chatIdToConvo = new Map(); readonly #conversations = new Map(); readonly #identityKeys = new Map(); readonly #saveMessageBatch = new Map< MessageAttributesType, Promise >(); #flushMessagesPromise: Promise | undefined; readonly #stickerPacks = new Array(); #ourConversation?: ConversationAttributesType; #pinnedConversations = new Array<[number, string]>(); #customColorById = new Map(); #releaseNotesRecipientId: Long | undefined; #releaseNotesChatId: Long | undefined; #pinnedMessages: Array = []; #frameErrorCount: number = 0; #backupTier: BackupLevel | undefined; private constructor(private readonly options: BackupImportOptions) { super({ objectMode: true }); } public static async create( options: BackupImportOptions ): Promise { await AttachmentDownloadManager.stop(); await DataWriter.removeAllBackupAttachmentDownloadJobs(); await resetBackupMediaDownloadStats(); return new BackupImportStream(options); } override async _write( data: Buffer, _enc: BufferEncoding, done: (error?: Error) => void ): Promise { try { if (!this.#parsedBackupInfo) { const info = Backups.BackupInfo.decode(data); this.#parsedBackupInfo = true; this.#logId = `BackupImport.run(${info.backupTimeMs})`; log.info(`${this.#logId}: got BackupInfo`); if (info.version?.toNumber() !== BACKUP_VERSION) { throw new UnsupportedBackupVersion(info.version); } if (Bytes.isEmpty(info.mediaRootBackupKey)) { throw new Error('Missing mediaRootBackupKey'); } await itemStorage.put( 'restoredBackupFirstAppVersion', info.firstAppVersion ); const theirKey = info.mediaRootBackupKey; const ourKey = getBackupMediaRootKey().serialize(); if (!constantTimeEqual(theirKey, ourKey)) { // Use root key from integration test if (isTestEnvironment(getEnvironment())) { await itemStorage.put( 'backupMediaRootKey', info.mediaRootBackupKey ); } else { throw new Error('Incorrect mediaRootBackupKey'); } } } else { const frame = Backups.Frame.decode(data); await this.#processFrame(frame, { aboutMe: this.#aboutMe }); if (!this.#aboutMe && this.#ourConversation) { const { serviceId, pni, e164 } = this.#ourConversation; strictAssert( isAciString(serviceId), 'ourConversation serviceId must be ACI' ); this.#aboutMe = { aci: serviceId, pni, e164, }; } } done(); } catch (error) { const entryType = this.#parsedBackupInfo ? 'frame' : 'info'; log.error(`${this.#logId}: failed to process ${entryType}`); done(error); } } override async _final(done: (error?: Error) => void): Promise { try { // Finish saving remaining conversations/messages // Save messages first since they depend on conversations in memory while (this.#flushMessagesPromise) { // eslint-disable-next-line no-await-in-loop await this.#flushMessagesPromise; } await this.#flushMessages(); await this.#flushConversations(); log.info(`${this.#logId}: flushed messages and conversations`); // Save pinned messages after messages const pinnedMessageLimit = getPinnedMessagesLimit(); const sortedPinnedMessages = this.#pinnedMessages.toSorted((a, b) => { return a.pinnedAt - b.pinnedAt; }); for (const params of sortedPinnedMessages) { try { // eslint-disable-next-line no-await-in-loop await DataWriter.appendPinnedMessage(pinnedMessageLimit, params); } catch (error) { log.error( `${this.#logId}: failed to append pinned message`, Errors.toLogFormat(error) ); } } // Store sticker packs and schedule downloads await createPacksFromBackup(this.#stickerPacks); // Add placeholder call history for unused admin call links to show in calls tab for (const [callLink, hasCall] of this.#adminCallLinksToHasCall) { if (!hasCall) { const callHistory = toCallHistoryFromUnusedCallLink(callLink); // eslint-disable-next-line no-await-in-loop await this.#saveCallHistory(callHistory); } } // Reset and reload conversations and storage again window.ConversationController.setReadOnly(false); window.ConversationController.reset(); await window.ConversationController.load(); await window.ConversationController.checkForConflicts(); itemStorage.reset(); await itemStorage.fetch(); // Load identity keys we just saved. await signalProtocolStore.hydrateCaches(); // Load all data into redux (need to do this before updating a // conversation's last message, which uses redux selectors) await loadAllAndReinitializeRedux(); const allConversations = window.ConversationController.getAll().sort( (convoA, convoB) => { if (convoA.get('isPinned')) { return -1; } if (convoB.get('isPinned')) { return 1; } return ( (convoB.get('active_at') ?? 0) - (convoA.get('active_at') ?? 0) ); } ); // Update last message in every active conversation now that we have // them loaded into memory. await pMap( allConversations.filter(convo => { return convo.get('active_at') || convo.get('isPinned'); }), async convo => { try { await convo.updateLastMessage(); } catch (error) { log.error( `${this.#logId}: failed to update conversation's last message` + `${Errors.toLogFormat(error)}` ); } }, { concurrency: MAX_CONCURRENCY } ); // Schedule group avatar download. await pMap( allConversations, async conversation => { if (this.options.type === 'cross-client-integration-test') { return; } if ( !isGroup(conversation.attributes) || !conversation.get('remoteAvatarUrl') ) { return; } await groupAvatarJobQueue.add({ conversationId: conversation.get('id'), newAvatarUrl: conversation.get('remoteAvatarUrl'), }); }, { concurrency: MAX_CONCURRENCY } ); await itemStorage.put( 'pinnedConversationIds', this.#pinnedConversations .sort(([a], [b]) => { return a - b; }) .map(([, id]) => id) ); await updateBackupMediaDownloadProgress( DataReader.getBackupAttachmentDownloadProgress ); if ( this.options.type !== 'cross-client-integration-test' && !isTestEnvironment(getEnvironment()) ) { await startBackupMediaDownload(); } if (this.#frameErrorCount > 0) { log.error( `${this.#logId}: errored while processing ${this.#frameErrorCount} frames.` ); if (isNightly(window.getVersion()) || isAdhoc(window.getVersion())) { window.reduxActions.toast.showToast({ toastType: ToastType.FailedToImportBackup, }); } } else { log.info(`${this.#logId}: successfully processed all frames.`); } done(); } catch (error) { done(error); } } async #processFrame( frame: Backups.Frame, options: { aboutMe?: AboutMe } ): Promise { const { aboutMe } = options; if (frame.account) { await this.#fromAccount(frame.account); // We run this outside of try catch below because failure to restore // the account data is fatal. return; } try { 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); } else if (recipient.releaseNotes) { strictAssert( this.#releaseNotesRecipientId == null, 'Duplicate release notes recipient' ); this.#releaseNotesRecipientId = recipient.id; // Not yet supported return; } else if (recipient.self) { convo = this.#fromSelf(recipient.self); } else if (recipient.group) { convo = await this.#fromGroup(recipient.group); } 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 { log.warn(`${this.#logId}: unsupported recipient destination`); throw new Error('Unsupported recipient destination'); } if (convo !== this.#ourConversation) { await this.#saveConversation(convo); } this.#recipientIdToConvo.set(recipientId, convo); } else if (frame.chat) { await this.#fromChat(frame.chat); } else if (frame.chatItem) { if (!aboutMe) { throw new Error( 'processFrame: Processing a chatItem frame, but no aboutMe data!' ); } 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 if (frame.notificationProfile) { await this.#fromNotificationProfile(frame.notificationProfile); } else if (frame.chatFolder) { await this.#fromChatFolder(frame.chatFolder); } else { log.warn( `${this.#logId}: unknown unsupported frame item ${frame.item}` ); throw new Error('Unknown unsupported frame type'); } } catch (error) { this.#frameErrorCount += 1; log.error( `${this.#logId}: failed to process a frame ${frame.item}, ` + `${Errors.toLogFormat(error)}` ); } } async #saveConversation( attributes: ConversationAttributesType ): Promise { this.#conversations.set(attributes.id, attributes); } async #updateConversation( attributes: ConversationAttributesType ): Promise { this.#conversations.set(attributes.id, attributes); } async #saveMessage(attributes: MessageAttributesType): Promise { this.#saveMessageBatch.set( attributes, this.#safeUpgradeMessage(attributes) ); if (this.#saveMessageBatch.size >= SAVE_MESSAGE_BATCH_SIZE) { // Wait for previous flush to finish before scheduling a new one. // (Unlikely to happen, but needed to make sure we don't save too many // messages at once) while (this.#flushMessagesPromise) { // eslint-disable-next-line no-await-in-loop await this.#flushMessagesPromise; } this.#flushMessagesPromise = this.#flushMessages(); } } async #safeUpgradeMessage( attributes: MessageAttributesType ): Promise { try { return await upgradeMessageSchema(attributes); } catch (error) { log.error( `${this.#logId}: failed to migrate a message ${attributes.sent_at}, ` + `${Errors.toLogFormat(error)}` ); return attributes; } } async #flushConversations(): Promise { const updates = new Array(); if (this.#ourConversation) { const us = this.#conversations.get(this.#ourConversation.id); if (us) { updates.push(us); this.#conversations.delete(us.id); } } const saves = Array.from(this.#conversations.values()); this.#conversations.clear(); const identityKeys = Array.from(this.#identityKeys.values()); this.#identityKeys.clear(); // Queue writes at the same time to prevent races. await Promise.all([ DataWriter.saveConversations(saves), DataWriter.updateConversations(updates), DataWriter.bulkAddIdentityKeys(identityKeys), ]); } async #flushMessages(): Promise { const ourAci = this.#ourConversation?.serviceId; strictAssert(isAciString(ourAci), 'Must have our aci for messages'); const batchPromises = Array.from(this.#saveMessageBatch.values()); this.#saveMessageBatch.clear(); const batch = await Promise.all(batchPromises); // There are a few indexes that start with message id, and many more that // start with conversationId. Sort messages by both to make sure that we // are not doing random insertions into the database file. // This improves bulk insert performance >2x. batch.sort((a, b) => { if (a.conversationId > b.conversationId) { return -1; } if (a.conversationId < b.conversationId) { return 1; } if (a.id < b.id) { return -1; } if (a.id > b.id) { return 1; } return 0; }); await DataWriter.saveMessages(batch, { forceSave: true, ourAci, postSaveUpdates, }); const attachmentDownloadJobPromises: Array> = []; // TODO (DESKTOP-7402): consider re-saving after updating the pending state for (const attributes of batch) { const { editHistory } = attributes; if (editHistory?.length) { // eslint-disable-next-line no-await-in-loop await DataWriter.saveEditedMessages( attributes, ourAci, editHistory.slice(0, -1).map(({ timestamp }) => ({ conversationId: attributes.conversationId, messageId: attributes.id, // Main message will track this readStatus: ReadStatus.Read, sentAt: timestamp, })) ); } if (hasAttachmentDownloads(attributes)) { const conversation = this.#conversations.get(attributes.conversationId); if (conversation && isConversationAccepted(conversation)) { const model = new MessageModel(attributes); const attachmentsAreLikelyExpired = expiresTooSoonForBackup({ messageExpiresAt: calculateExpirationTimestamp(attributes) ?? null, }); attachmentDownloadJobPromises.push( queueAttachmentDownloads(model, { source: this.#isMediaEnabledBackup() && !attachmentsAreLikelyExpired ? AttachmentDownloadSource.BACKUP_IMPORT_WITH_MEDIA : AttachmentDownloadSource.BACKUP_IMPORT_NO_MEDIA, isManualDownload: false, }) ); } } } await Promise.allSettled(attachmentDownloadJobPromises); await AttachmentDownloadManager.saveBatchedJobs(); this.#flushMessagesPromise = undefined; } async #saveCallHistory(callHistory: CallHistoryDetails): Promise { await DataWriter.saveCallHistory(callHistory); } async #fromAccount({ profileKey, username, usernameLink, givenName, familyName, avatarUrlPath, backupsSubscriberData, donationSubscriberData, accountSettings, svrPin, androidSpecificSettings, bioText, bioEmoji, keyTransparencyData, }: Backups.IAccountData): Promise { strictAssert(this.#ourConversation === undefined, 'Duplicate AccountData'); const me = { ...window.ConversationController.getOurConversationOrThrow().attributes, }; this.#ourConversation = me; strictAssert(Bytes.isNotEmpty(profileKey), 'Missing profile key'); await itemStorage.put('profileKey', profileKey); this.#ourConversation.profileKey = Bytes.toBase64(profileKey); await this.#updateConversation(this.#ourConversation); if (username != null) { me.username = username; } if (usernameLink != null) { const { entropy, serverId, color } = usernameLink; if (Bytes.isNotEmpty(entropy) && Bytes.isNotEmpty(serverId)) { await itemStorage.put('usernameLink', { entropy, serverId, }); } // Same numeric value, no conversion needed await itemStorage.put('usernameLinkColor', color ?? 0); } if (givenName != null) { me.profileName = givenName; } if (familyName != null) { me.profileFamilyName = familyName; } if (bioText != null) { me.about = bioText; } if (bioEmoji != null) { me.aboutEmoji = bioEmoji; } if (Bytes.isNotEmpty(keyTransparencyData)) { const ourAci = this.#ourConversation?.serviceId; strictAssert( isAciString(ourAci), 'Must have our aci for Key Transparency data' ); await DataWriter.setKTAccountData(ourAci, keyTransparencyData); } if (avatarUrlPath != null) { await itemStorage.put('avatarUrl', avatarUrlPath); } if (donationSubscriberData != null) { const { subscriberId, currencyCode, manuallyCancelled } = donationSubscriberData; if (Bytes.isNotEmpty(subscriberId)) { await itemStorage.put('subscriberId', subscriberId); } if (currencyCode != null) { await itemStorage.put('subscriberCurrencyCode', currencyCode); } if (manuallyCancelled != null) { await itemStorage.put( 'donorSubscriptionManuallyCancelled', manuallyCancelled ); } } if (isTestOrMockEnvironment()) { // Only relevant for tests await itemStorage.put('androidSpecificSettings', androidSpecificSettings); } await saveBackupsSubscriberData(backupsSubscriberData); await itemStorage.put( 'read-receipt-setting', accountSettings?.readReceipts === true ); await itemStorage.put( 'sealedSenderIndicators', accountSettings?.sealedSenderIndicators === true ); await itemStorage.put( 'typingIndicators', accountSettings?.typingIndicators === true ); await itemStorage.put( 'linkPreviews', accountSettings?.linkPreviews === true ); await itemStorage.put( 'preferContactAvatars', accountSettings?.preferContactAvatars === true ); if (accountSettings?.universalExpireTimerSeconds) { await itemStorage.put( 'universalExpireTimer', accountSettings.universalExpireTimerSeconds ); } await itemStorage.put( 'displayBadgesOnProfile', accountSettings?.displayBadgesOnProfile === true ); await itemStorage.put( 'keepMutedChatsArchived', accountSettings?.keepMutedChatsArchived === true ); await itemStorage.put( 'hasSetMyStoriesPrivacy', accountSettings?.hasSetMyStoriesPrivacy === true ); await itemStorage.put( 'hasViewedOnboardingStory', accountSettings?.hasViewedOnboardingStory === true ); await itemStorage.put( 'hasStoriesDisabled', accountSettings?.storiesDisabled === true ); await itemStorage.put( 'hasKeyTransparencyDisabled', accountSettings?.allowAutomaticKeyVerification !== true ); // an undefined value for storyViewReceiptsEnabled is semantically different from // false: it causes us to fallback to `read-receipt-setting` await itemStorage.put( 'storyViewReceiptsEnabled', accountSettings?.storyViewReceiptsEnabled ?? undefined ); await itemStorage.put( 'hasCompletedUsernameOnboarding', accountSettings?.hasCompletedUsernameOnboarding === true ); await itemStorage.put( 'hasSeenGroupStoryEducationSheet', accountSettings?.hasSeenGroupStoryEducationSheet === true ); await itemStorage.put( 'hasSeenAdminDeleteEducationDialog', accountSettings?.hasSeenAdminDeleteEducationDialog === true ); await itemStorage.put( 'preferredReactionEmoji', accountSettings?.preferredReactionEmoji || [] ); if (svrPin) { await itemStorage.put('svrPin', svrPin); } await window.Events.setThemeSetting( toThemeSetting(accountSettings?.appTheme) ); if (isTestOrMockEnvironment()) { // Only relevant for tests await itemStorage.put( 'optimizeOnDeviceStorage', accountSettings?.optimizeOnDeviceStorage === true ); await itemStorage.put( 'pinReminders', dropNull(accountSettings?.pinReminders) ); await itemStorage.put( 'screenLockTimeoutMinutes', dropNull(accountSettings?.screenLockTimeoutMinutes) ); await itemStorage.put( 'callsUseLessDataSetting', accountSettings?.callsUseLessDataSetting ); await itemStorage.put( 'allowSealedSenderFromAnyone', accountSettings?.allowSealedSenderFromAnyone ); const autoDownload = accountSettings?.autoDownloadSettings; if (autoDownload) { const autoDownloadEnum = Backups.AccountData.AutoDownloadSettings.AutoDownloadOption; await itemStorage.put('auto-download-attachment-primary', { photos: autoDownload?.images || autoDownloadEnum.NEVER, audio: autoDownload?.audio || autoDownloadEnum.NEVER, videos: autoDownload?.video || autoDownloadEnum.NEVER, documents: autoDownload?.documents || autoDownloadEnum.NEVER, }); } } this.#backupTier = accountSettings?.backupTier?.toNumber(); await itemStorage.put( 'backupTier', accountSettings?.backupTier?.toNumber() ); await itemStorage.put( 'sealedSenderIndicators', accountSettings?.sealedSenderIndicators === true ); await itemStorage.put( 'sent-media-quality', accountSettings?.defaultSentMediaQuality === Backups.AccountData.SentMediaQuality.HIGH ? 'high' : 'standard' ); const { PhoneNumberSharingMode: BackupMode } = Backups.AccountData; switch (accountSettings?.phoneNumberSharingMode) { case BackupMode.EVERYBODY: await itemStorage.put( 'phoneNumberSharingMode', PhoneNumberSharingMode.Everybody ); break; case BackupMode.UNKNOWN: case BackupMode.NOBODY: default: await itemStorage.put( 'phoneNumberSharingMode', PhoneNumberSharingMode.Nobody ); break; } if (accountSettings?.notDiscoverableByPhoneNumber) { await itemStorage.put( 'phoneNumberDiscoverability', PhoneNumberDiscoverability.NotDiscoverable ); } else { await itemStorage.put( 'phoneNumberDiscoverability', PhoneNumberDiscoverability.Discoverable ); } // It is important to import custom chat colors before default styles // because we build the uuid => integer id map for the colors. await this.#fromCustomChatColors(accountSettings?.customChatColors); const defaultChatStyle = this.#fromChatStyle( accountSettings?.defaultChatStyle ); if (defaultChatStyle.color != null) { await itemStorage.put('defaultConversationColor', { color: defaultChatStyle.color, customColorData: defaultChatStyle.customColorData, }); } if (defaultChatStyle.wallpaperPhotoPointer != null) { await itemStorage.put( 'defaultWallpaperPhotoPointer', defaultChatStyle.wallpaperPhotoPointer ); } if (defaultChatStyle.wallpaperPreset != null) { await itemStorage.put( 'defaultWallpaperPreset', defaultChatStyle.wallpaperPreset ); } if (defaultChatStyle.dimWallpaperInDarkMode != null) { await itemStorage.put( 'defaultDimWallpaperInDarkMode', defaultChatStyle.dimWallpaperInDarkMode ); } if (defaultChatStyle.autoBubbleColor != null) { await itemStorage.put( 'defaultAutoBubbleColor', defaultChatStyle.autoBubbleColor ); } await this.#updateConversation(me); } #fromSelf(self: Backups.ISelf): ConversationAttributesType { strictAssert(this.#ourConversation != null, 'Missing account data'); const convo = this.#ourConversation; if (self.avatarColor != null) { convo.color = fromAvatarColor(self.avatarColor); convo.colorFromPrimary = dropNull(self.avatarColor); } return convo; } async #fromContact( contact: Backups.IContact ): Promise { strictAssert( contact.aci != null || contact.pni != null || contact.e164 != null, 'fromContact: either aci, pni, or e164 must be present' ); const aci = contact.aci ? fromAciObject(Aci.fromUuidBytes(contact.aci)) : undefined; const pni = contact.pni ? fromPniObject(Pni.fromUuidBytes(contact.pni)) : undefined; const e164 = contact.e164 ? `+${contact.e164}` : undefined; let removalStage: 'justNotification' | 'messageRequest' | undefined; switch (contact.visibility) { case Backups.Contact.Visibility.HIDDEN: removalStage = 'justNotification'; break; case Backups.Contact.Visibility.HIDDEN_MESSAGE_REQUEST: removalStage = 'messageRequest'; break; case Backups.Contact.Visibility.VISIBLE: default: removalStage = undefined; break; } const serviceId = aci ?? pni; const attrs: ConversationAttributesType = { id: generateUuid(), type: 'private', version: 2, serviceId, pni, e164, removalStage, profileKey: contact.profileKey ? Bytes.toBase64(contact.profileKey) : undefined, accessKey: contact.profileKey ? Bytes.toBase64(deriveAccessKeyFromProfileKey(contact.profileKey)) : undefined, sealedSender: SEALED_SENDER.UNKNOWN, profileSharing: contact.profileSharing === true, profileName: dropNull(contact.profileGivenName), profileFamilyName: dropNull(contact.profileFamilyName), systemGivenName: dropNull(contact.systemGivenName), systemFamilyName: dropNull(contact.systemFamilyName), systemNickname: dropNull(contact.systemNickname), hideStory: contact.hideStory === true, username: dropNull(contact.username), expireTimerVersion: 1, nicknameGivenName: dropNull(contact.nickname?.given), nicknameFamilyName: dropNull(contact.nickname?.family), note: dropNull(contact.note), color: fromAvatarColor(contact.avatarColor), colorFromPrimary: dropNull(contact.avatarColor), }; if (serviceId != null && Bytes.isNotEmpty(contact.identityKey)) { const verified = contact.identityState || 0; this.#identityKeys.set(serviceId, { id: serviceId, publicKey: contact.identityKey, verified, firstUse: true, timestamp: this.#now, nonblockingApproval: true, }); attrs.verified = verified; } if (contact.notRegistered) { const timestamp = getCheckedTimestampOrUndefinedFromLong( contact.notRegistered.unregisteredTimestamp ); attrs.discoveredUnregisteredAt = timestamp || this.#now; attrs.firstUnregisteredAt = timestamp || undefined; } else if (!contact.registered) { log.error( 'contact is neither registered nor unregistered; treating as registered', contact.registered ); this.#frameErrorCount += 1; } if (contact.blocked) { if (serviceId) { await itemStorage.blocked.addBlockedServiceId(serviceId); } if (e164) { await itemStorage.blocked.addBlockedNumber(e164); } } if (Bytes.isNotEmpty(contact.keyTransparencyData)) { strictAssert( isAciString(serviceId), 'Must have contact aci for Key Transparency data' ); await DataWriter.setKTAccountData(serviceId, contact.keyTransparencyData); } return attrs; } async #fromGroup(group: Backups.IGroup): Promise { const { masterKey, snapshot } = group; strictAssert(masterKey != null, 'fromGroup: missing masterKey'); strictAssert(snapshot != null, 'fromGroup: missing snapshot'); const secretParams = deriveGroupSecretParams(masterKey); const publicParams = deriveGroupPublicParams(secretParams); const groupId = Bytes.toBase64(deriveGroupID(secretParams)); const { title, description, avatarUrl, disappearingMessagesTimer, accessControl, version, members, membersPendingProfileKey, membersPendingAdminApproval, membersBanned, inviteLinkPassword, announcementsOnly, } = snapshot; const expirationTimerS = disappearingMessagesTimer?.disappearingMessagesDuration; let storySendMode: StorySendMode | undefined; switch (group.storySendMode) { case Backups.Group.StorySendMode.ENABLED: storySendMode = StorySendMode.Always; break; case Backups.Group.StorySendMode.DISABLED: storySendMode = StorySendMode.Never; break; default: storySendMode = undefined; break; } const attrs: ConversationAttributesType = { id: generateUuid(), type: 'group', version: 2, groupVersion: 2, masterKey: Bytes.toBase64(masterKey), groupId, secretParams: Bytes.toBase64(secretParams), publicParams: Bytes.toBase64(publicParams), profileSharing: group.whitelisted === true, messageRequestResponseType: group.whitelisted === true ? SignalService.SyncMessage.MessageRequestResponse.Type.ACCEPT : undefined, hideStory: group.hideStory === true, storySendMode, avatar: avatarUrl ? { url: avatarUrl, } : undefined, remoteAvatarUrl: dropNull(avatarUrl), color: fromAvatarColor(group.avatarColor), colorFromPrimary: dropNull(group.avatarColor), // Snapshot name: dropNull(title?.title)?.trim(), description: dropNull(description?.descriptionText)?.trim(), expireTimer: expirationTimerS ? DurationInSeconds.fromSeconds(expirationTimerS) : undefined, expireTimerVersion: 1, accessControl: accessControl ? { attributes: dropNull(accessControl.attributes) ?? SignalService.AccessControl.AccessRequired.UNKNOWN, members: dropNull(accessControl.members) ?? SignalService.AccessControl.AccessRequired.UNKNOWN, addFromInviteLink: dropNull(accessControl.addFromInviteLink) ?? SignalService.AccessControl.AccessRequired.UNKNOWN, memberLabel: dropNull(accessControl.memberLabel) ?? SignalService.AccessControl.AccessRequired.UNKNOWN, } : undefined, membersV2: members?.map( ({ joinedAtVersion, labelEmoji, labelString, role, userId }) => { strictAssert(Bytes.isNotEmpty(userId), 'Empty gv2 member userId'); // Note that we deliberately ignore profile key since it has to be // in the Contact frame return { aci: fromAciObject(Aci.fromUuidBytes(userId)), joinedAtVersion: dropNull(joinedAtVersion) ?? 0, labelEmoji: dropNull(labelEmoji), labelString: dropNull(labelString), role: dropNull(role) ?? SignalService.Member.Role.UNKNOWN, }; } ), pendingMembersV2: membersPendingProfileKey?.map( ({ member, addedByUserId, timestamp }) => { strictAssert(member != null, 'Missing gv2 pending member'); strictAssert( Bytes.isNotEmpty(addedByUserId), 'Empty gv2 pending member addedByUserId' ); // profileKey is not available for pending members. const { userId, role } = member; strictAssert(Bytes.isNotEmpty(userId), 'Empty gv2 member userId'); const serviceId = fromServiceIdObject( ServiceId.parseFromServiceIdBinary(userId) ); return { serviceId, role: dropNull(role) ?? SignalService.Member.Role.UNKNOWN, addedByUserId: fromAciObject(Aci.fromUuidBytes(addedByUserId)), timestamp: timestamp != null ? getCheckedTimestampFromLong(timestamp) : 0, }; } ), pendingAdminApprovalV2: membersPendingAdminApproval?.map( ({ userId, timestamp }) => { strictAssert(Bytes.isNotEmpty(userId), 'Empty gv2 member userId'); // Note that we deliberately ignore profile key since it has to be // in the Contact frame return { aci: fromAciObject(Aci.fromUuidBytes(userId)), timestamp: timestamp != null ? getCheckedTimestampFromLong(timestamp) : 0, }; } ), bannedMembersV2: membersBanned?.map(({ userId, timestamp }) => { strictAssert(Bytes.isNotEmpty(userId), 'Empty gv2 member userId'); // Note that we deliberately ignore profile key since it has to be // in the Contact frame const serviceId = fromServiceIdObject( ServiceId.parseFromServiceIdBinary(userId) ); return { serviceId, timestamp: timestamp != null ? getCheckedTimestampFromLong(timestamp) : 0, }; }), revision: dropNull(version), groupInviteLinkPassword: Bytes.isNotEmpty(inviteLinkPassword) ? Bytes.toBase64(inviteLinkPassword) : undefined, announcementsOnly: dropNull(announcementsOnly), }; if (group.blocked) { await itemStorage.blocked.addBlockedGroup(groupId); } return attrs; } async #fromDistributionList( listItem: Backups.IDistributionListItem ): Promise { strictAssert( Bytes.isNotEmpty(listItem.distributionId), 'Missing distribution list id' ); const id = bytesToUuid(listItem.distributionId) || MY_STORY_ID; strictAssert(isStoryDistributionId(id), 'Invalid distribution list id'); const commonFields = { id, // Default values senderKeyInfo: undefined, storageNeedsSync: false, }; let result: StoryDistributionWithMembersType; if (listItem.deletionTimestamp == null) { const { distributionList: list } = listItem; strictAssert( list != null, 'Distribution list is either present or deleted' ); strictAssert( list.privacyMode != null, 'Missing distribution list privacy mode' ); let isBlockList: boolean; const { PrivacyMode } = Backups.DistributionList; switch (list.privacyMode) { case PrivacyMode.ALL: strictAssert( !list.memberRecipientIds?.length, 'Distribution list with ALL privacy mode has members' ); isBlockList = true; break; case PrivacyMode.ALL_EXCEPT: strictAssert( list.memberRecipientIds?.length, 'Distribution list with ALL_EXCEPT privacy mode has no members' ); isBlockList = true; break; case PrivacyMode.ONLY_WITH: isBlockList = false; break; case PrivacyMode.UNKNOWN: throw new Error('Invalid privacy mode for distribution list'); default: throw missingCaseError(list.privacyMode); } strictAssert( !isBlockList || id === MY_STORY_ID, 'Block list can be set only for my story' ); result = { ...commonFields, name: list.name ?? '', allowsReplies: list.allowReplies === true, isBlockList, members: (list.memberRecipientIds || []).map(recipientId => { const convo = this.#recipientIdToConvo.get(recipientId.toNumber()); strictAssert(convo != null, 'Missing story distribution list member'); strictAssert( convo.serviceId, 'Story distribution list member has no serviceId' ); return convo.serviceId; }), }; } else { result = { ...commonFields, name: '', allowsReplies: false, isBlockList: false, members: [], deletedAtTimestamp: getCheckedTimestampFromLong( listItem.deletionTimestamp ), }; } await DataWriter.createNewStoryDistribution(result); } 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: getTimestampOrUndefinedFromLong(expirationMs) ?? null, storageNeedsSync: false, }; this.#recipientIdToCallLink.set(recipientId, callLink); if ( isCallLinkAdmin(callLink) && !this.#adminCallLinksToHasCall.has(callLink) ) { this.#adminCallLinksToHasCall.set(callLink, false); } await DataWriter.insertCallLink(callLink); } async #fromChat(chat: Backups.IChat): Promise { strictAssert(chat.id != null, 'chat must have an id'); strictAssert(chat.recipientId != null, 'chat must have a recipientId'); // Drop release notes chat if (this.#releaseNotesRecipientId?.eq(chat.recipientId)) { strictAssert( this.#releaseNotesChatId == null, 'Duplicate release notes chat' ); this.#releaseNotesChatId = chat.id; return; } const conversation = this.#recipientIdToConvo.get( chat.recipientId.toNumber() ); strictAssert(conversation !== undefined, 'unknown conversation'); this.#chatIdToConvo.set(chat.id.toNumber(), conversation); if (isTestEnvironment(getEnvironment())) { conversation.test_chatFrameImportedFromBackup = true; } conversation.isArchived = chat.archived === true; conversation.isPinned = (chat.pinnedOrder || 0) !== 0; conversation.expireTimer = chat.expirationTimerMs && !chat.expirationTimerMs.isZero() ? DurationInSeconds.fromMillis(chat.expirationTimerMs.toNumber()) : undefined; conversation.expireTimerVersion = chat.expireTimerVersion || 1; if ( chat.muteUntilMs != null && chat.muteUntilMs.toNumber() >= MAX_SAFE_DATE ) { // Muted forever conversation.muteExpiresAt = Number.MAX_SAFE_INTEGER; } else { conversation.muteExpiresAt = getCheckedTimestampOrUndefinedFromLong( chat.muteUntilMs ); } conversation.markedUnread = chat.markedUnread === true; conversation.dontNotifyForMentionsIfMuted = chat.dontNotifyForMentionsIfMuted === true; const chatStyle = this.#fromChatStyle(chat.style); if (chatStyle.wallpaperPhotoPointer != null) { conversation.wallpaperPhotoPointerBase64 = Bytes.toBase64( chatStyle.wallpaperPhotoPointer ); } if (chatStyle.wallpaperPreset != null) { conversation.wallpaperPreset = chatStyle.wallpaperPreset; } if (chatStyle.color != null) { conversation.conversationColor = chatStyle.color; } if (chatStyle.customColorData != null) { conversation.customColor = chatStyle.customColorData.value; conversation.customColorId = chatStyle.customColorData.id; } if (chatStyle.dimWallpaperInDarkMode != null) { conversation.dimWallpaperInDarkMode = chatStyle.dimWallpaperInDarkMode; } if (chatStyle.autoBubbleColor) { conversation.autoBubbleColor = chatStyle.autoBubbleColor; } await this.#updateConversation(conversation); if (chat.pinnedOrder != null) { this.#pinnedConversations.push([chat.pinnedOrder, conversation.id]); } } async #fromChatItem( item: Backups.IChatItem, options: { aboutMe: AboutMe } ): Promise { const { aboutMe } = options; const timestamp = getCheckedTimestampOrUndefinedFromLong(item?.dateSent); const logId = `fromChatItem(${timestamp})`; strictAssert( this.#ourConversation != null, `${logId}: AccountData missing` ); strictAssert(item.chatId != null, `${logId}: must have a chatId`); strictAssert(item.dateSent != null, `${logId}: must have a dateSent`); strictAssert(timestamp, `${logId}: must have a timestamp`); if (this.#releaseNotesChatId?.eq(item.chatId)) { // Drop release notes messages return; } const chatConvo = this.#chatIdToConvo.get(item.chatId.toNumber()); strictAssert( chatConvo !== undefined, `${logId}: chat conversation not found` ); const authorConvo = item.authorId ? this.#recipientIdToConvo.get(item.authorId.toNumber()) : undefined; const { patch: directionDetails, newActiveAt, unread, } = this.#fromDirectionDetails(item, timestamp); if ( newActiveAt != null && this.#shouldChatItemAffectChatListPresence(item) ) { chatConvo.active_at = newActiveAt; } if (unread != null) { chatConvo.unreadCount = (chatConvo.unreadCount ?? 0) + 1; } const expirationStartTimestamp = getCheckedTimestampOrUndefinedFromLong( item.expireStartDate ); const expireTimer = item.expiresInMs && !item.expiresInMs.isZero() ? DurationInSeconds.fromMillis(item.expiresInMs.toNumber()) : undefined; const expirationTimestamp = calculateExpirationTimestamp({ expireTimer, expirationStartTimestamp, }); if (expirationTimestamp != null && expirationTimestamp < this.#now) { // Drop expired messages return; } let attributes: MessageAttributesType = { ...generateMessageId(incrementMessageCounter()), conversationId: chatConvo.id, sent_at: timestamp, source: authorConvo?.e164, sourceServiceId: authorConvo?.serviceId, timestamp, type: item.outgoing != null ? 'outgoing' : 'incoming', expirationStartTimestamp, expireTimer, sms: item.sms === true ? true : undefined, ...directionDetails, }; const additionalMessages: Array = []; if (item.incoming) { strictAssert( authorConvo && this.#ourConversation.id !== authorConvo?.id, `${logId}: message with incoming field must be incoming` ); } else if (item.outgoing) { strictAssert( authorConvo && this.#ourConversation.id === authorConvo?.id, `${logId}: outgoing message must have outgoing field` ); } if (item.standardMessage) { attributes = { ...attributes, ...(await this.#fromStandardMessage({ logId, data: item.standardMessage, })), }; } else if (item.viewOnceMessage) { attributes = { ...attributes, ...(await this.#fromViewOnceMessage(item)), }; } else if (item.directStoryReplyMessage) { strictAssert(item.directionless == null, 'reply cannot be directionless'); let storyAuthorAci: AciString | undefined; if (item.incoming) { strictAssert(this.#aboutMe?.aci, 'about me must exist'); storyAuthorAci = this.#aboutMe.aci; } else { strictAssert( isAciString(chatConvo.serviceId), 'must have ACI for story author' ); storyAuthorAci = chatConvo.serviceId; } attributes = { ...attributes, ...this.#fromDirectStoryReplyMessage( item.directStoryReplyMessage, storyAuthorAci ), }; } else if (item.poll) { const { poll } = item; const votesByVoter = new Map< string, { fromConversationId: string; optionIndexes: Array; voteCount: number; timestamp: number; } >(); poll.options?.forEach((option, optionIndex) => { option.votes?.forEach(vote => { if (!vote.voterId) { return; } const conversation = this.#recipientIdToConvo.get( vote.voterId.toNumber() ); if (!conversation) { log.warn(`${logId}: Poll vote has unknown voterId ${vote.voterId}`); return; } const conversationId = conversation.id; let voterRecord = votesByVoter.get(conversationId); if (!voterRecord) { voterRecord = { fromConversationId: conversationId, optionIndexes: [], voteCount: vote.voteCount ?? 1, timestamp, }; votesByVoter.set(conversationId, voterRecord); } voterRecord.optionIndexes.push(optionIndex); }); }); const votes = Array.from(votesByVoter.values()); attributes = { ...attributes, poll: { question: poll.question ?? '', options: poll.options?.map(option => option.option ?? '') ?? [], allowMultiple: poll.allowMultiple ?? false, votes: votes.length > 0 ? votes : undefined, terminatedAt: poll.hasEnded ? Number(item.dateSent) : undefined, }, reactions: this.#fromReactions(poll.reactions), }; } else { const result = await this.#fromNonBubbleChatItem(item, { aboutMe, author: authorConvo, conversation: chatConvo, timestamp, }); if (result === SKIP) { return; } if (!result) { throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`); } attributes = { ...attributes, ...result.message, }; let sentAt = attributes.sent_at; (result.additionalMessages || []).forEach(additional => { sentAt -= 1; additionalMessages.push({ ...attributes, ...generateMessageId(incrementMessageCounter()), sent_at: sentAt, ...additional, }); }); } if (item.revisions?.length) { strictAssert( item.standardMessage || item.directStoryReplyMessage, `${logId}: Only standard or story reply message can have revisions` ); const history = await this.#fromRevisions({ mainMessage: attributes, revisions: item.revisions, logId, }); attributes.editHistory = history; // Update timestamps on the parent message const oldest = history.at(-1); assertDev(oldest != null, `${logId}: History is non-empty`); attributes.editMessageReceivedAt = attributes.received_at; attributes.editMessageReceivedAtMs = attributes.received_at_ms; attributes.editMessageTimestamp = attributes.timestamp; attributes.received_at = oldest.received_at; attributes.received_at_ms = oldest.received_at_ms; attributes.timestamp = oldest.timestamp; attributes.sent_at = oldest.timestamp; } assertDev( isAciString(this.#ourConversation.serviceId), `${logId}: Our conversation must have ACI` ); await Promise.all([ this.#saveMessage(attributes), ...additionalMessages.map(additional => this.#saveMessage(additional)), ]); if (item.outgoing != null) { chatConvo.sentMessageCount = (chatConvo.sentMessageCount ?? 0) + 1; } else if (item.incoming != null) { chatConvo.messageCount = (chatConvo.messageCount ?? 0) + 1; } if (item.pinDetails != null) { strictAssert( item.pinDetails.pinnedAtTimestamp != null, 'pinDetails: Missing pinnedAtTimestamp' ); const pinnedAt = item.pinDetails.pinnedAtTimestamp.toNumber(); let expiresAt: number | null; if (item.pinDetails.pinExpiresAtTimestamp != null) { expiresAt = item.pinDetails.pinExpiresAtTimestamp.toNumber(); } else { strictAssert( item.pinDetails.pinNeverExpires === true, 'pinDetails: pinNeverExpires should be true if theres no pinExpiresAtTimestamp' ); expiresAt = null; } this.#pinnedMessages.push({ conversationId: chatConvo.id, messageId: attributes.id, pinnedAt, expiresAt, }); } await this.#updateConversation(chatConvo); } #fromDirectionDetails( item: Backups.IChatItem, timestamp: number ): { patch: Partial; newActiveAt?: number; unread?: boolean; } { const { outgoing, incoming, directionless } = item; if (outgoing) { const sendStateByConversationId: SendStateByConversationId = {}; const unidentifiedDeliveries = new Array(); const errors = new Array(); let sendStatuses = outgoing.sendStatus; if (!sendStatuses?.length) { // TODO: DESKTOP-8089 // If this outgoing message was not sent to anyone, we add ourselves to // sendStateByConversationId and mark read. This is to match existing desktop // behavior. sendStatuses = [ { recipientId: item.authorId, read: new Backups.SendStatus.Read(), timestamp: item.dateSent, }, ]; } for (const status of sendStatuses) { strictAssert( status.recipientId, 'sendStatus recipient must have an id' ); const target = this.#recipientIdToConvo.get( status.recipientId.toNumber() ); strictAssert( target !== undefined, 'status target conversation not found' ); const { serviceId } = target; let sendStatus: SendStatus; if (status.pending) { sendStatus = SendStatus.Pending; } else if (status.sent) { sendStatus = SendStatus.Sent; if (serviceId && status.sent.sealedSender) { unidentifiedDeliveries.push(serviceId); } } else if (status.delivered) { sendStatus = SendStatus.Delivered; if (serviceId && status.delivered.sealedSender) { unidentifiedDeliveries.push(serviceId); } } else if (status.read) { sendStatus = SendStatus.Read; if (serviceId && status.read.sealedSender) { unidentifiedDeliveries.push(serviceId); } } else if (status.viewed) { sendStatus = SendStatus.Viewed; if (serviceId && status.viewed.sealedSender) { unidentifiedDeliveries.push(serviceId); } } else if (status.failed) { sendStatus = SendStatus.Failed; strictAssert( status.failed.reason != null, 'Failure reason must exist' ); switch (status.failed.reason) { case Backups.SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH: errors.push({ serviceId, name: 'OutgoingIdentityKeyError', // See: ts/textsecure/Errors message: `The identity of ${serviceId} has changed.`, }); break; case Backups.SendStatus.Failed.FailureReason.NETWORK: errors.push({ serviceId, name: 'OutgoingMessageError', // See: ts/textsecure/Errors message: 'no http error', }); break; case Backups.SendStatus.Failed.FailureReason.UNKNOWN: errors.push({ serviceId, name: 'UnknownError', message: 'unknown error', }); break; default: throw missingCaseError(status.failed.reason); } // Desktop does not keep track of users we did not attempt to send to } else if (status.skipped) { sendStatus = SendStatus.Skipped; } else { log.error( `${timestamp}: Unknown sendStatus received: ${status}, falling back to Pending` ); // We fallback to pending for unknown send statuses sendStatus = SendStatus.Pending; this.#frameErrorCount += 1; } sendStateByConversationId[target.id] = { status: sendStatus, updatedAt: status.timestamp != null && !status.timestamp.isZero() ? getCheckedTimestampFromLong(status.timestamp) : undefined, }; } return { patch: { sendStateByConversationId, received_at_ms: getCheckedTimestampOrUndefinedFromLong(outgoing.dateReceived) ?? timestamp, unidentifiedDeliveries: unidentifiedDeliveries.length ? unidentifiedDeliveries : undefined, errors: errors.length ? errors : undefined, }, newActiveAt: timestamp, }; } if (incoming) { const receivedAtMs = getCheckedTimestampOrUndefinedFromLong(incoming.dateReceived) ?? this.#now; const serverTimestamp = getCheckedTimestampOrUndefinedFromLong( incoming.dateServerSent ); const unidentifiedDeliveryReceived = incoming.sealedSender === true; if (incoming.read) { return { patch: { readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, hasUnreadPollVotes: false, received_at_ms: receivedAtMs, serverTimestamp, unidentifiedDeliveryReceived, }, newActiveAt: receivedAtMs, }; } return { patch: { readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, hasUnreadPollVotes: false, received_at_ms: receivedAtMs, serverTimestamp, unidentifiedDeliveryReceived, }, newActiveAt: receivedAtMs, unread: true, }; } strictAssert(directionless, 'Absent direction state'); return { patch: { readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, hasUnreadPollVotes: false, }, newActiveAt: timestamp, }; } /** * Some update messages should not affect the chat's position in the left pane chat * list. For example, conversations with only an identity update (SN change) message * should not show in the left pane. * * iOS list: /main/SignalServiceKit/Messages/Interactions/TSInteraction.swift */ #shouldChatItemAffectChatListPresence(item: Backups.IChatItem): boolean { if (!item.updateMessage) { return true; } if ( item.updateMessage.profileChange || item.updateMessage.learnedProfileChange || item.updateMessage.sessionSwitchover || item.updateMessage.threadMerge ) { return false; } if ( item.updateMessage.groupChange?.updates?.every( update => Boolean(update.groupMemberLeftUpdate) || Boolean(update.groupV2MigrationUpdate) ) ) { return false; } if (item.updateMessage.simpleUpdate) { switch (item.updateMessage.simpleUpdate.type) { case Backups.SimpleChatUpdate.Type.IDENTITY_UPDATE: case Backups.SimpleChatUpdate.Type.CHANGE_NUMBER: case Backups.SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED: case Backups.SimpleChatUpdate.Type.REPORTED_SPAM: case Backups.SimpleChatUpdate.Type.IDENTITY_DEFAULT: case Backups.SimpleChatUpdate.Type.IDENTITY_VERIFIED: case Backups.SimpleChatUpdate.Type.UNKNOWN: case undefined: case null: return false; // Listing all of these out (rather than a default case) so that TS will force us // to update this list when a new type is introduced case Backups.SimpleChatUpdate.Type.BAD_DECRYPT: case Backups.SimpleChatUpdate.Type.BLOCKED: case Backups.SimpleChatUpdate.Type.CHAT_SESSION_REFRESH: case Backups.SimpleChatUpdate.Type.END_SESSION: case Backups.SimpleChatUpdate.Type.JOINED_SIGNAL: case Backups.SimpleChatUpdate.Type.PAYMENTS_ACTIVATED: case Backups.SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST: case Backups.SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST: case Backups.SimpleChatUpdate.Type.UNBLOCKED: case Backups.SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE: return true; default: throw missingCaseError(item.updateMessage.simpleUpdate.type); } } return true; } async #fromStandardMessage({ logId, data, }: { logId: string; data: Backups.IStandardMessage; }): Promise> { return { // We don't want to trim if we'll be downloading a body attachment; we might // drop bodyRanges which apply to the longer text we'll get in that download. ...(data.longText ? { body: data.text?.body || undefined, bodyRanges: this.#fromBodyRanges(data.text), } : trimMessageWhitespace({ body: data.text?.body || undefined, bodyRanges: this.#fromBodyRanges(data.text), })), bodyAttachment: data.longText ? convertFilePointerToAttachment(data.longText, this.options) : undefined, attachments: data.attachments?.length ? data.attachments .map(attachment => convertBackupMessageAttachmentToAttachment( attachment, this.options ) ) .filter(isNotNil) : undefined, preview: data.linkPreview?.length ? this.#fromLinkPreview({ logId, body: data.text?.body, previews: data.linkPreview, }) : undefined, reactions: this.#fromReactions(data.reactions), quote: data.quote ? await this.#fromQuote(data.quote) : undefined, }; } #fromLinkPreview({ logId, body, previews, }: { logId: string; body: string | null | undefined; previews: Array; }): Array { const urlsInBody = LinkPreview.findLinks(body ?? ''); return previews .map(preview => { if ( !LinkPreview.isValidLinkPreview(urlsInBody, preview, { isStory: false, }) ) { log.warn(`${logId}: dropping invalid link preview`); return; } strictAssert(preview.url, 'url must exist in valid link preview'); return { url: preview.url, title: dropNull(preview.title), description: dropNull(preview.description), date: getCheckedTimestampOrUndefinedFromLong(preview.date), image: preview.image ? convertFilePointerToAttachment(preview.image, this.options) : undefined, }; }) .filter(isNotNil); } async #fromViewOnceMessage( item: Backups.IChatItem ): Promise> { const { incoming, viewOnceMessage } = item; strictAssert(viewOnceMessage, 'view once message must not be null'); const { attachment, reactions } = viewOnceMessage; const result: Partial = { attachments: attachment ? [ convertBackupMessageAttachmentToAttachment( attachment, this.options ), ].filter(isNotNil) : undefined, reactions: this.#fromReactions(reactions), isViewOnce: true, }; if (!result.attachments?.length) { result.isErased = true; // Only mark it viewed if the message is read. Non-link-and-sync backups do not // roundtrip view-once attachments, even if unread. if (incoming?.read) { result.readStatus = ReadStatus.Viewed; } } return result; } #fromDirectStoryReplyMessage( directStoryReplyMessage: Backups.IDirectStoryReplyMessage, storyAuthorAci: AciString ): Partial { const { reactions, textReply, emoji } = directStoryReplyMessage; const result: Partial = { reactions: this.#fromReactions(reactions), storyReplyContext: { authorAci: storyAuthorAci, messageId: '', // stories are never imported }, }; if (textReply) { result.body = textReply.text?.body ?? undefined; result.bodyRanges = this.#fromBodyRanges(textReply.text); result.bodyAttachment = textReply.longText ? convertFilePointerToAttachment(textReply.longText, this.options) : undefined; } else if (emoji) { result.storyReaction = { emoji, targetAuthorAci: storyAuthorAci, targetTimestamp: 0, // stories are never imported }; } else { throw new Error( 'Direct story reply message missing both textReply and emoji' ); } return result; } async #fromDirectStoryReplyRevision( revision: Backups.IDirectStoryReplyMessage ): Promise> { const { textReply } = revision; if (!textReply) { return {}; } return { body: textReply.text?.body ?? undefined, bodyRanges: this.#fromBodyRanges(textReply.text), bodyAttachment: textReply.longText ? convertFilePointerToAttachment(textReply.longText, this.options) : undefined, }; } async #fromRevisions({ mainMessage, revisions, logId, }: { mainMessage: MessageAttributesType; revisions: ReadonlyArray; logId: string; }): Promise> { const result = await Promise.all( revisions .map(async rev => { strictAssert( rev.standardMessage || rev.directStoryReplyMessage, 'Edit history on a message that does not support revisions' ); const timestamp = getCheckedTimestampFromLong(rev.dateSent); const { patch: { sendStateByConversationId, // eslint-disable-next-line camelcase received_at_ms, serverTimestamp, readStatus, unidentifiedDeliveryReceived, }, } = this.#fromDirectionDetails(rev, timestamp); const commonFields = { timestamp, received_at: incrementMessageCounter(), sendStateByConversationId, // eslint-disable-next-line camelcase received_at_ms, serverTimestamp, readStatus, unidentifiedDeliveryReceived, }; if (rev.standardMessage) { return { ...(await this.#fromStandardMessage({ logId, data: rev.standardMessage, })), ...commonFields, }; } if (rev.directStoryReplyMessage) { return { ...(await this.#fromDirectStoryReplyRevision( rev.directStoryReplyMessage )), ...commonFields, }; } throw new Error( 'Edit history on a message that does not support revisions' ); }) // Fix order: from newest to oldest .reverse() ); // See `ts/util/handleEditMessage.ts`, the first history entry is always // the current message. result.unshift({ attachments: mainMessage.attachments, body: mainMessage.body, bodyAttachment: mainMessage.bodyAttachment, bodyRanges: mainMessage.bodyRanges, preview: mainMessage.preview, quote: mainMessage.quote, sendStateByConversationId: mainMessage.sendStateByConversationId ? { ...mainMessage.sendStateByConversationId } : undefined, timestamp: mainMessage.timestamp, received_at: mainMessage.received_at, received_at_ms: mainMessage.received_at_ms, serverTimestamp: mainMessage.serverTimestamp, readStatus: mainMessage.readStatus, unidentifiedDeliveryReceived: mainMessage.unidentifiedDeliveryReceived, }); return result; } async #fromQuote(quote: Backups.IQuote): Promise { strictAssert(quote.authorId != null, 'quote must have an authorId'); const authorConvo = this.#recipientIdToConvo.get(quote.authorId.toNumber()); strictAssert(authorConvo !== undefined, 'author conversation not found'); return { id: getCheckedTimestampOrUndefinedFromLong(quote.targetSentTimestamp) ?? null, referencedMessageNotFound: quote.targetSentTimestamp == null, authorAci: isAciString(authorConvo.serviceId) ? authorConvo.serviceId : undefined, author: isAciString(authorConvo.serviceId) ? undefined : authorConvo.e164, text: dropNull(quote.text?.body), bodyRanges: this.#fromBodyRanges(quote.text), isGiftBadge: quote.type === Backups.Quote.Type.GIFT_BADGE, isPoll: quote.type === Backups.Quote.Type.POLL ? true : undefined, isViewOnce: quote.type === Backups.Quote.Type.VIEW_ONCE, attachments: quote.attachments?.map(quotedAttachment => { const { fileName, contentType, thumbnail } = quotedAttachment; return { fileName: dropNull(fileName), contentType: contentType ? stringToMIMEType(contentType) : APPLICATION_OCTET_STREAM, thumbnail: thumbnail?.pointer ? convertFilePointerToAttachment(thumbnail.pointer, this.options) : undefined, }; }) ?? [], }; } #fromBodyRanges( text: Backups.IText | null | undefined ): ReadonlyArray | undefined { if (text == null) { return undefined; } const { bodyRanges } = text; if (!bodyRanges?.length) { return undefined; } return filterAndClean( bodyRanges.map(range => ({ ...range, mentionAci: range.mentionAci ? Aci.parseFromServiceIdBinary(range.mentionAci).getServiceIdString() : undefined, })) ); } #fromReactions( reactions: ReadonlyArray | null | undefined ): Array | undefined { if (!reactions?.length) { return undefined; } return reactions .slice() .sort((a, b) => { if (a.sortOrder && b.sortOrder) { return a.sortOrder.comp(b.sortOrder); } return 0; }) .map(({ emoji, authorId, sentTimestamp }) => { strictAssert(emoji != null, 'reaction must have an emoji'); strictAssert(authorId != null, 'reaction must have authorId'); strictAssert( sentTimestamp != null, 'reaction must have a sentTimestamp' ); const authorConvo = this.#recipientIdToConvo.get(authorId.toNumber()); strictAssert( authorConvo !== undefined, 'author conversation not found' ); return { emoji, fromId: authorConvo.id, targetTimestamp: getCheckedTimestampFromLong(sentTimestamp), timestamp: getCheckedTimestampFromLong(sentTimestamp), }; }); } async #fromNonBubbleChatItem( chatItem: Backups.IChatItem, options: { aboutMe: AboutMe; author?: ConversationAttributesType; conversation: ConversationAttributesType; timestamp: number; } ): Promise { const { timestamp } = options; const logId = `fromChatItemToNonBubble(${timestamp})`; if (chatItem.standardMessage) { throw new Error(`${logId}: Got chat item with standardMessage set!`); } if (chatItem.contactMessage) { const { contact: details } = chatItem.contactMessage; strictAssert(details != null, 'contactMessage must have a contact'); const { avatar, name, number, email, address, organization } = details; return { message: { contact: [ { name: name ? { givenName: name.givenName || undefined, familyName: name.familyName || undefined, prefix: name.prefix || undefined, suffix: name.suffix || undefined, middleName: name.middleName || undefined, nickname: name.nickname || undefined, } : undefined, number: number?.length ? number .map(({ value, type, label }) => { if (!value) { return undefined; } return { value, type: phoneToContactFormType(type), label: label || undefined, }; }) .filter(isNotNil) : undefined, email: email?.length ? email .map(({ value, type, label }) => { if (!value) { return undefined; } return { value, type: emailToContactFormType(type), label: label || undefined, }; }) .filter(isNotNil) : undefined, address: address?.length ? address.map(addr => { const { type, label, street, pobox, neighborhood, city, region, postcode, country, } = addr; return { type: addressToContactAddressType(type), label: label || undefined, street: street || undefined, pobox: pobox || undefined, neighborhood: neighborhood || undefined, city: city || undefined, region: region || undefined, postcode: postcode || undefined, country: country || undefined, }; }) : undefined, organization: organization || undefined, avatar: avatar ? { avatar: convertFilePointerToAttachment( avatar, this.options ), isProfile: false, } : undefined, }, ], reactions: this.#fromReactions(chatItem.contactMessage.reactions), }, additionalMessages: [], }; } if (chatItem.adminDeletedMessage) { strictAssert( chatItem.adminDeletedMessage.adminId != null, 'adminDeletedMessage: Missing adminId' ); const adminConversation = this.#recipientIdToConvo.get( chatItem.adminDeletedMessage.adminId.toNumber() ); strictAssert( adminConversation != null, 'adminDeletedMessage: Missing admin conversation' ); strictAssert( isAciString(adminConversation.serviceId), 'adminConversation: Missing serviceId' ); return { message: { isErased: true, deletedForEveryone: true, deletedForEveryoneByAdminAci: adminConversation.serviceId, }, additionalMessages: [], }; } if (chatItem.remoteDeletedMessage) { return { message: { isErased: true, deletedForEveryone: true, }, additionalMessages: [], }; } if (chatItem.stickerMessage) { strictAssert( chatItem.stickerMessage.sticker != null, 'stickerMessage must have a sticker' ); const { stickerMessage: { sticker: { emoji, packId, packKey, stickerId, data }, }, } = chatItem; strictAssert( packId?.length === STICKERPACK_ID_BYTE_LEN, 'stickerMessage must have a valid pack id' ); strictAssert( packKey?.length === STICKERPACK_KEY_BYTE_LEN, 'stickerMessage must have a valid pack key' ); strictAssert(stickerId != null, 'stickerMessage must have a sticker id'); return { message: { sticker: { emoji: dropNull(emoji), packId: Bytes.toHex(packId), packKey: Bytes.toBase64(packKey), stickerId, data: data ? convertFilePointerToAttachment(data, this.options) : undefined, }, reactions: this.#fromReactions(chatItem.stickerMessage.reactions), }, additionalMessages: [], }; } if (chatItem.paymentNotification) { const { paymentNotification: notification } = chatItem; return { message: { payment: { kind: PaymentEventKind.Notification, amountMob: dropNull(notification.amountMob), feeMob: dropNull(notification.feeMob), note: notification.note ?? null, transactionDetailsBase64: notification.transactionDetails ? Bytes.toBase64( Backups.PaymentNotification.TransactionDetails.encode( notification.transactionDetails ).finish() ) : undefined, }, }, additionalMessages: [], }; } if (chatItem.giftBadge) { const { giftBadge } = chatItem; if (giftBadge.state === Backups.GiftBadge.State.FAILED) { return { message: { giftBadge: { state: GiftBadgeStates.Failed, }, }, additionalMessages: [], }; } strictAssert( Bytes.isNotEmpty(giftBadge.receiptCredentialPresentation), 'Gift badge must have a presentation' ); let state: GiftBadgeStates; switch (giftBadge.state) { case Backups.GiftBadge.State.OPENED: state = GiftBadgeStates.Opened; break; case Backups.GiftBadge.State.REDEEMED: state = GiftBadgeStates.Redeemed; break; case Backups.GiftBadge.State.UNOPENED: default: state = GiftBadgeStates.Unopened; break; } const receipt = new ReceiptCredentialPresentation( giftBadge.receiptCredentialPresentation ); return { message: { giftBadge: { receiptCredentialPresentation: Bytes.toBase64( giftBadge.receiptCredentialPresentation ), expiration: Number(receipt.getReceiptExpirationTime()) * SECOND, id: undefined, level: Number(receipt.getReceiptLevel()), state, }, }, additionalMessages: [], }; } if (chatItem.updateMessage) { return this.#fromChatItemUpdateMessage(chatItem.updateMessage, options); } throw new Error(`${logId}: Message was missing all five message types`); } async #fromChatItemUpdateMessage( updateMessage: Backups.IChatUpdateMessage, options: { aboutMe: AboutMe; author?: ConversationAttributesType; conversation: ConversationAttributesType; timestamp: number; } ): Promise { const { aboutMe, author, conversation } = options; if (updateMessage.groupChange) { return this.#fromGroupUpdateMessage(updateMessage.groupChange, options); } if (updateMessage.expirationTimerChange) { const { expiresInMs } = updateMessage.expirationTimerChange; let sourceServiceId = author?.serviceId; let source = author?.e164; if (!sourceServiceId) { sourceServiceId = aboutMe.aci; source = aboutMe.e164; } const expireTimer = DurationInSeconds.fromMillis( expiresInMs?.toNumber() ?? 0 ); return { message: { type: 'timer-notification', sourceServiceId, source, flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, expirationTimerUpdate: { expireTimer, sourceServiceId, }, }, additionalMessages: [], }; } if (updateMessage.simpleUpdate) { const message = await this.#fromSimpleUpdateMessage( updateMessage.simpleUpdate, options ); if (!message) { return undefined; } return { message, additionalMessages: [], }; } if (updateMessage.profileChange) { const { newName, previousName: oldName } = updateMessage.profileChange; strictAssert(newName != null, 'profileChange must have a new name'); strictAssert(oldName != null, 'profileChange must have an old name'); return { message: { type: 'profile-change', changedId: author?.id, profileChange: { type: 'name', oldName, newName, }, }, additionalMessages: [], }; } if (updateMessage.learnedProfileChange) { const { e164, username } = updateMessage.learnedProfileChange; if (e164 == null && username == null) { log.error( `${options.timestamp}: learnedProfileChange had no previous e164 or username` ); this.#frameErrorCount += 1; } return { message: { type: 'title-transition-notification', titleTransition: { renderInfo: { type: 'private', e164: e164 && !e164.isZero() ? `+${e164}` : undefined, username: dropNull(username), }, }, }, additionalMessages: [], }; } if (updateMessage.threadMerge) { const { previousE164 } = updateMessage.threadMerge; strictAssert(previousE164 != null, 'threadMerge must have an old e164'); return { message: { type: 'conversation-merge', conversationMerge: { renderInfo: { type: 'private', e164: `+${previousE164}`, }, }, }, additionalMessages: [], }; } if (updateMessage.sessionSwitchover) { const { e164 } = updateMessage.sessionSwitchover; strictAssert(e164 != null, 'sessionSwitchover must have an old e164'); return { message: { type: 'phone-number-discovery', phoneNumberDiscovery: { e164: `+${e164}`, }, }, additionalMessages: [], }; } 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: ringerRecipientIdLong, startedCallRecipientId: startedCallRecipientIdLong, startedCallTimestamp, endedCallTimestamp, read, } = updateMessage.groupCall; const ringerRecipientId = ringerRecipientIdLong?.toNumber(); const startedCallRecipientId = startedCallRecipientIdLong?.toNumber(); const ringer = isNumber(ringerRecipientId) ? this.#recipientIdToConvo.get(ringerRecipientId) : undefined; const startedBy = isNumber(startedCallRecipientId) ? this.#recipientIdToConvo.get(startedCallRecipientId) : undefined; let callId: string; if (callIdLong?.toNumber()) { callId = callIdLong.toString(); } else { // Legacy calls may not have a callId, so we generate one locally callId = generateUuid(); } if (!startedCallTimestamp) { throw new Error('groupCall: startedCallTimestamp is required!'); } const isRingerMe = ringer?.serviceId === aboutMe.aci; const callHistory: CallHistoryDetails = { callId, status: fromGroupCallStateProto(state), mode: CallMode.Group, type: CallType.Group, ringerId: ringer?.serviceId ?? null, startedById: isAciString(startedBy?.serviceId) ? startedBy.serviceId : null, peerId: groupId, direction: isRingerMe ? CallDirection.Outgoing : CallDirection.Incoming, timestamp: getCheckedTimestampFromLong(startedCallTimestamp), endedTimestamp: getCheckedTimestampOrUndefinedFromLong(endedCallTimestamp) ?? null, }; await this.#saveCallHistory(callHistory); return { message: { type: 'call-history', callId, sourceServiceId: undefined, source: 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; let callId: string; if (callIdLong?.toNumber()) { callId = callIdLong.toString(); } else { // Legacy calls may not have a callId, so we generate one locally callId = generateUuid(); } if (!startedCallTimestamp) { throw new Error('individualCall: startedCallTimestamp is required!'); } const peerId = conversation.serviceId || conversation.e164; strictAssert(peerId, 'individualCall: no peerId found for call'); 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, startedById: null, peerId, direction, timestamp: getCheckedTimestampFromLong(startedCallTimestamp), endedTimestamp: null, }; await this.#saveCallHistory(callHistory); return { message: { type: 'call-history', callId, sourceServiceId: undefined, source: undefined, readStatus: ReadStatus.Read, seenStatus: read ? SeenStatus.Seen : SeenStatus.Unseen, }, additionalMessages: [], }; } if (updateMessage.pinMessage) { strictAssert( updateMessage.pinMessage.authorId != null, 'pinMessage: Missing authorId' ); const targetAuthor = this.#recipientIdToConvo.get( updateMessage.pinMessage.authorId.toNumber() ); strictAssert(targetAuthor != null, 'pinMessage: Missing target author'); const targetAuthorAci = targetAuthor.serviceId; strictAssert( isAciString(targetAuthorAci), 'pinMessage: Target author missing aci' ); strictAssert( updateMessage.pinMessage.targetSentTimestamp != null, 'pinMessage: Missing targetSentTimestamp' ); const targetSentTimestamp = updateMessage.pinMessage.targetSentTimestamp.toNumber(); return { message: { type: 'pinned-message-notification', pinMessage: { targetAuthorAci, targetSentTimestamp, }, }, additionalMessages: [], }; } if (updateMessage.pollTerminate) { // TODO (DESKTOP-9282) log.warn('Skipping pollTerminate update (not yet supported)'); return SKIP; } return undefined; } async #fromGroupUpdateMessage( groupChange: Backups.IGroupChangeChatUpdate, options: { aboutMe: AboutMe; author?: ConversationAttributesType; timestamp: number; } ): Promise { const { updates } = groupChange; const { aboutMe, timestamp } = options; const logId = `fromGroupUpdateMessage${timestamp}`; const details: Array = []; let from: ServiceIdString | undefined; const additionalMessages: Array> = []; let migrationMessage: Partial | undefined; function getDefaultMigrationMessage() { return { type: 'group-v1-migration' as const, groupMigration: { areWeInvited: false, droppedMemberCount: 0, invitedMemberCount: 0, }, }; } let openApprovalServiceId: ServiceIdString | undefined; let openBounceServiceId: ServiceIdString | undefined; updates?.forEach(update => { if (update.genericGroupUpdate) { const { updaterAci } = update.genericGroupUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'summary', }); } if (update.groupCreationUpdate) { const { updaterAci } = update.groupCreationUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'create', }); } if (update.groupNameUpdate) { const { updaterAci, newGroupName } = update.groupNameUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'title', newTitle: dropNull(newGroupName), }); } if (update.groupAvatarUpdate) { const { updaterAci, wasRemoved } = update.groupAvatarUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'avatar', removed: Boolean(dropNull(wasRemoved)), }); } if (update.groupDescriptionUpdate) { const { updaterAci, newDescription } = update.groupDescriptionUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } const description = dropNull(newDescription); details.push({ type: 'description', description, removed: description === undefined || description.length === 0 ? true : undefined, }); } if (update.groupMembershipAccessLevelChangeUpdate) { const { updaterAci, accessLevel } = update.groupMembershipAccessLevelChangeUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'access-members', newPrivilege: dropNull(accessLevel) ?? SignalService.AccessControl.AccessRequired.UNKNOWN, }); } if (update.groupMemberLabelAccessLevelChangeUpdate) { const { updaterAci, accessLevel } = update.groupMemberLabelAccessLevelChangeUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'access-member-label', newPrivilege: dropNull(accessLevel) ?? SignalService.AccessControl.AccessRequired.UNKNOWN, }); } if (update.groupAttributesAccessLevelChangeUpdate) { const { updaterAci, accessLevel } = update.groupAttributesAccessLevelChangeUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'access-attributes', newPrivilege: dropNull(accessLevel) ?? SignalService.AccessControl.AccessRequired.UNKNOWN, }); } if (update.groupAnnouncementOnlyChangeUpdate) { const { updaterAci, isAnnouncementOnly } = update.groupAnnouncementOnlyChangeUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'announcements-only', announcementsOnly: Boolean(dropNull(isAnnouncementOnly)), }); } if (update.groupAdminStatusUpdate) { const { updaterAci, memberAci, wasAdminStatusGranted } = update.groupAdminStatusUpdate; if (updaterAci) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } if (!memberAci) { throw new Error( `${logId}: We can't render this without a target member!` ); } details.push({ type: 'member-privilege', aci: fromAciObject(Aci.fromUuidBytes(memberAci)), newPrivilege: wasAdminStatusGranted ? SignalService.Member.Role.ADMINISTRATOR : SignalService.Member.Role.DEFAULT, }); } if (update.groupMemberLeftUpdate) { const { aci } = update.groupMemberLeftUpdate; if (!aci || Bytes.isEmpty(aci)) { throw new Error(`${logId}: groupMemberLeftUpdate had missing aci!`); } from = fromAciObject(Aci.fromUuidBytes(aci)); details.push({ type: 'member-remove', aci: fromAciObject(Aci.fromUuidBytes(aci)), }); } if (update.groupMemberRemovedUpdate) { const { removerAci, removedAci } = update.groupMemberRemovedUpdate; if (removerAci) { from = fromAciObject(Aci.fromUuidBytes(removerAci)); } if (!removedAci || Bytes.isEmpty(removedAci)) { throw new Error( `${logId}: groupMemberRemovedUpdate had missing removedAci!` ); } details.push({ type: 'member-remove', aci: fromAciObject(Aci.fromUuidBytes(removedAci)), }); } if (update.selfInvitedToGroupUpdate) { const { inviterAci } = update.selfInvitedToGroupUpdate; if (inviterAci) { from = fromAciObject(Aci.fromUuidBytes(inviterAci)); } details.push({ type: 'pending-add-one', serviceId: aboutMe.aci, }); } if (update.selfInvitedOtherUserToGroupUpdate) { const { inviteeServiceId } = update.selfInvitedOtherUserToGroupUpdate; from = aboutMe.aci; if (!inviteeServiceId || Bytes.isEmpty(inviteeServiceId)) { throw new Error( `${logId}: selfInvitedOtherUserToGroupUpdate had missing inviteeServiceId!` ); } details.push({ type: 'pending-add-one', serviceId: fromServiceIdObject( ServiceId.parseFromServiceIdBinary(inviteeServiceId) ), }); } if (update.groupUnknownInviteeUpdate) { const { inviterAci, inviteeCount } = update.groupUnknownInviteeUpdate; if (inviterAci) { from = fromAciObject(Aci.fromUuidBytes(inviterAci)); } if (!isNumber(inviteeCount)) { throw new Error( `${logId}: groupUnknownInviteeUpdate had non-number inviteeCount` ); } details.push({ type: 'pending-add-many', count: inviteeCount, }); } if (update.groupInvitationAcceptedUpdate) { const { inviterAci, newMemberAci } = update.groupInvitationAcceptedUpdate; if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { throw new Error( `${logId}: groupInvitationAcceptedUpdate had missing newMemberAci!` ); } from = fromAciObject(Aci.fromUuidBytes(newMemberAci)); const inviter = inviterAci && Bytes.isNotEmpty(inviterAci) ? fromAciObject(Aci.fromUuidBytes(inviterAci)) : undefined; details.push({ type: 'member-add-from-invite', aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), inviter, }); } if (update.groupInvitationDeclinedUpdate) { const { inviterAci, inviteeAci } = update.groupInvitationDeclinedUpdate; from = Bytes.isNotEmpty(inviteeAci) ? fromAciObject(Aci.fromUuidBytes(inviteeAci)) : undefined; details.push({ type: 'pending-remove-one', inviter: Bytes.isNotEmpty(inviterAci) ? fromAciObject(Aci.fromUuidBytes(inviterAci)) : undefined, serviceId: from, }); } if (update.groupMemberJoinedUpdate) { const { newMemberAci } = update.groupMemberJoinedUpdate; if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { throw new Error( `${logId}: groupMemberJoinedUpdate had missing newMemberAci!` ); } from = fromAciObject(Aci.fromUuidBytes(newMemberAci)); details.push({ type: 'member-add', aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), }); } if (update.groupMemberAddedUpdate) { const { hadOpenInvitation, inviterAci, newMemberAci, updaterAci } = update.groupMemberAddedUpdate; if (Bytes.isNotEmpty(updaterAci)) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { throw new Error( `${logId}: groupMemberAddedUpdate had missing newMemberAci!` ); } if (hadOpenInvitation || Bytes.isNotEmpty(inviterAci)) { const inviter = inviterAci && Bytes.isNotEmpty(inviterAci) ? fromAciObject(Aci.fromUuidBytes(inviterAci)) : undefined; details.push({ type: 'member-add-from-invite', aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), inviter, }); } else { details.push({ type: 'member-add', aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), }); } } if (update.groupSelfInvitationRevokedUpdate) { const { revokerAci } = update.groupSelfInvitationRevokedUpdate; if (Bytes.isNotEmpty(revokerAci)) { from = fromAciObject(Aci.fromUuidBytes(revokerAci)); } details.push({ type: 'pending-remove-one', serviceId: aboutMe.aci, }); } if (update.groupInvitationRevokedUpdate) { const { updaterAci, invitees } = update.groupInvitationRevokedUpdate; if (Bytes.isNotEmpty(updaterAci)) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } if (!invitees || invitees.length === 0) { throw new Error( `${logId}: groupInvitationRevokedUpdate had missing invitees list!` ); } const firstInviter = invitees[0].inviterAci; const inviterAci = firstInviter && invitees.every(invitee => invitee.inviterAci === firstInviter) ? fromAciObject(Aci.fromUuidBytes(firstInviter)) : undefined; if (invitees.length === 1) { const { inviteeAci, inviteePni } = invitees[0]; let serviceId: ServiceIdString | undefined = Bytes.isNotEmpty( inviteeAci ) ? fromAciObject(Aci.fromUuidBytes(inviteeAci)) : undefined; if (!serviceId) { serviceId = Bytes.isNotEmpty(inviteePni) ? fromPniObject(Pni.fromUuidBytes(inviteePni)) : undefined; } if (serviceId) { details.push({ type: 'pending-remove-one', serviceId, inviter: inviterAci, }); } else { details.push({ type: 'pending-remove-many', count: 1, inviter: inviterAci, }); } } else { details.push({ type: 'pending-remove-many', count: invitees.length, inviter: inviterAci, }); } } if (update.groupJoinRequestUpdate) { const { requestorAci } = update.groupJoinRequestUpdate; if (!requestorAci || Bytes.isEmpty(requestorAci)) { throw new Error( `${logId}: groupInvitationRevokedUpdate was missing requestorAci!` ); } from = fromAciObject(Aci.fromUuidBytes(requestorAci)); openApprovalServiceId = from; details.push({ type: 'admin-approval-add-one', aci: from, }); } if (update.groupJoinRequestApprovalUpdate) { const { updaterAci, requestorAci, wasApproved } = update.groupJoinRequestApprovalUpdate; if (!requestorAci || Bytes.isEmpty(requestorAci)) { throw new Error( `${logId}: groupJoinRequestApprovalUpdate was missing requestorAci!` ); } if (Bytes.isNotEmpty(updaterAci)) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } const aci = fromAciObject(Aci.fromUuidBytes(requestorAci)); if (wasApproved) { details.push({ type: 'member-add-from-admin-approval', aci, }); } else { details.push({ type: 'admin-approval-remove-one', aci, }); } } if (update.groupJoinRequestCanceledUpdate) { const { requestorAci } = update.groupJoinRequestCanceledUpdate; if (!requestorAci || Bytes.isEmpty(requestorAci)) { throw new Error( `${logId}: groupJoinRequestCanceledUpdate was missing requestorAci!` ); } from = fromAciObject(Aci.fromUuidBytes(requestorAci)); details.push({ type: 'admin-approval-remove-one', aci: from, }); } if (update.groupInviteLinkResetUpdate) { const { updaterAci } = update.groupInviteLinkResetUpdate; if (Bytes.isNotEmpty(updaterAci)) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'group-link-reset', }); } if (update.groupInviteLinkEnabledUpdate) { const { updaterAci, linkRequiresAdminApproval } = update.groupInviteLinkEnabledUpdate; if (Bytes.isNotEmpty(updaterAci)) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'group-link-add', privilege: linkRequiresAdminApproval ? SignalService.AccessControl.AccessRequired.ADMINISTRATOR : SignalService.AccessControl.AccessRequired.ANY, }); } if (update.groupInviteLinkAdminApprovalUpdate) { const { updaterAci, linkRequiresAdminApproval } = update.groupInviteLinkAdminApprovalUpdate; if (Bytes.isNotEmpty(updaterAci)) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'access-invite-link', newPrivilege: linkRequiresAdminApproval ? SignalService.AccessControl.AccessRequired.ADMINISTRATOR : SignalService.AccessControl.AccessRequired.ANY, }); } if (update.groupInviteLinkDisabledUpdate) { const { updaterAci } = update.groupInviteLinkDisabledUpdate; if (Bytes.isNotEmpty(updaterAci)) { from = fromAciObject(Aci.fromUuidBytes(updaterAci)); } details.push({ type: 'group-link-remove', }); } if (update.groupMemberJoinedByLinkUpdate) { const { newMemberAci } = update.groupMemberJoinedByLinkUpdate; if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { throw new Error( `${logId}: groupMemberJoinedByLinkUpdate was missing newMemberAci!` ); } from = fromAciObject(Aci.fromUuidBytes(newMemberAci)); details.push({ type: 'member-add-from-link', aci: from, }); } if (update.groupV2MigrationUpdate) { migrationMessage = migrationMessage || getDefaultMigrationMessage(); } if (update.groupV2MigrationSelfInvitedUpdate) { migrationMessage = migrationMessage || getDefaultMigrationMessage(); const { groupMigration } = migrationMessage; if (!groupMigration) { throw new Error( `${logId}: migrationMessage had no groupMigration processing groupV2MigrationSelfInvitedUpdate!` ); } groupMigration.areWeInvited = true; } if (update.groupV2MigrationInvitedMembersUpdate) { migrationMessage = migrationMessage || getDefaultMigrationMessage(); const { groupMigration } = migrationMessage; if (!groupMigration) { throw new Error( `${logId}: migrationMessage had no groupMigration processing groupV2MigrationInvitedMembersUpdate!` ); } const { invitedMembersCount } = update.groupV2MigrationInvitedMembersUpdate; if (!isNumber(invitedMembersCount)) { throw new Error( `${logId}: groupV2MigrationInvitedMembersUpdate had a non-number invitedMembersCount!` ); } groupMigration.invitedMemberCount = invitedMembersCount; } if (update.groupV2MigrationDroppedMembersUpdate) { migrationMessage = migrationMessage || getDefaultMigrationMessage(); const { groupMigration } = migrationMessage; if (!groupMigration) { throw new Error( `${logId}: migrationMessage had no groupMigration processing groupV2MigrationDroppedMembersUpdate!` ); } const { droppedMembersCount } = update.groupV2MigrationDroppedMembersUpdate; if (!isNumber(droppedMembersCount)) { throw new Error( `${logId}: groupV2MigrationDroppedMembersUpdate had a non-number droppedMembersCount!` ); } groupMigration.droppedMemberCount = droppedMembersCount; } if (update.groupSequenceOfRequestsAndCancelsUpdate) { const { count, requestorAci } = update.groupSequenceOfRequestsAndCancelsUpdate; if (!requestorAci || Bytes.isEmpty(requestorAci)) { throw new Error( `${logId}: groupSequenceOfRequestsAndCancelsUpdate was missing requestorAci!` ); } if (!isNumber(count)) { throw new Error( `${logId}: groupSequenceOfRequestsAndCancelsUpdate had a non-number count!` ); } const aci = fromAciObject(Aci.fromUuidBytes(requestorAci)); openBounceServiceId = aci; from = aci; details.push({ type: 'admin-approval-bounce', aci, times: count, // This will be set later if we find an open approval request for this aci isApprovalPending: false, }); } if (update.groupExpirationTimerUpdate) { const { updaterAci, expiresInMs } = update.groupExpirationTimerUpdate; let sourceServiceId: AciString | undefined; if (Bytes.isNotEmpty(updaterAci)) { sourceServiceId = fromAciObject(Aci.fromUuidBytes(updaterAci)); } const expireTimer = expiresInMs ? DurationInSeconds.fromMillis(expiresInMs.toNumber()) : undefined; additionalMessages.push({ type: 'timer-notification', sourceServiceId, flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, expirationTimerUpdate: { expireTimer, sourceServiceId, }, }); } }); let finalDetails = details; if ( openApprovalServiceId && openBounceServiceId && openApprovalServiceId === openBounceServiceId ) { finalDetails = details .map(item => { const approvalMatch = item.type === 'admin-approval-add-one' && item.aci === openApprovalServiceId; if (approvalMatch) { return undefined; } const bounceMatch = item.type === 'admin-approval-bounce' && item.aci === openApprovalServiceId; if (bounceMatch) { return { ...item, isApprovalPending: true, }; } return item; }) .filter(isNotNil); } if (migrationMessage) { additionalMessages.push(migrationMessage); } if (finalDetails.length === 0 && additionalMessages.length > 0) { return { message: additionalMessages[0], additionalMessages: additionalMessages.slice(1), }; } if (finalDetails.length === 0) { return undefined; } return { message: { type: 'group-v2-change', groupV2Change: { from, details: finalDetails, }, }, additionalMessages, }; } async #fromSimpleUpdateMessage( simpleUpdate: Backups.ISimpleChatUpdate, { author, conversation, }: { author?: ConversationAttributesType; conversation: ConversationAttributesType; } ): Promise | undefined> { const { Type } = Backups.SimpleChatUpdate; strictAssert(simpleUpdate.type != null, 'Simple update missing type'); switch (simpleUpdate.type) { case Type.END_SESSION: return { flags: SignalService.DataMessage.Flags.END_SESSION, }; case Type.CHAT_SESSION_REFRESH: return { type: 'chat-session-refreshed', }; case Type.IDENTITY_UPDATE: return { type: 'keychange', key_changed: isGroup(conversation) ? author?.serviceId : undefined, }; case Type.IDENTITY_VERIFIED: strictAssert(author != null, 'IDENTITY_VERIFIED must have an author'); return { type: 'verified-change', verifiedChanged: author.id, verified: true, }; case Type.IDENTITY_DEFAULT: strictAssert(author != null, 'IDENTITY_UNVERIFIED must have an author'); return { type: 'verified-change', verifiedChanged: author.id, verified: false, }; case Type.CHANGE_NUMBER: return { type: 'change-number-notification', }; case Type.JOINED_SIGNAL: return { type: 'joined-signal-notification', }; case Type.BAD_DECRYPT: return { type: 'delivery-issue', }; case Type.RELEASE_CHANNEL_DONATION_REQUEST: log.warn('backups: dropping boost request from release notes'); return undefined; case Type.PAYMENTS_ACTIVATED: return { payment: { kind: PaymentEventKind.Activation, }, }; case Type.PAYMENT_ACTIVATION_REQUEST: return { payment: { kind: PaymentEventKind.ActivationRequest, }, }; case Type.UNSUPPORTED_PROTOCOL_MESSAGE: return { supportedVersionAtReceive: SignalService.DataMessage.ProtocolVersion.CURRENT - 2, requiredProtocolVersion: SignalService.DataMessage.ProtocolVersion.CURRENT - 1, }; case Type.REPORTED_SPAM: return { type: 'message-request-response-event', messageRequestResponseEvent: MessageRequestResponseEvent.SPAM, }; case Type.BLOCKED: return { type: 'message-request-response-event', messageRequestResponseEvent: MessageRequestResponseEvent.BLOCK, }; case Type.UNBLOCKED: return { type: 'message-request-response-event', messageRequestResponseEvent: MessageRequestResponseEvent.UNBLOCK, }; case Type.MESSAGE_REQUEST_ACCEPTED: return { type: 'message-request-response-event', messageRequestResponseEvent: MessageRequestResponseEvent.ACCEPT, }; default: throw new Error(`Unsupported update type: ${simpleUpdate.type}`); } } async #fromStickerPack({ packId: packIdBytes, packKey: packKeyBytes, }: Backups.IStickerPack): Promise { strictAssert( packIdBytes?.length === STICKERPACK_ID_BYTE_LEN, 'Sticker pack must have a valid pack id' ); const id = Bytes.toHex(packIdBytes); const logId = `fromStickerPack(${id.slice(-2)})`; strictAssert( packKeyBytes?.length === STICKERPACK_KEY_BYTE_LEN, `${logId}: must have a valid pack key` ); const key = Bytes.toBase64(packKeyBytes); this.#stickerPacks.push({ id, key }); } async #fromAdHocCall({ callId: callIdLong, recipientId: recipientIdLong, state, callTimestamp, }: Backups.IAdHocCall): Promise { let callId: string; if (callIdLong?.toNumber()) { callId = callIdLong.toString(); } else { // Legacy calls may not have a callId, so we generate one locally callId = generateUuid(); } 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, startedById: null, mode: CallMode.Adhoc, type: CallType.Adhoc, direction: CallDirection.Unknown, timestamp: getCheckedTimestampFromLong(callTimestamp), status: fromAdHocCallStateProto(state), endedTimestamp: null, }; await this.#saveCallHistory(callHistory); if (isCallLinkAdmin(callLink)) { this.#adminCallLinksToHasCall.set(callLink, true); } } async #fromNotificationProfile( incomingProfile: Backups.INotificationProfile ) { const { id, name, emoji, color, createdAtMs, allowAllCalls, allowAllMentions, allowedMembers, scheduleEnabled, scheduleStartTime, scheduleEndTime, scheduleDaysEnabled, } = incomingProfile; strictAssert(name, 'notification profile must have a valid name'); if (!id || !id.length) { log.warn('Dropping notification profile; it was missing an id'); return; } const allowedMemberConversationIds: ReadonlyArray | undefined = allowedMembers ?.map(recipientIdLong => { const recipientId = recipientIdLong.toNumber(); const attributes = this.#recipientIdToConvo.get(recipientId); if (!attributes) { return undefined; } return attributes.id; }) .filter(isNotNil); const profile: NotificationProfileType = { id: normalizeNotificationProfileId(Bytes.toHex(id), 'import', log), name, emoji: dropNull(emoji), color: dropNull(color) ?? DEFAULT_PROFILE_COLOR, createdAtMs: getCheckedTimestampOrUndefinedFromLong(createdAtMs) ?? 0, allowAllCalls: Boolean(allowAllCalls), allowAllMentions: Boolean(allowAllMentions), allowedMembers: new Set(allowedMemberConversationIds ?? []), scheduleEnabled: Boolean(scheduleEnabled), scheduleStartTime: dropNull(scheduleStartTime), scheduleEndTime: dropNull(scheduleEndTime), scheduleDaysEnabled: fromDayOfWeekArray(scheduleDaysEnabled), deletedAtTimestampMs: undefined, storageNeedsSync: false, storageID: undefined, storageUnknownFields: undefined, storageVersion: undefined, }; await DataWriter.createNotificationProfile(profile); } #chatFolderPositionCursor = 0; async #fromChatFolder(proto: Backups.IChatFolder): Promise { if (proto.id == null || proto.id.length === 0) { log.warn('Dropping chat folder; it was missing an id'); return; } const id = bytesToUuid(proto.id) as ChatFolderId | undefined; if (id == null) { log.warn('Dropping chat folder; invalid uuid bytes'); return; } let folderType: ChatFolderType; if (proto.folderType === Backups.ChatFolder.FolderType.ALL) { folderType = ChatFolderType.ALL; } else if (proto.folderType === Backups.ChatFolder.FolderType.CUSTOM) { folderType = ChatFolderType.CUSTOM; } else { log.warn('Dropping chat folder; unknown folder type'); return; } const position = this.#chatFolderPositionCursor; this.#chatFolderPositionCursor += 1; const includedRecipientIds = proto.includedRecipientIds ?? []; const excludedRecipientIds = proto.excludedRecipientIds ?? []; const chatFolder: ChatFolder = { id, name: proto.name ?? '', folderType, showOnlyUnread: proto.showOnlyUnread ?? false, showMutedChats: proto.showMutedChats ?? false, includeAllIndividualChats: proto.includeAllIndividualChats ?? false, includeAllGroupChats: proto.includeAllGroupChats ?? false, includedConversationIds: includedRecipientIds.map(recipientId => { const convo = this.#recipientIdToConvo.get(recipientId.toNumber()); strictAssert(convo != null, 'Missing chat folder included recipient'); return convo.id; }), excludedConversationIds: excludedRecipientIds.map(recipientId => { const convo = this.#recipientIdToConvo.get(recipientId.toNumber()); strictAssert(convo != null, 'Missing chat folder included recipient'); return convo.id; }), position, deletedAtTimestampMs: 0, storageID: null, storageVersion: null, storageUnknownFields: null, storageNeedsSync: false, }; if (folderType === ChatFolderType.ALL) { await DataWriter.upsertAllChatsChatFolderFromSync(chatFolder); } else { await DataWriter.createChatFolder(chatFolder); } } async #fromCustomChatColors( customChatColors: | ReadonlyArray | undefined | null ): Promise { if (!customChatColors?.length) { return; } const order = new Array(); const customColors: CustomColorsItemType = { version: 1, colors: {}, order, }; for (const color of customChatColors) { const uuid = generateUuid(); let value: CustomColorType; order.push(uuid); if (color.solid) { value = { start: rgbIntToDesktopHSL(color.solid), }; } else if (color.gradient) { strictAssert(color.gradient != null, 'Either solid or gradient'); strictAssert(color.gradient.colors != null, 'Missing gradient colors'); const start = color.gradient.colors.at(0); const end = color.gradient.colors.at(-1); const backupAngle = color.gradient.angle; strictAssert(start != null, 'Missing start color'); strictAssert(end != null, 'Missing end color'); strictAssert(backupAngle != null, 'Missing angle'); // Desktop uses a different angle convention than the backup proto. Our degrees // rotate in the opposite direction (sadly!) and our start is shifted by 90 // degrees const desktopAngle = 360 - backupAngle - 90; value = { start: rgbIntToDesktopHSL(start), end: rgbIntToDesktopHSL(end), deg: (desktopAngle + 360) % 360, }; } else { log.error( 'CustomChatColor missing both solid and gradient fields, dropping' ); this.#frameErrorCount += 1; continue; } customColors.colors[uuid] = value; this.#customColorById.set(color.id?.toNumber() || 0, { id: uuid, value, }); } await itemStorage.put('customColors', customColors); } #fromChatStyle(chatStyle: Backups.IChatStyle | null | undefined): Omit< LocalChatStyle, 'customColorId' > & { customColorData: CustomColorDataType | undefined; } { if (!chatStyle) { return { wallpaperPhotoPointer: undefined, wallpaperPreset: undefined, color: undefined, customColorData: undefined, dimWallpaperInDarkMode: undefined, autoBubbleColor: true, }; } let wallpaperPhotoPointer: Uint8Array | undefined; let wallpaperPreset: number | undefined; const dimWallpaperInDarkMode = dropNull(chatStyle.dimWallpaperInDarkMode); if (chatStyle.wallpaperPhoto) { wallpaperPhotoPointer = Backups.FilePointer.encode( chatStyle.wallpaperPhoto ).finish(); } else if (chatStyle.wallpaperPreset != null) { wallpaperPreset = chatStyle.wallpaperPreset; } let color: ConversationColorType | undefined; let customColorData: CustomColorDataType | undefined; let autoBubbleColor = false; if (chatStyle.autoBubbleColor) { autoBubbleColor = true; if (wallpaperPreset != null) { color = WALLPAPER_TO_BUBBLE_COLOR.get(wallpaperPreset); } else { color = undefined; } } else if (chatStyle.bubbleColorPreset != null) { const { BubbleColorPreset } = Backups.ChatStyle; switch (chatStyle.bubbleColorPreset) { case BubbleColorPreset.SOLID_CRIMSON: color = 'crimson'; break; case BubbleColorPreset.SOLID_VERMILION: color = 'vermilion'; break; case BubbleColorPreset.SOLID_BURLAP: color = 'burlap'; break; case BubbleColorPreset.SOLID_FOREST: color = 'forest'; break; case BubbleColorPreset.SOLID_WINTERGREEN: color = 'wintergreen'; break; case BubbleColorPreset.SOLID_TEAL: color = 'teal'; break; case BubbleColorPreset.SOLID_BLUE: color = 'blue'; break; case BubbleColorPreset.SOLID_INDIGO: color = 'indigo'; break; case BubbleColorPreset.SOLID_VIOLET: color = 'violet'; break; case BubbleColorPreset.SOLID_PLUM: color = 'plum'; break; case BubbleColorPreset.SOLID_TAUPE: color = 'taupe'; break; case BubbleColorPreset.SOLID_STEEL: color = 'steel'; break; case BubbleColorPreset.GRADIENT_EMBER: color = 'ember'; break; case BubbleColorPreset.GRADIENT_MIDNIGHT: color = 'midnight'; break; case BubbleColorPreset.GRADIENT_INFRARED: color = 'infrared'; break; case BubbleColorPreset.GRADIENT_LAGOON: color = 'lagoon'; break; case BubbleColorPreset.GRADIENT_FLUORESCENT: color = 'fluorescent'; break; case BubbleColorPreset.GRADIENT_BASIL: color = 'basil'; break; case BubbleColorPreset.GRADIENT_SUBLIME: color = 'sublime'; break; case BubbleColorPreset.GRADIENT_SEA: color = 'sea'; break; case BubbleColorPreset.GRADIENT_TANGERINE: color = 'tangerine'; break; case BubbleColorPreset.SOLID_ULTRAMARINE: default: color = 'ultramarine'; break; } } else if (chatStyle.customColorId != null) { const entry = this.#customColorById.get( chatStyle.customColorId.toNumber() ); if (entry) { color = 'custom'; customColorData = entry; } else { log.error('Chat style referenced missing custom color'); this.#frameErrorCount += 1; autoBubbleColor = true; } } else { log.error('ChatStyle has no recognized field'); this.#frameErrorCount += 1; autoBubbleColor = true; } // We only roundtrip wallpaper info in tests since it is not synced in storage service const shouldImportWallpaper = isTestOrMockEnvironment(); return { color, customColorData, ...(shouldImportWallpaper ? { autoBubbleColor, wallpaperPhotoPointer, wallpaperPreset, dimWallpaperInDarkMode, } : { autoBubbleColor: undefined, wallpaperPhotoPointer: undefined, wallpaperPreset: undefined, dimWallpaperInDarkMode: undefined, }), }; } #isLocalBackup() { return this.options.type === 'local-encrypted'; } #isMediaEnabledBackup() { return this.#isLocalBackup() || this.#backupTier === BackupLevel.Paid; } } function rgbIntToDesktopHSL(intValue: number): { hue: number; saturation: number; lightness: number; } { const { h: hue, s: saturation, l: lightness } = rgbIntToHSL(intValue); // Desktop stores saturation not as 0.123 (0 to 1.0) but 12.3 (percentage) return { hue, saturation: saturation * 100, lightness }; } 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; } function fromAvatarColor( color: Backups.AvatarColor | null | undefined ): string | undefined { switch (color) { case Backups.AvatarColor.A100: return 'A100'; case Backups.AvatarColor.A110: return 'A110'; case Backups.AvatarColor.A120: return 'A120'; case Backups.AvatarColor.A130: return 'A130'; case Backups.AvatarColor.A140: return 'A140'; case Backups.AvatarColor.A150: return 'A150'; case Backups.AvatarColor.A160: return 'A160'; case Backups.AvatarColor.A170: return 'A170'; case Backups.AvatarColor.A180: return 'A180'; case Backups.AvatarColor.A190: return 'A190'; case Backups.AvatarColor.A200: return 'A200'; case Backups.AvatarColor.A210: return 'A210'; case null: case undefined: return undefined; default: throw missingCaseError(color); } } function toThemeSetting( theme: Backups.AccountData.AppTheme | undefined | null ): ThemeType { const ENUM = Backups.AccountData.AppTheme; if (theme === ENUM.LIGHT) { return 'light'; } if (theme === ENUM.DARK) { return 'dark'; } return 'system'; }