From 62647a357fdda12a65dc8469d00e2f8bae4e83af Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 19 Sep 2022 11:47:49 -0700 Subject: [PATCH] Support unregisteredAtTimestamp in storage service --- protos/SignalStorage.proto | 36 ++++++------ ts/model-types.d.ts | 1 + ts/models/conversations.ts | 82 ++++++++++++++++++++++++--- ts/services/storage.ts | 40 +++++++++++-- ts/services/storageRecordOps.ts | 16 ++++++ ts/util/isConversationUnregistered.ts | 13 ++++- 6 files changed, 154 insertions(+), 34 deletions(-) diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index a2c0255bc..4e5cc6954 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -66,27 +66,27 @@ message StorageRecord { message ContactRecord { enum IdentityState { - DEFAULT = 0; - VERIFIED = 1; + DEFAULT = 0; + VERIFIED = 1; UNVERIFIED = 2; } - optional string serviceUuid = 1; - optional string serviceE164 = 2; - optional string pni = 15; - optional bytes profileKey = 3; - optional bytes identityKey = 4; - optional IdentityState identityState = 5; - optional string givenName = 6; - optional string familyName = 7; - optional string username = 8; - optional bool blocked = 9; - optional bool whitelisted = 10; - optional bool archived = 11; - optional bool markedUnread = 12; - optional uint64 mutedUntilTimestamp = 13; - optional bool hideStory = 14; - // Next ID: 16 + optional string serviceUuid = 1; + optional string serviceE164 = 2; + optional string pni = 15; + optional bytes profileKey = 3; + optional bytes identityKey = 4; + optional IdentityState identityState = 5; + optional string givenName = 6; + optional string familyName = 7; + optional string username = 8; + optional bool blocked = 9; + optional bool whitelisted = 10; + optional bool archived = 11; + optional bool markedUnread = 12; + optional uint64 mutedUntilTimestamp = 13; + optional bool hideStory = 14; + optional uint64 unregisteredAtTimestamp = 16; } message GroupV1Record { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index d895f177d..43f629ab5 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -270,6 +270,7 @@ export type ConversationAttributesType = { customColor?: CustomColorType; customColorId?: string; discoveredUnregisteredAt?: number; + firstUnregisteredAt?: number; draftChanged?: boolean; draftAttachments?: Array; draftBodyRanges?: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 79bf03625..ceebabed9 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -50,7 +50,10 @@ import { getContact } from '../messages/helpers'; import { strictAssert } from '../util/assert'; import { isConversationMuted } from '../util/isConversationMuted'; import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; -import { isConversationUnregistered } from '../util/isConversationUnregistered'; +import { + isConversationUnregistered, + isConversationUnregisteredAndStale, +} from '../util/isConversationUnregistered'; import { missingCaseError } from '../util/missingCaseError'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { isValidE164 } from '../util/isValidE164'; @@ -790,6 +793,10 @@ export class ConversationModel extends window.Backbone return isConversationUnregistered(this.attributes); } + isUnregisteredAndStale(): boolean { + return isConversationUnregisteredAndStale(this.attributes); + } + isSMSOnly(): boolean { return isConversationSMSOnly({ ...this.attributes, @@ -797,24 +804,82 @@ export class ConversationModel extends window.Backbone }); } - setUnregistered(): void { - log.info(`Conversation ${this.idForLogging()} is now unregistered`); + setUnregistered({ + timestamp = Date.now(), + fromStorageService = false, + shouldSave = true, + }: { + timestamp?: number; + fromStorageService?: boolean; + shouldSave?: boolean; + } = {}): void { + log.info( + `Conversation ${this.idForLogging()} is now unregistered, ` + + `timestamp=${timestamp}` + ); + + const oldFirstUnregisteredAt = this.get('firstUnregisteredAt'); + this.set({ - discoveredUnregisteredAt: Date.now(), + // We always keep the latest `discoveredUnregisteredAt` because if it + // was less than 6 hours ago - `isUnregistered()` has to return `false` + // and let us retry sends. + discoveredUnregisteredAt: Math.max( + this.get('discoveredUnregisteredAt') ?? timestamp, + timestamp + ), + + // Here we keep the oldest `firstUnregisteredAt` unless timestamp is + // coming from storage service where remote value always wins. + firstUnregisteredAt: fromStorageService + ? timestamp + : Math.min(this.get('firstUnregisteredAt') ?? timestamp, timestamp), }); - window.Signal.Data.updateConversation(this.attributes); + + if (shouldSave) { + window.Signal.Data.updateConversation(this.attributes); + } + + if ( + !fromStorageService && + oldFirstUnregisteredAt !== this.get('firstUnregisteredAt') + ) { + this.captureChange('setUnregistered'); + } } - setRegistered(): void { - if (this.get('discoveredUnregisteredAt') === undefined) { + setRegistered({ + shouldSave = true, + fromStorageService = false, + }: { + shouldSave?: boolean; + fromStorageService?: boolean; + } = {}): void { + if ( + this.get('discoveredUnregisteredAt') === undefined && + this.get('firstUnregisteredAt') === undefined + ) { return; } + const oldFirstUnregisteredAt = this.get('firstUnregisteredAt'); + log.info(`Conversation ${this.idForLogging()} is registered once again`); this.set({ discoveredUnregisteredAt: undefined, + firstUnregisteredAt: undefined, }); - window.Signal.Data.updateConversation(this.attributes); + + if (shouldSave) { + window.Signal.Data.updateConversation(this.attributes); + } + + if ( + !fromStorageService && + oldFirstUnregisteredAt !== this.get('firstUnregisteredAt') + ) { + this.captureChange('setRegistered'); + } } isGroupV1AndDisabled(): boolean { @@ -5090,6 +5155,7 @@ export class ConversationModel extends window.Backbone // [X] archived // [X] markedUnread // [X] dontNotifyForMentionsIfMuted + // [x] firstUnregisteredAt captureChange(logMessage: string): void { log.info('storageService[captureChange]', logMessage, this.idForLogging()); this.set({ needsStorageServiceSync: true }); diff --git a/ts/services/storage.ts b/ts/services/storage.ts index dc687c7a1..db6789392 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -262,8 +262,19 @@ async function generateManifest( continue; } + let shouldDrop = false; + let dropReason: string | undefined; + const validationError = conversation.validate(); if (validationError) { + shouldDrop = true; + dropReason = `local validation error=${validationError}`; + } else if (conversation.isUnregisteredAndStale()) { + shouldDrop = true; + dropReason = 'unregistered and stale'; + } + + if (shouldDrop) { const droppedID = conversation.get('storageID'); const droppedVersion = conversation.get('storageVersion'); if (!droppedID) { @@ -278,8 +289,8 @@ async function generateManifest( log.warn( `storageService.generateManifest(${version}): ` + - `skipping contact=${recordID} ` + - `due to local validation error=${validationError}` + `dropping contact=${recordID} ` + + `due to ${dropReason}` ); conversation.unset('storageID'); deleteKeys.push(Bytes.fromBase64(droppedID)); @@ -1164,10 +1175,27 @@ async function processManifest( storageVersion, conversation ); - log.info( - `storageService.process(${version}): localKey=${missingKey} was not ` + - 'in remote manifest' - ); + + // Remote might have dropped this conversation already, but our value of + // `firstUnregisteredAt` is too high for us to drop it. Don't reupload it! + if (conversation.isUnregistered()) { + log.info( + `storageService.process(${version}): localKey=${missingKey} is ` + + 'unregistered and not in remote manifest' + ); + conversation.setUnregistered({ + timestamp: Date.now() - durations.MONTH, + fromStorageService: true, + + // Saving below + shouldSave: false, + }); + } else { + log.info( + `storageService.process(${version}): localKey=${missingKey} ` + + 'was not in remote manifest' + ); + } conversation.unset('storageID'); conversation.unset('storageVersion'); updateConversation(conversation.attributes); diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 9e44c54d8..ae524cd66 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -175,6 +175,9 @@ export async function toContactRecord( if (conversation.get('hideStory') !== undefined) { contactRecord.hideStory = Boolean(conversation.get('hideStory')); } + contactRecord.unregisteredAtTimestamp = getSafeLongFromTimestamp( + conversation.get('firstUnregisteredAt') + ); applyUnknownFields(contactRecord, conversation); @@ -986,6 +989,19 @@ export async function mergeContactRecord( } ); + if ( + !contactRecord.unregisteredAtTimestamp || + contactRecord.unregisteredAtTimestamp.equals(0) + ) { + conversation.setRegistered({ fromStorageService: true, shouldSave: false }); + } else { + conversation.setUnregistered({ + timestamp: getTimestampFromLong(contactRecord.unregisteredAtTimestamp), + fromStorageService: true, + shouldSave: false, + }); + } + const { hasConflict, details: extraDetails } = doesRecordHavePendingChanges( await toContactRecord(conversation), contactRecord, diff --git a/ts/util/isConversationUnregistered.ts b/ts/util/isConversationUnregistered.ts index f8a66063e..9d0621701 100644 --- a/ts/util/isConversationUnregistered.ts +++ b/ts/util/isConversationUnregistered.ts @@ -1,9 +1,10 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { isMoreRecentThan } from './timestamp'; +import { isMoreRecentThan, isOlderThan } from './timestamp'; +import { HOUR, MONTH } from './durations'; -const SIX_HOURS = 1000 * 60 * 60 * 6; +const SIX_HOURS = 6 * HOUR; export function isConversationUnregistered({ discoveredUnregisteredAt, @@ -13,3 +14,11 @@ export function isConversationUnregistered({ isMoreRecentThan(discoveredUnregisteredAt, SIX_HOURS) ); } + +export function isConversationUnregisteredAndStale({ + firstUnregisteredAt, +}: Readonly<{ firstUnregisteredAt?: number }>): boolean { + return Boolean( + firstUnregisteredAt && isOlderThan(firstUnregisteredAt, MONTH) + ); +}