Introduce versioning clock to timer system
This commit is contained in:
parent
bb1d957e49
commit
2fb50df0af
34 changed files with 703 additions and 28 deletions
8
package-lock.json
generated
8
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
1
ts/model-types.d.ts
vendored
|
@ -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>;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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})`,
|
||||||
|
|
|
@ -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
|
||||||
|
|
30
ts/sql/migrations/1150-expire-timer-version.ts
Normal file
30
ts/sql/migrations/1150-expire-timer-version.ts
Normal 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!');
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -1629,6 +1629,7 @@ function setDisappearingMessages(
|
||||||
task: async () =>
|
task: async () =>
|
||||||
conversation.updateExpirationTimer(valueToSet, {
|
conversation.updateExpirationTimer(valueToSet, {
|
||||||
reason: 'setDisappearingMessages',
|
reason: 'setDisappearingMessages',
|
||||||
|
version: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
416
ts/test-mock/messaging/expire_timer_version_test.ts
Normal file
416
ts/test-mock/messaging/expire_timer_version_test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
1
ts/textsecure/Types.d.ts
vendored
1
ts/textsecure/Types.d.ts
vendored
|
@ -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;
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue