2020-10-30 20:34:04 +00:00
|
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2020-09-16 18:04:28 +00:00
|
|
|
import { isNumber } from 'lodash';
|
2020-09-09 00:56:23 +00:00
|
|
|
|
|
|
|
import {
|
|
|
|
arrayBufferToBase64,
|
|
|
|
base64ToArrayBuffer,
|
2021-03-17 22:10:31 +00:00
|
|
|
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';
|
2020-09-24 20:57:54 +00:00
|
|
|
import { ConversationModel } from '../models/conversations';
|
2020-09-29 22:07:03 +00:00
|
|
|
import { ConversationAttributesTypeType } from '../model-types.d';
|
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,
|
2020-09-24 20:57:54 +00:00
|
|
|
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',
|
|
|
|
conversation.debugID()
|
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',
|
|
|
|
conversation.debugID()
|
|
|
|
);
|
|
|
|
conversation.unset('storageUnknownFields');
|
2020-09-09 00:56:23 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function applyUnknownFields(
|
|
|
|
record: RecordClass,
|
2020-09-24 20:57:54 +00:00
|
|
|
conversation: ConversationModel
|
2020-09-09 00:56:23 +00:00
|
|
|
): void {
|
|
|
|
if (conversation.get('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(
|
|
|
|
conversation.get('storageUnknownFields')
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function toContactRecord(
|
2020-09-24 20:57:54 +00:00
|
|
|
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'));
|
2020-10-28 22:54:32 +00:00
|
|
|
contactRecord.markedUnread = Boolean(conversation.get('markedUnread'));
|
2020-09-09 00:56:23 +00:00
|
|
|
|
|
|
|
applyUnknownFields(contactRecord, conversation);
|
|
|
|
|
|
|
|
return contactRecord;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function toAccountRecord(
|
2020-09-24 20:57:54 +00:00
|
|
|
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'));
|
2020-10-28 22:54:32 +00:00
|
|
|
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'));
|
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 (pinnedConversation.isGroupV1()) {
|
|
|
|
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
|
|
|
|
);
|
2020-10-02 22:19:52 +00:00
|
|
|
} else if (pinnedConversation.isGroupV2()) {
|
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(
|
|
|
|
`toAccountRecord: sending ${pinnedConversations.length} pinned conversations`
|
|
|
|
);
|
|
|
|
|
|
|
|
accountRecord.pinnedConversations = pinnedConversations;
|
2020-09-09 00:56:23 +00:00
|
|
|
applyUnknownFields(accountRecord, conversation);
|
|
|
|
|
|
|
|
return accountRecord;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function toGroupV1Record(
|
2020-09-24 20:57:54 +00:00
|
|
|
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'));
|
2020-10-28 22:54:32 +00:00
|
|
|
groupV1Record.markedUnread = Boolean(conversation.get('markedUnread'));
|
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(
|
2020-09-24 20:57:54 +00:00
|
|
|
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'));
|
2020-10-28 22:54:32 +00:00
|
|
|
groupV2Record.markedUnread = Boolean(conversation.get('markedUnread'));
|
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,
|
2020-09-24 20:57:54 +00:00
|
|
|
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 });
|
|
|
|
}
|
|
|
|
|
2021-03-04 23:08:44 +00:00
|
|
|
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 = {
|
2020-09-25 14:20:36 +00:00
|
|
|
// 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,
|
2020-09-24 20:57:54 +00:00
|
|
|
conversation: ConversationModel
|
2020-09-16 18:04:28 +00:00
|
|
|
): boolean {
|
|
|
|
const debugID = conversation.debugID();
|
|
|
|
|
|
|
|
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',
|
|
|
|
debugID,
|
|
|
|
localKeys.join(','),
|
|
|
|
remoteKeys.join(',')
|
|
|
|
);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
return localKeys.reduce((hasConflict: boolean, key: string): boolean => {
|
|
|
|
const localValue = localRecord[key];
|
|
|
|
const remoteValue = remoteRecord[key];
|
|
|
|
if (Object.prototype.toString.call(localValue) === '[object ArrayBuffer]') {
|
|
|
|
const isEqual =
|
|
|
|
arrayBufferToBase64(localValue) === arrayBufferToBase64(remoteValue);
|
|
|
|
if (!isEqual) {
|
|
|
|
window.log.info(
|
|
|
|
'storageService.doRecordsConflict: Conflict found for',
|
|
|
|
key,
|
|
|
|
debugID
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return hasConflict || !isEqual;
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
|
|
) {
|
|
|
|
return hasConflict || false;
|
|
|
|
}
|
|
|
|
|
|
|
|
window.log.info(
|
|
|
|
'storageService.doRecordsConflict: Conflict found for',
|
|
|
|
key,
|
|
|
|
debugID
|
|
|
|
);
|
|
|
|
return true;
|
|
|
|
}, false);
|
|
|
|
}
|
|
|
|
|
2020-09-09 00:56:23 +00:00
|
|
|
function doesRecordHavePendingChanges(
|
|
|
|
mergedRecord: RecordClass,
|
|
|
|
serviceRecord: RecordClass,
|
2020-09-24 20:57:54 +00:00
|
|
|
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) {
|
2020-09-29 23:29:11 +00:00
|
|
|
throw new Error(`No ID for ${storageID}`);
|
2020-09-09 00:56:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const groupId = groupV1Record.id.toBinary();
|
|
|
|
|
2021-03-17 22:10:31 +00:00
|
|
|
// 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);
|
2020-09-09 00:56:23 +00:00
|
|
|
if (!conversation) {
|
2021-03-17 22:10:31 +00:00
|
|
|
const masterKeyBuffer = await deriveMasterKeyFromGroupV1(groupId);
|
|
|
|
const fields = deriveGroupFields(masterKeyBuffer);
|
|
|
|
const derivedGroupV2Id = arrayBufferToBase64(fields.id);
|
2020-09-29 23:29:11 +00:00
|
|
|
|
2021-03-17 22:10:31 +00:00
|
|
|
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.debugID()
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
conversation = await window.ConversationController.getOrCreateAndWait(
|
|
|
|
groupId,
|
|
|
|
'group'
|
|
|
|
);
|
|
|
|
window.log.info(
|
|
|
|
'storageService.mergeGroupV1Record: created a new group locally',
|
|
|
|
conversation.debugID()
|
|
|
|
);
|
2020-09-09 00:56:23 +00:00
|
|
|
}
|
|
|
|
|
2021-03-17 22:10:31 +00:00
|
|
|
// If we receive a group V1 record, remote data should take precendence
|
|
|
|
// even if the group is actually V2 on our end.
|
2020-09-09 00:56:23 +00:00
|
|
|
conversation.set({
|
|
|
|
isArchived: Boolean(groupV1Record.archived),
|
2020-10-28 22:54:32 +00:00
|
|
|
markedUnread: Boolean(groupV1Record.markedUnread),
|
2020-09-09 00:56:23 +00:00
|
|
|
storageID,
|
|
|
|
});
|
|
|
|
|
|
|
|
applyMessageRequestState(groupV1Record, conversation);
|
|
|
|
|
2021-03-17 22:10:31 +00:00
|
|
|
let hasPendingChanges: boolean;
|
2020-09-09 00:56:23 +00:00
|
|
|
|
2021-03-17 22:10:31 +00:00
|
|
|
if (conversation.isGroupV1()) {
|
|
|
|
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.debugID()
|
|
|
|
);
|
|
|
|
|
|
|
|
// 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-03-04 23:08:44 +00:00
|
|
|
window.log.info('storageService.mergeGroupV2Record:', conversation.debugID());
|
|
|
|
|
2020-09-09 02:25:05 +00:00
|
|
|
conversation.set({
|
|
|
|
isArchived: Boolean(groupV2Record.archived),
|
2020-10-28 22:54:32 +00:00
|
|
|
markedUnread: Boolean(groupV2Record.markedUnread),
|
2020-09-09 02:25:05 +00:00
|
|
|
storageID,
|
|
|
|
});
|
|
|
|
|
|
|
|
applyMessageRequestState(groupV2Record, conversation);
|
|
|
|
|
|
|
|
addUnknownFields(groupV2Record, conversation);
|
|
|
|
|
|
|
|
const hasPendingChanges = doesRecordHavePendingChanges(
|
|
|
|
await toGroupV2Record(conversation),
|
|
|
|
groupV2Record,
|
|
|
|
conversation
|
|
|
|
);
|
|
|
|
|
|
|
|
updateConversation(conversation.attributes);
|
|
|
|
|
2020-10-07 21:22:14 +00:00
|
|
|
const isGroupNewToUs = !isNumber(conversation.get('revision'));
|
2020-10-29 00:40:50 +00:00
|
|
|
const isFirstSync = !window.storage.get('storageFetchComplete');
|
2020-09-09 02:25:05 +00:00
|
|
|
const dropInitialJoinMessage = isFirstSync;
|
2020-10-07 21:22:14 +00:00
|
|
|
|
2020-11-20 17:30:45 +00:00
|
|
|
if (conversation.isGroupV1()) {
|
|
|
|
// 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.
|
2020-10-07 21:22:14 +00:00
|
|
|
waitThenMaybeUpdateGroup({
|
|
|
|
conversation,
|
|
|
|
dropInitialJoinMessage,
|
|
|
|
});
|
|
|
|
}
|
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) {
|
2020-09-29 23:29:11 +00:00
|
|
|
throw new Error(`No ID for ${storageID}`);
|
2020-09-09 00:56:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const conversation = await window.ConversationController.getOrCreateAndWait(
|
|
|
|
id,
|
|
|
|
'private'
|
|
|
|
);
|
|
|
|
|
2021-03-04 23:08:44 +00:00
|
|
|
window.log.info('storageService.mergeContactRecord:', conversation.debugID());
|
|
|
|
|
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 = { 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),
|
2020-10-28 22:54:32 +00:00
|
|
|
markedUnread: Boolean(contactRecord.markedUnread),
|
2020-09-09 00:56:23 +00:00
|
|
|
storageID,
|
|
|
|
});
|
|
|
|
|
|
|
|
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,
|
|
|
|
noteToSelfArchived,
|
2020-10-28 22:54:32 +00:00
|
|
|
noteToSelfMarkedUnread,
|
2020-09-29 22:07:03 +00:00
|
|
|
pinnedConversations: remotelyPinnedConversationClasses,
|
2020-09-09 00:56:23 +00:00
|
|
|
profileKey,
|
|
|
|
readReceipts,
|
|
|
|
sealedSenderIndicators,
|
|
|
|
typingIndicators,
|
|
|
|
} = 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 (profileKey) {
|
|
|
|
window.storage.put('profileKey', profileKey.toArrayBuffer());
|
|
|
|
}
|
|
|
|
|
2020-09-29 22:07:03 +00:00
|
|
|
if (remotelyPinnedConversationClasses) {
|
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-09-29 22:07:03 +00:00
|
|
|
);
|
|
|
|
|
2020-10-15 00:36:31 +00:00
|
|
|
window.log.info(
|
|
|
|
'storageService.mergeAccountRecord: Local pinned',
|
|
|
|
locallyPinnedConversations.length
|
|
|
|
);
|
|
|
|
|
2020-09-29 22:07:03 +00:00
|
|
|
const remotelyPinnedConversationPromises = remotelyPinnedConversationClasses.map(
|
|
|
|
async pinnedConversation => {
|
|
|
|
let conversationId;
|
|
|
|
let conversationType: ConversationAttributesTypeType = 'private';
|
|
|
|
|
|
|
|
switch (pinnedConversation.identifier) {
|
|
|
|
case 'contact': {
|
|
|
|
if (!pinnedConversation.contact) {
|
|
|
|
throw new Error('mergeAccountRecord: no contact found');
|
|
|
|
}
|
|
|
|
conversationId = window.ConversationController.ensureContactIds(
|
|
|
|
pinnedConversation.contact
|
|
|
|
);
|
|
|
|
conversationType = 'private';
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
case 'legacyGroupId': {
|
|
|
|
if (!pinnedConversation.legacyGroupId) {
|
|
|
|
throw new Error('mergeAccountRecord: no legacyGroupId found');
|
|
|
|
}
|
|
|
|
conversationId = pinnedConversation.legacyGroupId.toBinary();
|
|
|
|
conversationType = 'group';
|
|
|
|
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;
|
|
|
|
conversationType = 'group';
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
default: {
|
2020-10-15 00:36:31 +00:00
|
|
|
window.log.error(
|
|
|
|
'storageService.mergeAccountRecord: Invalid identifier received'
|
|
|
|
);
|
2020-09-29 22:07:03 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
2020-09-29 22:07:03 +00:00
|
|
|
);
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (conversationType === 'private') {
|
|
|
|
return window.ConversationController.getOrCreateAndWait(
|
|
|
|
conversationId,
|
|
|
|
conversationType
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
2020-09-29 22:07:03 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
window.log.info(
|
2020-10-15 00:36:31 +00:00
|
|
|
'storageService.mergeAccountRecord: pinning',
|
|
|
|
remotelyPinnedConversations.length
|
2020-09-29 22:07:03 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
conversationsToUnpin.forEach(conversation => {
|
2020-10-10 14:25:17 +00:00
|
|
|
conversation.set({ isPinned: false });
|
2020-09-29 22:07:03 +00:00
|
|
|
updateConversation(conversation.attributes);
|
|
|
|
});
|
|
|
|
|
2020-10-10 14:25:17 +00:00
|
|
|
remotelyPinnedConversations.forEach(conversation => {
|
2020-11-09 15:32:59 +00:00
|
|
|
conversation.set({ isPinned: true, isArchived: false });
|
2020-09-29 22:07:03 +00:00
|
|
|
updateConversation(conversation.attributes);
|
|
|
|
});
|
2020-10-02 18:30:43 +00:00
|
|
|
|
|
|
|
window.storage.put('pinnedConversationIds', remotelyPinnedConversationIds);
|
2020-09-29 22:07:03 +00:00
|
|
|
}
|
|
|
|
|
2020-09-09 00:56:23 +00:00
|
|
|
const ourID = window.ConversationController.getOurConversationId();
|
|
|
|
|
|
|
|
if (!ourID) {
|
2020-09-29 23:29:11 +00:00
|
|
|
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),
|
2020-10-28 22:54:32 +00:00
|
|
|
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;
|
|
|
|
}
|