// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client'; import { ReceiptCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; import { v4 as generateUuid } from 'uuid'; import pMap from 'p-map'; import { Writable } from 'stream'; import { isNumber } from 'lodash'; import { Backups, SignalService } from '../../protobuf'; import { DataWriter } from '../../sql/Client'; import type { StoryDistributionWithMembersType } from '../../sql/Interface'; import * as log from '../../logging/log'; import { GiftBadgeStates } from '../../components/conversation/Message'; import { StorySendMode } from '../../types/Stories'; import type { ServiceIdString, AciString } from '../../types/ServiceId'; import { fromAciObject, fromPniObject, fromServiceIdObject, } from '../../types/ServiceId'; import { isStoryDistributionId } from '../../types/StoryDistributionId'; import * as Errors from '../../types/errors'; import { PaymentEventKind } from '../../types/Payment'; import { MessageRequestResponseEvent } from '../../types/MessageRequestResponseEvent'; import { ContactFormType, AddressType as ContactAddressType, } from '../../types/EmbeddedContact'; import { STICKERPACK_ID_BYTE_LEN, STICKERPACK_KEY_BYTE_LEN, downloadStickerPack, } from '../../types/Stickers'; import type { ConversationColorType, CustomColorsItemType, CustomColorType, CustomColorDataType, } from '../../types/Colors'; import type { ConversationAttributesType, CustomError, MessageAttributesType, MessageReactionType, EditHistoryType, QuotedMessageType, } from '../../model-types.d'; import { assertDev, strictAssert } from '../../util/assert'; import { getTimestampFromLong } from '../../util/timestampLongUtils'; import { DurationInSeconds, SECOND } from '../../util/durations'; import { dropNull } from '../../util/dropNull'; import { deriveGroupID, deriveGroupSecretParams, deriveGroupPublicParams, } from '../../util/zkgroup'; import { incrementMessageCounter } from '../../util/incrementMessageCounter'; import { isAciString } from '../../util/isAciString'; import { createBatcher } from '../../util/batcher'; import { PhoneNumberDiscoverability } from '../../util/phoneNumberDiscoverability'; import { PhoneNumberSharingMode } from '../../util/phoneNumberSharingMode'; import { bytesToUuid } from '../../util/uuidToBytes'; import { missingCaseError } from '../../util/missingCaseError'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SendStatus } from '../../messages/MessageSendState'; import type { SendStateByConversationId } from '../../messages/MessageSendState'; import { SeenStatus } from '../../MessageSeenStatus'; import * as Bytes from '../../Bytes'; import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants'; import type { AboutMe, LocalChatStyle } from './types'; import type { GroupV2ChangeDetailType } from '../../groups'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { drop } from '../../util/drop'; import { isNotNil } from '../../util/isNotNil'; import { isGroup, isGroupV2 } from '../../util/whatTypeOfConversation'; import { rgbToHSL } from '../../util/rgbToHSL'; import { convertBackupMessageAttachmentToAttachment, convertFilePointerToAttachment, } from './util/filePointers'; import { filterAndClean } from '../../types/BodyRange'; import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME'; import { copyFromQuotedMessage } from '../../messages/copyQuote'; import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue'; const MAX_CONCURRENCY = 10; type ConversationOpType = Readonly<{ isUpdate: boolean; attributes: ConversationAttributesType; }>; type ChatItemParseResult = { message: Partial; additionalMessages: Array>; }; async function processConversationOpBatch( batch: ReadonlyArray ): Promise { // Note that we might have duplicates since we update attributes in-place const saves = [ ...new Set(batch.filter(x => x.isUpdate === false).map(x => x.attributes)), ]; const updates = [ ...new Set(batch.filter(x => x.isUpdate === true).map(x => x.attributes)), ]; log.info( `backups: running conversation op batch, saves=${saves.length} ` + `updates=${updates.length}` ); await DataWriter.saveConversations(saves); await DataWriter.updateConversations(updates); } async function processMessagesBatch( ourAci: AciString, batch: ReadonlyArray ): Promise { const ids = await DataWriter.saveMessages(batch, { forceSave: true, ourAci, }); strictAssert(ids.length === batch.length, 'Should get same number of ids'); // TODO (DESKTOP-7402): consider re-saving after updating the pending state for (const [index, rawAttributes] of batch.entries()) { const attributes = { ...rawAttributes, id: ids[index], }; window.MessageCache.__DEPRECATED$unregister(attributes.id); const { editHistory } = attributes; if (editHistory?.length) { drop( 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, })) ) ); } drop(queueAttachmentDownloads(attributes)); } } 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 { private parsedBackupInfo = false; private logId = 'BackupImportStream(unknown)'; private aboutMe: AboutMe | undefined; private readonly recipientIdToConvo = new Map< number, ConversationAttributesType >(); private readonly chatIdToConvo = new Map< number, ConversationAttributesType >(); private readonly conversationOpBatcher = createBatcher<{ isUpdate: boolean; attributes: ConversationAttributesType; }>({ name: 'BackupImport.conversationOpBatcher', wait: 0, maxSize: 1000, processBatch: processConversationOpBatch, }); private readonly saveMessageBatcher = createBatcher({ name: 'BackupImport.saveMessageBatcher', wait: 0, maxSize: 1000, processBatch: batch => { const ourAci = this.ourConversation?.serviceId; assertDev(isAciString(ourAci), 'Our conversation must have ACI'); return processMessagesBatch(ourAci, batch); }, }); private ourConversation?: ConversationAttributesType; private pinnedConversations = new Array<[number, string]>(); private customColorById = new Map(); constructor() { super({ objectMode: true }); } 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 Error(`Unsupported backup version: ${info.version}`); } } else { const frame = Backups.Frame.decode(data); await this.processFrame(frame, { aboutMe: this.aboutMe }); if (!this.aboutMe && this.ourConversation) { const { serviceId, pni } = this.ourConversation; strictAssert( isAciString(serviceId), 'ourConversation serviceId must be ACI' ); this.aboutMe = { aci: serviceId, pni, }; } } 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 await this.conversationOpBatcher.flushAndWait(); await this.saveMessageBatcher.flushAndWait(); // Reset and reload conversations and storage again window.ConversationController.reset(); await window.ConversationController.load(); await window.ConversationController.checkForConflicts(); window.storage.reset(); await window.storage.fetch(); const allConversations = window.ConversationController.getAll(); // 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'); }), convo => convo.updateLastMessage(), { concurrency: MAX_CONCURRENCY } ); // Schedule group avatar download. await pMap( allConversations.filter(({ attributes: convo }) => { const { avatar } = convo; return isGroupV2(convo) && avatar?.url && !avatar.path; }), convo => groupAvatarJobQueue.add({ conversationId: convo.id }), { concurrency: MAX_CONCURRENCY } ); await window.storage.put( 'pinnedConversationIds', this.pinnedConversations .sort(([a], [b]) => { return a - b; }) .map(([, id]) => id) ); done(); } catch (error) { done(error); } } public cleanup(): void { this.conversationOpBatcher.unregister(); this.saveMessageBatcher.unregister(); } private 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'); let convo: ConversationAttributesType; if (recipient.contact) { convo = await this.fromContact(recipient.contact); } else if (recipient.self) { strictAssert(this.ourConversation != null, 'Missing account data'); convo = this.ourConversation; } else if (recipient.group) { convo = await this.fromGroup(recipient.group); } else if (recipient.distributionList) { await this.fromDistributionList(recipient.distributionList); // Not a conversation return; } else { log.warn(`${this.logId}: unsupported recipient item`); return; } if (convo !== this.ourConversation) { this.saveConversation(convo); } this.recipientIdToConvo.set(recipient.id.toNumber(), 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 { log.warn(`${this.logId}: unsupported frame item ${frame.item}`); } } catch (error) { log.error( `${this.logId}: failed to process a frame ${frame.item}, ` + `${Errors.toLogFormat(error)}` ); } } private saveConversation(attributes: ConversationAttributesType): void { // add the conversation into memory without saving it to DB (that will happen in // batcher); if we didn't do this, when we register messages to MessageCache, it would // automatically create (and save to DB) a duplicate conversation which would have to // be later merged window.ConversationController.dangerouslyCreateAndAdd(attributes); this.conversationOpBatcher.add({ isUpdate: false, attributes }); } private updateConversation(attributes: ConversationAttributesType): void { const existing = window.ConversationController.get(attributes.id); if (existing) { existing.set(attributes); } this.conversationOpBatcher.add({ isUpdate: true, attributes }); } private saveMessage(attributes: MessageAttributesType): void { window.MessageCache.__DEPRECATED$register( attributes.id, attributes, 'import.saveMessage' ); this.saveMessageBatcher.add(attributes); } private async fromAccount({ profileKey, username, usernameLink, givenName, familyName, avatarUrlPath, backupsSubscriberData, donationSubscriberData, accountSettings, }: Backups.IAccountData): Promise { strictAssert(this.ourConversation === undefined, 'Duplicate AccountData'); const me = window.ConversationController.getOurConversationOrThrow().attributes; this.ourConversation = me; const { storage } = window; strictAssert(Bytes.isNotEmpty(profileKey), 'Missing profile key'); await storage.put('profileKey', profileKey); if (username != null) { me.username = username; } if (usernameLink != null) { const { entropy, serverId, color } = usernameLink; if (Bytes.isNotEmpty(entropy) && Bytes.isNotEmpty(serverId)) { await storage.put('usernameLink', { entropy, serverId, }); } // Same numeric value, no conversion needed await storage.put('usernameLinkColor', color ?? 0); } if (givenName != null) { me.profileName = givenName; } if (familyName != null) { me.profileFamilyName = familyName; } if (avatarUrlPath != null) { await storage.put('avatarUrl', avatarUrlPath); } if (donationSubscriberData != null) { const { subscriberId, currencyCode, manuallyCancelled } = donationSubscriberData; if (Bytes.isNotEmpty(subscriberId)) { await storage.put('subscriberId', subscriberId); } if (currencyCode != null) { await storage.put('subscriberCurrencyCode', currencyCode); } if (manuallyCancelled != null) { await storage.put( 'donorSubscriptionManuallyCancelled', manuallyCancelled ); } } if (backupsSubscriberData != null) { const { subscriberId, currencyCode, manuallyCancelled } = backupsSubscriberData; if (Bytes.isNotEmpty(subscriberId)) { await storage.put('backupsSubscriberId', subscriberId); } if (currencyCode != null) { await storage.put('backupsSubscriberCurrencyCode', currencyCode); } if (manuallyCancelled != null) { await storage.put( 'backupsSubscriptionManuallyCancelled', manuallyCancelled ); } } await storage.put( 'read-receipt-setting', accountSettings?.readReceipts === true ); await storage.put( 'sealedSenderIndicators', accountSettings?.sealedSenderIndicators === true ); await storage.put( 'typingIndicators', accountSettings?.typingIndicators === true ); await storage.put('linkPreviews', accountSettings?.linkPreviews === true); await storage.put( 'preferContactAvatars', accountSettings?.preferContactAvatars === true ); if (accountSettings?.universalExpireTimerSeconds) { await storage.put( 'universalExpireTimer', accountSettings.universalExpireTimerSeconds ); } await storage.put( 'displayBadgesOnProfile', accountSettings?.displayBadgesOnProfile === true ); await storage.put( 'keepMutedChatsArchived', accountSettings?.keepMutedChatsArchived === true ); await storage.put( 'hasSetMyStoriesPrivacy', accountSettings?.hasSetMyStoriesPrivacy === true ); await storage.put( 'hasViewedOnboardingStory', accountSettings?.hasViewedOnboardingStory === true ); await storage.put( 'hasStoriesDisabled', accountSettings?.storiesDisabled === true ); await storage.put( 'storyViewReceiptsEnabled', accountSettings?.storyViewReceiptsEnabled === true ); await storage.put( 'hasCompletedUsernameOnboarding', accountSettings?.hasCompletedUsernameOnboarding === true ); await storage.put( 'hasSeenGroupStoryEducationSheet', accountSettings?.hasSeenGroupStoryEducationSheet === true ); await storage.put( 'preferredReactionEmoji', accountSettings?.preferredReactionEmoji || [] ); const { PhoneNumberSharingMode: BackupMode } = Backups.AccountData; switch (accountSettings?.phoneNumberSharingMode) { case BackupMode.EVERYBODY: await storage.put( 'phoneNumberSharingMode', PhoneNumberSharingMode.Everybody ); break; case BackupMode.UNKNOWN: case BackupMode.NOBODY: default: await storage.put( 'phoneNumberSharingMode', PhoneNumberSharingMode.Nobody ); break; } if (accountSettings?.notDiscoverableByPhoneNumber) { await window.storage.put( 'phoneNumberDiscoverability', PhoneNumberDiscoverability.NotDiscoverable ); } else { await window.storage.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 window.storage.put('defaultConversationColor', { color: defaultChatStyle.color, customColorData: defaultChatStyle.customColorData, }); } if (defaultChatStyle.wallpaperPhotoPointer != null) { await window.storage.put( 'defaultWallpaperPhotoPointer', defaultChatStyle.wallpaperPhotoPointer ); } if (defaultChatStyle.wallpaperPreset != null) { await window.storage.put( 'defaultWallpaperPreset', defaultChatStyle.wallpaperPreset ); } if (defaultChatStyle.dimWallpaperInDarkMode != null) { await window.storage.put( 'defaultDimWallpaperInDarkMode', defaultChatStyle.dimWallpaperInDarkMode ); } this.updateConversation(me); } private 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 attrs: ConversationAttributesType = { id: generateUuid(), type: 'private', version: 2, serviceId: aci ?? pni, pni, e164, removalStage, profileKey: contact.profileKey ? Bytes.toBase64(contact.profileKey) : undefined, profileSharing: contact.profileSharing === true, profileName: dropNull(contact.profileGivenName), profileFamilyName: dropNull(contact.profileFamilyName), hideStory: contact.hideStory === true, username: dropNull(contact.username), }; if (contact.notRegistered) { const timestamp = contact.notRegistered.unregisteredTimestamp?.toNumber() ?? Date.now(); attrs.discoveredUnregisteredAt = timestamp; attrs.firstUnregisteredAt = timestamp; } else { strictAssert( contact.registered, 'contact is either registered or unregistered' ); } if (contact.blocked) { const serviceId = aci || pni; if (serviceId) { await window.storage.blocked.addBlockedServiceId(serviceId); } if (e164) { await window.storage.blocked.addBlockedNumber(e164); } } return attrs; } private 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, hideStory: group.hideStory === true, storySendMode, // Snapshot name: dropNull(title?.title), description: dropNull(description?.descriptionText), avatar: avatarUrl ? { url: avatarUrl, path: '', } : undefined, expireTimer: expirationTimerS ? DurationInSeconds.fromSeconds(expirationTimerS) : undefined, 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, } : undefined, membersV2: members?.map(({ userId, role, joinedAtVersion }) => { 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)), role: dropNull(role) ?? SignalService.Member.Role.UNKNOWN, joinedAtVersion: dropNull(joinedAtVersion) ?? 0, }; }), 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(Buffer.from(userId)) ); return { serviceId, role: dropNull(role) ?? SignalService.Member.Role.UNKNOWN, addedByUserId: fromAciObject(Aci.fromUuidBytes(addedByUserId)), timestamp: timestamp != null ? getTimestampFromLong(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 ? getTimestampFromLong(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(Buffer.from(userId)) ); return { serviceId, timestamp: timestamp != null ? getTimestampFromLong(timestamp) : 0, }; }), revision: dropNull(version), groupInviteLinkPassword: Bytes.isNotEmpty(inviteLinkPassword) ? Bytes.toBase64(inviteLinkPassword) : undefined, announcementsOnly: dropNull(announcementsOnly), }; return attrs; } private async fromDistributionList( listItem: Backups.IDistributionListItem ): Promise { strictAssert( Bytes.isNotEmpty(listItem.distributionId), 'Missing distribution list id' ); const id = bytesToUuid(listItem.distributionId); 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); } 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: getTimestampFromLong(listItem.deletionTimestamp), }; } await DataWriter.createNewStoryDistribution(result); } private async fromChat(chat: Backups.IChat): Promise { strictAssert(chat.id != null, 'chat must have an id'); strictAssert(chat.recipientId != null, 'chat must have a recipientId'); const conversation = this.recipientIdToConvo.get( chat.recipientId.toNumber() ); strictAssert(conversation !== undefined, 'unknown conversation'); this.chatIdToConvo.set(chat.id.toNumber(), conversation); conversation.isArchived = chat.archived === true; conversation.isPinned = chat.pinnedOrder != null; conversation.expireTimer = chat.expirationTimerMs && !chat.expirationTimerMs.isZero() ? DurationInSeconds.fromMillis(chat.expirationTimerMs.toNumber()) : undefined; conversation.muteExpiresAt = chat.muteUntilMs && !chat.muteUntilMs.isZero() ? getTimestampFromLong(chat.muteUntilMs) : undefined; 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; } this.updateConversation(conversation); if (chat.pinnedOrder != null) { this.pinnedConversations.push([chat.pinnedOrder, conversation.id]); } } private async fromChatItem( item: Backups.IChatItem, options: { aboutMe: AboutMe } ): Promise { const { aboutMe } = options; const timestamp = item?.dateSent?.toNumber(); 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`); 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) { chatConvo.active_at = newActiveAt; } if (unread != null) { chatConvo.unreadCount = (chatConvo.unreadCount ?? 0) + 1; } let attributes: MessageAttributesType = { id: generateUuid(), conversationId: chatConvo.id, received_at: incrementMessageCounter(), sent_at: timestamp, source: authorConvo?.e164, sourceServiceId: authorConvo?.serviceId, timestamp, type: item.outgoing != null ? 'outgoing' : 'incoming', expirationStartTimestamp: item.expireStartDate && !item.expireStartDate.isZero() ? getTimestampFromLong(item.expireStartDate) : undefined, expireTimer: item.expiresInMs && !item.expiresInMs.isZero() ? DurationInSeconds.fromMillis(item.expiresInMs.toNumber()) : undefined, 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(item.standardMessage, chatConvo.id)), }; } else { const result = await this.fromNonBubbleChatItem(item, { aboutMe, author: authorConvo, conversation: chatConvo, timestamp, }); 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, sent_at: sentAt, ...additional, }); }); } if (item.revisions?.length) { strictAssert( item.standardMessage, 'Only standard message can have revisions' ); const history = await this.fromRevisions(attributes, item.revisions); attributes.editHistory = history; // Update timestamps on the parent message const oldest = history.at(-1); assertDev(oldest != null, '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` ); this.saveMessage(attributes); additionalMessages.forEach(additional => this.saveMessage(additional)); // TODO (DESKTOP-6964): We'll want to increment for more types here - stickers, etc. if (item.standardMessage) { if (item.outgoing != null) { chatConvo.sentMessageCount = (chatConvo.sentMessageCount ?? 0) + 1; } else { chatConvo.messageCount = (chatConvo.messageCount ?? 0) + 1; } } this.updateConversation(chatConvo); } private fromDirectionDetails( item: Backups.IChatItem, timestamp: number ): { patch: Partial; newActiveAt?: number; unread?: boolean; } { const { outgoing, incoming, directionless } = item; if (outgoing) { const sendStateByConversationId: SendStateByConversationId = {}; const BackupSendStatus = Backups.SendStatus.Status; const unidentifiedDeliveries = new Array(); const errors = new Array(); for (const status of outgoing.sendStatus ?? []) { 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' ); let sendStatus: SendStatus; switch (status.deliveryStatus) { case BackupSendStatus.PENDING: sendStatus = SendStatus.Pending; break; case BackupSendStatus.SENT: sendStatus = SendStatus.Sent; break; case BackupSendStatus.DELIVERED: sendStatus = SendStatus.Delivered; break; case BackupSendStatus.READ: sendStatus = SendStatus.Read; break; case BackupSendStatus.VIEWED: sendStatus = SendStatus.Viewed; break; case BackupSendStatus.FAILED: default: sendStatus = SendStatus.Failed; break; } if (target.serviceId) { if (status.sealedSender) { unidentifiedDeliveries.push(target.serviceId); } if (status.identityKeyMismatch) { errors.push({ serviceId: target.serviceId, name: 'OutgoingIdentityKeyError', // See: ts/textsecure/Errors message: `The identity of ${target.serviceId} has changed.`, }); } else if (status.networkFailure) { errors.push({ serviceId: target.serviceId, name: 'OutgoingMessageError', // See: ts/textsecure/Errors message: 'no http error', }); } } sendStateByConversationId[target.id] = { status: sendStatus, updatedAt: status.lastStatusUpdateTimestamp != null && !status.lastStatusUpdateTimestamp.isZero() ? getTimestampFromLong(status.lastStatusUpdateTimestamp) : undefined, }; } return { patch: { sendStateByConversationId, received_at_ms: timestamp, unidentifiedDeliveries: unidentifiedDeliveries.length ? unidentifiedDeliveries : undefined, errors: errors.length ? errors : undefined, }, newActiveAt: timestamp, }; } if (incoming) { const receivedAtMs = incoming.dateReceived?.toNumber() ?? Date.now(); const unidentifiedDeliveryReceived = incoming.sealedSender === true; if (incoming.read) { return { patch: { readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, received_at_ms: receivedAtMs, unidentifiedDeliveryReceived, }, newActiveAt: receivedAtMs, }; } return { patch: { readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, received_at_ms: receivedAtMs, unidentifiedDeliveryReceived, }, newActiveAt: receivedAtMs, unread: true, }; } strictAssert(directionless, 'Absent direction state'); return { patch: {} }; } private async fromStandardMessage( data: Backups.IStandardMessage, conversationId: string ): Promise> { return { body: data.text?.body || undefined, attachments: data.attachments?.length ? data.attachments .map(convertBackupMessageAttachmentToAttachment) .filter(isNotNil) : undefined, preview: data.linkPreview?.length ? data.linkPreview.map(preview => { const { url } = preview; strictAssert(url, 'preview must have a URL'); return { url, title: dropNull(preview.title), description: dropNull(preview.description), date: getTimestampFromLong(preview.date), image: preview.image ? convertFilePointerToAttachment(preview.image) : undefined, }; }) : undefined, reactions: this.fromReactions(data.reactions), quote: data.quote ? await this.fromQuote(data.quote, conversationId) : undefined, }; } private async fromRevisions( mainMessage: MessageAttributesType, revisions: ReadonlyArray ): Promise> { const result = await Promise.all( revisions .map(async rev => { strictAssert( rev.standardMessage, 'Edit history has non-standard messages' ); const timestamp = getTimestampFromLong(rev.dateSent); const { // eslint-disable-next-line camelcase patch: { sendStateByConversationId, received_at_ms }, } = this.fromDirectionDetails(rev, timestamp); return { ...(await this.fromStandardMessage( rev.standardMessage, mainMessage.conversationId )), timestamp, received_at: incrementMessageCounter(), sendStateByConversationId, // eslint-disable-next-line camelcase received_at_ms, }; }) // 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, }); return result; } private convertQuoteType( type: Backups.Quote.Type | null | undefined ): SignalService.DataMessage.Quote.Type { switch (type) { case Backups.Quote.Type.GIFTBADGE: return SignalService.DataMessage.Quote.Type.GIFT_BADGE; case Backups.Quote.Type.NORMAL: case Backups.Quote.Type.UNKNOWN: case null: case undefined: return SignalService.DataMessage.Quote.Type.NORMAL; default: throw missingCaseError(type); } } private async fromQuote( quote: Backups.IQuote, conversationId: string ): 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'); strictAssert( isAciString(authorConvo.serviceId), 'must have ACI for authorId in quote' ); return copyFromQuotedMessage( { id: getTimestampFromLong(quote.targetSentTimestamp), authorAci: authorConvo.serviceId, text: dropNull(quote.text), bodyRanges: quote.bodyRanges?.length ? filterAndClean( quote.bodyRanges.map(range => ({ ...range, mentionAci: range.mentionAci ? Aci.parseFromServiceIdBinary( Buffer.from(range.mentionAci) ).getServiceIdString() : undefined, })) ) : undefined, 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) : undefined, }; }) ?? [], type: this.convertQuoteType(quote.type), }, conversationId ); } private 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, receivedTimestamp }) => { strictAssert(emoji != null, 'reaction must have an emoji'); strictAssert(authorId != null, 'reaction must have authorId'); strictAssert( sentTimestamp != null, 'reaction must have a sentTimestamp' ); strictAssert( receivedTimestamp != null, 'reaction must have a receivedTimestamp' ); const authorConvo = this.recipientIdToConvo.get(authorId.toNumber()); strictAssert( authorConvo !== undefined, 'author conversation not found' ); return { emoji, fromId: authorConvo.id, targetTimestamp: getTimestampFromLong(sentTimestamp), receivedAtDate: getTimestampFromLong(receivedTimestamp), timestamp: getTimestampFromLong(sentTimestamp), }; }); } private 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) { return { message: { contact: (chatItem.contactMessage.contact ?? []).map(details => { const { avatar, name, number, email, address, organization } = details; return { name: name ? { givenName: dropNull(name.givenName), familyName: dropNull(name.familyName), prefix: dropNull(name.prefix), suffix: dropNull(name.suffix), middleName: dropNull(name.middleName), displayName: dropNull(name.displayName), } : undefined, number: number?.length ? number .map(({ value, type, label }) => { if (!value) { return undefined; } return { value, type: phoneToContactFormType(type), label: dropNull(label), }; }) .filter(isNotNil) : undefined, email: email?.length ? email .map(({ value, type, label }) => { if (!value) { return undefined; } return { value, type: emailToContactFormType(type), label: dropNull(label), }; }) .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: dropNull(label), street: dropNull(street), pobox: dropNull(pobox), neighborhood: dropNull(neighborhood), city: dropNull(city), region: dropNull(region), postcode: dropNull(postcode), country: dropNull(country), }; }) : undefined, organization: dropNull(organization), avatar: avatar ? { avatar: convertFilePointerToAttachment(avatar), isProfile: false, } : undefined, }; }), reactions: this.fromReactions(chatItem.contactMessage.reactions), }, additionalMessages: [], }; } if (chatItem.remoteDeletedMessage) { return { message: { isErased: 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(emoji != null, 'stickerMessage must have an emoji'); 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, packId: Bytes.toHex(packId), packKey: Bytes.toBase64(packKey), stickerId, data: data ? convertFilePointerToAttachment(data) : 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; 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.FAILED: case Backups.GiftBadge.State.REDEEMED: state = GiftBadgeStates.Redeemed; break; case Backups.GiftBadge.State.UNOPENED: state = GiftBadgeStates.Unopened; break; default: state = GiftBadgeStates.Unopened; break; } const receipt = new ReceiptCredentialPresentation( Buffer.from(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`); } private async fromChatItemUpdateMessage( updateMessage: Backups.IChatUpdateMessage, options: { aboutMe: AboutMe; author?: ConversationAttributesType; conversation: ConversationAttributesType; timestamp: number; } ): Promise { const { aboutMe, author } = options; if (updateMessage.groupChange) { return this.fromGroupUpdateMessage(updateMessage.groupChange, options); } if (updateMessage.expirationTimerChange) { const { expiresInMs } = updateMessage.expirationTimerChange; const sourceServiceId = author?.serviceId ?? aboutMe.aci; const expireTimer = DurationInSeconds.fromMillis( expiresInMs?.toNumber() ?? 0 ); return { message: { type: 'timer-notification', sourceServiceId, 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; strictAssert( e164 != null || username != null, 'learnedProfileChange must have an old name' ); 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: [], }; } // TODO (DESKTOP-6964): check these fields // updateMessage.callingMessage return undefined; } private async fromGroupUpdateMessage( groupChange: Backups.IGroupChangeChatUpdate, options: { aboutMe: AboutMe; 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.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: fromAciObject(Aci.fromUuidBytes(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; if (!inviteeAci || Bytes.isEmpty(inviteeAci)) { throw new Error( `${logId}: groupInvitationDeclinedUpdate had missing inviteeAci!` ); } from = fromAciObject(Aci.fromUuidBytes(inviteeAci)); 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!` ); } 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, }); } else { details.push({ type: 'pending-remove-many', count: 1, }); } } else { details.push({ type: 'pending-remove-many', count: invitees.length, }); } } 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; if (!updaterAci || Bytes.isEmpty(updaterAci)) { throw new Error( `${logId}: groupExpirationTimerUpdate was missing updaterAci!` ); } const 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, }; } private 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?.id : 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, }; default: throw new Error(`Unsupported update type: ${simpleUpdate.type}`); } } private async fromStickerPack({ packId: id, packKey: key, }: Backups.IStickerPack): Promise { strictAssert( id?.length === STICKERPACK_ID_BYTE_LEN, 'Sticker pack must have a valid pack id' ); const logId = `fromStickerPack(${Bytes.toHex(id).slice(-2)})`; strictAssert( key?.length === STICKERPACK_KEY_BYTE_LEN, `${logId}: must have a valid pack key` ); drop( downloadStickerPack(Bytes.toHex(id), Bytes.toBase64(key), { fromBackup: true, }) ); } private async fromCustomChatColors( customChatColors: | ReadonlyArray | undefined | null ): Promise { if (!customChatColors?.length) { return; } const customColors: CustomColorsItemType = { version: 1, colors: {}, }; for (const color of customChatColors) { const uuid = generateUuid(); let value: CustomColorType; if (color.solid) { value = { start: rgbIntToHSL(color.solid), }; } else { 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 deg = color.gradient.angle; strictAssert(start != null, 'Missing start color'); strictAssert(end != null, 'Missing end color'); strictAssert(deg != null, 'Missing angle'); value = { start: rgbIntToHSL(start), end: rgbIntToHSL(end), deg, }; } customColors.colors[uuid] = value; this.customColorById.set(color.id?.toNumber() || 0, { id: uuid, value, }); } await window.storage.put('customColors', customColors); } private fromChatStyle(chatStyle: Backups.IChatStyle | null | undefined): Omit< LocalChatStyle, 'customColorId' > & { customColorData: CustomColorDataType | undefined; } { if (!chatStyle) { return { wallpaperPhotoPointer: undefined, wallpaperPreset: undefined, color: 'ultramarine', customColorData: undefined, dimWallpaperInDarkMode: undefined, }; } 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; if (chatStyle.autoBubbleColor) { if (wallpaperPreset != null) { color = WALLPAPER_TO_BUBBLE_COLOR.get(wallpaperPreset) || 'ultramarine'; } else { color = 'ultramarine'; } } 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 { strictAssert(chatStyle.customColorId != null, 'Missing custom color id'); const entry = this.customColorById.get( chatStyle.customColorId.toNumber() ); strictAssert(entry != null, 'Missing custom color'); color = 'custom'; customColorData = entry; } return { wallpaperPhotoPointer, wallpaperPreset, color, customColorData, dimWallpaperInDarkMode, }; } } function rgbIntToHSL(intValue: number): { hue: number; saturation: number } { const { h: hue, s: saturation } = rgbToHSL( // eslint-disable-next-line no-bitwise (intValue >>> 16) & 0xff, // eslint-disable-next-line no-bitwise (intValue >>> 8) & 0xff, // eslint-disable-next-line no-bitwise intValue & 0xff ); return { hue, saturation }; }