// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { isEqual, isNumber } from 'lodash'; import { arrayBufferToBase64, base64ToArrayBuffer, deriveMasterKeyFromGroupV1, fromEncodedBinaryToArrayBuffer, } from '../Crypto'; import dataInterface from '../sql/Client'; import { AccountRecordClass, ContactRecordClass, GroupV1RecordClass, GroupV2RecordClass, PinnedConversationClass, } from '../textsecure.d'; import { deriveGroupFields, waitThenMaybeUpdateGroup, waitThenRespondToGroupV2Migration, } from '../groups'; import { assert } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { PhoneNumberSharingMode, parsePhoneNumberSharingMode, } from '../util/phoneNumberSharingMode'; import { PhoneNumberDiscoverability, parsePhoneNumberDiscoverability, } from '../util/phoneNumberDiscoverability'; import { arePinnedConversationsEqual } from '../util/arePinnedConversationsEqual'; import { ConversationModel } from '../models/conversations'; import { getSafeLongFromTimestamp, getTimestampFromLong, } from '../util/timestampLongUtils'; import { get as getUniversalExpireTimer, set as setUniversalExpireTimer, } from '../util/universalExpireTimer'; import { ourProfileKeyService } from './ourProfileKey'; import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation'; const { updateConversation } = dataInterface; type RecordClass = | AccountRecordClass | ContactRecordClass | GroupV1RecordClass | GroupV2RecordClass; function toRecordVerified(verified: number): number { const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus; const STATE_ENUM = window.textsecure.protobuf.ContactRecord.IdentityState; switch (verified) { case VERIFIED_ENUM.VERIFIED: return STATE_ENUM.VERIFIED; case VERIFIED_ENUM.UNVERIFIED: return STATE_ENUM.UNVERIFIED; default: return STATE_ENUM.DEFAULT; } } function addUnknownFields( record: RecordClass, conversation: ConversationModel ): void { if (record.__unknownFields) { window.log.info( 'storageService.addUnknownFields: Unknown fields found for', conversation.idForLogging() ); conversation.set({ storageUnknownFields: arrayBufferToBase64(record.__unknownFields), }); } else if (conversation.get('storageUnknownFields')) { // If the record doesn't have unknown fields attached but we have them // saved locally then we need to clear it out window.log.info( 'storageService.addUnknownFields: Clearing unknown fields for', conversation.idForLogging() ); conversation.unset('storageUnknownFields'); } } function applyUnknownFields( record: RecordClass, conversation: ConversationModel ): void { const storageUnknownFields = conversation.get('storageUnknownFields'); if (storageUnknownFields) { window.log.info( 'storageService.applyUnknownFields: Applying unknown fields for', conversation.get('id') ); // eslint-disable-next-line no-param-reassign record.__unknownFields = base64ToArrayBuffer(storageUnknownFields); } } export async function toContactRecord( conversation: ConversationModel ): Promise { const contactRecord = new window.textsecure.protobuf.ContactRecord(); if (conversation.get('uuid')) { contactRecord.serviceUuid = conversation.get('uuid'); } if (conversation.get('e164')) { contactRecord.serviceE164 = conversation.get('e164'); } if (conversation.get('profileKey')) { contactRecord.profileKey = base64ToArrayBuffer( String(conversation.get('profileKey')) ); } const identityKey = await window.textsecure.storage.protocol.loadIdentityKey( conversation.id ); if (identityKey) { contactRecord.identityKey = identityKey; } if (conversation.get('verified')) { contactRecord.identityState = toRecordVerified( Number(conversation.get('verified')) ); } if (conversation.get('profileName')) { contactRecord.givenName = conversation.get('profileName'); } if (conversation.get('profileFamilyName')) { contactRecord.familyName = conversation.get('profileFamilyName'); } contactRecord.blocked = conversation.isBlocked(); contactRecord.whitelisted = Boolean(conversation.get('profileSharing')); contactRecord.archived = Boolean(conversation.get('isArchived')); contactRecord.markedUnread = Boolean(conversation.get('markedUnread')); contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp( conversation.get('muteExpiresAt') ); applyUnknownFields(contactRecord, conversation); return contactRecord; } export async function toAccountRecord( conversation: ConversationModel ): Promise { const accountRecord = new window.textsecure.protobuf.AccountRecord(); if (conversation.get('profileKey')) { accountRecord.profileKey = base64ToArrayBuffer( String(conversation.get('profileKey')) ); } if (conversation.get('profileName')) { accountRecord.givenName = conversation.get('profileName') || ''; } if (conversation.get('profileFamilyName')) { accountRecord.familyName = conversation.get('profileFamilyName') || ''; } accountRecord.avatarUrl = window.storage.get('avatarUrl') || ''; accountRecord.noteToSelfArchived = Boolean(conversation.get('isArchived')); accountRecord.noteToSelfMarkedUnread = Boolean( conversation.get('markedUnread') ); accountRecord.readReceipts = Boolean( window.storage.get('read-receipt-setting') ); accountRecord.sealedSenderIndicators = Boolean( window.storage.get('sealedSenderIndicators') ); accountRecord.typingIndicators = Boolean( window.storage.get('typingIndicators') ); accountRecord.linkPreviews = Boolean(window.storage.get('linkPreviews')); const primarySendsSms = window.storage.get('primarySendsSms'); if (primarySendsSms !== undefined) { accountRecord.primarySendsSms = Boolean(primarySendsSms); } const universalExpireTimer = getUniversalExpireTimer(); if (universalExpireTimer) { accountRecord.universalExpireTimer = Number(universalExpireTimer); } const PHONE_NUMBER_SHARING_MODE_ENUM = window.textsecure.protobuf.AccountRecord.PhoneNumberSharingMode; const phoneNumberSharingMode = parsePhoneNumberSharingMode( window.storage.get('phoneNumberSharingMode') ); switch (phoneNumberSharingMode) { case PhoneNumberSharingMode.Everybody: accountRecord.phoneNumberSharingMode = PHONE_NUMBER_SHARING_MODE_ENUM.EVERYBODY; break; case PhoneNumberSharingMode.ContactsOnly: accountRecord.phoneNumberSharingMode = PHONE_NUMBER_SHARING_MODE_ENUM.CONTACTS_ONLY; break; case PhoneNumberSharingMode.Nobody: accountRecord.phoneNumberSharingMode = PHONE_NUMBER_SHARING_MODE_ENUM.NOBODY; break; default: throw missingCaseError(phoneNumberSharingMode); } const phoneNumberDiscoverability = parsePhoneNumberDiscoverability( window.storage.get('phoneNumberDiscoverability') ); switch (phoneNumberDiscoverability) { case PhoneNumberDiscoverability.Discoverable: accountRecord.notDiscoverableByPhoneNumber = false; break; case PhoneNumberDiscoverability.NotDiscoverable: accountRecord.notDiscoverableByPhoneNumber = true; break; default: throw missingCaseError(phoneNumberDiscoverability); } const pinnedConversations = window.storage .get('pinnedConversationIds', new Array()) .map(id => { const pinnedConversation = window.ConversationController.get(id); if (pinnedConversation) { const pinnedConversationRecord = new window.textsecure.protobuf.AccountRecord.PinnedConversation(); if (pinnedConversation.get('type') === 'private') { pinnedConversationRecord.identifier = 'contact'; pinnedConversationRecord.contact = { uuid: pinnedConversation.get('uuid'), e164: pinnedConversation.get('e164'), }; } else if (isGroupV1(pinnedConversation.attributes)) { pinnedConversationRecord.identifier = 'legacyGroupId'; const groupId = pinnedConversation.get('groupId'); if (!groupId) { throw new Error( 'toAccountRecord: trying to pin a v1 Group without groupId' ); } pinnedConversationRecord.legacyGroupId = fromEncodedBinaryToArrayBuffer( groupId ); } else if (isGroupV2(pinnedConversation.attributes)) { pinnedConversationRecord.identifier = 'groupMasterKey'; const masterKey = pinnedConversation.get('masterKey'); if (!masterKey) { throw new Error( 'toAccountRecord: trying to pin a v2 Group without masterKey' ); } pinnedConversationRecord.groupMasterKey = base64ToArrayBuffer( masterKey ); } return pinnedConversationRecord; } return undefined; }) .filter( ( pinnedConversationClass ): pinnedConversationClass is PinnedConversationClass => pinnedConversationClass !== undefined ); window.log.info( 'storageService.toAccountRecord: pinnedConversations', pinnedConversations.length ); accountRecord.pinnedConversations = pinnedConversations; applyUnknownFields(accountRecord, conversation); return accountRecord; } export async function toGroupV1Record( conversation: ConversationModel ): Promise { const groupV1Record = new window.textsecure.protobuf.GroupV1Record(); groupV1Record.id = fromEncodedBinaryToArrayBuffer( String(conversation.get('groupId')) ); groupV1Record.blocked = conversation.isBlocked(); groupV1Record.whitelisted = Boolean(conversation.get('profileSharing')); groupV1Record.archived = Boolean(conversation.get('isArchived')); groupV1Record.markedUnread = Boolean(conversation.get('markedUnread')); groupV1Record.mutedUntilTimestamp = getSafeLongFromTimestamp( conversation.get('muteExpiresAt') ); applyUnknownFields(groupV1Record, conversation); return groupV1Record; } export async function toGroupV2Record( conversation: ConversationModel ): Promise { const groupV2Record = new window.textsecure.protobuf.GroupV2Record(); const masterKey = conversation.get('masterKey'); if (masterKey !== undefined) { groupV2Record.masterKey = base64ToArrayBuffer(masterKey); } groupV2Record.blocked = conversation.isBlocked(); groupV2Record.whitelisted = Boolean(conversation.get('profileSharing')); groupV2Record.archived = Boolean(conversation.get('isArchived')); groupV2Record.markedUnread = Boolean(conversation.get('markedUnread')); groupV2Record.mutedUntilTimestamp = getSafeLongFromTimestamp( conversation.get('muteExpiresAt') ); applyUnknownFields(groupV2Record, conversation); return groupV2Record; } type MessageRequestCapableRecord = ContactRecordClass | GroupV1RecordClass; function applyMessageRequestState( record: MessageRequestCapableRecord, conversation: ConversationModel ): void { const messageRequestEnum = window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; if (record.blocked) { conversation.applyMessageRequestResponse(messageRequestEnum.BLOCK, { fromSync: true, viaStorageServiceSync: true, }); } else if (record.whitelisted) { // unblocking is also handled by this function which is why the next // condition is part of the else-if and not separate conversation.applyMessageRequestResponse(messageRequestEnum.ACCEPT, { fromSync: true, viaStorageServiceSync: true, }); } else if (!record.blocked) { // if the condition above failed the state could still be blocked=false // in which case we should unblock the conversation conversation.unblock({ viaStorageServiceSync: true }); } if (record.whitelisted === false) { conversation.disableProfileSharing({ viaStorageServiceSync: true }); } } type RecordClassObject = { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; }; function doRecordsConflict( localRecord: RecordClassObject, remoteRecord: RecordClassObject, conversation: ConversationModel ): boolean { const idForLogging = conversation.idForLogging(); const localKeys = Object.keys(localRecord); const remoteKeys = Object.keys(remoteRecord); if (localKeys.length !== remoteKeys.length) { window.log.info( 'storageService.doRecordsConflict: Local keys do not match remote keys', idForLogging, localKeys.join(','), remoteKeys.join(',') ); return true; } return localKeys.reduce((hasConflict: boolean, key: string): boolean => { const localValue = localRecord[key]; const remoteValue = remoteRecord[key]; // Sometimes we have a ByteBuffer and an ArrayBuffer, this ensures that we // are comparing them both equally by converting them into base64 string. if (Object.prototype.toString.call(localValue) === '[object ArrayBuffer]') { const areEqual = arrayBufferToBase64(localValue) === arrayBufferToBase64(remoteValue); if (!areEqual) { window.log.info( 'storageService.doRecordsConflict: Conflict found for ArrayBuffer', key, idForLogging ); } return hasConflict || !areEqual; } // If both types are Long we can use Long's equals to compare them if ( window.dcodeIO.Long.isLong(localValue) && window.dcodeIO.Long.isLong(remoteValue) ) { const areEqual = localValue.equals(remoteValue); if (!areEqual) { window.log.info( 'storageService.doRecordsConflict: Conflict found for Long', key, idForLogging ); } return hasConflict || !areEqual; } if (key === 'pinnedConversations') { const areEqual = arePinnedConversationsEqual(localValue, remoteValue); if (!areEqual) { window.log.info( 'storageService.doRecordsConflict: Conflict found for pinnedConversations', idForLogging ); } return hasConflict || !areEqual; } if (localValue === remoteValue) { return hasConflict || false; } // Sometimes we get `null` values from Protobuf and they should default to // false, empty string, or 0 for these records we do not count them as // conflicting. if ( remoteValue === null && (localValue === false || localValue === '' || localValue === 0 || (window.dcodeIO.Long.isLong(localValue) && localValue.toNumber() === 0)) ) { return hasConflict || false; } const areEqual = isEqual(localValue, remoteValue); if (!areEqual) { window.log.info( 'storageService.doRecordsConflict: Conflict found for', key, idForLogging ); } return !areEqual; }, false); } function doesRecordHavePendingChanges( mergedRecord: RecordClass, serviceRecord: RecordClass, conversation: ConversationModel ): boolean { const shouldSync = Boolean(conversation.get('needsStorageServiceSync')); if (!shouldSync) { return false; } const hasConflict = doRecordsConflict( mergedRecord, serviceRecord, conversation ); if (!hasConflict) { conversation.set({ needsStorageServiceSync: false }); } return hasConflict; } export async function mergeGroupV1Record( storageID: string, groupV1Record: GroupV1RecordClass ): Promise { if (!groupV1Record.id) { throw new Error(`No ID for ${storageID}`); } const groupId = groupV1Record.id.toBinary(); // Attempt to fetch an existing group pertaining to the `groupId` or create // a new group and populate it with the attributes from the record. let conversation = window.ConversationController.get(groupId); // Because ConversationController.get retrieves all types of records we // may sometimes have a situation where we get a record of groupv1 type // where the binary representation of its ID matches a v2 record in memory. // Here we ensure that the record we're about to process is GV1 otherwise // we drop the update. if (conversation && !isGroupV1(conversation.attributes)) { throw new Error( `Record has group type mismatch ${conversation.idForLogging()}` ); } if (!conversation) { // It's possible this group was migrated to a GV2 if so we attempt to // retrieve the master key and find the conversation locally. If we // are successful then we continue setting and applying state. const masterKeyBuffer = await deriveMasterKeyFromGroupV1(groupId); const fields = deriveGroupFields(masterKeyBuffer); const derivedGroupV2Id = arrayBufferToBase64(fields.id); window.log.info( 'storageService.mergeGroupV1Record: failed to find group by v1 id ' + `attempting lookup by v2 groupv2(${derivedGroupV2Id})` ); conversation = window.ConversationController.get(derivedGroupV2Id); } if (conversation) { window.log.info( 'storageService.mergeGroupV1Record: found existing group', conversation.idForLogging() ); } else { if (groupV1Record.id.byteLength !== 16) { throw new Error('Not a valid gv1'); } conversation = await window.ConversationController.getOrCreateAndWait( groupId, 'group' ); window.log.info( 'storageService.mergeGroupV1Record: created a new group locally', conversation.idForLogging() ); } conversation.set({ isArchived: Boolean(groupV1Record.archived), markedUnread: Boolean(groupV1Record.markedUnread), storageID, }); conversation.setMuteExpiration( getTimestampFromLong(groupV1Record.mutedUntilTimestamp), { viaStorageServiceSync: true, } ); applyMessageRequestState(groupV1Record, conversation); let hasPendingChanges: boolean; if (isGroupV1(conversation.attributes)) { addUnknownFields(groupV1Record, conversation); hasPendingChanges = doesRecordHavePendingChanges( await toGroupV1Record(conversation), groupV1Record, conversation ); } else { // We cannot preserve unknown fields if local group is V2 and the remote is // still V1, because the storageItem that we'll put into manifest will have // a different record type. window.log.info( 'storageService.mergeGroupV1Record marking v1 ' + ' group for an update to v2', conversation.idForLogging() ); // We want to upgrade group in the storage after merging it. hasPendingChanges = true; } updateConversation(conversation.attributes); return hasPendingChanges; } async function getGroupV2Conversation( masterKeyBuffer: ArrayBuffer ): Promise { const groupFields = deriveGroupFields(masterKeyBuffer); const groupId = arrayBufferToBase64(groupFields.id); const masterKey = arrayBufferToBase64(masterKeyBuffer); const secretParams = arrayBufferToBase64(groupFields.secretParams); const publicParams = arrayBufferToBase64(groupFields.publicParams); // First we check for an existing GroupV2 group const groupV2 = window.ConversationController.get(groupId); if (groupV2) { await groupV2.maybeRepairGroupV2({ masterKey, secretParams, publicParams, }); return groupV2; } // Then check for V1 group with matching derived GV2 id const groupV1 = window.ConversationController.getByDerivedGroupV2Id(groupId); if (groupV1) { return groupV1; } const conversationId = window.ConversationController.ensureGroup(groupId, { // Note: We don't set active_at, because we don't want the group to show until // we have information about it beyond these initial details. // see maybeUpdateGroup(). groupVersion: 2, masterKey, secretParams, publicParams, }); const conversation = window.ConversationController.get(conversationId); if (!conversation) { throw new Error( `getGroupV2Conversation: Failed to create conversation for groupv2(${groupId})` ); } return conversation; } export async function mergeGroupV2Record( storageID: string, groupV2Record: GroupV2RecordClass ): Promise { if (!groupV2Record.masterKey) { throw new Error(`No master key for ${storageID}`); } const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer(); const conversation = await getGroupV2Conversation(masterKeyBuffer); window.log.info( 'storageService.mergeGroupV2Record:', conversation.idForLogging() ); conversation.set({ isArchived: Boolean(groupV2Record.archived), markedUnread: Boolean(groupV2Record.markedUnread), storageID, }); conversation.setMuteExpiration( getTimestampFromLong(groupV2Record.mutedUntilTimestamp), { viaStorageServiceSync: true, } ); applyMessageRequestState(groupV2Record, conversation); addUnknownFields(groupV2Record, conversation); const hasPendingChanges = doesRecordHavePendingChanges( await toGroupV2Record(conversation), groupV2Record, conversation ); updateConversation(conversation.attributes); const isGroupNewToUs = !isNumber(conversation.get('revision')); const isFirstSync = !window.storage.get('storageFetchComplete'); const dropInitialJoinMessage = isFirstSync; if (isGroupV1(conversation.attributes)) { // If we found a GroupV1 conversation from this incoming GroupV2 record, we need to // migrate it! // We don't await this because this could take a very long time, waiting for queues to // empty, etc. waitThenRespondToGroupV2Migration({ conversation, }); } else if (isGroupNewToUs) { // We don't need to update GroupV2 groups all the time. We fetch group state the first // time we hear about these groups, from then on we rely on incoming messages or // the user opening that conversation. // We don't await this because this could take a very long time, waiting for queues to // empty, etc. waitThenMaybeUpdateGroup( { conversation, dropInitialJoinMessage, }, { viaSync: true } ); } return hasPendingChanges; } export async function mergeContactRecord( storageID: string, contactRecord: ContactRecordClass ): Promise { window.normalizeUuids( contactRecord, ['serviceUuid'], 'storageService.mergeContactRecord' ); const e164 = contactRecord.serviceE164 || undefined; const uuid = contactRecord.serviceUuid || undefined; const id = window.ConversationController.ensureContactIds({ e164, uuid, highTrust: true, }); if (!id) { throw new Error(`No ID for ${storageID}`); } const conversation = await window.ConversationController.getOrCreateAndWait( id, 'private' ); window.log.info( 'storageService.mergeContactRecord:', conversation.idForLogging() ); if (contactRecord.profileKey) { await conversation.setProfileKey( arrayBufferToBase64(contactRecord.profileKey.toArrayBuffer()), { viaStorageServiceSync: true } ); } const verified = await conversation.safeGetVerified(); const storageServiceVerified = contactRecord.identityState || 0; if (verified !== storageServiceVerified) { const verifiedOptions = { key: contactRecord.identityKey ? contactRecord.identityKey.toArrayBuffer() : undefined, viaStorageServiceSync: true, }; const STATE_ENUM = window.textsecure.protobuf.ContactRecord.IdentityState; switch (storageServiceVerified) { case STATE_ENUM.VERIFIED: await conversation.setVerified(verifiedOptions); break; case STATE_ENUM.UNVERIFIED: await conversation.setUnverified(verifiedOptions); break; default: await conversation.setVerifiedDefault(verifiedOptions); } } applyMessageRequestState(contactRecord, conversation); addUnknownFields(contactRecord, conversation); conversation.set({ isArchived: Boolean(contactRecord.archived), markedUnread: Boolean(contactRecord.markedUnread), storageID, }); conversation.setMuteExpiration( getTimestampFromLong(contactRecord.mutedUntilTimestamp), { viaStorageServiceSync: true, } ); const hasPendingChanges = doesRecordHavePendingChanges( await toContactRecord(conversation), contactRecord, conversation ); updateConversation(conversation.attributes); return hasPendingChanges; } export async function mergeAccountRecord( storageID: string, accountRecord: AccountRecordClass ): Promise { const { avatarUrl, linkPreviews, notDiscoverableByPhoneNumber, noteToSelfArchived, noteToSelfMarkedUnread, phoneNumberSharingMode, pinnedConversations, profileKey, readReceipts, sealedSenderIndicators, typingIndicators, primarySendsSms, universalExpireTimer, } = accountRecord; window.storage.put('read-receipt-setting', Boolean(readReceipts)); if (typeof sealedSenderIndicators === 'boolean') { window.storage.put('sealedSenderIndicators', sealedSenderIndicators); } if (typeof typingIndicators === 'boolean') { window.storage.put('typingIndicators', typingIndicators); } if (typeof linkPreviews === 'boolean') { window.storage.put('linkPreviews', linkPreviews); } if (typeof primarySendsSms === 'boolean') { window.storage.put('primarySendsSms', primarySendsSms); } setUniversalExpireTimer(universalExpireTimer || 0); const PHONE_NUMBER_SHARING_MODE_ENUM = window.textsecure.protobuf.AccountRecord.PhoneNumberSharingMode; let phoneNumberSharingModeToStore: PhoneNumberSharingMode; switch (phoneNumberSharingMode) { case undefined: case null: case PHONE_NUMBER_SHARING_MODE_ENUM.EVERYBODY: phoneNumberSharingModeToStore = PhoneNumberSharingMode.Everybody; break; case PHONE_NUMBER_SHARING_MODE_ENUM.CONTACTS_ONLY: phoneNumberSharingModeToStore = PhoneNumberSharingMode.ContactsOnly; break; case PHONE_NUMBER_SHARING_MODE_ENUM.NOBODY: phoneNumberSharingModeToStore = PhoneNumberSharingMode.Nobody; break; default: assert( false, `storageService.mergeAccountRecord: Got an unexpected phone number sharing mode: ${phoneNumberSharingMode}. Falling back to default` ); phoneNumberSharingModeToStore = PhoneNumberSharingMode.Everybody; break; } window.storage.put('phoneNumberSharingMode', phoneNumberSharingModeToStore); const discoverability = notDiscoverableByPhoneNumber ? PhoneNumberDiscoverability.NotDiscoverable : PhoneNumberDiscoverability.Discoverable; window.storage.put('phoneNumberDiscoverability', discoverability); if (profileKey) { ourProfileKeyService.set(profileKey.toArrayBuffer()); } if (pinnedConversations) { const modelPinnedConversations = window .getConversations() .filter(conversation => Boolean(conversation.get('isPinned'))); const modelPinnedConversationIds = modelPinnedConversations.map( conversation => conversation.get('id') ); const missingStoragePinnedConversationIds = window.storage .get('pinnedConversationIds', new Array()) .filter(id => !modelPinnedConversationIds.includes(id)); if (missingStoragePinnedConversationIds.length !== 0) { window.log.info( 'mergeAccountRecord: pinnedConversationIds in storage does not match pinned Conversation models' ); } const locallyPinnedConversations = modelPinnedConversations.concat( missingStoragePinnedConversationIds .map(conversationId => window.ConversationController.get(conversationId) ) .filter( (conversation): conversation is ConversationModel => conversation !== undefined ) ); window.log.info( 'storageService.mergeAccountRecord: Local pinned', locallyPinnedConversations.length ); window.log.info( 'storageService.mergeAccountRecord: Remote pinned', pinnedConversations.length ); const remotelyPinnedConversationPromises = pinnedConversations.map( async pinnedConversation => { let conversationId; switch (pinnedConversation.identifier) { case 'contact': { if (!pinnedConversation.contact) { throw new Error('mergeAccountRecord: no contact found'); } conversationId = window.ConversationController.ensureContactIds( pinnedConversation.contact ); break; } case 'legacyGroupId': { if (!pinnedConversation.legacyGroupId) { throw new Error('mergeAccountRecord: no legacyGroupId found'); } conversationId = pinnedConversation.legacyGroupId.toBinary(); break; } case 'groupMasterKey': { if (!pinnedConversation.groupMasterKey) { throw new Error('mergeAccountRecord: no groupMasterKey found'); } const masterKeyBuffer = pinnedConversation.groupMasterKey.toArrayBuffer(); const groupFields = deriveGroupFields(masterKeyBuffer); const groupId = arrayBufferToBase64(groupFields.id); conversationId = groupId; break; } default: { window.log.error( 'storageService.mergeAccountRecord: Invalid identifier received' ); } } if (!conversationId) { window.log.error( 'storageService.mergeAccountRecord: missing conversation id. looking based on', pinnedConversation.identifier ); return undefined; } return window.ConversationController.get(conversationId); } ); const remotelyPinnedConversations = ( await Promise.all(remotelyPinnedConversationPromises) ).filter( (conversation): conversation is ConversationModel => conversation !== undefined ); const remotelyPinnedConversationIds = remotelyPinnedConversations.map( ({ id }) => id ); const conversationsToUnpin = locallyPinnedConversations.filter( ({ id }) => !remotelyPinnedConversationIds.includes(id) ); window.log.info( 'storageService.mergeAccountRecord: unpinning', conversationsToUnpin.length ); window.log.info( 'storageService.mergeAccountRecord: pinning', remotelyPinnedConversations.length ); conversationsToUnpin.forEach(conversation => { conversation.set({ isPinned: false }); updateConversation(conversation.attributes); }); remotelyPinnedConversations.forEach(conversation => { conversation.set({ isPinned: true, isArchived: false }); updateConversation(conversation.attributes); }); window.storage.put('pinnedConversationIds', remotelyPinnedConversationIds); } const ourID = window.ConversationController.getOurConversationId(); if (!ourID) { throw new Error('Could not find ourID'); } const conversation = await window.ConversationController.getOrCreateAndWait( ourID, 'private' ); addUnknownFields(accountRecord, conversation); conversation.set({ isArchived: Boolean(noteToSelfArchived), markedUnread: Boolean(noteToSelfMarkedUnread), storageID, }); if (accountRecord.profileKey) { await conversation.setProfileKey( arrayBufferToBase64(accountRecord.profileKey.toArrayBuffer()) ); } if (avatarUrl) { await conversation.setProfileAvatar(avatarUrl); window.storage.put('avatarUrl', avatarUrl); } const hasPendingChanges = doesRecordHavePendingChanges( await toAccountRecord(conversation), accountRecord, conversation ); updateConversation(conversation.attributes); return hasPendingChanges; }