Introduce versioning clock to timer system

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-08-21 13:14:12 -05:00 committed by GitHub
parent d7dfb0739d
commit d90b7fddc7
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/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",

View file

@ -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",

View file

@ -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 {

View file

@ -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<ConversationAttributesType> = pick(

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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,

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

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

View file

@ -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<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:
// [-] serviceId
// [-] e164

View file

@ -1975,6 +1975,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
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<MessageAttributesType> {
receivedAtMS: message.get('received_at_ms'),
sentAt: message.get('sent_at'),
reason: idLog,
version: initialMessage.expireTimerVersion,
});
}
}

View file

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

View file

@ -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:

View file

@ -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})`,

View file

@ -204,6 +204,7 @@ import { redactGenericText } from '../util/privacy';
type ConversationRow = Readonly<{
json: string;
profileLastFetchedAt: null | number;
expireTimerVersion: number;
}>;
type ConversationRows = Array<ConversationRow>;
type StickerRow = Readonly<{
@ -547,6 +548,7 @@ export function prepare<T extends Array<unknown> | Record<string, unknown>>(
}
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<ConversationType> {
const rows: ConversationRows = db
.prepare<EmptyQuery>(
`
SELECT json, profileLastFetchedAt
SELECT json, profileLastFetchedAt, expireTimerVersion
FROM conversations
ORDER BY id ASC;
`
@ -1766,7 +1772,7 @@ function getAllGroupsInvolvingServiceId(
const rows: ConversationRows = db
.prepare<Query>(
`
SELECT json, profileLastFetchedAt
SELECT json, profileLastFetchedAt, expireTimerVersion
FROM conversations WHERE
type = 'group' AND
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 { 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 {

View file

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

View file

@ -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(

View file

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

View file

@ -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);

View file

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

View file

@ -31,6 +31,7 @@ type MessageWithAvatar<Message extends OptionalFields> = 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),

View file

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

View file

@ -188,6 +188,7 @@ export type MessageOptionsType = {
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
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,

View file

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

View file

@ -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 = {

View file

@ -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)