signal-desktop/ts/services/storageRecordOps.ts

1051 lines
31 KiB
TypeScript
Raw Normal View History

// Copyright 2020-2021 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2021-04-06 22:54:47 +00:00
import { isEqual, isNumber } from 'lodash';
2020-09-09 00:56:23 +00:00
import {
arrayBufferToBase64,
base64ToArrayBuffer,
deriveMasterKeyFromGroupV1,
2020-09-09 00:56:23 +00:00
fromEncodedBinaryToArrayBuffer,
} from '../Crypto';
import dataInterface from '../sql/Client';
import {
AccountRecordClass,
ContactRecordClass,
GroupV1RecordClass,
2020-09-09 02:25:05 +00:00
GroupV2RecordClass,
2020-10-02 18:30:43 +00:00
PinnedConversationClass,
2020-09-09 00:56:23 +00:00
} from '../textsecure.d';
2020-11-20 17:30:45 +00:00
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';
2021-04-08 19:27:20 +00:00
import { arePinnedConversationsEqual } from '../util/arePinnedConversationsEqual';
import { ConversationModel } from '../models/conversations';
2021-04-09 16:19:38 +00:00
import {
getSafeLongFromTimestamp,
getTimestampFromLong,
} from '../util/timestampLongUtils';
2021-06-01 20:45:43 +00:00
import {
get as getUniversalExpireTimer,
set as setUniversalExpireTimer,
} from '../util/universalExpireTimer';
import { ourProfileKeyService } from './ourProfileKey';
import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation';
2020-09-09 00:56:23 +00:00
const { updateConversation } = dataInterface;
2020-09-09 02:25:05 +00:00
type RecordClass =
| AccountRecordClass
| ContactRecordClass
| GroupV1RecordClass
| GroupV2RecordClass;
2020-09-09 00:56:23 +00:00
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
2020-09-09 00:56:23 +00:00
): void {
if (record.__unknownFields) {
2020-09-16 18:04:28 +00:00
window.log.info(
2020-10-06 22:25:00 +00:00
'storageService.addUnknownFields: Unknown fields found for',
2021-04-06 22:54:47 +00:00
conversation.idForLogging()
2020-09-16 18:04:28 +00:00
);
2020-09-09 00:56:23 +00:00
conversation.set({
storageUnknownFields: arrayBufferToBase64(record.__unknownFields),
});
2020-10-06 22:25:00 +00:00
} 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',
2021-04-06 22:54:47 +00:00
conversation.idForLogging()
2020-10-06 22:25:00 +00:00
);
conversation.unset('storageUnknownFields');
2020-09-09 00:56:23 +00:00
}
}
function applyUnknownFields(
record: RecordClass,
conversation: ConversationModel
2020-09-09 00:56:23 +00:00
): void {
const storageUnknownFields = conversation.get('storageUnknownFields');
if (storageUnknownFields) {
2020-09-16 18:04:28 +00:00
window.log.info(
2020-10-07 23:44:55 +00:00
'storageService.applyUnknownFields: Applying unknown fields for',
conversation.get('id')
2020-09-16 18:04:28 +00:00
);
2020-09-09 00:56:23 +00:00
// eslint-disable-next-line no-param-reassign
record.__unknownFields = base64ToArrayBuffer(storageUnknownFields);
2020-09-09 00:56:23 +00:00
}
}
export async function toContactRecord(
conversation: ConversationModel
2020-09-09 00:56:23 +00:00
): Promise<ContactRecordClass> {
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'));
2021-04-09 16:19:38 +00:00
contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp(
conversation.get('muteExpiresAt')
);
2020-09-09 00:56:23 +00:00
applyUnknownFields(contactRecord, conversation);
return contactRecord;
}
export async function toAccountRecord(
conversation: ConversationModel
2020-09-09 00:56:23 +00:00
): Promise<AccountRecordClass> {
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')
);
2020-09-09 00:56:23 +00:00
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);
}
2021-06-01 20:45:43 +00:00
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);
}
2020-10-10 14:25:17 +00:00
const pinnedConversations = window.storage
2020-10-02 18:30:43 +00:00
.get<Array<string>>('pinnedConversationIds', [])
.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)) {
2020-10-02 18:30:43 +00:00
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)) {
2020-10-02 18:30:43 +00:00
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
);
2020-09-09 00:56:23 +00:00
2020-10-10 14:25:17 +00:00
window.log.info(
2021-04-06 22:54:47 +00:00
'storageService.toAccountRecord: pinnedConversations',
pinnedConversations.length
2020-10-10 14:25:17 +00:00
);
accountRecord.pinnedConversations = pinnedConversations;
2020-09-09 00:56:23 +00:00
applyUnknownFields(accountRecord, conversation);
return accountRecord;
}
export async function toGroupV1Record(
conversation: ConversationModel
2020-09-09 00:56:23 +00:00
): Promise<GroupV1RecordClass> {
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'));
2021-04-09 16:19:38 +00:00
groupV1Record.mutedUntilTimestamp = getSafeLongFromTimestamp(
conversation.get('muteExpiresAt')
);
2020-09-09 00:56:23 +00:00
applyUnknownFields(groupV1Record, conversation);
return groupV1Record;
}
2020-09-09 02:25:05 +00:00
export async function toGroupV2Record(
conversation: ConversationModel
2020-09-09 02:25:05 +00:00
): Promise<GroupV2RecordClass> {
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'));
2021-04-09 16:19:38 +00:00
groupV2Record.mutedUntilTimestamp = getSafeLongFromTimestamp(
conversation.get('muteExpiresAt')
);
2020-09-09 02:25:05 +00:00
applyUnknownFields(groupV2Record, conversation);
return groupV2Record;
}
2020-09-09 00:56:23 +00:00
type MessageRequestCapableRecord = ContactRecordClass | GroupV1RecordClass;
function applyMessageRequestState(
record: MessageRequestCapableRecord,
conversation: ConversationModel
2020-09-09 00:56:23 +00:00
): void {
2020-10-06 17:06:34 +00:00
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
2020-09-09 00:56:23 +00:00
if (record.blocked) {
2020-10-06 17:06:34 +00:00
conversation.applyMessageRequestResponse(messageRequestEnum.BLOCK, {
fromSync: true,
viaStorageServiceSync: true,
});
2020-09-09 00:56:23 +00:00
} 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
2020-10-06 17:06:34 +00:00
conversation.applyMessageRequestResponse(messageRequestEnum.ACCEPT, {
fromSync: true,
viaStorageServiceSync: true,
});
2020-09-09 00:56:23 +00:00
} 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) {
2020-09-09 00:56:23 +00:00
conversation.disableProfileSharing({ viaStorageServiceSync: true });
}
}
2020-09-16 18:04:28 +00:00
type RecordClassObject = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2020-09-16 18:04:28 +00:00
[key: string]: any;
};
function doRecordsConflict(
localRecord: RecordClassObject,
remoteRecord: RecordClassObject,
conversation: ConversationModel
2020-09-16 18:04:28 +00:00
): boolean {
2021-04-06 22:54:47 +00:00
const idForLogging = conversation.idForLogging();
2020-09-16 18:04:28 +00:00
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',
2021-04-06 22:54:47 +00:00
idForLogging,
2020-09-16 18:04:28 +00:00
localKeys.join(','),
remoteKeys.join(',')
);
return true;
}
return localKeys.reduce((hasConflict: boolean, key: string): boolean => {
const localValue = localRecord[key];
const remoteValue = remoteRecord[key];
2021-04-08 19:27:20 +00:00
// 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;
}
2020-09-16 18:04:28 +00:00
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 &&
2021-04-08 19:27:20 +00:00
(localValue === false ||
localValue === '' ||
localValue === 0 ||
(window.dcodeIO.Long.isLong(localValue) && localValue.toNumber() === 0))
2020-09-16 18:04:28 +00:00
) {
return hasConflict || false;
}
2021-04-06 22:54:47 +00:00
const areEqual = isEqual(localValue, remoteValue);
if (!areEqual) {
window.log.info(
'storageService.doRecordsConflict: Conflict found for',
key,
idForLogging
);
}
return !areEqual;
2020-09-16 18:04:28 +00:00
}, false);
}
2020-09-09 00:56:23 +00:00
function doesRecordHavePendingChanges(
mergedRecord: RecordClass,
serviceRecord: RecordClass,
conversation: ConversationModel
2020-09-09 00:56:23 +00:00
): boolean {
const shouldSync = Boolean(conversation.get('needsStorageServiceSync'));
2020-09-16 18:04:28 +00:00
if (!shouldSync) {
return false;
}
const hasConflict = doRecordsConflict(
mergedRecord,
serviceRecord,
conversation
);
2020-09-09 00:56:23 +00:00
2020-09-16 18:04:28 +00:00
if (!hasConflict) {
2020-09-09 00:56:23 +00:00
conversation.set({ needsStorageServiceSync: false });
}
2020-09-16 18:04:28 +00:00
return hasConflict;
2020-09-09 00:56:23 +00:00
}
export async function mergeGroupV1Record(
storageID: string,
groupV1Record: GroupV1RecordClass
): Promise<boolean> {
if (!groupV1Record.id) {
throw new Error(`No ID for ${storageID}`);
2020-09-09 00:56:23 +00:00
}
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)) {
2021-04-06 22:54:47 +00:00
throw new Error(
`Record has group type mismatch ${conversation.idForLogging()}`
);
}
2020-09-09 00:56:23 +00:00
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',
2021-04-06 22:54:47 +00:00
conversation.idForLogging()
);
} else {
2021-04-09 20:12:05 +00:00
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',
2021-04-06 22:54:47 +00:00
conversation.idForLogging()
);
2020-09-09 00:56:23 +00:00
}
conversation.set({
isArchived: Boolean(groupV1Record.archived),
markedUnread: Boolean(groupV1Record.markedUnread),
2020-09-09 00:56:23 +00:00
storageID,
});
2021-04-09 16:19:38 +00:00
conversation.setMuteExpiration(
getTimestampFromLong(groupV1Record.mutedUntilTimestamp),
{
viaStorageServiceSync: true,
}
);
2020-09-09 00:56:23 +00:00
applyMessageRequestState(groupV1Record, conversation);
let hasPendingChanges: boolean;
2020-09-09 00:56:23 +00:00
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',
2021-04-06 22:54:47 +00:00
conversation.idForLogging()
);
// We want to upgrade group in the storage after merging it.
hasPendingChanges = true;
}
2020-09-09 00:56:23 +00:00
updateConversation(conversation.attributes);
return hasPendingChanges;
}
2020-11-20 17:30:45 +00:00
async function getGroupV2Conversation(
masterKeyBuffer: ArrayBuffer
): Promise<ConversationModel> {
2020-09-09 02:25:05 +00:00
const groupFields = deriveGroupFields(masterKeyBuffer);
const groupId = arrayBufferToBase64(groupFields.id);
const masterKey = arrayBufferToBase64(masterKeyBuffer);
const secretParams = arrayBufferToBase64(groupFields.secretParams);
const publicParams = arrayBufferToBase64(groupFields.publicParams);
2020-11-20 17:30:45 +00:00
// 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;
}
2020-09-09 02:25:05 +00:00
const conversationId = window.ConversationController.ensureGroup(groupId, {
2020-10-06 17:06:34 +00:00
// 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().
2020-09-09 02:25:05 +00:00
groupVersion: 2,
masterKey,
secretParams,
publicParams,
});
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
2020-11-20 17:30:45 +00:00
throw new Error(
`getGroupV2Conversation: Failed to create conversation for groupv2(${groupId})`
);
2020-09-09 02:25:05 +00:00
}
2020-11-20 17:30:45 +00:00
return conversation;
}
export async function mergeGroupV2Record(
storageID: string,
groupV2Record: GroupV2RecordClass
): Promise<boolean> {
if (!groupV2Record.masterKey) {
throw new Error(`No master key for ${storageID}`);
}
const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer();
const conversation = await getGroupV2Conversation(masterKeyBuffer);
2020-09-09 02:25:05 +00:00
2021-04-06 22:54:47 +00:00
window.log.info(
'storageService.mergeGroupV2Record:',
conversation.idForLogging()
);
2020-09-09 02:25:05 +00:00
conversation.set({
isArchived: Boolean(groupV2Record.archived),
markedUnread: Boolean(groupV2Record.markedUnread),
2020-09-09 02:25:05 +00:00
storageID,
});
2021-04-09 16:19:38 +00:00
conversation.setMuteExpiration(
getTimestampFromLong(groupV2Record.mutedUntilTimestamp),
{
viaStorageServiceSync: true,
}
);
2020-09-09 02:25:05 +00:00
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');
2020-09-09 02:25:05 +00:00
const dropInitialJoinMessage = isFirstSync;
if (isGroupV1(conversation.attributes)) {
2020-11-20 17:30:45 +00:00
// 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 }
);
}
2020-09-09 02:25:05 +00:00
return hasPendingChanges;
}
2020-09-09 00:56:23 +00:00
export async function mergeContactRecord(
storageID: string,
contactRecord: ContactRecordClass
): Promise<boolean> {
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}`);
2020-09-09 00:56:23 +00:00
}
const conversation = await window.ConversationController.getOrCreateAndWait(
id,
'private'
);
2021-04-06 22:54:47 +00:00
window.log.info(
'storageService.mergeContactRecord:',
conversation.idForLogging()
);
2020-09-09 00:56:23 +00:00
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,
};
2020-09-09 00:56:23 +00:00
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),
2020-09-09 00:56:23 +00:00
storageID,
});
2021-04-09 16:19:38 +00:00
conversation.setMuteExpiration(
getTimestampFromLong(contactRecord.mutedUntilTimestamp),
{
viaStorageServiceSync: true,
}
);
2020-09-09 00:56:23 +00:00
const hasPendingChanges = doesRecordHavePendingChanges(
await toContactRecord(conversation),
contactRecord,
conversation
);
updateConversation(conversation.attributes);
return hasPendingChanges;
}
export async function mergeAccountRecord(
storageID: string,
accountRecord: AccountRecordClass
): Promise<boolean> {
const {
avatarUrl,
linkPreviews,
notDiscoverableByPhoneNumber,
2020-09-09 00:56:23 +00:00
noteToSelfArchived,
noteToSelfMarkedUnread,
phoneNumberSharingMode,
2021-04-06 22:54:47 +00:00
pinnedConversations,
2020-09-09 00:56:23 +00:00
profileKey,
readReceipts,
sealedSenderIndicators,
typingIndicators,
primarySendsSms,
2021-06-01 20:45:43 +00:00
universalExpireTimer,
2020-09-09 00:56:23 +00:00
} = accountRecord;
window.storage.put('read-receipt-setting', 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);
}
2021-06-01 20:45:43 +00:00
if (typeof universalExpireTimer === 'number') {
setUniversalExpireTimer(universalExpireTimer);
}
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);
2020-09-09 00:56:23 +00:00
if (profileKey) {
ourProfileKeyService.set(profileKey.toArrayBuffer());
2020-09-09 00:56:23 +00:00
}
2021-04-06 22:54:47 +00:00
if (pinnedConversations) {
2020-10-10 14:25:17 +00:00
const modelPinnedConversations = window
.getConversations()
.filter(conversation => Boolean(conversation.get('isPinned')));
const modelPinnedConversationIds = modelPinnedConversations.map(
conversation => conversation.get('id')
);
2020-11-09 18:30:05 +00:00
const missingStoragePinnedConversationIds = window.storage
.get<Array<string>>('pinnedConversationIds', [])
.filter(id => !modelPinnedConversationIds.includes(id));
2020-10-10 14:25:17 +00:00
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
)
);
2020-10-15 00:36:31 +00:00
window.log.info(
'storageService.mergeAccountRecord: Local pinned',
locallyPinnedConversations.length
);
2021-04-06 22:54:47 +00:00
window.log.info(
'storageService.mergeAccountRecord: Remote pinned',
pinnedConversations.length
);
2020-10-15 00:36:31 +00:00
2021-04-06 22:54:47 +00:00
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: {
2020-10-15 00:36:31 +00:00
window.log.error(
'storageService.mergeAccountRecord: Invalid identifier received'
);
}
}
if (!conversationId) {
window.log.error(
2020-10-15 00:36:31 +00:00
'storageService.mergeAccountRecord: missing conversation id. looking based on',
2020-10-07 23:44:55 +00:00
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(
2020-10-15 00:36:31 +00:00
'storageService.mergeAccountRecord: unpinning',
conversationsToUnpin.length
);
window.log.info(
2020-10-15 00:36:31 +00:00
'storageService.mergeAccountRecord: pinning',
remotelyPinnedConversations.length
);
conversationsToUnpin.forEach(conversation => {
2020-10-10 14:25:17 +00:00
conversation.set({ isPinned: false });
updateConversation(conversation.attributes);
});
2020-10-10 14:25:17 +00:00
remotelyPinnedConversations.forEach(conversation => {
conversation.set({ isPinned: true, isArchived: false });
updateConversation(conversation.attributes);
});
2020-10-02 18:30:43 +00:00
window.storage.put('pinnedConversationIds', remotelyPinnedConversationIds);
}
2020-09-09 00:56:23 +00:00
const ourID = window.ConversationController.getOurConversationId();
if (!ourID) {
throw new Error('Could not find ourID');
2020-09-09 00:56:23 +00:00
}
const conversation = await window.ConversationController.getOrCreateAndWait(
ourID,
'private'
);
addUnknownFields(accountRecord, conversation);
conversation.set({
isArchived: Boolean(noteToSelfArchived),
markedUnread: Boolean(noteToSelfMarkedUnread),
2020-09-09 00:56:23 +00:00
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;
}