Update storageService logging

This commit is contained in:
Fedor Indutny 2022-02-08 10:00:18 -08:00 committed by GitHub
parent 0a18cc50bd
commit cb5131420f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 444 additions and 339 deletions

1
ts/model-types.d.ts vendored
View file

@ -273,6 +273,7 @@ export type ConversationAttributesType = {
needsVerification?: boolean; needsVerification?: boolean;
profileSharing: boolean; profileSharing: boolean;
storageID?: string; storageID?: string;
storageVersion?: number;
storageUnknownFields?: string; storageUnknownFields?: string;
unreadCount?: number; unreadCount?: number;
version: number; version: number;

View file

@ -144,6 +144,7 @@ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
'profileLastFetchedAt', 'profileLastFetchedAt',
'needsStorageServiceSync', 'needsStorageServiceSync',
'storageID', 'storageID',
'storageVersion',
'storageUnknownFields', 'storageUnknownFields',
]); ]);
@ -5047,7 +5048,7 @@ export class ConversationModel extends window.Backbone
}); });
} }
startMuteTimer(): void { startMuteTimer({ viaStorageServiceSync = false } = {}): void {
if (this.muteTimer !== undefined) { if (this.muteTimer !== undefined) {
clearTimeout(this.muteTimer); clearTimeout(this.muteTimer);
this.muteTimer = undefined; this.muteTimer = undefined;
@ -5057,7 +5058,7 @@ export class ConversationModel extends window.Backbone
if (isNumber(muteExpiresAt) && muteExpiresAt < Number.MAX_SAFE_INTEGER) { if (isNumber(muteExpiresAt) && muteExpiresAt < Number.MAX_SAFE_INTEGER) {
const delay = muteExpiresAt - Date.now(); const delay = muteExpiresAt - Date.now();
if (delay <= 0) { if (delay <= 0) {
this.setMuteExpiration(0); this.setMuteExpiration(0, { viaStorageServiceSync });
return; return;
} }
@ -5076,7 +5077,10 @@ export class ConversationModel extends window.Backbone
} }
this.set({ muteExpiresAt }); this.set({ muteExpiresAt });
this.startMuteTimer();
// Don't cause duplicate captureChange
this.startMuteTimer({ viaStorageServiceSync: true });
if (!viaStorageServiceSync) { if (!viaStorageServiceSync) {
this.captureChange('mutedUntilTimestamp'); this.captureChange('mutedUntilTimestamp');
} }

View file

@ -23,6 +23,7 @@ import {
toGroupV1Record, toGroupV1Record,
toGroupV2Record, toGroupV2Record,
} from './storageRecordOps'; } from './storageRecordOps';
import type { MergeResultType } from './storageRecordOps';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
@ -70,17 +71,30 @@ const conflictBackOff = new BackOff([
30 * durations.SECOND, 30 * durations.SECOND,
]); ]);
function redactStorageID(storageID: string): string { function redactStorageID(
return storageID.substring(0, 3); storageID: string,
version?: number,
conversation?: ConversationModel
): string {
const convoId = conversation ? ` ${conversation?.idForLogging()}` : '';
return `${version ?? '?'}:${storageID.substring(0, 3)}${convoId}`;
} }
type RemoteRecord = { type RemoteRecord = {
itemType: number; itemType: number;
storageID: string; storageID: string;
storageVersion?: number;
}; };
type UnknownRecord = RemoteRecord; type UnknownRecord = RemoteRecord;
function unknownRecordToRedactedID({
storageID,
storageVersion,
}: UnknownRecord): string {
return redactStorageID(storageID, storageVersion);
}
async function encryptRecord( async function encryptRecord(
storageID: string | undefined, storageID: string | undefined,
storageRecord: Proto.IStorageRecord storageRecord: Proto.IStorageRecord
@ -132,9 +146,8 @@ async function generateManifest(
isNewManifest = false isNewManifest = false
): Promise<GeneratedManifestType> { ): Promise<GeneratedManifestType> {
log.info( log.info(
'storageService.generateManifest: generating manifest', `storageService.upload(${version}): generating manifest ` +
version, `new=${isNewManifest}`
isNewManifest
); );
await window.ConversationController.checkForConflicts(); await window.ConversationController.checkForConflicts();
@ -195,82 +208,84 @@ async function generateManifest(
storageRecord.groupV1 = await toGroupV1Record(conversation); storageRecord.groupV1 = await toGroupV1Record(conversation);
identifier.type = ITEM_TYPE.GROUPV1; identifier.type = ITEM_TYPE.GROUPV1;
} else { } else {
log.info( log.warn(
'storageService.generateManifest: unknown conversation', `storageService.upload(${version}): ` +
conversation.idForLogging() `unknown conversation=${conversation.idForLogging()}`
); );
} }
if (storageRecord) { if (!storageRecord) {
const currentStorageID = conversation.get('storageID'); continue;
const isNewItem =
isNewManifest ||
Boolean(conversation.get('needsStorageServiceSync')) ||
!currentStorageID;
const storageID = isNewItem
? Bytes.toBase64(generateStorageID())
: currentStorageID;
let storageItem;
try {
// eslint-disable-next-line no-await-in-loop
storageItem = await encryptRecord(storageID, storageRecord);
} catch (err) {
log.error(
'storageService.generateManifest: encrypt record failed:',
err && err.stack ? err.stack : String(err)
);
throw err;
}
identifier.raw = storageItem.key;
// When a client needs to update a given record it should create it
// under a new key and delete the existing key.
if (isNewItem) {
newItems.add(storageItem);
if (storageID) {
insertKeys.push(storageID);
log.info(
'storageService.generateManifest: new key',
conversation.idForLogging(),
redactStorageID(storageID)
);
} else {
log.info(
'storageService.generateManifest: no storage id',
conversation.idForLogging()
);
}
const oldStorageID = conversation.get('storageID');
if (oldStorageID) {
log.info(
'storageService.generateManifest: deleting key',
redactStorageID(oldStorageID)
);
deleteKeys.push(Bytes.fromBase64(oldStorageID));
}
conversationsToUpdate.push({
conversation,
storageID,
});
}
manifestRecordKeys.add(identifier);
} }
const currentStorageID = conversation.get('storageID');
const currentStorageVersion = conversation.get('storageVersion');
const currentRedactedID = currentStorageID
? redactStorageID(currentStorageID, currentStorageVersion)
: undefined;
const isNewItem =
isNewManifest ||
Boolean(conversation.get('needsStorageServiceSync')) ||
!currentStorageID;
const storageID = isNewItem
? Bytes.toBase64(generateStorageID())
: currentStorageID;
let storageItem;
try {
// eslint-disable-next-line no-await-in-loop
storageItem = await encryptRecord(storageID, storageRecord);
} catch (err) {
log.error(
`storageService.upload(${version}): encrypt record failed:`,
Errors.toLogFormat(err)
);
throw err;
}
identifier.raw = storageItem.key;
// When a client needs to update a given record it should create it
// under a new key and delete the existing key.
if (isNewItem) {
newItems.add(storageItem);
insertKeys.push(storageID);
const newRedactedID = redactStorageID(storageID, version, conversation);
if (currentStorageID) {
log.info(
`storageService.upload(${version}): ` +
`updating from=${currentRedactedID} ` +
`to=${newRedactedID}`
);
deleteKeys.push(Bytes.fromBase64(currentStorageID));
} else {
log.info(
`storageService.upload(${version}): adding key=${newRedactedID}`
);
}
conversationsToUpdate.push({
conversation,
storageID,
});
}
manifestRecordKeys.add(identifier);
} }
const unknownRecordsArray: ReadonlyArray<UnknownRecord> = ( const unknownRecordsArray: ReadonlyArray<UnknownRecord> = (
window.storage.get('storage-service-unknown-records') || [] window.storage.get('storage-service-unknown-records') || []
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType)); ).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
const redactedUnknowns = unknownRecordsArray.map(unknownRecordToRedactedID);
log.info( log.info(
'storageService.generateManifest: adding unknown records:', `storageService.upload(${version}): adding unknown ` +
unknownRecordsArray.length `records=${JSON.stringify(redactedUnknowns)} ` +
`count=${redactedUnknowns.length}`
); );
// When updating the manifest, ensure all "unknown" keys are added to the // When updating the manifest, ensure all "unknown" keys are added to the
@ -287,10 +302,11 @@ async function generateManifest(
'storage-service-error-records', 'storage-service-error-records',
new Array<UnknownRecord>() new Array<UnknownRecord>()
); );
const redactedErrors = recordsWithErrors.map(unknownRecordToRedactedID);
log.info( log.info(
'storageService.generateManifest: adding records that had errors in the previous merge', `storageService.upload(${version}): adding error ` +
recordsWithErrors.length `records=${JSON.stringify(redactedErrors)} count=${redactedErrors.length}`
); );
// These records failed to merge in the previous fetchManifest, but we still // These records failed to merge in the previous fetchManifest, but we still
@ -320,8 +336,10 @@ async function generateManifest(
rawDuplicates.has(identifier.raw) || rawDuplicates.has(identifier.raw) ||
typeRawDuplicates.has(typeAndRaw) typeRawDuplicates.has(typeAndRaw)
) { ) {
log.info( log.warn(
'storageService.generateManifest: removing duplicate identifier from manifest', `storageService.upload(${version}): removing from duplicate item ` +
'from the manifest',
redactStorageID(storageID),
identifier.type identifier.type
); );
manifestRecordKeys.delete(identifier); manifestRecordKeys.delete(identifier);
@ -334,8 +352,9 @@ async function generateManifest(
key => Bytes.toBase64(key) === storageID key => Bytes.toBase64(key) === storageID
); );
if (hasDeleteKey) { if (hasDeleteKey) {
log.info( log.warn(
'storageService.generateManifest: removing key which has been deleted', `storageService.upload(${version}): removing key which has been deleted`,
redactStorageID(storageID),
identifier.type identifier.type
); );
manifestRecordKeys.delete(identifier); manifestRecordKeys.delete(identifier);
@ -344,7 +363,10 @@ async function generateManifest(
// Ensure that there is *exactly* one Account type in the manifest // Ensure that there is *exactly* one Account type in the manifest
if (identifier.type === ITEM_TYPE.ACCOUNT) { if (identifier.type === ITEM_TYPE.ACCOUNT) {
if (hasAccountType) { if (hasAccountType) {
log.info('storageService.generateManifest: removing duplicate account'); log.warn(
`storageService.upload(${version}): removing duplicate account`,
redactStorageID(storageID)
);
manifestRecordKeys.delete(identifier); manifestRecordKeys.delete(identifier);
} }
hasAccountType = true; hasAccountType = true;
@ -362,8 +384,9 @@ async function generateManifest(
const storageID = Bytes.toBase64(storageItem.key); const storageID = Bytes.toBase64(storageItem.key);
if (storageKeyDuplicates.has(storageID)) { if (storageKeyDuplicates.has(storageID)) {
log.info( log.warn(
'storageService.generateManifest: removing duplicate identifier from inserts', `storageService.upload(${version}): ` +
'removing duplicate identifier from inserts',
redactStorageID(storageID) redactStorageID(storageID)
); );
newItems.delete(storageItem); newItems.delete(storageItem);
@ -413,7 +436,7 @@ async function generateManifest(
const remoteDeletes: Array<string> = []; const remoteDeletes: Array<string> = [];
pendingDeletes.forEach(id => remoteDeletes.push(redactStorageID(id))); pendingDeletes.forEach(id => remoteDeletes.push(redactStorageID(id)));
log.error( log.error(
'Delete key sizes do not match', `storageService.upload(${version}): delete key sizes do not match`,
'local', 'local',
localDeletes.join(','), localDeletes.join(','),
'remote', 'remote',
@ -482,16 +505,15 @@ async function uploadManifest(
} }
if (newItems.size === 0 && deleteKeys.length === 0) { if (newItems.size === 0 && deleteKeys.length === 0) {
log.info('storageService.uploadManifest: nothing to upload'); log.info(`storageService.upload(${version}): nothing to upload`);
return; return;
} }
const credentials = window.storage.get('storageCredentials'); const credentials = window.storage.get('storageCredentials');
try { try {
log.info( log.info(
'storageService.uploadManifest: keys inserting, deleting:', `storageService.upload(${version}): inserting=${newItems.size} ` +
newItems.size, `deleting=${deleteKeys.length}`
deleteKeys.length
); );
const writeOperation = new Proto.WriteOperation(); const writeOperation = new Proto.WriteOperation();
@ -499,7 +521,6 @@ async function uploadManifest(
writeOperation.insertItem = Array.from(newItems); writeOperation.insertItem = Array.from(newItems);
writeOperation.deleteKey = deleteKeys; writeOperation.deleteKey = deleteKeys;
log.info('storageService.uploadManifest: uploading...', version);
await window.textsecure.messaging.modifyStorageRecords( await window.textsecure.messaging.modifyStorageRecords(
Proto.WriteOperation.encode(writeOperation).finish(), Proto.WriteOperation.encode(writeOperation).finish(),
{ {
@ -508,34 +529,38 @@ async function uploadManifest(
); );
log.info( log.info(
'storageService.uploadManifest: upload done, updating conversation(s) with new storageIDs:', `storageService.upload(${version}): upload complete, updating ` +
conversationsToUpdate.length `conversations=${conversationsToUpdate.length}`
); );
// update conversations with the new storageID // update conversations with the new storageID
conversationsToUpdate.forEach(({ conversation, storageID }) => { conversationsToUpdate.forEach(({ conversation, storageID }) => {
conversation.set({ conversation.set({
needsStorageServiceSync: false, needsStorageServiceSync: false,
storageVersion: version,
storageID, storageID,
}); });
updateConversation(conversation.attributes); updateConversation(conversation.attributes);
}); });
} catch (err) { } catch (err) {
log.error( log.error(
'storageService.uploadManifest: failed!', `storageService.upload(${version}): failed!`,
err && err.stack ? err.stack : String(err) Errors.toLogFormat(err)
); );
if (err.code === 409) { if (err.code === 409) {
if (conflictBackOff.isFull()) { if (conflictBackOff.isFull()) {
log.error( log.error(
'storageService.uploadManifest: Exceeded maximum consecutive conflicts' `storageService.upload(${version}): exceeded maximum consecutive ` +
'conflicts'
); );
return; return;
} }
log.info( log.info(
`storageService.uploadManifest: Conflict found with v${version}, running sync job times(${conflictBackOff.getIndex()})` `storageService.upload(${version}): conflict found with ` +
`version=${version}, running sync job ` +
`times=${conflictBackOff.getIndex()}`
); );
throw err; throw err;
@ -544,40 +569,30 @@ async function uploadManifest(
throw err; throw err;
} }
log.info( log.info(`storageService.upload(${version}): setting new manifestVersion`);
'storageService.uploadManifest: setting new manifestVersion',
version
);
window.storage.put('manifestVersion', version); window.storage.put('manifestVersion', version);
conflictBackOff.reset(); conflictBackOff.reset();
backOff.reset(); backOff.reset();
if (window.ConversationController.areWePrimaryDevice()) {
log.warn(
'storageService.uploadManifest: We are primary device; not sending sync manifest'
);
return;
}
try { try {
await singleProtoJobQueue.add( await singleProtoJobQueue.add(
window.textsecure.messaging.getFetchManifestSyncMessage() window.textsecure.messaging.getFetchManifestSyncMessage()
); );
} catch (error) { } catch (error) {
log.error( log.error(
'storageService.uploadManifest: Failed to queue sync message', `storageService.upload(${version}): Failed to queue sync message`,
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
} }
} }
async function stopStorageServiceSync() { async function stopStorageServiceSync(reason: Error) {
log.info('storageService.stopStorageServiceSync'); log.warn('storageService.stopStorageServiceSync', Errors.toLogFormat(reason));
await window.storage.remove('storageKey'); await window.storage.remove('storageKey');
if (backOff.isFull()) { if (backOff.isFull()) {
log.info( log.warn(
'storageService.stopStorageServiceSync: too many consecutive stops' 'storageService.stopStorageServiceSync: too many consecutive stops'
); );
return; return;
@ -650,10 +665,10 @@ async function decryptManifest(
async function fetchManifest( async function fetchManifest(
manifestVersion: number manifestVersion: number
): Promise<Proto.ManifestRecord | undefined> { ): Promise<Proto.ManifestRecord | undefined> {
log.info('storageService.fetchManifest'); log.info('storageService.sync: fetch start');
if (!window.textsecure.messaging) { if (!window.textsecure.messaging) {
throw new Error('storageService.fetchManifest: We are offline!'); throw new Error('storageService.sync: we are offline!');
} }
try { try {
@ -669,28 +684,19 @@ async function fetchManifest(
); );
const encryptedManifest = Proto.StorageManifest.decode(manifestBinary); const encryptedManifest = Proto.StorageManifest.decode(manifestBinary);
// if we don't get a value we're assuming that there's no newer manifest
if (!encryptedManifest.value || !encryptedManifest.version) {
log.info('storageService.fetchManifest: nothing changed');
return;
}
try { try {
return decryptManifest(encryptedManifest); return decryptManifest(encryptedManifest);
} catch (err) { } catch (err) {
await stopStorageServiceSync(); await stopStorageServiceSync(err);
return; return;
} }
} catch (err) { } catch (err) {
if (err.code === 204) { if (err.code === 204) {
log.info('storageService.fetchManifest: no newer manifest, ok'); log.info('storageService.sync: no newer manifest, ok');
return; return;
} }
log.error( log.error('storageService.sync: failed!', Errors.toLogFormat(err));
'storageService.fetchManifest: failed!',
err && err.stack ? err.stack : String(err)
);
if (err.code === 404) { if (err.code === 404) {
await createNewManifest(); await createNewManifest();
@ -714,49 +720,80 @@ type MergedRecordType = UnknownRecord & {
}; };
async function mergeRecord( async function mergeRecord(
storageVersion: number,
itemToMerge: MergeableItemType itemToMerge: MergeableItemType
): Promise<MergedRecordType> { ): Promise<MergedRecordType> {
const { itemType, storageID, storageRecord } = itemToMerge; const { itemType, storageID, storageRecord } = itemToMerge;
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type; const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
let hasConflict = false; let mergeResult: MergeResultType = { hasConflict: false, details: [] };
let isUnsupported = false; let isUnsupported = false;
let hasError = false; let hasError = false;
try { try {
if (itemType === ITEM_TYPE.UNKNOWN) { if (itemType === ITEM_TYPE.UNKNOWN) {
log.info('storageService.mergeRecord: Unknown item type', storageID); log.warn('storageService.mergeRecord: Unknown item type', storageID);
} else if (itemType === ITEM_TYPE.CONTACT && storageRecord.contact) { } else if (itemType === ITEM_TYPE.CONTACT && storageRecord.contact) {
hasConflict = await mergeContactRecord(storageID, storageRecord.contact); mergeResult = await mergeContactRecord(
storageID,
storageVersion,
storageRecord.contact
);
} else if (itemType === ITEM_TYPE.GROUPV1 && storageRecord.groupV1) { } else if (itemType === ITEM_TYPE.GROUPV1 && storageRecord.groupV1) {
hasConflict = await mergeGroupV1Record(storageID, storageRecord.groupV1); mergeResult = await mergeGroupV1Record(
storageID,
storageVersion,
storageRecord.groupV1
);
} else if (itemType === ITEM_TYPE.GROUPV2 && storageRecord.groupV2) { } else if (itemType === ITEM_TYPE.GROUPV2 && storageRecord.groupV2) {
hasConflict = await mergeGroupV2Record(storageID, storageRecord.groupV2); mergeResult = await mergeGroupV2Record(
storageID,
storageVersion,
storageRecord.groupV2
);
} else if (itemType === ITEM_TYPE.ACCOUNT && storageRecord.account) { } else if (itemType === ITEM_TYPE.ACCOUNT && storageRecord.account) {
hasConflict = await mergeAccountRecord(storageID, storageRecord.account); mergeResult = await mergeAccountRecord(
storageID,
storageVersion,
storageRecord.account
);
} else { } else {
isUnsupported = true; isUnsupported = true;
log.info('storageService.mergeRecord: Unknown record:', itemType); log.warn(
`storageService.merge(${redactStorageID(
storageID,
storageVersion
)}): unknown item type=${itemType}`
);
} }
const redactedID = redactStorageID(
storageID,
storageVersion,
mergeResult.conversation
);
const oldID = mergeResult.oldStorageID
? redactStorageID(mergeResult.oldStorageID, mergeResult.oldStorageVersion)
: '?';
log.info( log.info(
'storageService.mergeRecord: merged', `storageService.merge(${redactedID}): merged item type=${itemType} ` +
redactStorageID(storageID), `oldID=${oldID} ` +
itemType, `conflict=${mergeResult.hasConflict} ` +
hasConflict `details=${JSON.stringify(mergeResult.details)}`
); );
} catch (err) { } catch (err) {
hasError = true; hasError = true;
const redactedID = redactStorageID(storageID, storageVersion);
log.error( log.error(
'storageService.mergeRecord: Error with', `storageService.merge(${redactedID}): error with ` +
redactStorageID(storageID), `item type=${itemType} ` +
itemType, `details=${Errors.toLogFormat(err)}`
String(err)
); );
} }
return { return {
hasConflict, hasConflict: mergeResult.hasConflict,
hasError, hasError,
isUnsupported, isUnsupported,
itemType, itemType,
@ -765,8 +802,9 @@ async function mergeRecord(
} }
async function processManifest( async function processManifest(
manifest: Proto.IManifestRecord manifest: Proto.IManifestRecord,
): Promise<boolean> { version: number
): Promise<number> {
if (!window.textsecure.messaging) { if (!window.textsecure.messaging) {
throw new Error('storageService.processManifest: We are offline!'); throw new Error('storageService.processManifest: We are offline!');
} }
@ -778,13 +816,13 @@ async function processManifest(
}); });
const remoteKeys = new Set(remoteKeysTypeMap.keys()); const remoteKeys = new Set(remoteKeysTypeMap.keys());
const localKeys: Set<string> = new Set(); const localVersions = new Map<string, number | undefined>();
const conversations = window.getConversations(); const conversations = window.getConversations();
conversations.forEach((conversation: ConversationModel) => { conversations.forEach((conversation: ConversationModel) => {
const storageID = conversation.get('storageID'); const storageID = conversation.get('storageID');
if (storageID) { if (storageID) {
localKeys.add(storageID); localVersions.set(storageID, conversation.get('storageVersion'));
} }
}); });
@ -794,33 +832,47 @@ async function processManifest(
const stillUnknown = unknownRecordsArray.filter((record: UnknownRecord) => { const stillUnknown = unknownRecordsArray.filter((record: UnknownRecord) => {
// Do not include any unknown records that we already support // Do not include any unknown records that we already support
if (!validRecordTypes.has(record.itemType)) { if (!validRecordTypes.has(record.itemType)) {
localKeys.add(record.storageID); localVersions.set(record.storageID, record.storageVersion);
return false; return false;
} }
return true; return true;
}); });
log.info( const remoteOnlySet = new Set<string>();
'storageService.processManifest: local records:', for (const key of remoteKeys) {
conversations.length if (!localVersions.has(key)) {
);
log.info('storageService.processManifest: local keys:', localKeys.size);
log.info(
'storageService.processManifest: unknown records:',
stillUnknown.length
);
log.info('storageService.processManifest: remote keys:', remoteKeys.size);
const remoteOnlySet: Set<string> = new Set();
remoteKeys.forEach((key: string) => {
if (!localKeys.has(key)) {
remoteOnlySet.add(key); remoteOnlySet.add(key);
} }
}); }
const localOnlySet = new Set<string>();
for (const key of localVersions.keys()) {
if (!remoteKeys.has(key)) {
localOnlySet.add(key);
}
}
const redactedRemoteOnly = Array.from(remoteOnlySet).map(id =>
redactStorageID(id, version)
);
const redactedLocalOnly = Array.from(localOnlySet).map(id =>
redactStorageID(id, localVersions.get(id))
);
log.info( log.info(
'storageService.processManifest: remote ids:', `storageService.process(${version}): localRecords=${conversations.length} ` +
Array.from(remoteOnlySet).map(redactStorageID).join(',') `localKeys=${localVersions.size} unknownKeys=${stillUnknown.length} ` +
`remoteKeys=${remoteKeys.size}`
);
log.info(
`storageService.process(${version}): ` +
`remoteOnlyCount=${remoteOnlySet.size} ` +
`remoteOnlyKeys=${JSON.stringify(redactedRemoteOnly)}`
);
log.info(
`storageService.process(${version}): ` +
`localOnlyCount=${localOnlySet.size} ` +
`localOnlyKeys=${JSON.stringify(redactedLocalOnly)}`
); );
const remoteOnlyRecords = new Map<string, RemoteRecord>(); const remoteOnlyRecords = new Map<string, RemoteRecord>();
@ -831,12 +883,11 @@ async function processManifest(
}); });
}); });
if (!remoteOnlyRecords.size) { let conflictCount = 0;
return false; if (remoteOnlyRecords.size) {
conflictCount = await processRemoteRecords(version, remoteOnlyRecords);
} }
const conflictCount = await processRemoteRecords(remoteOnlyRecords);
// Post-merge, if our local records contain any storage IDs that were not // Post-merge, if our local records contain any storage IDs that were not
// present in the remote manifest then we'll need to clear it, generate a // present in the remote manifest then we'll need to clear it, generate a
// new storageID for that record, and upload. // new storageID for that record, and upload.
@ -845,20 +896,31 @@ async function processManifest(
window.getConversations().forEach((conversation: ConversationModel) => { window.getConversations().forEach((conversation: ConversationModel) => {
const storageID = conversation.get('storageID'); const storageID = conversation.get('storageID');
if (storageID && !remoteKeys.has(storageID)) { if (storageID && !remoteKeys.has(storageID)) {
const storageVersion = conversation.get('storageVersion');
const missingKey = redactStorageID(
storageID,
storageVersion,
conversation
);
log.info( log.info(
'storageService.processManifest: local key was not in remote manifest', `storageService.process(${version}): localKey=${missingKey} was not ` +
redactStorageID(storageID), 'in remote manifest'
conversation.idForLogging()
); );
conversation.unset('storageID'); conversation.unset('storageID');
conversation.unset('storageVersion');
updateConversation(conversation.attributes); updateConversation(conversation.attributes);
} }
}); });
return conflictCount !== 0; log.info(
`storageService.process(${version}): conflictCount=${conflictCount}`
);
return conflictCount;
} }
async function processRemoteRecords( async function processRemoteRecords(
storageVersion: number,
remoteOnlyRecords: Map<string, RemoteRecord> remoteOnlyRecords: Map<string, RemoteRecord>
): Promise<number> { ): Promise<number> {
const storageKeyBase64 = window.storage.get('storageKey'); const storageKeyBase64 = window.storage.get('storageKey');
@ -868,8 +930,8 @@ async function processRemoteRecords(
const storageKey = Bytes.fromBase64(storageKeyBase64); const storageKey = Bytes.fromBase64(storageKeyBase64);
log.info( log.info(
'storageService.processRemoteRecords: remote only keys', `storageService.process(${storageVersion}): fetching remote keys ` +
remoteOnlyRecords.size `count=${remoteOnlyRecords.size}`
); );
const readOperation = new Proto.ReadOperation(); const readOperation = new Proto.ReadOperation();
@ -888,11 +950,6 @@ async function processRemoteRecords(
const storageItems = Proto.StorageItems.decode(storageItemsBuffer); const storageItems = Proto.StorageItems.decode(storageItemsBuffer);
if (!storageItems.items) {
log.info('storageService.processRemoteRecords: No storage items retrieved');
return 0;
}
const decryptedStorageItems = await pMap( const decryptedStorageItems = await pMap(
storageItems.items, storageItems.items,
async ( async (
@ -901,13 +958,12 @@ async function processRemoteRecords(
const { key, value: storageItemCiphertext } = storageRecordWrapper; const { key, value: storageItemCiphertext } = storageRecordWrapper;
if (!key || !storageItemCiphertext) { if (!key || !storageItemCiphertext) {
log.error( const error = new Error(
'storageService.processRemoteRecords: No key or Ciphertext available' `storageService.process(${storageVersion}): ` +
); 'missing key and/or Ciphertext'
await stopStorageServiceSync();
throw new Error(
'storageService.processRemoteRecords: Missing key and/or Ciphertext'
); );
await stopStorageServiceSync(error);
throw error;
} }
const base64ItemID = Bytes.toBase64(key); const base64ItemID = Bytes.toBase64(key);
@ -922,9 +978,11 @@ async function processRemoteRecords(
); );
} catch (err) { } catch (err) {
log.error( log.error(
'storageService.processRemoteRecords: Error decrypting storage item' `storageService.process(${storageVersion}): ` +
'Error decrypting storage item',
Errors.toLogFormat(err)
); );
await stopStorageServiceSync(); await stopStorageServiceSync(err);
throw err; throw err;
} }
@ -957,24 +1015,28 @@ async function processRemoteRecords(
try { try {
log.info( log.info(
`storageService.processRemoteRecords: Attempting to merge ${sortedStorageItems.length} records` `storageService.process(${storageVersion}): ` +
`attempting to merge records=${sortedStorageItems.length}`
);
const mergedRecords = await pMap(
sortedStorageItems,
(item: MergeableItemType) => mergeRecord(storageVersion, item),
{ concurrency: 5 }
); );
const mergedRecords = await pMap(sortedStorageItems, mergeRecord, {
concurrency: 5,
});
log.info( log.info(
`storageService.processRemoteRecords: Processed ${mergedRecords.length} records` `storageService.process(${storageVersion}): ` +
`processed records=${mergedRecords.length}`
); );
// Collect full map of previously and currently unknown records // Collect full map of previously and currently unknown records
const unknownRecords: Map<string, UnknownRecord> = new Map(); const unknownRecords: Map<string, UnknownRecord> = new Map();
const unknownRecordsArray: ReadonlyArray<UnknownRecord> = const previousUnknownRecords: ReadonlyArray<UnknownRecord> =
window.storage.get( window.storage.get(
'storage-service-unknown-records', 'storage-service-unknown-records',
new Array<UnknownRecord>() new Array<UnknownRecord>()
); );
unknownRecordsArray.forEach((record: UnknownRecord) => { previousUnknownRecords.forEach((record: UnknownRecord) => {
unknownRecords.set(record.storageID, record); unknownRecords.set(record.storageID, record);
}); });
@ -987,11 +1049,13 @@ async function processRemoteRecords(
unknownRecords.set(mergedRecord.storageID, { unknownRecords.set(mergedRecord.storageID, {
itemType: mergedRecord.itemType, itemType: mergedRecord.itemType,
storageID: mergedRecord.storageID, storageID: mergedRecord.storageID,
storageVersion,
}); });
} else if (mergedRecord.hasError) { } else if (mergedRecord.hasError) {
newRecordsWithErrors.push({ newRecordsWithErrors.push({
itemType: mergedRecord.itemType, itemType: mergedRecord.itemType,
storageID: mergedRecord.storageID, storageID: mergedRecord.storageID,
storageVersion,
}); });
} }
@ -1004,36 +1068,46 @@ async function processRemoteRecords(
const newUnknownRecords = Array.from(unknownRecords.values()).filter( const newUnknownRecords = Array.from(unknownRecords.values()).filter(
(record: UnknownRecord) => !validRecordTypes.has(record.itemType) (record: UnknownRecord) => !validRecordTypes.has(record.itemType)
); );
const redactedNewUnknowns = newUnknownRecords.map(
log.info( unknownRecordToRedactedID
'storageService.processRemoteRecords: Unknown records found:',
newUnknownRecords.length
); );
window.storage.put('storage-service-unknown-records', newUnknownRecords);
log.info( log.info(
'storageService.processRemoteRecords: Records with errors:', `storageService.process(${storageVersion}): ` +
newRecordsWithErrors.length `unknown records=${JSON.stringify(redactedNewUnknowns)} ` +
`count=${redactedNewUnknowns.length}`
);
await window.storage.put(
'storage-service-unknown-records',
newUnknownRecords
);
const redactedErrorRecords = newRecordsWithErrors.map(
unknownRecordToRedactedID
);
log.info(
`storageService.process(${storageVersion}): ` +
`error records=${JSON.stringify(redactedErrorRecords)} ` +
`count=${redactedErrorRecords.length}`
); );
// Refresh the list of records that had errors with every push, that way // Refresh the list of records that had errors with every push, that way
// this list doesn't grow unbounded and we keep the list of storage keys // this list doesn't grow unbounded and we keep the list of storage keys
// fresh. // fresh.
window.storage.put('storage-service-error-records', newRecordsWithErrors); await window.storage.put(
'storage-service-error-records',
newRecordsWithErrors
);
if (conflictCount !== 0) { if (conflictCount === 0) {
log.info( conflictBackOff.reset();
'storageService.processRemoteRecords: ' +
`${conflictCount} conflicts found, uploading changes`
);
return conflictCount;
} }
conflictBackOff.reset(); return conflictCount;
} catch (err) { } catch (err) {
log.error( log.error(
'storageService.processRemoteRecords: failed!', `storageService.process(${storageVersion}): ` +
err && err.stack ? err.stack : String(err) 'failed to process remote records',
Errors.toLogFormat(err)
); );
} }
@ -1060,12 +1134,17 @@ async function sync(
const localManifestVersion = manifestFromStorage || 0; const localManifestVersion = manifestFromStorage || 0;
log.info(`storageService.sync: fetching ${localManifestVersion}`); log.info(
'storageService.sync: fetching latest ' +
`after version=${localManifestVersion}`
);
manifest = await fetchManifest(localManifestVersion); manifest = await fetchManifest(localManifestVersion);
// Guarding against no manifests being returned, everything should be ok // Guarding against no manifests being returned, everything should be ok
if (!manifest) { if (!manifest) {
log.info('storageService.sync: no new manifest'); log.info(
`storageService.sync: no updates, version=${localManifestVersion}`
);
return undefined; return undefined;
} }
@ -1076,25 +1155,30 @@ async function sync(
const version = normalizeNumber(manifest.version); const version = normalizeNumber(manifest.version);
log.info( log.info(
`storageService.sync: manifest versions - previous: ${localManifestVersion}, current: ${version}` `storageService.sync: updating to remoteVersion=${version} from ` +
`version=${localManifestVersion}`
); );
const hasConflicts = await processManifest(manifest); const conflictCount = await processManifest(manifest, version);
log.info(`storageService.sync: storing new manifest version ${version}`); log.info(
`storageService.sync: updated to version=${version} ` +
`conflicts=${conflictCount}`
);
window.storage.put('manifestVersion', version); await window.storage.put('manifestVersion', version);
const hasConflicts = conflictCount !== 0;
if (hasConflicts && !ignoreConflicts) { if (hasConflicts && !ignoreConflicts) {
await upload(true); await upload(true);
} }
// We now know that we've successfully completed a storage service fetch // We now know that we've successfully completed a storage service fetch
window.storage.put('storageFetchComplete', true); await window.storage.put('storageFetchComplete', true);
} catch (err) { } catch (err) {
log.error( log.error(
'storageService.sync: error processing manifest', 'storageService.sync: error processing manifest',
err && err.stack ? err.stack : String(err) Errors.toLogFormat(err)
); );
} }
@ -1180,10 +1264,7 @@ async function upload(fromSync = false): Promise<void> {
setTimeout(runStorageServiceSyncJob); setTimeout(runStorageServiceSyncJob);
return; return;
} }
log.error( log.error('storageService.upload', Errors.toLogFormat(err));
'storageService.upload',
err && err.stack ? err.stack : String(err)
);
} }
} }

View file

@ -50,6 +50,19 @@ type RecordClass =
| Proto.IGroupV1Record | Proto.IGroupV1Record
| Proto.IGroupV2Record; | Proto.IGroupV2Record;
export type MergeResultType = Readonly<{
hasConflict: boolean;
conversation?: ConversationModel;
oldStorageID?: string;
oldStorageVersion?: number;
details: ReadonlyArray<string>;
}>;
type HasConflictResultType = Readonly<{
hasConflict: boolean;
details: ReadonlyArray<string>;
}>;
function toRecordVerified(verified: number): Proto.ContactRecord.IdentityState { function toRecordVerified(verified: number): Proto.ContactRecord.IdentityState {
const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus; const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus;
const STATE_ENUM = Proto.ContactRecord.IdentityState; const STATE_ENUM = Proto.ContactRecord.IdentityState;
@ -66,13 +79,11 @@ function toRecordVerified(verified: number): Proto.ContactRecord.IdentityState {
function addUnknownFields( function addUnknownFields(
record: RecordClass, record: RecordClass,
conversation: ConversationModel conversation: ConversationModel,
details: Array<string>
): void { ): void {
if (record.__unknownFields) { if (record.__unknownFields) {
log.info( details.push('adding unknown fields');
'storageService.addUnknownFields: Unknown fields found for',
conversation.idForLogging()
);
conversation.set({ conversation.set({
storageUnknownFields: Bytes.toBase64( storageUnknownFields: Bytes.toBase64(
Bytes.concatenate(record.__unknownFields) Bytes.concatenate(record.__unknownFields)
@ -81,10 +92,7 @@ function addUnknownFields(
} else if (conversation.get('storageUnknownFields')) { } else if (conversation.get('storageUnknownFields')) {
// If the record doesn't have unknown fields attached but we have them // If the record doesn't have unknown fields attached but we have them
// saved locally then we need to clear it out // saved locally then we need to clear it out
log.info( details.push('clearing unknown fields');
'storageService.addUnknownFields: Clearing unknown fields for',
conversation.idForLogging()
);
conversation.unset('storageUnknownFields'); conversation.unset('storageUnknownFields');
} }
} }
@ -97,7 +105,7 @@ function applyUnknownFields(
if (storageUnknownFields) { if (storageUnknownFields) {
log.info( log.info(
'storageService.applyUnknownFields: Applying unknown fields for', 'storageService.applyUnknownFields: Applying unknown fields for',
conversation.get('id') conversation.idForLogging()
); );
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
record.__unknownFields = [Bytes.fromBase64(storageUnknownFields)]; record.__unknownFields = [Bytes.fromBase64(storageUnknownFields)];
@ -292,11 +300,6 @@ export async function toAccountRecord(
pinnedConversationClass !== undefined pinnedConversationClass !== undefined
); );
log.info(
'storageService.toAccountRecord: pinnedConversations',
pinnedConversations.length
);
accountRecord.pinnedConversations = pinnedConversations; accountRecord.pinnedConversations = pinnedConversations;
const subscriberId = window.storage.get('subscriberId'); const subscriberId = window.storage.get('subscriberId');
@ -398,12 +401,11 @@ type RecordClassObject = {
function doRecordsConflict( function doRecordsConflict(
localRecord: RecordClassObject, localRecord: RecordClassObject,
remoteRecord: RecordClassObject, remoteRecord: RecordClassObject
conversation: ConversationModel ): HasConflictResultType {
): boolean { const details = new Array<string>();
const idForLogging = conversation.idForLogging();
return Object.keys(remoteRecord).some((key: string): boolean => { for (const key of Object.keys(remoteRecord)) {
const localValue = localRecord[key]; const localValue = localRecord[key];
const remoteValue = remoteRecord[key]; const remoteValue = remoteRecord[key];
@ -412,52 +414,37 @@ function doRecordsConflict(
if (localValue instanceof Uint8Array) { if (localValue instanceof Uint8Array) {
const areEqual = Bytes.areEqual(localValue, remoteValue); const areEqual = Bytes.areEqual(localValue, remoteValue);
if (!areEqual) { if (!areEqual) {
log.info( details.push(`key=${key}: different bytes`);
'storageService.doRecordsConflict: Conflict found for Uint8Array',
key,
idForLogging
);
} }
return !areEqual; continue;
} }
// If both types are Long we can use Long's equals to compare them // If both types are Long we can use Long's equals to compare them
if (Long.isLong(localValue) || typeof localValue === 'number') { if (Long.isLong(localValue) || typeof localValue === 'number') {
if (!Long.isLong(remoteValue) && typeof remoteValue !== 'number') { if (!Long.isLong(remoteValue) && typeof remoteValue !== 'number') {
log.info( details.push(`key=${key}: type mismatch`);
'storageService.doRecordsConflict: Conflict found, remote value ' + continue;
'is not a number',
key
);
return true;
} }
const areEqual = Long.fromValue(localValue).equals( const areEqual = Long.fromValue(localValue).equals(
Long.fromValue(remoteValue) Long.fromValue(remoteValue)
); );
if (!areEqual) { if (!areEqual) {
log.info( details.push(`key=${key}: different integers`);
'storageService.doRecordsConflict: Conflict found for Long',
key,
idForLogging
);
} }
return !areEqual; continue;
} }
if (key === 'pinnedConversations') { if (key === 'pinnedConversations') {
const areEqual = arePinnedConversationsEqual(localValue, remoteValue); const areEqual = arePinnedConversationsEqual(localValue, remoteValue);
if (!areEqual) { if (!areEqual) {
log.info( details.push('pinnedConversations');
'storageService.doRecordsConflict: Conflict found for pinnedConversations',
idForLogging
);
} }
return !areEqual; continue;
} }
if (localValue === remoteValue) { if (localValue === remoteValue) {
return false; continue;
} }
// Sometimes we get `null` values from Protobuf and they should default to // Sometimes we get `null` values from Protobuf and they should default to
@ -470,56 +457,59 @@ function doRecordsConflict(
localValue === 0 || localValue === 0 ||
(Long.isLong(localValue) && localValue.toNumber() === 0)) (Long.isLong(localValue) && localValue.toNumber() === 0))
) { ) {
return false; continue;
} }
const areEqual = isEqual(localValue, remoteValue); const areEqual = isEqual(localValue, remoteValue);
if (!areEqual) { if (!areEqual) {
log.info( details.push(`key=${key}: different values`);
'storageService.doRecordsConflict: Conflict found for',
key,
idForLogging
);
} }
}
return !areEqual; return {
}); hasConflict: details.length > 0,
details,
};
} }
function doesRecordHavePendingChanges( function doesRecordHavePendingChanges(
mergedRecord: RecordClass, mergedRecord: RecordClass,
serviceRecord: RecordClass, serviceRecord: RecordClass,
conversation: ConversationModel conversation: ConversationModel
): boolean { ): HasConflictResultType {
const shouldSync = Boolean(conversation.get('needsStorageServiceSync')); const shouldSync = Boolean(conversation.get('needsStorageServiceSync'));
if (!shouldSync) { if (!shouldSync) {
return false; return { hasConflict: false, details: [] };
} }
const hasConflict = doRecordsConflict( const { hasConflict, details } = doRecordsConflict(
mergedRecord, mergedRecord,
serviceRecord, serviceRecord
conversation
); );
if (!hasConflict) { if (!hasConflict) {
conversation.set({ needsStorageServiceSync: false }); conversation.set({ needsStorageServiceSync: false });
} }
return hasConflict; return {
hasConflict,
details,
};
} }
export async function mergeGroupV1Record( export async function mergeGroupV1Record(
storageID: string, storageID: string,
storageVersion: number,
groupV1Record: Proto.IGroupV1Record groupV1Record: Proto.IGroupV1Record
): Promise<boolean> { ): Promise<MergeResultType> {
if (!groupV1Record.id) { if (!groupV1Record.id) {
throw new Error(`No ID for ${storageID}`); throw new Error(`No ID for ${storageID}`);
} }
const groupId = Bytes.toBinary(groupV1Record.id); const groupId = Bytes.toBinary(groupV1Record.id);
let details = new Array<string>();
// Attempt to fetch an existing group pertaining to the `groupId` or create // Attempt to fetch an existing group pertaining to the `groupId` or create
// a new group and populate it with the attributes from the record. // a new group and populate it with the attributes from the record.
@ -544,18 +534,13 @@ export async function mergeGroupV1Record(
const fields = deriveGroupFields(masterKeyBuffer); const fields = deriveGroupFields(masterKeyBuffer);
const derivedGroupV2Id = Bytes.toBase64(fields.id); const derivedGroupV2Id = Bytes.toBase64(fields.id);
log.info( details.push(
'storageService.mergeGroupV1Record: failed to find group by v1 id ' + 'failed to find group by v1 id ' +
`attempting lookup by v2 groupv2(${derivedGroupV2Id})` `attempting lookup by v2 groupv2(${derivedGroupV2Id})`
); );
conversation = window.ConversationController.get(derivedGroupV2Id); conversation = window.ConversationController.get(derivedGroupV2Id);
} }
if (conversation) { if (!conversation) {
log.info(
'storageService.mergeGroupV1Record: found existing group',
conversation.idForLogging()
);
} else {
if (groupV1Record.id.byteLength !== 16) { if (groupV1Record.id.byteLength !== 16) {
throw new Error('Not a valid gv1'); throw new Error('Not a valid gv1');
} }
@ -564,16 +549,17 @@ export async function mergeGroupV1Record(
groupId, groupId,
'group' 'group'
); );
log.info( details.push('created a new group locally');
'storageService.mergeGroupV1Record: created a new group locally',
conversation.idForLogging()
);
} }
const oldStorageID = conversation.get('storageID');
const oldStorageVersion = conversation.get('storageVersion');
conversation.set({ conversation.set({
isArchived: Boolean(groupV1Record.archived), isArchived: Boolean(groupV1Record.archived),
markedUnread: Boolean(groupV1Record.markedUnread), markedUnread: Boolean(groupV1Record.markedUnread),
storageID, storageID,
storageVersion,
}); });
conversation.setMuteExpiration( conversation.setMuteExpiration(
@ -588,30 +574,35 @@ export async function mergeGroupV1Record(
let hasPendingChanges: boolean; let hasPendingChanges: boolean;
if (isGroupV1(conversation.attributes)) { if (isGroupV1(conversation.attributes)) {
addUnknownFields(groupV1Record, conversation); addUnknownFields(groupV1Record, conversation, details);
hasPendingChanges = doesRecordHavePendingChanges( const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toGroupV1Record(conversation), await toGroupV1Record(conversation),
groupV1Record, groupV1Record,
conversation conversation
); );
details = details.concat(extraDetails);
hasPendingChanges = hasConflict;
} else { } else {
// We cannot preserve unknown fields if local group is V2 and the remote is // 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 // still V1, because the storageItem that we'll put into manifest will have
// a different record type. // a different record type.
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. // We want to upgrade group in the storage after merging it.
hasPendingChanges = true; hasPendingChanges = true;
details.push('marking v1 group for an update to v2');
} }
updateConversation(conversation.attributes); updateConversation(conversation.attributes);
return hasPendingChanges; return {
hasConflict: hasPendingChanges,
conversation,
oldStorageID,
oldStorageVersion,
details,
};
} }
async function getGroupV2Conversation( async function getGroupV2Conversation(
@ -663,8 +654,9 @@ async function getGroupV2Conversation(
export async function mergeGroupV2Record( export async function mergeGroupV2Record(
storageID: string, storageID: string,
storageVersion: number,
groupV2Record: Proto.IGroupV2Record groupV2Record: Proto.IGroupV2Record
): Promise<boolean> { ): Promise<MergeResultType> {
if (!groupV2Record.masterKey) { if (!groupV2Record.masterKey) {
throw new Error(`No master key for ${storageID}`); throw new Error(`No master key for ${storageID}`);
} }
@ -672,7 +664,8 @@ export async function mergeGroupV2Record(
const masterKeyBuffer = groupV2Record.masterKey; const masterKeyBuffer = groupV2Record.masterKey;
const conversation = await getGroupV2Conversation(masterKeyBuffer); const conversation = await getGroupV2Conversation(masterKeyBuffer);
log.info('storageService.mergeGroupV2Record:', conversation.idForLogging()); const oldStorageID = conversation.get('storageID');
const oldStorageVersion = conversation.get('storageVersion');
conversation.set({ conversation.set({
isArchived: Boolean(groupV2Record.archived), isArchived: Boolean(groupV2Record.archived),
@ -681,6 +674,7 @@ export async function mergeGroupV2Record(
groupV2Record.dontNotifyForMentionsIfMuted groupV2Record.dontNotifyForMentionsIfMuted
), ),
storageID, storageID,
storageVersion,
}); });
conversation.setMuteExpiration( conversation.setMuteExpiration(
@ -692,14 +686,18 @@ export async function mergeGroupV2Record(
applyMessageRequestState(groupV2Record, conversation); applyMessageRequestState(groupV2Record, conversation);
addUnknownFields(groupV2Record, conversation); let details = new Array<string>();
const hasPendingChanges = doesRecordHavePendingChanges( addUnknownFields(groupV2Record, conversation, details);
const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toGroupV2Record(conversation), await toGroupV2Record(conversation),
groupV2Record, groupV2Record,
conversation conversation
); );
details = details.concat(extraDetails);
updateConversation(conversation.attributes); updateConversation(conversation.attributes);
const isGroupNewToUs = !isNumber(conversation.get('revision')); const isGroupNewToUs = !isNumber(conversation.get('revision'));
@ -731,13 +729,20 @@ export async function mergeGroupV2Record(
); );
} }
return hasPendingChanges; return {
hasConflict,
conversation,
oldStorageID,
oldStorageVersion,
details,
};
} }
export async function mergeContactRecord( export async function mergeContactRecord(
storageID: string, storageID: string,
storageVersion: number,
originalContactRecord: Proto.IContactRecord originalContactRecord: Proto.IContactRecord
): Promise<boolean> { ): Promise<MergeResultType> {
const contactRecord = { const contactRecord = {
...originalContactRecord, ...originalContactRecord,
@ -754,11 +759,11 @@ export async function mergeContactRecord(
// All contacts must have UUID // All contacts must have UUID
if (!uuid) { if (!uuid) {
return false; return { hasConflict: false, details: ['no uuid'] };
} }
if (!isValidUuid(uuid)) { if (!isValidUuid(uuid)) {
return false; return { hasConflict: false, details: ['invalid uuid'] };
} }
const c = new window.Whisper.Conversation({ const c = new window.Whisper.Conversation({
@ -769,11 +774,10 @@ export async function mergeContactRecord(
const validationError = c.validate(); const validationError = c.validate();
if (validationError) { if (validationError) {
log.error( return {
'storageService.mergeContactRecord: invalid contact', hasConflict: false,
validationError details: [`validation error=${validationError}`],
); };
return false;
} }
const id = window.ConversationController.ensureContactIds({ const id = window.ConversationController.ensureContactIds({
@ -792,8 +796,6 @@ export async function mergeContactRecord(
'private' 'private'
); );
log.info('storageService.mergeContactRecord:', conversation.idForLogging());
if (contactRecord.profileKey) { if (contactRecord.profileKey) {
await conversation.setProfileKey(Bytes.toBase64(contactRecord.profileKey), { await conversation.setProfileKey(Bytes.toBase64(contactRecord.profileKey), {
viaStorageServiceSync: true, viaStorageServiceSync: true,
@ -823,12 +825,17 @@ export async function mergeContactRecord(
applyMessageRequestState(contactRecord, conversation); applyMessageRequestState(contactRecord, conversation);
addUnknownFields(contactRecord, conversation); let details = new Array<string>();
addUnknownFields(contactRecord, conversation, details);
const oldStorageID = conversation.get('storageID');
const oldStorageVersion = conversation.get('storageVersion');
conversation.set({ conversation.set({
isArchived: Boolean(contactRecord.archived), isArchived: Boolean(contactRecord.archived),
markedUnread: Boolean(contactRecord.markedUnread), markedUnread: Boolean(contactRecord.markedUnread),
storageID, storageID,
storageVersion,
}); });
conversation.setMuteExpiration( conversation.setMuteExpiration(
@ -838,21 +845,30 @@ export async function mergeContactRecord(
} }
); );
const hasPendingChanges = doesRecordHavePendingChanges( const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toContactRecord(conversation), await toContactRecord(conversation),
contactRecord, contactRecord,
conversation conversation
); );
details = details.concat(extraDetails);
updateConversation(conversation.attributes); updateConversation(conversation.attributes);
return hasPendingChanges; return {
hasConflict,
conversation,
oldStorageID,
oldStorageVersion,
details,
};
} }
export async function mergeAccountRecord( export async function mergeAccountRecord(
storageID: string, storageID: string,
storageVersion: number,
accountRecord: Proto.IAccountRecord accountRecord: Proto.IAccountRecord
): Promise<boolean> { ): Promise<MergeResultType> {
let details = new Array<string>();
const { const {
avatarUrl, avatarUrl,
linkPreviews, linkPreviews,
@ -911,7 +927,7 @@ export async function mergeAccountRecord(
const localPreferredReactionEmoji = const localPreferredReactionEmoji =
window.storage.get('preferredReactionEmoji') || []; window.storage.get('preferredReactionEmoji') || [];
if (!isEqual(localPreferredReactionEmoji, rawPreferredReactionEmoji)) { if (!isEqual(localPreferredReactionEmoji, rawPreferredReactionEmoji)) {
log.info( log.warn(
'storageService: remote and local preferredReactionEmoji do not match', 'storageService: remote and local preferredReactionEmoji do not match',
localPreferredReactionEmoji.length, localPreferredReactionEmoji.length,
rawPreferredReactionEmoji.length rawPreferredReactionEmoji.length
@ -970,7 +986,7 @@ export async function mergeAccountRecord(
.filter(id => !modelPinnedConversationIds.includes(id)); .filter(id => !modelPinnedConversationIds.includes(id));
if (missingStoragePinnedConversationIds.length !== 0) { if (missingStoragePinnedConversationIds.length !== 0) {
log.info( log.warn(
'mergeAccountRecord: pinnedConversationIds in storage does not match pinned Conversation models' 'mergeAccountRecord: pinnedConversationIds in storage does not match pinned Conversation models'
); );
} }
@ -986,13 +1002,9 @@ export async function mergeAccountRecord(
) )
); );
log.info( details.push(
'storageService.mergeAccountRecord: Local pinned', `local pinned=${locallyPinnedConversations.length}`,
locallyPinnedConversations.length `remote pinned=${pinnedConversations.length}`
);
log.info(
'storageService.mergeAccountRecord: Remote pinned',
pinnedConversations.length
); );
const remotelyPinnedConversationPromises = pinnedConversations.map( const remotelyPinnedConversationPromises = pinnedConversations.map(
@ -1041,14 +1053,9 @@ export async function mergeAccountRecord(
({ id }) => !remotelyPinnedConversationIds.includes(id) ({ id }) => !remotelyPinnedConversationIds.includes(id)
); );
log.info( details.push(
'storageService.mergeAccountRecord: unpinning', `unpinning=${conversationsToUnpin.length}`,
conversationsToUnpin.length `pinning=${remotelyPinnedConversations.length}`
);
log.info(
'storageService.mergeAccountRecord: pinning',
remotelyPinnedConversations.length
); );
conversationsToUnpin.forEach(conversation => { conversationsToUnpin.forEach(conversation => {
@ -1083,12 +1090,16 @@ export async function mergeAccountRecord(
'private' 'private'
); );
addUnknownFields(accountRecord, conversation); addUnknownFields(accountRecord, conversation, details);
const oldStorageID = conversation.get('storageID');
const oldStorageVersion = conversation.get('storageVersion');
conversation.set({ conversation.set({
isArchived: Boolean(noteToSelfArchived), isArchived: Boolean(noteToSelfArchived),
markedUnread: Boolean(noteToSelfMarkedUnread), markedUnread: Boolean(noteToSelfMarkedUnread),
storageID, storageID,
storageVersion,
}); });
if (accountRecord.profileKey) { if (accountRecord.profileKey) {
@ -1100,7 +1111,7 @@ export async function mergeAccountRecord(
window.storage.put('avatarUrl', avatarUrl); window.storage.put('avatarUrl', avatarUrl);
} }
const hasPendingChanges = doesRecordHavePendingChanges( const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges(
await toAccountRecord(conversation), await toAccountRecord(conversation),
accountRecord, accountRecord,
conversation conversation
@ -1108,5 +1119,13 @@ export async function mergeAccountRecord(
updateConversation(conversation.attributes); updateConversation(conversation.attributes);
return hasPendingChanges; details = details.concat(extraDetails);
return {
hasConflict,
conversation,
oldStorageID,
oldStorageVersion,
details,
};
} }