Introduce versioning clock to timer system

This commit is contained in:
Fedor Indutny 2024-08-21 09:03:28 -07:00 committed by GitHub
parent bb1d957e49
commit 2fb50df0af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 703 additions and 28 deletions

8
package-lock.json generated
View file

@ -128,7 +128,7 @@
"@indutny/parallel-prettier": "3.0.0", "@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.1", "@indutny/rezip-electron": "1.3.1",
"@indutny/symbolicate-mac": "2.3.0", "@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "6.8.1", "@signalapp/mock-server": "6.9.0",
"@storybook/addon-a11y": "8.1.11", "@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11", "@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11", "@storybook/addon-controls": "8.1.11",
@ -7253,9 +7253,9 @@
} }
}, },
"node_modules/@signalapp/mock-server": { "node_modules/@signalapp/mock-server": {
"version": "6.8.1", "version": "6.9.0",
"resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.8.1.tgz", "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.9.0.tgz",
"integrity": "sha512-RYAaNoCMuIPoMTAuvgEwMh8D12pdvpjOA/qfEOnwXRRLJU1XWXKuAHBc0uJ7deZWLM6qbC/egST/hXImLcsV7Q==", "integrity": "sha512-NXiroPSMvJzfIjrj7+RJgF5v3RH4UTg7pAUCt7cghdITxuZ0SqpcJ5Od3cbuWnbSHUzlMFeaujBrKcQ5P8Fn8g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@signalapp/libsignal-client": "^0.45.0", "@signalapp/libsignal-client": "^0.45.0",

View file

@ -211,7 +211,7 @@
"@indutny/parallel-prettier": "3.0.0", "@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "1.3.1", "@indutny/rezip-electron": "1.3.1",
"@indutny/symbolicate-mac": "2.3.0", "@indutny/symbolicate-mac": "2.3.0",
"@signalapp/mock-server": "6.8.1", "@signalapp/mock-server": "6.9.0",
"@storybook/addon-a11y": "8.1.11", "@storybook/addon-a11y": "8.1.11",
"@storybook/addon-actions": "8.1.11", "@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11", "@storybook/addon-controls": "8.1.11",

View file

@ -352,6 +352,7 @@ message DataMessage {
optional GroupContextV2 groupV2 = 15; optional GroupContextV2 groupV2 = 15;
optional uint32 flags = 4; optional uint32 flags = 4;
optional uint32 expireTimer = 5; optional uint32 expireTimer = 5;
optional uint32 expireTimerVersion = 23;
optional bytes profileKey = 6; optional bytes profileKey = 6;
optional uint64 timestamp = 7; optional uint64 timestamp = 7;
optional Quote quote = 8; optional Quote quote = 8;
@ -769,16 +770,17 @@ message ContactDetails {
optional uint32 length = 2; optional uint32 length = 2;
} }
optional string number = 1; optional string number = 1;
optional string aci = 9; optional string aci = 9;
optional string name = 2; optional string name = 2;
optional Avatar avatar = 3; optional Avatar avatar = 3;
// reserved 4; // formerly color // reserved 4; // formerly color
// reserved 5; // formerly verified // reserved 5; // formerly verified
// reserved 6; // formerly profileKey // reserved 6; // formerly profileKey
// reserved 7; // formerly blocked // reserved 7; // formerly blocked
optional uint32 expireTimer = 8; optional uint32 expireTimer = 8;
optional uint32 inboxPosition = 10; optional uint32 expireTimerVersion = 12;
optional uint32 inboxPosition = 10;
} }
message PniSignatureMessage { message PniSignatureMessage {

View file

@ -1070,6 +1070,23 @@ export class ConversationController {
} }
current.set('active_at', activeAt); current.set('active_at', activeAt);
current.set(
'expireTimerVersion',
Math.max(
obsolete.get('expireTimerVersion') ?? 1,
current.get('expireTimerVersion') ?? 1
)
);
const obsoleteExpireTimer = obsolete.get('expireTimer');
const currentExpireTimer = current.get('expireTimer');
if (
!currentExpireTimer ||
(obsoleteExpireTimer && obsoleteExpireTimer < currentExpireTimer)
) {
current.set('expireTimer', obsoleteExpireTimer);
}
const currentHadMessages = (current.get('messageCount') ?? 0) > 0; const currentHadMessages = (current.get('messageCount') ?? 0) > 0;
const dataToCopy: Partial<ConversationAttributesType> = pick( const dataToCopy: Partial<ConversationAttributesType> = pick(

View file

@ -1856,6 +1856,7 @@ export async function startApp(): Promise<void> {
// after connect on every startup // after connect on every startup
await server.registerCapabilities({ await server.registerCapabilities({
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true,
}); });
} catch (error) { } catch (error) {
log.error( log.error(

View file

@ -2049,6 +2049,7 @@ export async function createGroupV2(
if (expireTimer) { if (expireTimer) {
await conversation.updateExpirationTimer(expireTimer, { await conversation.updateExpirationTimer(expireTimer, {
reason: 'createGroupV2', reason: 'createGroupV2',
version: undefined,
}); });
} }

View file

@ -133,6 +133,7 @@ export async function sendDeleteForEveryone(
profileKey, profileKey,
recipients: conversation.getRecipients(), recipients: conversation.getRecipients(),
timestamp, timestamp,
expireTimerVersion: undefined,
}); });
strictAssert( strictAssert(
proto.dataMessage, proto.dataMessage,
@ -202,6 +203,7 @@ export async function sendDeleteForEveryone(
deletedForEveryoneTimestamp: targetTimestamp, deletedForEveryoneTimestamp: targetTimestamp,
timestamp, timestamp,
expireTimer: undefined, expireTimer: undefined,
expireTimerVersion: undefined,
contentHint, contentHint,
groupId: undefined, groupId: undefined,
profileKey, profileKey,

View file

@ -183,6 +183,7 @@ export async function sendDeleteStoryForEveryone(
deletedForEveryoneTimestamp: targetTimestamp, deletedForEveryoneTimestamp: targetTimestamp,
timestamp, timestamp,
expireTimer: undefined, expireTimer: undefined,
expireTimerVersion: undefined,
contentHint, contentHint,
groupId: undefined, groupId: undefined,
profileKey: conversation.get('profileSharing') profileKey: conversation.get('profileSharing')

View file

@ -81,6 +81,7 @@ export async function sendDirectExpirationTimerUpdate(
expireTimer === undefined expireTimer === undefined
? undefined ? undefined
: DurationInSeconds.fromSeconds(expireTimer), : DurationInSeconds.fromSeconds(expireTimer),
expireTimerVersion: await conversation.incrementAndGetExpireTimerVersion(),
flags, flags,
profileKey, profileKey,
recipients: conversation.getRecipients(), recipients: conversation.getRecipients(),

View file

@ -262,6 +262,7 @@ export async function sendNormalMessage(
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
expireTimer, expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
groupV2: conversation.getGroupV2Info({ groupV2: conversation.getGroupV2Info({
members: recipientServiceIdsWithoutMe, members: recipientServiceIdsWithoutMe,
}), }),
@ -378,6 +379,7 @@ export async function sendNormalMessage(
contentHint: ContentHint.RESENDABLE, contentHint: ContentHint.RESENDABLE,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
expireTimer, expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
groupId: undefined, groupId: undefined,
serviceId: recipientServiceIdsWithoutMe[0], serviceId: recipientServiceIdsWithoutMe[0],
messageText: body, messageText: body,

View file

@ -119,6 +119,7 @@ export async function sendProfileKey(
flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE, flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE,
profileKey, profileKey,
recipients: conversation.getRecipients(), recipients: conversation.getRecipients(),
expireTimerVersion: undefined,
timestamp, timestamp,
includePniSignatureMessage: true, includePniSignatureMessage: true,
}); });

View file

@ -190,6 +190,7 @@ export async function sendReaction(
const dataMessage = await messaging.getDataOrEditMessage({ const dataMessage = await messaging.getDataOrEditMessage({
attachments: [], attachments: [],
expireTimer, expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
groupV2: conversation.getGroupV2Info({ groupV2: conversation.getGroupV2Info({
members: recipientServiceIdsWithoutMe, members: recipientServiceIdsWithoutMe,
}), }),
@ -247,6 +248,7 @@ export async function sendReaction(
deletedForEveryoneTimestamp: undefined, deletedForEveryoneTimestamp: undefined,
timestamp: pendingReaction.timestamp, timestamp: pendingReaction.timestamp,
expireTimer, expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
contentHint: ContentHint.RESENDABLE, contentHint: ContentHint.RESENDABLE,
groupId: undefined, groupId: undefined,
profileKey, profileKey,

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

@ -460,6 +460,7 @@ export type ConversationAttributesType = {
avatars?: ReadonlyArray<Readonly<AvatarDataType>>; avatars?: ReadonlyArray<Readonly<AvatarDataType>>;
description?: string; description?: string;
expireTimer?: DurationInSeconds; expireTimer?: DurationInSeconds;
expireTimerVersion: number;
membersV2?: Array<GroupV2MemberType>; membersV2?: Array<GroupV2MemberType>;
pendingMembersV2?: Array<GroupV2PendingMemberType>; pendingMembersV2?: Array<GroupV2PendingMemberType>;
pendingAdminApprovalV2?: Array<GroupV2PendingAdminApprovalType>; pendingAdminApprovalV2?: Array<GroupV2PendingAdminApprovalType>;

View file

@ -302,6 +302,7 @@ export class ConversationModel extends window.Backbone
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
messageCount: 0, messageCount: 0,
sentMessageCount: 0, sentMessageCount: 0,
expireTimerVersion: 1,
}; };
} }
@ -3349,6 +3350,7 @@ export class ConversationModel extends window.Backbone
await this.updateExpirationTimer(expireTimer, { await this.updateExpirationTimer(expireTimer, {
reason: 'maybeApplyUniversalTimer', reason: 'maybeApplyUniversalTimer',
version: undefined,
}); });
} }
} }
@ -4434,6 +4436,7 @@ export class ConversationModel extends window.Backbone
receivedAtMS = Date.now(), receivedAtMS = Date.now(),
sentAt: providedSentAt, sentAt: providedSentAt,
source: providedSource, source: providedSource,
version,
fromSync = false, fromSync = false,
isInitialSync = false, isInitialSync = false,
}: { }: {
@ -4442,6 +4445,7 @@ export class ConversationModel extends window.Backbone
receivedAtMS?: number; receivedAtMS?: number;
sentAt?: number; sentAt?: number;
source?: string; source?: string;
version: number | undefined;
fromSync?: boolean; fromSync?: boolean;
isInitialSync?: boolean; isInitialSync?: boolean;
} }
@ -4482,6 +4486,29 @@ export class ConversationModel extends window.Backbone
if (!expireTimer) { if (!expireTimer) {
expireTimer = undefined; expireTimer = undefined;
} }
const logId =
`updateExpirationTimer(${this.idForLogging()}, ` +
`${expireTimer || 'disabled'}, version=${version || 0}) ` +
`source=${source ?? '?'} reason=${reason}`;
if (isSetByOther) {
const expireTimerVersion = this.getExpireTimerVersion();
if (version) {
if (expireTimerVersion && version < expireTimerVersion) {
log.warn(
`${logId}: not updating, local version is ${expireTimerVersion}`
);
return;
}
if (version === expireTimerVersion) {
log.warn(`${logId}: expire version glare`);
} else {
this.set({ expireTimerVersion: version });
log.info(`${logId}: updating expire version`);
}
}
}
if ( if (
this.get('expireTimer') === expireTimer || this.get('expireTimer') === expireTimer ||
(!expireTimer && !this.get('expireTimer')) (!expireTimer && !this.get('expireTimer'))
@ -4489,15 +4516,9 @@ export class ConversationModel extends window.Backbone
return; return;
} }
const logId =
`updateExpirationTimer(${this.idForLogging()}, ` +
`${expireTimer || 'disabled'}) ` +
`source=${source ?? '?'} reason=${reason}`;
log.info(`${logId}: updating`);
// if change wasn't made remotely, send it to the number/group
if (!isSetByOther) { if (!isSetByOther) {
log.info(`${logId}: queuing send job`);
// if change wasn't made remotely, send it to the number/group
try { try {
await conversationJobQueue.add({ await conversationJobQueue.add({
type: conversationQueueJobEnum.enum.DirectExpirationTimerUpdate, type: conversationQueueJobEnum.enum.DirectExpirationTimerUpdate,
@ -4513,12 +4534,17 @@ export class ConversationModel extends window.Backbone
} }
} }
log.info(`${logId}: updating`);
const ourConversation = const ourConversation =
window.ConversationController.getOurConversationOrThrow(); window.ConversationController.getOurConversationOrThrow();
source = source || ourConversation.id; source = source || ourConversation.id;
const sourceServiceId = ourConversation.get('serviceId'); const sourceServiceId =
window.ConversationController.get(source)?.get('serviceId');
this.set({ expireTimer }); this.set({
expireTimer,
});
// This call actually removes universal timer notification and clears // This call actually removes universal timer notification and clears
// the pending flags. // the pending flags.
@ -5129,6 +5155,48 @@ export class ConversationModel extends window.Backbone
return areWeAdmin(this.attributes); return areWeAdmin(this.attributes);
} }
getExpireTimerVersion(): number | undefined {
return isDirectConversation(this.attributes)
? this.get('expireTimerVersion')
: undefined;
}
async incrementAndGetExpireTimerVersion(): Promise<number | undefined> {
const logId = `incrementAndGetExpireTimerVersion(${this.idForLogging()})`;
if (!isDirectConversation(this.attributes)) {
return undefined;
}
const { expireTimerVersion, capabilities } = this.attributes;
// This should not happen in practice, but be ready to handle
const MAX_EXPIRE_TIMER_VERSION = 0xffffffff;
if (expireTimerVersion >= MAX_EXPIRE_TIMER_VERSION) {
log.warn(`${logId}: expire version overflow`);
return MAX_EXPIRE_TIMER_VERSION;
}
if (expireTimerVersion <= 2) {
if (!capabilities?.versionedExpirationTimer) {
log.warn(`${logId}: missing recipient capability`);
return expireTimerVersion;
}
const me = window.ConversationController.getOurConversationOrThrow();
if (!me.get('capabilities')?.versionedExpirationTimer) {
log.warn(`${logId}: missing sender capability`);
return expireTimerVersion;
}
// Increment only if sender and receiver are both capable
} else {
// If we or them updated the timer version past 2 - we are both capable
}
const newVersion = expireTimerVersion + 1;
this.set('expireTimerVersion', newVersion);
await DataWriter.updateConversation(this.attributes);
return newVersion;
}
// Set of items to captureChanges on: // Set of items to captureChanges on:
// [-] serviceId // [-] serviceId
// [-] e164 // [-] e164

View file

@ -1975,6 +1975,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
receivedAtMS: message.get('received_at_ms'), receivedAtMS: message.get('received_at_ms'),
sentAt: message.get('sent_at'), sentAt: message.get('sent_at'),
reason: idLog, reason: idLog,
version: initialMessage.expireTimerVersion,
}); });
} else if ( } else if (
// We won't turn off timers for these kinds of messages: // We won't turn off timers for these kinds of messages:
@ -1987,6 +1988,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
receivedAtMS: message.get('received_at_ms'), receivedAtMS: message.get('received_at_ms'),
sentAt: message.get('sent_at'), sentAt: message.get('sent_at'),
reason: idLog, reason: idLog,
version: initialMessage.expireTimerVersion,
}); });
} }
} }

View file

@ -741,7 +741,10 @@ export class BackupExportStream extends Readable {
private toRecipient( private toRecipient(
recipientId: Long, recipientId: Long,
convo: Omit<ConversationAttributesType, 'id' | 'version'> convo: Omit<
ConversationAttributesType,
'id' | 'version' | 'expireTimerVersion'
>
): Backups.IRecipient | undefined { ): Backups.IRecipient | undefined {
const res: Backups.IRecipient = { const res: Backups.IRecipient = {
id: recipientId, id: recipientId,

View file

@ -747,6 +747,7 @@ export class BackupImportStream extends Writable {
profileFamilyName: dropNull(contact.profileFamilyName), profileFamilyName: dropNull(contact.profileFamilyName),
hideStory: contact.hideStory === true, hideStory: contact.hideStory === true,
username: dropNull(contact.username), username: dropNull(contact.username),
expireTimerVersion: 1,
}; };
if (contact.notRegistered) { if (contact.notRegistered) {
@ -840,6 +841,7 @@ export class BackupImportStream extends Writable {
expireTimer: expirationTimerS expireTimer: expirationTimerS
? DurationInSeconds.fromSeconds(expirationTimerS) ? DurationInSeconds.fromSeconds(expirationTimerS)
: undefined, : undefined,
expireTimerVersion: 1,
accessControl: accessControl accessControl: accessControl
? { ? {
attributes: attributes:

View file

@ -68,6 +68,7 @@ async function updateConversationFromContactSync(
// setting this will make 'isSetByOther' check true. // setting this will make 'isSetByOther' check true.
source: window.ConversationController.getOurConversationId(), source: window.ConversationController.getOurConversationId(),
receivedAt: receivedAtCounter, receivedAt: receivedAtCounter,
version: details.expireTimerVersion ?? 1,
fromSync: true, fromSync: true,
isInitialSync, isInitialSync,
reason: `contact sync (sent=${sentAt})`, reason: `contact sync (sent=${sentAt})`,

View file

@ -204,6 +204,7 @@ import { redactGenericText } from '../util/privacy';
type ConversationRow = Readonly<{ type ConversationRow = Readonly<{
json: string; json: string;
profileLastFetchedAt: null | number; profileLastFetchedAt: null | number;
expireTimerVersion: number;
}>; }>;
type ConversationRows = Array<ConversationRow>; type ConversationRows = Array<ConversationRow>;
type StickerRow = Readonly<{ type StickerRow = Readonly<{
@ -547,6 +548,7 @@ export function prepare<T extends Array<unknown> | Record<string, unknown>>(
} }
function rowToConversation(row: ConversationRow): ConversationType { function rowToConversation(row: ConversationRow): ConversationType {
const { expireTimerVersion } = row;
const parsedJson = JSON.parse(row.json); const parsedJson = JSON.parse(row.json);
let profileLastFetchedAt: undefined | number; let profileLastFetchedAt: undefined | number;
@ -562,6 +564,7 @@ function rowToConversation(row: ConversationRow): ConversationType {
return { return {
...parsedJson, ...parsedJson,
expireTimerVersion,
profileLastFetchedAt, profileLastFetchedAt,
}; };
} }
@ -1635,6 +1638,7 @@ function updateConversation(db: WritableDB, data: ConversationType): void {
profileLastFetchedAt, profileLastFetchedAt,
e164, e164,
serviceId, serviceId,
expireTimerVersion,
} = data; } = data;
const membersList = getConversationMembersList(data); const membersList = getConversationMembersList(data);
@ -1654,7 +1658,8 @@ function updateConversation(db: WritableDB, data: ConversationType): void {
profileName = $profileName, profileName = $profileName,
profileFamilyName = $profileFamilyName, profileFamilyName = $profileFamilyName,
profileFullName = $profileFullName, profileFullName = $profileFullName,
profileLastFetchedAt = $profileLastFetchedAt profileLastFetchedAt = $profileLastFetchedAt,
expireTimerVersion = $expireTimerVersion
WHERE id = $id; WHERE id = $id;
` `
).run({ ).run({
@ -1674,6 +1679,7 @@ function updateConversation(db: WritableDB, data: ConversationType): void {
profileFamilyName: profileFamilyName || null, profileFamilyName: profileFamilyName || null,
profileFullName: combineNames(profileName, profileFamilyName) || null, profileFullName: combineNames(profileName, profileFamilyName) || null,
profileLastFetchedAt: profileLastFetchedAt || null, profileLastFetchedAt: profileLastFetchedAt || null,
expireTimerVersion,
}); });
} }
@ -1737,7 +1743,7 @@ function getAllConversations(db: ReadableDB): Array<ConversationType> {
const rows: ConversationRows = db const rows: ConversationRows = db
.prepare<EmptyQuery>( .prepare<EmptyQuery>(
` `
SELECT json, profileLastFetchedAt SELECT json, profileLastFetchedAt, expireTimerVersion
FROM conversations FROM conversations
ORDER BY id ASC; ORDER BY id ASC;
` `
@ -1766,7 +1772,7 @@ function getAllGroupsInvolvingServiceId(
const rows: ConversationRows = db const rows: ConversationRows = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json, profileLastFetchedAt SELECT json, profileLastFetchedAt, expireTimerVersion
FROM conversations WHERE FROM conversations WHERE
type = 'group' AND type = 'group' AND
members LIKE $serviceId members LIKE $serviceId

View file

@ -0,0 +1,30 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import type { LoggerType } from '../../types/Logging';
export const version = 1150;
export function updateToSchemaVersion1150(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 1150) {
return;
}
db.transaction(() => {
db.exec(`
-- All future conversations will start from '1'
ALTER TABLE conversations
ADD COLUMN expireTimerVersion INTEGER NOT NULL DEFAULT 1;
-- All current conversations will start from '2'
UPDATE conversations SET expireTimerVersion = 2;
`);
db.pragma('user_version = 1150');
})();
logger.info('updateToSchemaVersion1150: success!');
}

View file

@ -90,10 +90,11 @@ import { updateToSchemaVersion1100 } from './1100-optimize-mark-call-history-rea
import { updateToSchemaVersion1110 } from './1110-sticker-local-key'; import { updateToSchemaVersion1110 } from './1110-sticker-local-key';
import { updateToSchemaVersion1120 } from './1120-messages-foreign-keys-indexes'; import { updateToSchemaVersion1120 } from './1120-messages-foreign-keys-indexes';
import { updateToSchemaVersion1130 } from './1130-isStory-index'; import { updateToSchemaVersion1130 } from './1130-isStory-index';
import { updateToSchemaVersion1140 } from './1140-call-links-deleted-column';
import { import {
updateToSchemaVersion1140, updateToSchemaVersion1150,
version as MAX_VERSION, version as MAX_VERSION,
} from './1140-call-links-deleted-column'; } from './1150-expire-timer-version';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -2052,6 +2053,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1120, updateToSchemaVersion1120,
updateToSchemaVersion1130, updateToSchemaVersion1130,
updateToSchemaVersion1140, updateToSchemaVersion1140,
updateToSchemaVersion1150,
]; ];
export class DBVersionFromFutureError extends Error { export class DBVersionFromFutureError extends Error {

View file

@ -1629,6 +1629,7 @@ function setDisappearingMessages(
task: async () => task: async () =>
conversation.updateExpirationTimer(valueToSet, { conversation.updateExpirationTimer(valueToSet, {
reason: 'setDisappearingMessages', reason: 'setDisappearingMessages',
version: undefined,
}), }),
}); });
dispatch({ dispatch({

View file

@ -39,6 +39,7 @@ describe('Conversations', () => {
sentMessageCount: 0, sentMessageCount: 0,
profileSharing: true, profileSharing: true,
version: 0, version: 0,
expireTimerVersion: 1,
}); });
await window.textsecure.storage.user.setCredentials({ await window.textsecure.storage.user.setCredentials({
@ -132,6 +133,7 @@ describe('Conversations', () => {
sentMessageCount: 0, sentMessageCount: 0,
profileSharing: true, profileSharing: true,
version: 0, version: 0,
expireTimerVersion: 1,
}); });
const resultNoImage = await conversation.getQuoteAttachment( const resultNoImage = await conversation.getQuoteAttachment(

View file

@ -57,6 +57,7 @@ describe('routineProfileRefresh', () => {
type: 'private', type: 'private',
serviceId: generateAci(), serviceId: generateAci(),
version: 2, version: 2,
expireTimerVersion: 1,
...overrideAttributes, ...overrideAttributes,
}); });
return result; return result;

View file

@ -198,6 +198,7 @@ describe('sql/getCallHistoryGroups', () => {
version: 0, version: 0,
id: 'id:1', id: 'id:1',
serviceId: conversation1Uuid, serviceId: conversation1Uuid,
expireTimerVersion: 1,
}; };
const conversation2: ConversationAttributesType = { const conversation2: ConversationAttributesType = {
@ -205,6 +206,7 @@ describe('sql/getCallHistoryGroups', () => {
version: 2, version: 2,
id: 'id:2', id: 'id:2',
groupId: conversation2GroupId, groupId: conversation2GroupId,
expireTimerVersion: 1,
}; };
await saveConversation(conversation1); await saveConversation(conversation1);
@ -270,6 +272,7 @@ describe('sql/getCallHistoryGroups', () => {
type: 'private', type: 'private',
version: 0, version: 0,
id: conversationId, id: conversationId,
expireTimerVersion: 1,
}; };
await saveConversation(conversation); await saveConversation(conversation);
@ -396,6 +399,7 @@ describe('sql/getCallHistoryGroups', () => {
version: 0, version: 0,
id: 'id:1', id: 'id:1',
serviceId: conversation1Uuid, serviceId: conversation1Uuid,
expireTimerVersion: 1,
}; };
const conversation2: ConversationAttributesType = { const conversation2: ConversationAttributesType = {
@ -403,6 +407,7 @@ describe('sql/getCallHistoryGroups', () => {
version: 2, version: 2,
id: 'id:2', id: 'id:2',
groupId: conversation2GroupId, groupId: conversation2GroupId,
expireTimerVersion: 1,
}; };
await saveConversation(conversation1); await saveConversation(conversation1);

View file

@ -139,6 +139,7 @@ describe('updateConversationsWithUuidLookup', () => {
sentMessageCount: 0, sentMessageCount: 0,
type: 'private' as const, type: 'private' as const,
version: 0, version: 0,
expireTimerVersion: 2,
...attributes, ...attributes,
}); });
} }

View file

@ -0,0 +1,416 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
type PrimaryDevice,
Proto,
StorageState,
} from '@signalapp/mock-server';
import createDebug from 'debug';
import Long from 'long';
import * as durations from '../../util/durations';
import { uuidToBytes } from '../../util/uuidToBytes';
import { MY_STORY_ID } from '../../types/Stories';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
import { expectSystemMessages, typeIntoInput } from '../helpers';
export const debug = createDebug('mock:test:messaging');
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
const DAY = 24 * 3600;
describe('messaging/expireTimerVersion', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
let bootstrap: Bootstrap;
let app: App;
let stranger: PrimaryDevice;
const STRANGER_NAME = 'Stranger';
beforeEach(async () => {
bootstrap = new Bootstrap({ contactCount: 1 });
await bootstrap.init();
const {
server,
phone,
contacts: [contact],
} = bootstrap;
stranger = await server.createPrimaryDevice({
profileName: STRANGER_NAME,
});
let state = StorageState.getEmpty();
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
e164: phone.device.number,
});
state = state.addContact(stranger, {
identityState: Proto.ContactRecord.IdentityState.DEFAULT,
whitelisted: true,
serviceE164: undefined,
profileKey: stranger.profileKey.serialize(),
});
state = state.addContact(contact, {
identityState: Proto.ContactRecord.IdentityState.DEFAULT,
whitelisted: true,
serviceE164: undefined,
profileKey: contact.profileKey.serialize(),
});
contact.device.capabilities.versionedExpirationTimer = false;
// Put both contacts in left pane
state = state.pin(stranger);
state = state.pin(contact);
// Add my story
state = state.addRecord({
type: IdentifierType.STORY_DISTRIBUTION_LIST,
record: {
storyDistributionList: {
allowsReplies: true,
identifier: uuidToBytes(MY_STORY_ID),
isBlockList: true,
name: MY_STORY_ID,
recipientServiceIds: [],
},
},
});
await phone.setStorageState(state);
app = await bootstrap.link();
});
afterEach(async function (this: Mocha.Context) {
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
const SCENARIOS = [
{
name: 'they win and we start',
theyFirst: false,
ourTimer: 60 * DAY,
ourVersion: 3,
theirTimer: 90 * DAY,
theirVersion: 4,
finalTimer: 90 * DAY,
finalVersion: 4,
systemMessages: [
'You set the disappearing message time to 60 days.',
`${STRANGER_NAME} set the disappearing message time to 90 days.`,
],
},
{
name: 'they win and start',
theyFirst: true,
ourTimer: 60 * DAY,
ourVersion: 3,
theirTimer: 90 * DAY,
theirVersion: 4,
finalTimer: 90 * DAY,
finalVersion: 4,
systemMessages: [
`${STRANGER_NAME} set the disappearing message time to 90 days.`,
],
},
{
name: 'we win and start',
theyFirst: false,
ourTimer: 60 * DAY,
ourVersion: 4,
theirTimer: 90 * DAY,
theirVersion: 3,
finalTimer: 60 * DAY,
finalVersion: 4,
systemMessages: ['You set the disappearing message time to 60 days.'],
},
{
name: 'we win and they start',
theyFirst: true,
ourTimer: 60 * DAY,
ourVersion: 4,
theirTimer: 90 * DAY,
theirVersion: 3,
finalTimer: 60 * DAY,
finalVersion: 4,
systemMessages: [
`${STRANGER_NAME} set the disappearing message time to 90 days.`,
'You set the disappearing message time to 60 days.',
],
},
{
name: 'race and we start',
theyFirst: false,
ourTimer: 60 * DAY,
ourVersion: 4,
theirTimer: 90 * DAY,
theirVersion: 4,
finalTimer: 90 * DAY,
finalVersion: 4,
systemMessages: [
'You set the disappearing message time to 60 days.',
`${STRANGER_NAME} set the disappearing message time to 90 days.`,
],
},
{
name: 'race and they start',
theyFirst: true,
ourTimer: 60 * DAY,
ourVersion: 4,
theirTimer: 90 * DAY,
theirVersion: 4,
finalTimer: 60 * DAY,
finalVersion: 4,
systemMessages: [
`${STRANGER_NAME} set the disappearing message time to 90 days.`,
'You set the disappearing message time to 60 days.',
],
},
];
for (const scenario of SCENARIOS) {
const testName =
`sets correct version after ${scenario.name}, ` +
`theyFirst=${scenario.theyFirst}`;
// eslint-disable-next-line no-loop-func
it(testName, async () => {
const { phone, desktop } = bootstrap;
const sendSync = async () => {
debug('Send a sync message');
const timestamp = bootstrap.getTimestamp();
const destinationServiceId = stranger.device.aci;
const content = {
syncMessage: {
sent: {
destinationServiceId,
timestamp: Long.fromNumber(timestamp),
message: {
body: 'request',
timestamp: Long.fromNumber(timestamp),
expireTimer: scenario.ourTimer,
expireTimerVersion: scenario.ourVersion,
},
unidentifiedStatus: [
{
destinationServiceId,
},
],
},
},
};
const sendOptions = {
timestamp,
};
await phone.sendRaw(desktop, content, sendOptions);
};
const sendResponse = async () => {
debug('Send a response message');
const timestamp = bootstrap.getTimestamp();
const content = {
dataMessage: {
body: 'response',
timestamp: Long.fromNumber(timestamp),
expireTimer: scenario.theirTimer,
expireTimerVersion: scenario.theirVersion,
},
};
const sendOptions = {
timestamp,
};
const key = await desktop.popSingleUseKey();
await stranger.addSingleUseKey(desktop, key);
await stranger.sendRaw(desktop, content, sendOptions);
};
if (scenario.theyFirst) {
await sendResponse();
await sendSync();
} else {
await sendSync();
await sendResponse();
}
const window = await app.getWindow();
const leftPane = window.locator('#LeftPane');
debug('opening conversation with the contact');
await leftPane
.locator(
`[data-testid="${stranger.device.aci}"] >> "${stranger.profileName}"`
)
.click();
await expectSystemMessages(window, scenario.systemMessages);
await window.locator('.module-conversation-hero').waitFor();
debug('Send message to merged contact');
{
const compositionInput = await app.waitForEnabledComposer();
await typeIntoInput(compositionInput, 'Hello');
await compositionInput.press('Enter');
}
debug('Getting message to contact');
const { body, dataMessage } = await stranger.waitForMessage();
assert.strictEqual(body, 'Hello');
assert.strictEqual(dataMessage.expireTimer, scenario.finalTimer);
assert.strictEqual(dataMessage.expireTimerVersion, scenario.finalVersion);
});
}
it('should not bump version for not capable recipient', async () => {
const {
contacts: [contact],
} = bootstrap;
const window = await app.getWindow();
const leftPane = window.locator('#LeftPane');
debug('opening conversation with the contact');
await leftPane
.locator(
`[data-testid="${contact.device.aci}"] >> "${contact.profileName}"`
)
.click();
await window.locator('.module-conversation-hero').waitFor();
const conversationStack = window.locator('.Inbox__conversation-stack');
debug('setting timer to 1 week');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await window
.locator('.react-contextmenu-item >> "Disappearing messages"')
.click();
await window
.locator(
'.module-ConversationHeader__disappearing-timer__item >> "1 week"'
)
.click();
debug('Getting first expiration update');
{
const { dataMessage } = await contact.waitForMessage();
assert.strictEqual(
dataMessage.flags,
Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE
);
assert.strictEqual(dataMessage.expireTimer, 604800);
assert.strictEqual(dataMessage.expireTimerVersion, 1);
}
debug('setting timer to 4 weeks');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await window
.locator('.react-contextmenu-item >> "Disappearing messages"')
.click();
await window
.locator(
'.module-ConversationHeader__disappearing-timer__item >> "4 weeks"'
)
.click();
debug('Getting second expiration update');
{
const { dataMessage } = await contact.waitForMessage();
assert.strictEqual(
dataMessage.flags,
Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE
);
assert.strictEqual(dataMessage.expireTimer, 2419200);
assert.strictEqual(dataMessage.expireTimerVersion, 1);
}
});
it('should bump version for capable recipient', async () => {
const window = await app.getWindow();
const leftPane = window.locator('#LeftPane');
debug('opening conversation with the contact');
await leftPane
.locator(
`[data-testid="${stranger.device.aci}"] >> "${stranger.profileName}"`
)
.click();
await window.locator('.module-conversation-hero').waitFor();
const conversationStack = window.locator('.Inbox__conversation-stack');
debug('setting timer to 1 week');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await window
.locator('.react-contextmenu-item >> "Disappearing messages"')
.click();
await window
.locator(
'.module-ConversationHeader__disappearing-timer__item >> "1 week"'
)
.click();
debug('Getting first expiration update');
{
const { dataMessage } = await stranger.waitForMessage();
assert.strictEqual(
dataMessage.flags,
Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE
);
assert.strictEqual(dataMessage.expireTimer, 604800);
assert.strictEqual(dataMessage.expireTimerVersion, 2);
}
debug('setting timer to 4 weeks');
await conversationStack
.locator('button.module-ConversationHeader__button--more')
.click();
await window
.locator('.react-contextmenu-item >> "Disappearing messages"')
.click();
await window
.locator(
'.module-ConversationHeader__disappearing-timer__item >> "4 weeks"'
)
.click();
debug('Getting second expiration update');
{
const { dataMessage } = await stranger.waitForMessage();
assert.strictEqual(
dataMessage.flags,
Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE
);
assert.strictEqual(dataMessage.expireTimer, 2419200);
assert.strictEqual(dataMessage.expireTimerVersion, 3);
}
});
});

View file

@ -68,7 +68,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
serviceE164: undefined, serviceE164: undefined,
identityKey: aciIdentityKey, identityKey: aciIdentityKey,
givenName: pniContact.profileName, givenName: 'ACI Contact',
}); });
// Put both contacts in left pane // Put both contacts in left pane
@ -454,4 +454,88 @@ describe('pnp/merge', function (this: Mocha.Suite) {
assert.strictEqual(await messages.count(), 0, 'message count'); assert.strictEqual(await messages.count(), 0, 'message count');
} }
}); });
it('preserves expireTimerVersion after merge', async () => {
const { phone, desktop } = bootstrap;
for (const key of ['aci' as const, 'pni' as const]) {
debug(`Send a ${key} sync message`);
const timestamp = bootstrap.getTimestamp();
const destinationServiceId = pniContact.device[key];
const destination = key === 'pni' ? pniContact.device.number : undefined;
const content = {
syncMessage: {
sent: {
destinationServiceId,
destination,
timestamp: Long.fromNumber(timestamp),
message: {
timestamp: Long.fromNumber(timestamp),
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expireTimer: key === 'pni' ? 90 * 24 * 3600 : 60 * 24 * 3600,
expireTimerVersion: key === 'pni' ? 3 : 4,
},
unidentifiedStatus: [
{
destinationServiceId,
destination,
},
],
},
},
};
const sendOptions = {
timestamp,
};
// eslint-disable-next-line no-await-in-loop
await phone.sendRaw(desktop, content, sendOptions);
}
debug(
'removing both contacts from storage service, adding one combined contact'
);
{
const state = await phone.expectStorageState('consistency check');
await phone.setStorageState(
state.mergeContact(pniContact, {
identityState: Proto.ContactRecord.IdentityState.DEFAULT,
whitelisted: true,
identityKey: pniContact.publicKey.serialize(),
profileKey: pniContact.profileKey.serialize(),
pniSignatureVerified: true,
})
);
await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(),
});
await app.waitForManifestVersion(state.version);
}
const window = await app.getWindow();
const leftPane = window.locator('#LeftPane');
debug('opening conversation with the merged contact');
await leftPane
.locator(
`[data-testid="${pniContact.device.aci}"] >> ` +
`"${pniContact.profileName}"`
)
.click();
await window.locator('.module-conversation-hero').waitFor();
debug('Send message to merged contact');
{
const compositionInput = await app.waitForEnabledComposer();
await typeIntoInput(compositionInput, 'Hello merged');
await compositionInput.press('Enter');
}
debug('Getting message to merged contact');
const { body, dataMessage } = await pniContact.waitForMessage();
assert.strictEqual(body, 'Hello merged');
assert.strictEqual(dataMessage.expireTimer, 60 * 24 * 3600);
assert.strictEqual(dataMessage.expireTimerVersion, 4);
});
}); });

View file

@ -31,6 +31,7 @@ type MessageWithAvatar<Message extends OptionalFields> = Omit<
> & { > & {
avatar?: ContactAvatarType; avatar?: ContactAvatarType;
expireTimer?: DurationInSeconds; expireTimer?: DurationInSeconds;
expireTimerVersion: number | null;
number?: string | undefined; number?: string | undefined;
}; };
@ -207,6 +208,7 @@ function prepareContact(
const result = { const result = {
...proto, ...proto,
expireTimer, expireTimer,
expireTimerVersion: proto.expireTimerVersion ?? null,
aci, aci,
avatar, avatar,
number: dropNull(proto.number), number: dropNull(proto.number),

View file

@ -2281,6 +2281,7 @@ export default class MessageReceiver
preview, preview,
canReplyToStory: Boolean(msg.allowsReplies), canReplyToStory: Boolean(msg.allowsReplies),
expireTimer: DurationInSeconds.DAY, expireTimer: DurationInSeconds.DAY,
expireTimerVersion: 0,
flags: 0, flags: 0,
groupV2, groupV2,
isStory: true, isStory: true,

View file

@ -188,6 +188,7 @@ export type MessageOptionsType = {
bodyRanges?: ReadonlyArray<RawBodyRange>; bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>; contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
expireTimer?: DurationInSeconds; expireTimer?: DurationInSeconds;
expireTimerVersion: number | undefined;
flags?: number; flags?: number;
group?: { group?: {
id: string; id: string;
@ -238,6 +239,8 @@ class Message {
expireTimer?: DurationInSeconds; expireTimer?: DurationInSeconds;
expireTimerVersion?: number;
flags?: number; flags?: number;
group?: { group?: {
@ -277,6 +280,7 @@ class Message {
this.bodyRanges = options.bodyRanges; this.bodyRanges = options.bodyRanges;
this.contact = options.contact; this.contact = options.contact;
this.expireTimer = options.expireTimer; this.expireTimer = options.expireTimer;
this.expireTimerVersion = options.expireTimerVersion;
this.flags = options.flags; this.flags = options.flags;
this.group = options.group; this.group = options.group;
this.groupV2 = options.groupV2; this.groupV2 = options.groupV2;
@ -534,6 +538,9 @@ class Message {
if (this.expireTimer) { if (this.expireTimer) {
proto.expireTimer = this.expireTimer; proto.expireTimer = this.expireTimer;
} }
if (this.expireTimerVersion) {
proto.expireTimerVersion = this.expireTimerVersion;
}
if (this.profileKey) { if (this.profileKey) {
proto.profileKey = this.profileKey; proto.profileKey = this.profileKey;
} }
@ -930,6 +937,7 @@ export default class MessageSender {
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
expireTimer, expireTimer,
expireTimerVersion: undefined,
flags, flags,
groupCallUpdate, groupCallUpdate,
groupV2, groupV2,
@ -1163,6 +1171,7 @@ export default class MessageSender {
contentHint, contentHint,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
expireTimer, expireTimer,
expireTimerVersion,
groupId, groupId,
serviceId, serviceId,
messageText, messageText,
@ -1185,6 +1194,7 @@ export default class MessageSender {
contentHint: number; contentHint: number;
deletedForEveryoneTimestamp: number | undefined; deletedForEveryoneTimestamp: number | undefined;
expireTimer: DurationInSeconds | undefined; expireTimer: DurationInSeconds | undefined;
expireTimerVersion: number | undefined;
groupId: string | undefined; groupId: string | undefined;
serviceId: ServiceIdString; serviceId: ServiceIdString;
messageText: string | undefined; messageText: string | undefined;
@ -1209,6 +1219,7 @@ export default class MessageSender {
contact, contact,
deletedForEveryoneTimestamp, deletedForEveryoneTimestamp,
expireTimer, expireTimer,
expireTimerVersion,
preview, preview,
profileKey, profileKey,
quote, quote,

View file

@ -204,6 +204,7 @@ export type ProcessedDataMessage = {
groupV2?: ProcessedGroupV2Context; groupV2?: ProcessedGroupV2Context;
flags: number; flags: number;
expireTimer: DurationInSeconds; expireTimer: DurationInSeconds;
expireTimerVersion: number;
profileKey?: string; profileKey?: string;
timestamp: number; timestamp: number;
payment?: AnyPaymentEvent; payment?: AnyPaymentEvent;

View file

@ -740,9 +740,11 @@ export type WebAPIConnectType = {
export type CapabilitiesType = { export type CapabilitiesType = {
deleteSync: boolean; deleteSync: boolean;
versionedExpirationTimer: boolean;
}; };
export type CapabilitiesUploadType = { export type CapabilitiesUploadType = {
deleteSync: true; deleteSync: true;
versionedExpirationTimer: true;
}; };
type StickerPackManifestType = Uint8Array; type StickerPackManifestType = Uint8Array;
@ -2612,6 +2614,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = { const capabilities: CapabilitiesUploadType = {
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true,
}; };
const jsonData = { const jsonData = {
@ -2666,6 +2669,7 @@ export function initialize({
}: LinkDeviceOptionsType) { }: LinkDeviceOptionsType) {
const capabilities: CapabilitiesUploadType = { const capabilities: CapabilitiesUploadType = {
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true,
}; };
const jsonData = { const jsonData = {

View file

@ -321,6 +321,7 @@ export function processDataMessage(
groupV2: processGroupV2Context(message.groupV2), groupV2: processGroupV2Context(message.groupV2),
flags: message.flags ?? 0, flags: message.flags ?? 0,
expireTimer: DurationInSeconds.fromSeconds(message.expireTimer ?? 0), expireTimer: DurationInSeconds.fromSeconds(message.expireTimer ?? 0),
expireTimerVersion: message.expireTimerVersion ?? 0,
profileKey: profileKey:
message.profileKey && message.profileKey.length > 0 message.profileKey && message.profileKey.length > 0
? Bytes.toBase64(message.profileKey) ? Bytes.toBase64(message.profileKey)