diff --git a/package-lock.json b/package-lock.json index 84a463829..32612416c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,7 +128,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "1.3.1", "@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-actions": "8.1.11", "@storybook/addon-controls": "8.1.11", @@ -7253,9 +7253,9 @@ } }, "node_modules/@signalapp/mock-server": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.8.1.tgz", - "integrity": "sha512-RYAaNoCMuIPoMTAuvgEwMh8D12pdvpjOA/qfEOnwXRRLJU1XWXKuAHBc0uJ7deZWLM6qbC/egST/hXImLcsV7Q==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-6.9.0.tgz", + "integrity": "sha512-NXiroPSMvJzfIjrj7+RJgF5v3RH4UTg7pAUCt7cghdITxuZ0SqpcJ5Od3cbuWnbSHUzlMFeaujBrKcQ5P8Fn8g==", "dev": true, "dependencies": { "@signalapp/libsignal-client": "^0.45.0", diff --git a/package.json b/package.json index b7eef29ac..654b4212d 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,7 @@ "@indutny/parallel-prettier": "3.0.0", "@indutny/rezip-electron": "1.3.1", "@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-actions": "8.1.11", "@storybook/addon-controls": "8.1.11", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index cf4f19bd3..428824f20 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -352,6 +352,7 @@ message DataMessage { optional GroupContextV2 groupV2 = 15; optional uint32 flags = 4; optional uint32 expireTimer = 5; + optional uint32 expireTimerVersion = 23; optional bytes profileKey = 6; optional uint64 timestamp = 7; optional Quote quote = 8; @@ -769,16 +770,17 @@ message ContactDetails { optional uint32 length = 2; } - optional string number = 1; - optional string aci = 9; - optional string name = 2; - optional Avatar avatar = 3; + optional string number = 1; + optional string aci = 9; + optional string name = 2; + optional Avatar avatar = 3; // reserved 4; // formerly color // reserved 5; // formerly verified // reserved 6; // formerly profileKey // reserved 7; // formerly blocked - optional uint32 expireTimer = 8; - optional uint32 inboxPosition = 10; + optional uint32 expireTimer = 8; + optional uint32 expireTimerVersion = 12; + optional uint32 inboxPosition = 10; } message PniSignatureMessage { diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index daf99bc0b..f849773d2 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -1070,6 +1070,23 @@ export class ConversationController { } 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 dataToCopy: Partial = pick( diff --git a/ts/background.ts b/ts/background.ts index f32c6b3a1..e18ea3959 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1856,6 +1856,7 @@ export async function startApp(): Promise { // after connect on every startup await server.registerCapabilities({ deleteSync: true, + versionedExpirationTimer: true, }); } catch (error) { log.error( diff --git a/ts/groups.ts b/ts/groups.ts index 244b314ec..bf8c0e535 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -2049,6 +2049,7 @@ export async function createGroupV2( if (expireTimer) { await conversation.updateExpirationTimer(expireTimer, { reason: 'createGroupV2', + version: undefined, }); } diff --git a/ts/jobs/helpers/sendDeleteForEveryone.ts b/ts/jobs/helpers/sendDeleteForEveryone.ts index 6bd9ba4a9..7c566e7e8 100644 --- a/ts/jobs/helpers/sendDeleteForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteForEveryone.ts @@ -133,6 +133,7 @@ export async function sendDeleteForEveryone( profileKey, recipients: conversation.getRecipients(), timestamp, + expireTimerVersion: undefined, }); strictAssert( proto.dataMessage, @@ -202,6 +203,7 @@ export async function sendDeleteForEveryone( deletedForEveryoneTimestamp: targetTimestamp, timestamp, expireTimer: undefined, + expireTimerVersion: undefined, contentHint, groupId: undefined, profileKey, diff --git a/ts/jobs/helpers/sendDeleteStoryForEveryone.ts b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts index ae00353e7..915f7725a 100644 --- a/ts/jobs/helpers/sendDeleteStoryForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts @@ -183,6 +183,7 @@ export async function sendDeleteStoryForEveryone( deletedForEveryoneTimestamp: targetTimestamp, timestamp, expireTimer: undefined, + expireTimerVersion: undefined, contentHint, groupId: undefined, profileKey: conversation.get('profileSharing') diff --git a/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts index 8c8f19515..2b7411271 100644 --- a/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts +++ b/ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts @@ -81,6 +81,7 @@ export async function sendDirectExpirationTimerUpdate( expireTimer === undefined ? undefined : DurationInSeconds.fromSeconds(expireTimer), + expireTimerVersion: await conversation.incrementAndGetExpireTimerVersion(), flags, profileKey, recipients: conversation.getRecipients(), diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 72d183286..5c997fb81 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -262,6 +262,7 @@ export async function sendNormalMessage( contact, deletedForEveryoneTimestamp, expireTimer, + expireTimerVersion: conversation.getExpireTimerVersion(), groupV2: conversation.getGroupV2Info({ members: recipientServiceIdsWithoutMe, }), @@ -378,6 +379,7 @@ export async function sendNormalMessage( contentHint: ContentHint.RESENDABLE, deletedForEveryoneTimestamp, expireTimer, + expireTimerVersion: conversation.getExpireTimerVersion(), groupId: undefined, serviceId: recipientServiceIdsWithoutMe[0], messageText: body, diff --git a/ts/jobs/helpers/sendProfileKey.ts b/ts/jobs/helpers/sendProfileKey.ts index d828746b6..38434800f 100644 --- a/ts/jobs/helpers/sendProfileKey.ts +++ b/ts/jobs/helpers/sendProfileKey.ts @@ -119,6 +119,7 @@ export async function sendProfileKey( flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE, profileKey, recipients: conversation.getRecipients(), + expireTimerVersion: undefined, timestamp, includePniSignatureMessage: true, }); diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 83882d613..86372b04a 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -190,6 +190,7 @@ export async function sendReaction( const dataMessage = await messaging.getDataOrEditMessage({ attachments: [], expireTimer, + expireTimerVersion: conversation.getExpireTimerVersion(), groupV2: conversation.getGroupV2Info({ members: recipientServiceIdsWithoutMe, }), @@ -247,6 +248,7 @@ export async function sendReaction( deletedForEveryoneTimestamp: undefined, timestamp: pendingReaction.timestamp, expireTimer, + expireTimerVersion: conversation.getExpireTimerVersion(), contentHint: ContentHint.RESENDABLE, groupId: undefined, profileKey, diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 485e01281..39383420b 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -460,6 +460,7 @@ export type ConversationAttributesType = { avatars?: ReadonlyArray>; description?: string; expireTimer?: DurationInSeconds; + expireTimerVersion: number; membersV2?: Array; pendingMembersV2?: Array; pendingAdminApprovalV2?: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 81b580ff1..a2eae042c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -302,6 +302,7 @@ export class ConversationModel extends window.Backbone verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, messageCount: 0, sentMessageCount: 0, + expireTimerVersion: 1, }; } @@ -3349,6 +3350,7 @@ export class ConversationModel extends window.Backbone await this.updateExpirationTimer(expireTimer, { reason: 'maybeApplyUniversalTimer', + version: undefined, }); } } @@ -4434,6 +4436,7 @@ export class ConversationModel extends window.Backbone receivedAtMS = Date.now(), sentAt: providedSentAt, source: providedSource, + version, fromSync = false, isInitialSync = false, }: { @@ -4442,6 +4445,7 @@ export class ConversationModel extends window.Backbone receivedAtMS?: number; sentAt?: number; source?: string; + version: number | undefined; fromSync?: boolean; isInitialSync?: boolean; } @@ -4482,6 +4486,29 @@ export class ConversationModel extends window.Backbone if (!expireTimer) { 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 ( this.get('expireTimer') === expireTimer || (!expireTimer && !this.get('expireTimer')) @@ -4489,15 +4516,9 @@ export class ConversationModel extends window.Backbone 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) { + log.info(`${logId}: queuing send job`); + // if change wasn't made remotely, send it to the number/group try { await conversationJobQueue.add({ type: conversationQueueJobEnum.enum.DirectExpirationTimerUpdate, @@ -4513,12 +4534,17 @@ export class ConversationModel extends window.Backbone } } + log.info(`${logId}: updating`); + const ourConversation = window.ConversationController.getOurConversationOrThrow(); 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 // the pending flags. @@ -5129,6 +5155,48 @@ export class ConversationModel extends window.Backbone return areWeAdmin(this.attributes); } + getExpireTimerVersion(): number | undefined { + return isDirectConversation(this.attributes) + ? this.get('expireTimerVersion') + : undefined; + } + + async incrementAndGetExpireTimerVersion(): Promise { + 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: // [-] serviceId // [-] e164 diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 1ef4ba259..3ae1f1c63 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -1975,6 +1975,7 @@ export class MessageModel extends window.Backbone.Model { receivedAtMS: message.get('received_at_ms'), sentAt: message.get('sent_at'), reason: idLog, + version: initialMessage.expireTimerVersion, }); } else if ( // We won't turn off timers for these kinds of messages: @@ -1987,6 +1988,7 @@ export class MessageModel extends window.Backbone.Model { receivedAtMS: message.get('received_at_ms'), sentAt: message.get('sent_at'), reason: idLog, + version: initialMessage.expireTimerVersion, }); } } diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 78d8d54fe..697fec45a 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -741,7 +741,10 @@ export class BackupExportStream extends Readable { private toRecipient( recipientId: Long, - convo: Omit + convo: Omit< + ConversationAttributesType, + 'id' | 'version' | 'expireTimerVersion' + > ): Backups.IRecipient | undefined { const res: Backups.IRecipient = { id: recipientId, diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 55dd9d188..bc4195d4e 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -747,6 +747,7 @@ export class BackupImportStream extends Writable { profileFamilyName: dropNull(contact.profileFamilyName), hideStory: contact.hideStory === true, username: dropNull(contact.username), + expireTimerVersion: 1, }; if (contact.notRegistered) { @@ -840,6 +841,7 @@ export class BackupImportStream extends Writable { expireTimer: expirationTimerS ? DurationInSeconds.fromSeconds(expirationTimerS) : undefined, + expireTimerVersion: 1, accessControl: accessControl ? { attributes: diff --git a/ts/services/contactSync.ts b/ts/services/contactSync.ts index f88c504c6..e9d49ffa9 100644 --- a/ts/services/contactSync.ts +++ b/ts/services/contactSync.ts @@ -68,6 +68,7 @@ async function updateConversationFromContactSync( // setting this will make 'isSetByOther' check true. source: window.ConversationController.getOurConversationId(), receivedAt: receivedAtCounter, + version: details.expireTimerVersion ?? 1, fromSync: true, isInitialSync, reason: `contact sync (sent=${sentAt})`, diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index fe91f43ed..6739edc0e 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -204,6 +204,7 @@ import { redactGenericText } from '../util/privacy'; type ConversationRow = Readonly<{ json: string; profileLastFetchedAt: null | number; + expireTimerVersion: number; }>; type ConversationRows = Array; type StickerRow = Readonly<{ @@ -547,6 +548,7 @@ export function prepare | Record>( } function rowToConversation(row: ConversationRow): ConversationType { + const { expireTimerVersion } = row; const parsedJson = JSON.parse(row.json); let profileLastFetchedAt: undefined | number; @@ -562,6 +564,7 @@ function rowToConversation(row: ConversationRow): ConversationType { return { ...parsedJson, + expireTimerVersion, profileLastFetchedAt, }; } @@ -1635,6 +1638,7 @@ function updateConversation(db: WritableDB, data: ConversationType): void { profileLastFetchedAt, e164, serviceId, + expireTimerVersion, } = data; const membersList = getConversationMembersList(data); @@ -1654,7 +1658,8 @@ function updateConversation(db: WritableDB, data: ConversationType): void { profileName = $profileName, profileFamilyName = $profileFamilyName, profileFullName = $profileFullName, - profileLastFetchedAt = $profileLastFetchedAt + profileLastFetchedAt = $profileLastFetchedAt, + expireTimerVersion = $expireTimerVersion WHERE id = $id; ` ).run({ @@ -1674,6 +1679,7 @@ function updateConversation(db: WritableDB, data: ConversationType): void { profileFamilyName: profileFamilyName || null, profileFullName: combineNames(profileName, profileFamilyName) || null, profileLastFetchedAt: profileLastFetchedAt || null, + expireTimerVersion, }); } @@ -1737,7 +1743,7 @@ function getAllConversations(db: ReadableDB): Array { const rows: ConversationRows = db .prepare( ` - SELECT json, profileLastFetchedAt + SELECT json, profileLastFetchedAt, expireTimerVersion FROM conversations ORDER BY id ASC; ` @@ -1766,7 +1772,7 @@ function getAllGroupsInvolvingServiceId( const rows: ConversationRows = db .prepare( ` - SELECT json, profileLastFetchedAt + SELECT json, profileLastFetchedAt, expireTimerVersion FROM conversations WHERE type = 'group' AND members LIKE $serviceId diff --git a/ts/sql/migrations/1150-expire-timer-version.ts b/ts/sql/migrations/1150-expire-timer-version.ts new file mode 100644 index 000000000..5899fde63 --- /dev/null +++ b/ts/sql/migrations/1150-expire-timer-version.ts @@ -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!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 90cce4767..db2f1cbde 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -90,10 +90,11 @@ import { updateToSchemaVersion1100 } from './1100-optimize-mark-call-history-rea import { updateToSchemaVersion1110 } from './1110-sticker-local-key'; import { updateToSchemaVersion1120 } from './1120-messages-foreign-keys-indexes'; import { updateToSchemaVersion1130 } from './1130-isStory-index'; +import { updateToSchemaVersion1140 } from './1140-call-links-deleted-column'; import { - updateToSchemaVersion1140, + updateToSchemaVersion1150, version as MAX_VERSION, -} from './1140-call-links-deleted-column'; +} from './1150-expire-timer-version'; function updateToSchemaVersion1( currentVersion: number, @@ -2052,6 +2053,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1120, updateToSchemaVersion1130, updateToSchemaVersion1140, + updateToSchemaVersion1150, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index cd2ad2d58..a9a651ddd 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -1629,6 +1629,7 @@ function setDisappearingMessages( task: async () => conversation.updateExpirationTimer(valueToSet, { reason: 'setDisappearingMessages', + version: undefined, }), }); dispatch({ diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts index 735f8a193..22d516fd1 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -39,6 +39,7 @@ describe('Conversations', () => { sentMessageCount: 0, profileSharing: true, version: 0, + expireTimerVersion: 1, }); await window.textsecure.storage.user.setCredentials({ @@ -132,6 +133,7 @@ describe('Conversations', () => { sentMessageCount: 0, profileSharing: true, version: 0, + expireTimerVersion: 1, }); const resultNoImage = await conversation.getQuoteAttachment( diff --git a/ts/test-electron/routineProfileRefresh_test.ts b/ts/test-electron/routineProfileRefresh_test.ts index d2be75557..5e9216e6a 100644 --- a/ts/test-electron/routineProfileRefresh_test.ts +++ b/ts/test-electron/routineProfileRefresh_test.ts @@ -57,6 +57,7 @@ describe('routineProfileRefresh', () => { type: 'private', serviceId: generateAci(), version: 2, + expireTimerVersion: 1, ...overrideAttributes, }); return result; diff --git a/ts/test-electron/sql/getCallHistoryGroups_test.ts b/ts/test-electron/sql/getCallHistoryGroups_test.ts index 782a32c89..6c21db852 100644 --- a/ts/test-electron/sql/getCallHistoryGroups_test.ts +++ b/ts/test-electron/sql/getCallHistoryGroups_test.ts @@ -198,6 +198,7 @@ describe('sql/getCallHistoryGroups', () => { version: 0, id: 'id:1', serviceId: conversation1Uuid, + expireTimerVersion: 1, }; const conversation2: ConversationAttributesType = { @@ -205,6 +206,7 @@ describe('sql/getCallHistoryGroups', () => { version: 2, id: 'id:2', groupId: conversation2GroupId, + expireTimerVersion: 1, }; await saveConversation(conversation1); @@ -270,6 +272,7 @@ describe('sql/getCallHistoryGroups', () => { type: 'private', version: 0, id: conversationId, + expireTimerVersion: 1, }; await saveConversation(conversation); @@ -396,6 +399,7 @@ describe('sql/getCallHistoryGroups', () => { version: 0, id: 'id:1', serviceId: conversation1Uuid, + expireTimerVersion: 1, }; const conversation2: ConversationAttributesType = { @@ -403,6 +407,7 @@ describe('sql/getCallHistoryGroups', () => { version: 2, id: 'id:2', groupId: conversation2GroupId, + expireTimerVersion: 1, }; await saveConversation(conversation1); diff --git a/ts/test-electron/updateConversationsWithUuidLookup_test.ts b/ts/test-electron/updateConversationsWithUuidLookup_test.ts index a0de713f4..a6991a2ba 100644 --- a/ts/test-electron/updateConversationsWithUuidLookup_test.ts +++ b/ts/test-electron/updateConversationsWithUuidLookup_test.ts @@ -139,6 +139,7 @@ describe('updateConversationsWithUuidLookup', () => { sentMessageCount: 0, type: 'private' as const, version: 0, + expireTimerVersion: 2, ...attributes, }); } diff --git a/ts/test-mock/messaging/expire_timer_version_test.ts b/ts/test-mock/messaging/expire_timer_version_test.ts new file mode 100644 index 000000000..55f41950e --- /dev/null +++ b/ts/test-mock/messaging/expire_timer_version_test.ts @@ -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); + } + }); +}); diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts index 6dc24eb7c..461589ac8 100644 --- a/ts/test-mock/pnp/merge_test.ts +++ b/ts/test-mock/pnp/merge_test.ts @@ -68,7 +68,7 @@ describe('pnp/merge', function (this: Mocha.Suite) { serviceE164: undefined, identityKey: aciIdentityKey, - givenName: pniContact.profileName, + givenName: 'ACI Contact', }); // 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'); } }); + + 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); + }); }); diff --git a/ts/textsecure/ContactsParser.ts b/ts/textsecure/ContactsParser.ts index 7cd3e53db..7cad7b2f7 100644 --- a/ts/textsecure/ContactsParser.ts +++ b/ts/textsecure/ContactsParser.ts @@ -31,6 +31,7 @@ type MessageWithAvatar = Omit< > & { avatar?: ContactAvatarType; expireTimer?: DurationInSeconds; + expireTimerVersion: number | null; number?: string | undefined; }; @@ -207,6 +208,7 @@ function prepareContact( const result = { ...proto, expireTimer, + expireTimerVersion: proto.expireTimerVersion ?? null, aci, avatar, number: dropNull(proto.number), diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 239fa4fd1..3bd5baa58 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -2281,6 +2281,7 @@ export default class MessageReceiver preview, canReplyToStory: Boolean(msg.allowsReplies), expireTimer: DurationInSeconds.DAY, + expireTimerVersion: 0, flags: 0, groupV2, isStory: true, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index fc05909aa..d7b10b11b 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -188,6 +188,7 @@ export type MessageOptionsType = { bodyRanges?: ReadonlyArray; contact?: ReadonlyArray; expireTimer?: DurationInSeconds; + expireTimerVersion: number | undefined; flags?: number; group?: { id: string; @@ -238,6 +239,8 @@ class Message { expireTimer?: DurationInSeconds; + expireTimerVersion?: number; + flags?: number; group?: { @@ -277,6 +280,7 @@ class Message { this.bodyRanges = options.bodyRanges; this.contact = options.contact; this.expireTimer = options.expireTimer; + this.expireTimerVersion = options.expireTimerVersion; this.flags = options.flags; this.group = options.group; this.groupV2 = options.groupV2; @@ -534,6 +538,9 @@ class Message { if (this.expireTimer) { proto.expireTimer = this.expireTimer; } + if (this.expireTimerVersion) { + proto.expireTimerVersion = this.expireTimerVersion; + } if (this.profileKey) { proto.profileKey = this.profileKey; } @@ -930,6 +937,7 @@ export default class MessageSender { contact, deletedForEveryoneTimestamp, expireTimer, + expireTimerVersion: undefined, flags, groupCallUpdate, groupV2, @@ -1163,6 +1171,7 @@ export default class MessageSender { contentHint, deletedForEveryoneTimestamp, expireTimer, + expireTimerVersion, groupId, serviceId, messageText, @@ -1185,6 +1194,7 @@ export default class MessageSender { contentHint: number; deletedForEveryoneTimestamp: number | undefined; expireTimer: DurationInSeconds | undefined; + expireTimerVersion: number | undefined; groupId: string | undefined; serviceId: ServiceIdString; messageText: string | undefined; @@ -1209,6 +1219,7 @@ export default class MessageSender { contact, deletedForEveryoneTimestamp, expireTimer, + expireTimerVersion, preview, profileKey, quote, diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 1ff3c3d7f..dfc5b2638 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -204,6 +204,7 @@ export type ProcessedDataMessage = { groupV2?: ProcessedGroupV2Context; flags: number; expireTimer: DurationInSeconds; + expireTimerVersion: number; profileKey?: string; timestamp: number; payment?: AnyPaymentEvent; diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 56d70d93e..8bf81b8de 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -740,9 +740,11 @@ export type WebAPIConnectType = { export type CapabilitiesType = { deleteSync: boolean; + versionedExpirationTimer: boolean; }; export type CapabilitiesUploadType = { deleteSync: true; + versionedExpirationTimer: true; }; type StickerPackManifestType = Uint8Array; @@ -2612,6 +2614,7 @@ export function initialize({ const capabilities: CapabilitiesUploadType = { deleteSync: true, + versionedExpirationTimer: true, }; const jsonData = { @@ -2666,6 +2669,7 @@ export function initialize({ }: LinkDeviceOptionsType) { const capabilities: CapabilitiesUploadType = { deleteSync: true, + versionedExpirationTimer: true, }; const jsonData = { diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts index a9304ad91..b42f7a309 100644 --- a/ts/textsecure/processDataMessage.ts +++ b/ts/textsecure/processDataMessage.ts @@ -321,6 +321,7 @@ export function processDataMessage( groupV2: processGroupV2Context(message.groupV2), flags: message.flags ?? 0, expireTimer: DurationInSeconds.fromSeconds(message.expireTimer ?? 0), + expireTimerVersion: message.expireTimerVersion ?? 0, profileKey: message.profileKey && message.profileKey.length > 0 ? Bytes.toBase64(message.profileKey)