diff --git a/ts/groups.ts b/ts/groups.ts
index 2fc480299..aa92d76b6 100644
--- a/ts/groups.ts
+++ b/ts/groups.ts
@@ -1259,6 +1259,9 @@ export async function modifyGroupV2({
const sendOptions = await conversation.getSendOptions();
const timestamp = Date.now();
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const promise = conversation.wrapSend(
window.Signal.Util.sendToGroup(
@@ -1272,6 +1275,7 @@ export async function modifyGroupV2({
profileKey,
},
conversation,
+ ContentHint.SUPPLEMENTARY,
sendOptions
)
);
@@ -1629,6 +1633,10 @@ export async function createGroupV2({
const groupV2Info = conversation.getGroupV2Info({
includePendingMembers: true,
});
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+ const sendOptions = await conversation.getSendOptions();
await wrapWithSyncMessageSend({
conversation,
@@ -1640,7 +1648,9 @@ export async function createGroupV2({
timestamp,
profileKey,
},
- conversation
+ conversation,
+ ContentHint.SUPPLEMENTARY,
+ sendOptions
),
timestamp,
});
@@ -2145,6 +2155,11 @@ export async function initiateMigrationToGroupV2(
| ArrayBuffer
| undefined = await ourProfileKeyService.get();
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+ const sendOptions = await conversation.getSendOptions();
+
await wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/${logId}`,
@@ -2158,7 +2173,9 @@ export async function initiateMigrationToGroupV2(
timestamp,
profileKey: ourProfileKey,
},
- conversation
+ conversation,
+ ContentHint.SUPPLEMENTARY,
+ sendOptions
),
timestamp,
});
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts
index ee503765d..bcf8af7d4 100644
--- a/ts/model-types.d.ts
+++ b/ts/model-types.d.ts
@@ -129,18 +129,19 @@ export type MessageAttributesType = {
id: string;
type?:
- | 'incoming'
- | 'outgoing'
- | 'group'
- | 'keychange'
- | 'verified-change'
- | 'message-history-unsynced'
| 'call-history'
| 'chat-session-refreshed'
+ | 'delivery-issue'
+ | 'group'
| 'group-v1-migration'
| 'group-v2-change'
+ | 'incoming'
+ | 'keychange'
+ | 'message-history-unsynced'
+ | 'outgoing'
| 'profile-change'
- | 'timer-notification';
+ | 'timer-notification'
+ | 'verified-change';
body: string;
attachments: Array
;
preview: Array;
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index 4dfabbbf0..3fc9f5944 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -139,6 +139,8 @@ export class ConversationModel extends window.Backbone
throttledFetchSMSOnlyUUID?: () => Promise | void;
+ throttledMaybeMigrateV1Group?: () => Promise | void;
+
typingRefreshTimer?: NodeJS.Timer | null;
typingPauseTimer?: NodeJS.Timer | null;
@@ -304,7 +306,11 @@ export class ConversationModel extends window.Backbone
this.isFetchingUUID = this.isSMSOnly();
this.throttledFetchSMSOnlyUUID = window._.throttle(
- this.fetchSMSOnlyUUID,
+ this.fetchSMSOnlyUUID.bind(this),
+ FIVE_MINUTES
+ );
+ this.throttledMaybeMigrateV1Group = window._.throttle(
+ this.maybeMigrateV1Group.bind(this),
FIVE_MINUTES
);
@@ -811,6 +817,10 @@ export class ConversationModel extends window.Backbone
}
setRegistered(): void {
+ if (this.get('discoveredUnregisteredAt') === undefined) {
+ return;
+ }
+
window.log.info(
`Conversation ${this.idForLogging()} is registered once again`
);
@@ -1193,15 +1203,18 @@ export class ConversationModel extends window.Backbone
}
);
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const sendOptions = await this.getSendOptions();
if (this.isPrivate()) {
- const silent = true;
this.wrapSend(
window.textsecure.messaging.sendMessageProtoAndWait(
timestamp,
groupMembers,
contentMessage,
- silent,
+ ContentHint.SUPPLEMENTARY,
+ undefined,
{
...sendOptions,
online: true,
@@ -1211,6 +1224,7 @@ export class ConversationModel extends window.Backbone
} else {
this.wrapSend(
window.Signal.Util.sendContentMessageToGroup({
+ contentHint: ContentHint.SUPPLEMENTARY,
contentMessage,
conversation: this,
online: true,
@@ -2438,7 +2452,8 @@ export class ConversationModel extends window.Backbone
async addChatSessionRefreshed(receivedAt: number): Promise {
window.log.info(
- `addChatSessionRefreshed: adding for ${this.idForLogging()}`
+ `addChatSessionRefreshed: adding for ${this.idForLogging()}`,
+ { receivedAt }
);
const message = ({
@@ -2466,6 +2481,43 @@ export class ConversationModel extends window.Backbone
this.trigger('newmessage', model);
}
+ async addDeliveryIssue(
+ receivedAt: number,
+ senderUuid: string
+ ): Promise {
+ window.log.info(`addDeliveryIssue: adding for ${this.idForLogging()}`, {
+ receivedAt,
+ senderUuid,
+ });
+
+ const message = ({
+ conversationId: this.id,
+ type: 'delivery-issue',
+ sourceUuid: senderUuid,
+ sent_at: receivedAt,
+ received_at: window.Signal.Util.incrementMessageCounter(),
+ received_at_ms: receivedAt,
+ unread: 1,
+ // TODO: DESKTOP-722
+ // this type does not fully implement the interface it is expected to
+ } as unknown) as typeof window.Whisper.MessageAttributesType;
+
+ const id = await window.Signal.Data.saveMessage(message, {
+ Message: window.Whisper.Message,
+ });
+ const model = window.MessageController.register(
+ id,
+ new window.Whisper.Message({
+ ...message,
+ id,
+ })
+ );
+
+ this.trigger('newmessage', model);
+
+ await this.notify(model);
+ }
+
async addKeyChange(keyChangedId: string): Promise {
window.log.info(
'adding key change advisory for',
@@ -3108,6 +3160,10 @@ export class ConversationModel extends window.Backbone
profileKey = await ourProfileKeyService.get();
}
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
if (this.isPrivate()) {
return window.textsecure.messaging.sendMessageToIdentifier(
destination,
@@ -3120,6 +3176,8 @@ export class ConversationModel extends window.Backbone
targetTimestamp,
timestamp,
undefined, // expireTimer
+ ContentHint.SUPPLEMENTARY,
+ undefined, // groupId
profileKey,
options
);
@@ -3134,6 +3192,7 @@ export class ConversationModel extends window.Backbone
profileKey,
},
this,
+ ContentHint.SUPPLEMENTARY,
options
);
})();
@@ -3254,6 +3313,9 @@ export class ConversationModel extends window.Backbone
}
const options = await this.getSendOptions();
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const promise = (() => {
if (this.isPrivate()) {
@@ -3268,6 +3330,8 @@ export class ConversationModel extends window.Backbone
undefined, // deletedForEveryoneTimestamp
timestamp,
expireTimer,
+ ContentHint.SUPPLEMENTARY,
+ undefined, // groupId
profileKey,
options
);
@@ -3285,6 +3349,7 @@ export class ConversationModel extends window.Backbone
profileKey,
},
this,
+ ContentHint.SUPPLEMENTARY,
options
);
})();
@@ -3492,6 +3557,9 @@ export class ConversationModel extends window.Backbone
const conversationType = this.get('type');
const options = await this.getSendOptions();
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
let promise;
if (conversationType === Message.GROUP) {
@@ -3510,6 +3578,7 @@ export class ConversationModel extends window.Backbone
mentions,
},
this,
+ ContentHint.RESENDABLE,
options
);
} else {
@@ -3524,6 +3593,8 @@ export class ConversationModel extends window.Backbone
undefined, // deletedForEveryoneTimestamp
now,
expireTimer,
+ ContentHint.RESENDABLE,
+ undefined, // groupId
profileKey,
options
);
diff --git a/ts/models/messages.ts b/ts/models/messages.ts
index b527711d0..818117cb1 100644
--- a/ts/models/messages.ts
+++ b/ts/models/messages.ts
@@ -41,6 +41,7 @@ import {
import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification';
import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification';
import { PropsDataType as GroupV1MigrationPropsType } from '../components/conversation/GroupV1Migration';
+import { PropsDataType as DeliveryIssuePropsType } from '../components/conversation/DeliveryIssueNotification';
import {
PropsData as GroupNotificationProps,
ChangeType,
@@ -132,6 +133,10 @@ type MessageBubbleProps =
type: 'chatSessionRefreshed';
data: null;
}
+ | {
+ type: 'deliveryIssue';
+ data: DeliveryIssuePropsType;
+ }
| {
type: 'message';
data: PropsForMessage;
@@ -407,6 +412,12 @@ export class MessageModel extends window.Backbone.Model {
data: null,
};
}
+ if (this.isDeliveryIssue()) {
+ return {
+ type: 'deliveryIssue',
+ data: this.getPropsForDeliveryIssue(),
+ };
+ }
return {
type: 'message',
@@ -581,6 +592,10 @@ export class MessageModel extends window.Backbone.Model {
return this.get('type') === 'chat-session-refreshed';
}
+ isDeliveryIssue(): boolean {
+ return this.get('type') === 'delivery-issue';
+ }
+
isProfileChange(): boolean {
return this.get('type') === 'profile-change';
}
@@ -874,6 +889,14 @@ export class MessageModel extends window.Backbone.Model {
}
}
+ getPropsForDeliveryIssue(): DeliveryIssuePropsType {
+ const sender = this.getContact()?.format();
+
+ return {
+ sender,
+ };
+ }
+
getPropsForProfileChange(): ProfileChangeNotificationPropsType {
const change = this.get('profileChange');
const changedId = this.get('changedId');
@@ -1359,6 +1382,13 @@ export class MessageModel extends window.Backbone.Model {
}
getNotificationData(): { emoji?: string; text: string } {
+ if (this.isDeliveryIssue()) {
+ return {
+ emoji: '⚠️',
+ text: window.i18n('DeliveryIssue--preview'),
+ };
+ }
+
if (this.isChatSessionRefreshed()) {
return {
emoji: '🔁',
@@ -1893,6 +1923,7 @@ export class MessageModel extends window.Backbone.Model {
// Rendered sync messages
const isCallHistory = this.isCallHistory();
const isChatSessionRefreshed = this.isChatSessionRefreshed();
+ const isDeliveryIssue = this.isDeliveryIssue();
const isGroupUpdate = this.isGroupUpdate();
const isGroupV2Change = this.isGroupV2Change();
const isEndSession = this.isEndSession();
@@ -1922,6 +1953,7 @@ export class MessageModel extends window.Backbone.Model {
// Rendered sync messages
isCallHistory ||
isChatSessionRefreshed ||
+ isDeliveryIssue ||
isGroupUpdate ||
isGroupV2Change ||
isEndSession ||
@@ -2216,6 +2248,10 @@ export class MessageModel extends window.Backbone.Model {
let promise;
const options = await conversation.getSendOptions();
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
if (conversation.isPrivate()) {
const [identifier] = recipients;
promise = window.textsecure.messaging.sendMessageToIdentifier(
@@ -2229,6 +2265,8 @@ export class MessageModel extends window.Backbone.Model {
this.get('deletedForEveryoneTimestamp'),
this.get('sent_at'),
this.get('expireTimer'),
+ ContentHint.RESENDABLE,
+ undefined, // groupId
profileKey,
options
);
@@ -2271,6 +2309,7 @@ export class MessageModel extends window.Backbone.Model {
groupV1,
},
conversation,
+ ContentHint.RESENDABLE,
options,
partialSend
);
@@ -2403,7 +2442,13 @@ export class MessageModel extends window.Backbone.Model {
async resend(identifier: string): Promise> {
const error = this.removeOutgoingErrors(identifier);
if (!error) {
- window.log.warn('resend: requested number was not present in errors');
+ window.log.warn(
+ 'resend: requested number was not present in errors. continuing.'
+ );
+ }
+
+ if (this.isErased()) {
+ window.log.warn('resend: message is erased; refusing to resend');
return null;
}
@@ -2431,7 +2476,6 @@ export class MessageModel extends window.Backbone.Model {
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
- // flags
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
@@ -2444,22 +2488,59 @@ export class MessageModel extends window.Backbone.Model {
return this.sendSyncMessageOnly(dataMessage);
}
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+ const parentConversation = this.getConversation();
+ const groupId = parentConversation?.get('groupId');
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(identifier);
- const promise = window.textsecure.messaging.sendMessageToIdentifier(
- identifier,
- body,
+ const group =
+ groupId && parentConversation?.isGroupV1()
+ ? {
+ id: groupId,
+ type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
+ }
+ : undefined;
+
+ const timestamp = this.get('sent_at');
+ const contentMessage = await window.textsecure.messaging.getContentMessage({
attachments,
- quoteWithData,
- previewWithData,
- stickerWithData,
- null,
- this.get('deletedForEveryoneTimestamp'),
- this.get('sent_at'),
- this.get('expireTimer'),
- profileKey,
+ body,
+ expireTimer: this.get('expireTimer'),
+ group,
+ groupV2: parentConversation?.getGroupV2Info(),
+ preview: previewWithData,
+ quote: quoteWithData,
+ mentions: this.get('bodyRanges'),
+ recipients: [identifier],
+ sticker: stickerWithData,
+ timestamp,
+ });
+
+ if (parentConversation) {
+ const senderKeyInfo = parentConversation.get('senderKeyInfo');
+ if (senderKeyInfo && senderKeyInfo.distributionId) {
+ const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage(
+ senderKeyInfo.distributionId
+ );
+
+ window.dcodeIO.ByteBuffer.wrap(
+ window.Signal.Crypto.typedArrayToArrayBuffer(
+ senderKeyDistributionMessage.serialize()
+ )
+ );
+ }
+ }
+
+ const promise = window.textsecure.messaging.sendMessageProtoAndWait(
+ timestamp,
+ [identifier],
+ contentMessage,
+ ContentHint.RESENDABLE,
+ groupId && parentConversation?.isGroupV2() ? groupId : undefined,
sendOptions
);
@@ -2506,7 +2587,10 @@ export class MessageModel extends window.Backbone.Model {
sent_to: _.union(sentTo, result.successfulIdentifiers),
sent: true,
expirationStartTimestamp: Date.now(),
- unidentifiedDeliveries: result.unidentifiedDeliveries,
+ unidentifiedDeliveries: _.union(
+ this.get('unidentifiedDeliveries') || [],
+ result.unidentifiedDeliveries
+ ),
});
if (!this.doNotSave) {
@@ -2595,7 +2679,10 @@ export class MessageModel extends window.Backbone.Model {
sent_to: _.union(sentTo, result.successfulIdentifiers),
sent: true,
expirationStartTimestamp,
- unidentifiedDeliveries: result.unidentifiedDeliveries,
+ unidentifiedDeliveries: _.union(
+ this.get('unidentifiedDeliveries') || [],
+ result.unidentifiedDeliveries
+ ),
});
promises.push(this.sendSyncMessage());
} else if (result.errors) {
@@ -3452,6 +3539,24 @@ export class MessageModel extends window.Backbone.Model {
}
}
+ // Now check for decryption error placeholders
+ const { retryPlaceholders } = window.Signal.Services;
+ if (retryPlaceholders) {
+ const item = await retryPlaceholders.findByMessageAndRemove(
+ conversationId,
+ message.get('sent_at')
+ );
+ if (item) {
+ window.log.info(
+ `handleDataMessage: found retry placeholder. Updating ${message.idForLogging()} received_at/received_at_ms`
+ );
+ message.set({
+ received_at: item.receivedAtCounter,
+ received_at_ms: item.receivedAt,
+ });
+ }
+ }
+
// GroupV2
if (initialMessage.groupV2) {
diff --git a/ts/services/calling.ts b/ts/services/calling.ts
index d286facea..540e84302 100644
--- a/ts/services/calling.ts
+++ b/ts/services/calling.ts
@@ -766,6 +766,9 @@ export class CallingClass {
const timestamp = Date.now();
// We "fire and forget" because sending this message is non-essential.
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`,
@@ -773,6 +776,7 @@ export class CallingClass {
window.Signal.Util.sendToGroup(
{ groupCallUpdate: { eraId }, groupV2, timestamp },
conversation,
+ ContentHint.SUPPLEMENTARY,
sendOptions
),
timestamp,
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index a45b76899..30a5710c4 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -159,18 +159,19 @@ export type MessageType = {
source?: string;
sourceUuid?: string;
type?:
- | 'incoming'
- | 'outgoing'
- | 'group'
- | 'keychange'
- | 'verified-change'
- | 'message-history-unsynced'
| 'call-history'
| 'chat-session-refreshed'
+ | 'delivery-issue'
+ | 'group'
| 'group-v1-migration'
| 'group-v2-change'
+ | 'incoming'
+ | 'keychange'
+ | 'message-history-unsynced'
+ | 'outgoing'
| 'profile-change'
- | 'timer-notification';
+ | 'timer-notification'
+ | 'verified-change';
quote?: { author?: string; authorUuid?: string };
received_at: number;
sent_at?: number;
diff --git a/ts/test-both/util/retryPlaceholders_test.ts b/ts/test-both/util/retryPlaceholders_test.ts
new file mode 100644
index 000000000..1b7f71218
--- /dev/null
+++ b/ts/test-both/util/retryPlaceholders_test.ts
@@ -0,0 +1,285 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import {
+ getOneHourAgo,
+ RetryItemType,
+ RetryPlaceholders,
+ STORAGE_KEY,
+} from '../../util/retryPlaceholders';
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+describe('RetryPlaceholders', () => {
+ beforeEach(() => {
+ window.storage.put(STORAGE_KEY, null);
+ });
+
+ function getDefaultItem(): RetryItemType {
+ return {
+ conversationId: 'conversation-id',
+ sentAt: Date.now() - 10,
+ receivedAt: Date.now() - 5,
+ receivedAtCounter: 4,
+ senderUuid: 'sender-uuid',
+ };
+ }
+
+ describe('constructor', () => {
+ it('loads previously-saved data on creation', () => {
+ const items: Array = [
+ getDefaultItem(),
+ { ...getDefaultItem(), conversationId: 'conversation-id-2' },
+ ];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+
+ assert.strictEqual(2, placeholders.getCount());
+ });
+ it('starts with no data if provided data fails to parse', () => {
+ window.storage.put(STORAGE_KEY, [
+ { item: 'is wrong shape!' },
+ { bad: 'is not good!' },
+ ]);
+
+ const placeholders = new RetryPlaceholders();
+
+ assert.strictEqual(0, placeholders.getCount());
+ });
+ });
+
+ describe('#add', () => {
+ it('adds one item', async () => {
+ const placeholders = new RetryPlaceholders();
+ await placeholders.add(getDefaultItem());
+ assert.strictEqual(1, placeholders.getCount());
+ });
+
+ it('throws if provided data fails to parse', () => {
+ const placeholders = new RetryPlaceholders();
+ assert.isRejected(
+ placeholders.add({
+ item: 'is wrong shape!',
+ } as any),
+ 'Item did not match schema'
+ );
+ });
+ });
+
+ describe('#getNextToExpire', () => {
+ it('returns nothing if no items', () => {
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(0, placeholders.getCount());
+ assert.isUndefined(placeholders.getNextToExpire());
+ });
+ it('returns only item if just one item', () => {
+ const item = getDefaultItem();
+ const items: Array = [item];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(1, placeholders.getCount());
+ assert.deepEqual(item, placeholders.getNextToExpire());
+ });
+ it('returns soonest expiration given a list, and after add', async () => {
+ const older = {
+ ...getDefaultItem(),
+ receivedAt: Date.now(),
+ };
+ const newer = {
+ ...getDefaultItem(),
+ receivedAt: Date.now() + 10,
+ };
+ const items: Array = [older, newer];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(2, placeholders.getCount());
+ assert.deepEqual(older, placeholders.getNextToExpire());
+
+ const oldest = {
+ ...getDefaultItem(),
+ receivedAt: Date.now() - 5,
+ };
+
+ await placeholders.add(oldest);
+ assert.strictEqual(3, placeholders.getCount());
+ assert.deepEqual(oldest, placeholders.getNextToExpire());
+ });
+ });
+
+ describe('#getExpiredAndRemove', () => {
+ it('does nothing if no item expired', async () => {
+ const older = {
+ ...getDefaultItem(),
+ receivedAt: Date.now() + 10,
+ };
+ const newer = {
+ ...getDefaultItem(),
+ receivedAt: Date.now() + 15,
+ };
+ const items: Array = [older, newer];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(2, placeholders.getCount());
+ assert.deepEqual([], await placeholders.getExpiredAndRemove());
+ assert.strictEqual(2, placeholders.getCount());
+ });
+ it('removes just one if expired', async () => {
+ const older = {
+ ...getDefaultItem(),
+ receivedAt: getOneHourAgo() - 1000,
+ };
+ const newer = {
+ ...getDefaultItem(),
+ receivedAt: Date.now() + 15,
+ };
+ const items: Array = [older, newer];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(2, placeholders.getCount());
+ assert.deepEqual([older], await placeholders.getExpiredAndRemove());
+ assert.strictEqual(1, placeholders.getCount());
+ assert.deepEqual(newer, placeholders.getNextToExpire());
+ });
+ it('removes all if expired', async () => {
+ const older = {
+ ...getDefaultItem(),
+ receivedAt: getOneHourAgo() - 1000,
+ };
+ const newer = {
+ ...getDefaultItem(),
+ receivedAt: getOneHourAgo() - 900,
+ };
+ const items: Array = [older, newer];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(2, placeholders.getCount());
+ assert.deepEqual(
+ [older, newer],
+ await placeholders.getExpiredAndRemove()
+ );
+ assert.strictEqual(0, placeholders.getCount());
+ });
+ });
+
+ describe('#findByConversationAndRemove', () => {
+ it('does nothing if no items found matching conversation', async () => {
+ const older = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-1',
+ };
+ const newer = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-2',
+ };
+ const items: Array = [older, newer];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(2, placeholders.getCount());
+ assert.deepEqual(
+ [],
+ await placeholders.findByConversationAndRemove('conversation-id-3')
+ );
+ assert.strictEqual(2, placeholders.getCount());
+ });
+ it('removes all items matching conversation', async () => {
+ const convo1a = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-1',
+ receivedAt: Date.now() - 5,
+ };
+ const convo1b = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-1',
+ receivedAt: Date.now() - 4,
+ };
+ const convo2a = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-2',
+ receivedAt: Date.now() + 15,
+ };
+ const items: Array = [convo1a, convo1b, convo2a];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(3, placeholders.getCount());
+ assert.deepEqual(
+ [convo1a, convo1b],
+ await placeholders.findByConversationAndRemove('conversation-id-1')
+ );
+ assert.strictEqual(1, placeholders.getCount());
+
+ const convo2b = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-2',
+ receivedAt: Date.now() + 16,
+ };
+
+ await placeholders.add(convo2b);
+ assert.strictEqual(2, placeholders.getCount());
+ assert.deepEqual(
+ [convo2a, convo2b],
+ await placeholders.findByConversationAndRemove('conversation-id-2')
+ );
+ assert.strictEqual(0, placeholders.getCount());
+ });
+ });
+
+ describe('#findByMessageAndRemove', () => {
+ it('does nothing if no item matching message found', async () => {
+ const sentAt = Date.now() - 20;
+
+ const older = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-1',
+ sentAt: Date.now() - 10,
+ };
+ const newer = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-1',
+ sentAt: Date.now() - 11,
+ };
+ const items: Array = [older, newer];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(2, placeholders.getCount());
+ assert.isUndefined(
+ await placeholders.findByMessageAndRemove('conversation-id-1', sentAt)
+ );
+ assert.strictEqual(2, placeholders.getCount());
+ });
+ it('removes the item matching message', async () => {
+ const sentAt = Date.now() - 20;
+
+ const older = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-1',
+ sentAt: Date.now() - 10,
+ };
+ const newer = {
+ ...getDefaultItem(),
+ conversationId: 'conversation-id-1',
+ sentAt,
+ };
+ const items: Array = [older, newer];
+ window.storage.put(STORAGE_KEY, items);
+
+ const placeholders = new RetryPlaceholders();
+ assert.strictEqual(2, placeholders.getCount());
+ assert.deepEqual(
+ newer,
+ await placeholders.findByMessageAndRemove('conversation-id-1', sentAt)
+ );
+ assert.strictEqual(1, placeholders.getCount());
+ });
+ });
+});
diff --git a/ts/test-electron/background_test.ts b/ts/test-electron/background_test.ts
new file mode 100644
index 000000000..753997ca7
--- /dev/null
+++ b/ts/test-electron/background_test.ts
@@ -0,0 +1,57 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { isOverHourIntoPast, cleanupSessionResets } from '../background';
+
+describe('#isOverHourIntoPast', () => {
+ it('returns false for now', () => {
+ assert.isFalse(isOverHourIntoPast(Date.now()));
+ });
+ it('returns false for 5 minutes ago', () => {
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
+ assert.isFalse(isOverHourIntoPast(fiveMinutesAgo));
+ });
+ it('returns true for 65 minutes ago', () => {
+ const sixtyFiveMinutesAgo = Date.now() - 65 * 60 * 1000;
+ assert.isTrue(isOverHourIntoPast(sixtyFiveMinutesAgo));
+ });
+});
+
+describe('#cleanupSessionResets', () => {
+ it('leaves empty object alone', () => {
+ window.storage.put('sessionResets', {});
+ cleanupSessionResets();
+ const actual = window.storage.get('sessionResets');
+
+ const expected = {};
+ assert.deepEqual(actual, expected);
+ });
+ it('filters out any timestamp older than one hour', () => {
+ const startValue = {
+ one: Date.now() - 1,
+ two: Date.now(),
+ three: Date.now() - 65 * 60 * 1000,
+ };
+ window.storage.put('sessionResets', startValue);
+ cleanupSessionResets();
+ const actual = window.storage.get('sessionResets');
+
+ const expected = window._.pick(startValue, ['one', 'two']);
+ assert.deepEqual(actual, expected);
+ });
+ it('filters out falsey items', () => {
+ const startValue = {
+ one: 0,
+ two: false,
+ three: Date.now(),
+ };
+ window.storage.put('sessionResets', startValue);
+ cleanupSessionResets();
+ const actual = window.storage.get('sessionResets');
+
+ const expected = window._.pick(startValue, ['three']);
+ assert.deepEqual(actual, expected);
+ });
+});
diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts
index 838dd3d44..ed67741dc 100644
--- a/ts/textsecure.d.ts
+++ b/ts/textsecure.d.ts
@@ -1,6 +1,8 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import { UnidentifiedSenderMessageContent } from '@signalapp/signal-client';
+
import Crypto from './textsecure/Crypto';
import MessageReceiver from './textsecure/MessageReceiver';
import MessageSender from './textsecure/SendMessage';
@@ -571,6 +573,7 @@ export declare class ContentClass {
receiptMessage?: ReceiptMessageClass;
typingMessage?: TypingMessageClass;
senderKeyDistributionMessage?: ByteBufferClass;
+ decryptionErrorMessage?: ByteBufferClass;
}
export declare class DataMessageClass {
@@ -722,6 +725,9 @@ export declare class EnvelopeClass {
receivedAtDate: number;
unidentifiedDeliveryReceived?: boolean;
messageAgeSec?: number;
+ contentHint?: number;
+ groupId?: string;
+ usmc?: UnidentifiedSenderMessageContent;
}
// Note: we need to use namespaces to express nested classes in Typescript
@@ -731,6 +737,7 @@ export declare namespace EnvelopeClass {
static PREKEY_BUNDLE: number;
static RECEIPT: number;
static UNIDENTIFIED_SENDER: number;
+ static PLAINTEXT_CONTENT: number;
}
}
@@ -1386,10 +1393,11 @@ export declare namespace UnidentifiedSenderMessageClass.Message {
static PREKEY_MESSAGE: number;
static MESSAGE: number;
static SENDERKEY_MESSAGE: number;
+ static PLAINTEXT_CONTENT: number;
}
class ContentHint {
static SUPPLEMENTARY: number;
- static RETRY: number;
+ static RESENDABLE: number;
}
}
diff --git a/ts/textsecure/Errors.ts b/ts/textsecure/Errors.ts
index 6b1ad21b3..11da58f2f 100644
--- a/ts/textsecure/Errors.ts
+++ b/ts/textsecure/Errors.ts
@@ -70,8 +70,8 @@ export class OutgoingMessageError extends ReplayableError {
// Note: Data to resend message is no longer captured
constructor(
incomingIdentifier: string,
- _m: ArrayBuffer,
- _t: number,
+ _m: unknown,
+ _t: unknown,
httpError?: Error
) {
const identifier = incomingIdentifier.split('.')[0];
diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts
index 9cfe43afe..d0c50cc95 100644
--- a/ts/textsecure/MessageReceiver.ts
+++ b/ts/textsecure/MessageReceiver.ts
@@ -13,9 +13,12 @@
import { isNumber, map, omit, noop } from 'lodash';
import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid';
+import { z } from 'zod';
import {
+ DecryptionErrorMessage,
groupDecrypt,
+ PlaintextContent,
PreKeySignalMessage,
processSenderKeyDistributionMessage,
ProtocolAddress,
@@ -73,7 +76,30 @@ const GROUPV1_ID_LENGTH = 16;
const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000;
-type SessionResetsType = Record;
+const decryptionErrorTypeSchema = z
+ .object({
+ cipherTextBytes: z.instanceof(ArrayBuffer).optional(),
+ cipherTextType: z.number().optional(),
+ contentHint: z.number().optional(),
+ groupId: z.string().optional(),
+ receivedAtCounter: z.number(),
+ receivedAtDate: z.number(),
+ senderDevice: z.number(),
+ senderUuid: z.string(),
+ timestamp: z.number(),
+ })
+ .passthrough();
+export type DecryptionErrorType = z.infer;
+
+const retryRequestTypeSchema = z
+ .object({
+ requesterUuid: z.string(),
+ requesterDevice: z.number(),
+ senderDevice: z.number(),
+ sentAt: z.number(),
+ })
+ .passthrough();
+export type RetryRequestType = z.infer;
declare global {
// We want to extend `Event`, so we need an interface.
@@ -107,6 +133,8 @@ declare global {
timestamp?: any;
typing?: any;
verified?: any;
+ retryRequest?: RetryRequestType;
+ decryptionError?: DecryptionErrorType;
}
// We want to extend `Error`, so we need an interface.
// eslint-disable-next-line no-restricted-syntax
@@ -261,8 +289,6 @@ class MessageReceiverInner extends EventTarget {
maxSize: 30,
processBatch: this.cacheRemoveBatch.bind(this),
});
-
- this.cleanupSessionResets();
}
static stringToArrayBuffer = (string: string): ArrayBuffer =>
@@ -1122,7 +1148,14 @@ class MessageReceiverInner extends EventTarget {
ArrayBuffer | { isMe: boolean } | { isBlocked: boolean } | undefined
>;
- if (envelope.type === envelopeTypeEnum.CIPHERTEXT) {
+ if (envelope.type === envelopeTypeEnum.PLAINTEXT_CONTENT) {
+ const buffer = Buffer.from(ciphertext.toArrayBuffer());
+ const plaintextContent = PlaintextContent.deserialize(buffer);
+
+ promise = Promise.resolve(
+ this.unpad(typedArrayToArrayBuffer(plaintextContent.body()))
+ );
+ } else if (envelope.type === envelopeTypeEnum.CIPHERTEXT) {
window.log.info('message from', this.getEnvelopeId(envelope));
if (!identifier) {
throw new Error(
@@ -1215,6 +1248,13 @@ class MessageReceiverInner extends EventTarget {
originalSource || originalSourceUuid
);
+ // eslint-disable-next-line no-param-reassign
+ envelope.contentHint = messageContent.contentHint();
+ // eslint-disable-next-line no-param-reassign
+ envelope.groupId = messageContent.groupId()?.toString('base64');
+ // eslint-disable-next-line no-param-reassign
+ envelope.usmc = messageContent;
+
if (
(envelope.source && this.isBlocked(envelope.source)) ||
(envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid))
@@ -1231,6 +1271,17 @@ class MessageReceiverInner extends EventTarget {
);
}
+ if (
+ messageContent.msgType() ===
+ unidentifiedSenderTypeEnum.PLAINTEXT_CONTENT
+ ) {
+ const plaintextContent = PlaintextContent.deserialize(
+ messageContent.contents()
+ );
+
+ return plaintextContent.body();
+ }
+
if (
messageContent.msgType() ===
unidentifiedSenderTypeEnum.SENDERKEY_MESSAGE
@@ -1345,10 +1396,26 @@ class MessageReceiverInner extends EventTarget {
}
if (uuid && deviceId) {
- // It is safe (from deadlocks) to await this call because the session
- // reset is going to be scheduled on a separate p-queue in
- // ts/background.ts
- await this.lightSessionReset(uuid, deviceId);
+ const event = new Event('decryption-error');
+ event.decryptionError = {
+ cipherTextBytes: envelope.usmc
+ ? typedArrayToArrayBuffer(envelope.usmc.contents())
+ : undefined,
+ cipherTextType: envelope.usmc ? envelope.usmc.msgType() : undefined,
+ contentHint: envelope.contentHint,
+ groupId: envelope.groupId,
+ receivedAtCounter: envelope.receivedAtCounter,
+ receivedAtDate: envelope.receivedAtDate,
+ senderDevice: deviceId,
+ senderUuid: uuid,
+ timestamp: envelope.timestamp.toNumber(),
+ };
+
+ // Avoid deadlocks by scheduling processing on decrypted queue
+ this.addToQueue(
+ () => this.dispatchAndWait(event),
+ TaskType.Decrypted
+ );
} else {
const envelopeId = this.getEnvelopeId(envelope);
window.log.error(
@@ -1360,40 +1427,6 @@ class MessageReceiverInner extends EventTarget {
});
}
- isOverHourIntoPast(timestamp: number): boolean {
- const HOUR = 1000 * 60 * 60;
- const now = Date.now();
- const oneHourIntoPast = now - HOUR;
-
- return isNumber(timestamp) && timestamp <= oneHourIntoPast;
- }
-
- // We don't lose anything if we delete keys over an hour into the past, because we only
- // change our behavior if the timestamps stored are less than an hour ago.
- cleanupSessionResets(): void {
- const sessionResets = window.storage.get(
- 'sessionResets',
- {}
- ) as SessionResetsType;
-
- const keys = Object.keys(sessionResets);
- keys.forEach(key => {
- const timestamp = sessionResets[key];
- if (!timestamp || this.isOverHourIntoPast(timestamp)) {
- delete sessionResets[key];
- }
- });
-
- window.storage.put('sessionResets', sessionResets);
- }
-
- async lightSessionReset(uuid: string, deviceId: number): Promise {
- const event = new Event('light-session-reset');
- event.senderUuid = uuid;
- event.senderDevice = deviceId;
- await this.dispatchAndWait(event);
- }
-
async handleSentMessage(
envelope: EnvelopeClass,
sentContainer: SyncMessageClass.Sent
@@ -1630,7 +1663,10 @@ class MessageReceiverInner extends EventTarget {
// make sure to process it first. If that fails, we still try to process
// the rest of the message.
try {
- if (content.senderKeyDistributionMessage) {
+ if (
+ content.senderKeyDistributionMessage &&
+ !isByteBufferEmpty(content.senderKeyDistributionMessage)
+ ) {
await this.handleSenderKeyDistributionMessage(
envelope,
content.senderKeyDistributionMessage
@@ -1643,6 +1679,16 @@ class MessageReceiverInner extends EventTarget {
);
}
+ if (
+ content.decryptionErrorMessage &&
+ !isByteBufferEmpty(content.decryptionErrorMessage)
+ ) {
+ await this.handleDecryptionError(
+ envelope,
+ content.decryptionErrorMessage
+ );
+ return;
+ }
if (content.syncMessage) {
await this.handleSyncMessage(envelope, content.syncMessage);
return;
@@ -1675,6 +1721,34 @@ class MessageReceiverInner extends EventTarget {
}
}
+ async handleDecryptionError(
+ envelope: EnvelopeClass,
+ decryptionError: ByteBufferClass
+ ) {
+ const envelopeId = this.getEnvelopeId(envelope);
+ window.log.info(`handleDecryptionError: ${envelopeId}`);
+
+ const buffer = Buffer.from(decryptionError.toArrayBuffer());
+ const request = DecryptionErrorMessage.deserialize(buffer);
+
+ this.removeFromCache(envelope);
+
+ const { sourceUuid, sourceDevice } = envelope;
+ if (!sourceUuid || !sourceDevice) {
+ window.log.error('handleDecryptionError: Missing uuid or device!');
+ return;
+ }
+
+ const event = new Event('retry-request');
+ event.retryRequest = {
+ sentAt: request.timestamp(),
+ requesterUuid: sourceUuid,
+ requesterDevice: sourceDevice,
+ senderDevice: request.deviceId(),
+ };
+ await this.dispatchAndWait(event);
+ }
+
async handleSenderKeyDistributionMessage(
envelope: EnvelopeClass,
distributionMessage: ByteBufferClass
@@ -2603,10 +2677,6 @@ export default class MessageReceiver {
this.stopProcessing = inner.stopProcessing.bind(inner);
this.unregisterBatchers = inner.unregisterBatchers.bind(inner);
- // For tests
- this.isOverHourIntoPast = inner.isOverHourIntoPast.bind(inner);
- this.cleanupSessionResets = inner.cleanupSessionResets.bind(inner);
-
inner.connect();
this.getProcessedCount = () => inner.processedCount;
}
@@ -2629,10 +2699,6 @@ export default class MessageReceiver {
unregisterBatchers: () => void;
- isOverHourIntoPast: (timestamp: number) => boolean;
-
- cleanupSessionResets: () => void;
-
getProcessedCount: () => number;
static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer;
diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts
index 47f37093f..ea7cf8831 100644
--- a/ts/textsecure/OutgoingMessage.ts
+++ b/ts/textsecure/OutgoingMessage.ts
@@ -13,10 +13,13 @@ import { reject } from 'lodash';
import { z } from 'zod';
import {
CiphertextMessageType,
+ CiphertextMessage,
+ PlaintextContent,
ProtocolAddress,
- sealedSenderEncryptMessage,
+ sealedSenderEncrypt,
SenderCertificate,
signalEncrypt,
+ UnidentifiedSenderMessageContent,
} from '@signalapp/signal-client';
import { WebAPIType } from './WebAPI';
@@ -73,6 +76,9 @@ function ciphertextMessageTypeToEnvelopeType(type: number) {
if (type === CiphertextMessageType.Whisper) {
return window.textsecure.protobuf.Envelope.Type.CIPHERTEXT;
}
+ if (type === CiphertextMessageType.Plaintext) {
+ return window.textsecure.protobuf.Envelope.Type.PLAINTEXT_CONTENT;
+ }
throw new Error(
`ciphertextMessageTypeToEnvelopeType: Unrecognized type ${type}`
);
@@ -106,12 +112,10 @@ export default class OutgoingMessage {
identifiers: Array;
- message: ContentClass;
+ message: ContentClass | PlaintextContent;
callback: (result: CallbackResultType) => void;
- silent?: boolean;
-
plaintext?: Uint8Array;
identifiersCompleted: number;
@@ -128,12 +132,17 @@ export default class OutgoingMessage {
online?: boolean;
+ groupId?: string;
+
+ contentHint: number;
+
constructor(
server: WebAPIType,
timestamp: number,
identifiers: Array,
- message: ContentClass | DataMessageClass,
- silent: boolean | undefined,
+ message: ContentClass | DataMessageClass | PlaintextContent,
+ contentHint: number,
+ groupId: string | undefined,
callback: (result: CallbackResultType) => void,
options: OutgoingMessageOptionsType = {}
) {
@@ -149,8 +158,9 @@ export default class OutgoingMessage {
this.server = server;
this.timestamp = timestamp;
this.identifiers = identifiers;
+ this.contentHint = contentHint;
+ this.groupId = groupId;
this.callback = callback;
- this.silent = silent;
this.identifiersCompleted = 0;
this.errors = [];
@@ -186,12 +196,7 @@ export default class OutgoingMessage {
if (error && error.code === 428) {
error = new SendMessageChallengeError(identifier, error);
} else {
- error = new OutgoingMessageError(
- identifier,
- this.message.toArrayBuffer(),
- this.timestamp,
- error
- );
+ error = new OutgoingMessageError(identifier, null, null, error);
}
}
@@ -246,7 +251,6 @@ export default class OutgoingMessage {
} catch (error) {
if (error?.message?.includes('untrusted identity for address')) {
error.timestamp = this.timestamp;
- error.originalMessage = this.message.toArrayBuffer();
}
throw error;
}
@@ -265,7 +269,6 @@ export default class OutgoingMessage {
identifier,
jsonData,
timestamp,
- this.silent,
this.online,
{ accessKey }
);
@@ -274,7 +277,6 @@ export default class OutgoingMessage {
identifier,
jsonData,
timestamp,
- this.silent,
this.online
);
}
@@ -299,18 +301,45 @@ export default class OutgoingMessage {
getPlaintext(): ArrayBuffer {
if (!this.plaintext) {
- this.plaintext = padMessage(this.message.toArrayBuffer());
+ const { message } = this;
+
+ if (message instanceof window.textsecure.protobuf.Content) {
+ this.plaintext = padMessage(message.toArrayBuffer());
+ } else {
+ this.plaintext = message.serialize();
+ }
}
return this.plaintext;
}
+ async getCiphertextMessage({
+ identityKeyStore,
+ protocolAddress,
+ sessionStore,
+ }: {
+ identityKeyStore: IdentityKeys;
+ protocolAddress: ProtocolAddress;
+ sessionStore: Sessions;
+ }): Promise {
+ const { message } = this;
+
+ if (message instanceof window.textsecure.protobuf.Content) {
+ return signalEncrypt(
+ Buffer.from(this.getPlaintext()),
+ protocolAddress,
+ sessionStore,
+ identityKeyStore
+ );
+ }
+
+ return message.asCiphertextMessage();
+ }
+
async doSendMessage(
identifier: string,
deviceIds: Array,
recurse?: boolean
): Promise {
- const plaintext = this.getPlaintext();
-
const { sendMetadata } = this;
const { accessKey, senderCertificate } = sendMetadata?.[identifier] || {};
@@ -364,15 +393,29 @@ export default class OutgoingMessage {
const destinationRegistrationId = activeSession.remoteRegistrationId();
if (sealedSender && senderCertificate) {
+ const ciphertextMessage = await this.getCiphertextMessage({
+ identityKeyStore,
+ protocolAddress,
+ sessionStore,
+ });
+
const certificate = SenderCertificate.deserialize(
Buffer.from(senderCertificate.serialized)
);
+ const groupIdBuffer = this.groupId
+ ? Buffer.from(this.groupId, 'base64')
+ : null;
- const buffer = await sealedSenderEncryptMessage(
- Buffer.from(plaintext),
- protocolAddress,
+ const content = UnidentifiedSenderMessageContent.new(
+ ciphertextMessage,
certificate,
- sessionStore,
+ this.contentHint,
+ groupIdBuffer
+ );
+
+ const buffer = await sealedSenderEncrypt(
+ content,
+ protocolAddress,
identityKeyStore
);
@@ -385,12 +428,11 @@ export default class OutgoingMessage {
};
}
- const ciphertextMessage = await signalEncrypt(
- Buffer.from(plaintext),
+ const ciphertextMessage = await this.getCiphertextMessage({
+ identityKeyStore,
protocolAddress,
sessionStore,
- identityKeyStore
- );
+ });
const type = ciphertextMessageTypeToEnvelopeType(
ciphertextMessage.type()
);
@@ -487,8 +529,6 @@ export default class OutgoingMessage {
if (error?.message?.includes('untrusted identity for address')) {
// eslint-disable-next-line no-param-reassign
error.timestamp = this.timestamp;
- // eslint-disable-next-line no-param-reassign
- error.originalMessage = this.message.toArrayBuffer();
window.log.error(
'Got "key changed" error from encrypt - no identityKey for application layer',
identifier,
diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts
index 41368b9d7..1e7ac3004 100644
--- a/ts/textsecure/SendMessage.ts
+++ b/ts/textsecure/SendMessage.ts
@@ -12,6 +12,7 @@ import { Dictionary } from 'lodash';
import PQueue from 'p-queue';
import { AbortSignal } from 'abort-controller';
import {
+ PlaintextContent,
ProtocolAddress,
SenderKeyDistributionMessage,
} from '@signalapp/signal-client';
@@ -795,10 +796,11 @@ export default class MessageSender {
async sendMessage(
attrs: MessageOptionsType,
+ contentHint: number,
+ groupId: string | undefined,
options?: SendOptionsType
): Promise {
const message = new Message(attrs);
- const silent = false;
return Promise.all([
this.uploadAttachments(message),
@@ -812,6 +814,8 @@ export default class MessageSender {
message.timestamp,
message.recipients || [],
message.toProto(),
+ contentHint,
+ groupId,
(res: CallbackResultType) => {
res.dataMessage = message.toArrayBuffer();
if (res.errors && res.errors.length > 0) {
@@ -820,7 +824,6 @@ export default class MessageSender {
resolve(res);
}
},
- silent,
options
);
})
@@ -830,9 +833,10 @@ export default class MessageSender {
sendMessageProto(
timestamp: number,
recipients: Array,
- messageProto: ContentClass | DataMessageClass,
+ messageProto: ContentClass | DataMessageClass | PlaintextContent,
+ contentHint: number,
+ groupId: string | undefined,
callback: (result: CallbackResultType) => void,
- silent?: boolean,
options?: SendOptionsType
): void {
const rejections = window.textsecure.storage.get(
@@ -848,7 +852,8 @@ export default class MessageSender {
timestamp,
recipients,
messageProto,
- silent,
+ contentHint,
+ groupId,
callback,
options
);
@@ -863,8 +868,9 @@ export default class MessageSender {
async sendMessageProtoAndWait(
timestamp: number,
identifiers: Array,
- messageProto: DataMessageClass,
- silent?: boolean,
+ messageProto: ContentClass | DataMessageClass | PlaintextContent,
+ contentHint: number,
+ groupId: string | undefined,
options?: SendOptionsType
): Promise {
return new Promise((resolve, reject) => {
@@ -881,8 +887,9 @@ export default class MessageSender {
timestamp,
identifiers,
messageProto,
+ contentHint,
+ groupId,
callback,
- silent,
options
);
});
@@ -890,9 +897,9 @@ export default class MessageSender {
async sendIndividualProto(
identifier: string,
- proto: DataMessageClass | ContentClass,
+ proto: DataMessageClass | ContentClass | PlaintextContent,
timestamp: number,
- silent?: boolean,
+ contentHint: number,
options?: SendOptionsType
): Promise {
return new Promise((resolve, reject) => {
@@ -907,13 +914,16 @@ export default class MessageSender {
timestamp,
[identifier],
proto,
+ contentHint,
+ undefined, // groupId
callback,
- silent,
options
);
});
}
+ // You might wonder why this takes a groupId. models/messages.resend() can send a group
+ // message to just one person.
async sendMessageToIdentifier(
identifier: string,
messageText: string | undefined,
@@ -925,6 +935,8 @@ export default class MessageSender {
deletedForEveryoneTimestamp: number | undefined,
timestamp: number,
expireTimer: number | undefined,
+ contentHint: number,
+ groupId: string | undefined,
profileKey?: ArrayBuffer,
options?: SendOptionsType
): Promise {
@@ -942,6 +954,8 @@ export default class MessageSender {
expireTimer,
profileKey,
},
+ contentHint,
+ groupId,
options
);
}
@@ -1018,12 +1032,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
timestamp,
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1043,12 +1060,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1071,12 +1091,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1098,12 +1121,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1127,12 +1153,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1160,12 +1189,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
await this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1189,12 +1221,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
await this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1224,12 +1259,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1261,12 +1299,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1299,12 +1340,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
sendOptions
);
}
@@ -1344,12 +1388,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1397,12 +1444,15 @@ export default class MessageSender {
const secondMessage = new window.textsecure.protobuf.Content();
secondMessage.syncMessage = syncMessage;
- const innerSilent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
myUuid || myNumber,
secondMessage,
now,
- innerSilent,
+ ContentHint.SUPPLEMENTARY,
options
);
});
@@ -1416,6 +1466,10 @@ export default class MessageSender {
sendOptions: SendOptionsType,
groupId?: string
): Promise {
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendMessage(
{
recipients,
@@ -1431,6 +1485,8 @@ export default class MessageSender {
}
: {}),
},
+ ContentHint.SUPPLEMENTARY,
+ undefined, // groupId
sendOptions
);
}
@@ -1446,13 +1502,16 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.callingMessage = callingMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
await this.sendMessageProtoAndWait(
finalTimestamp,
recipients,
contentMessage,
- silent,
+ ContentHint.SUPPLEMENTARY,
+ undefined, // groupId
sendOptions
);
}
@@ -1481,12 +1540,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.receiptMessage = receiptMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
recipientUuid || recipientE164,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1504,12 +1566,15 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.receiptMessage = receiptMessage;
- const silent = true;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendIndividualProto(
senderUuid || senderE164,
contentMessage,
Date.now(),
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1534,14 +1599,17 @@ export default class MessageSender {
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.nullMessage = nullMessage;
- // We want the NullMessage to look like a normal outgoing message; not silent
- const silent = false;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
+ // We want the NullMessage to look like a normal outgoing message
const timestamp = Date.now();
return this.sendIndividualProto(
identifier,
contentMessage,
timestamp,
- silent,
+ ContentHint.SUPPLEMENTARY,
options
);
}
@@ -1555,7 +1623,6 @@ export default class MessageSender {
CallbackResultType | void | Array>
> {
window.log.info('resetSession: start');
- const silent = false;
const proto = new window.textsecure.protobuf.DataMessage();
proto.body = 'TERMINATE';
proto.flags = window.textsecure.protobuf.DataMessage.Flags.END_SESSION;
@@ -1568,6 +1635,10 @@ export default class MessageSender {
throw error;
};
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
const sendToContactPromise = window.textsecure.storage.protocol
.archiveAllSessions(identifier)
.catch(logError('resetSession/archiveAllSessions1 error:'))
@@ -1579,7 +1650,7 @@ export default class MessageSender {
identifier,
proto,
timestamp,
- silent,
+ ContentHint.SUPPLEMENTARY,
options
).catch(logError('resetSession/sendToContact error:'));
})
@@ -1619,6 +1690,10 @@ export default class MessageSender {
profileKey?: ArrayBuffer,
options?: SendOptionsType
): Promise {
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
return this.sendMessage(
{
recipients: [identifier],
@@ -1628,6 +1703,31 @@ export default class MessageSender {
flags:
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
},
+ ContentHint.SUPPLEMENTARY,
+ undefined, // groupId
+ options
+ );
+ }
+
+ async sendRetryRequest({
+ options,
+ plaintext,
+ uuid,
+ }: {
+ options?: SendOptionsType;
+ plaintext: PlaintextContent;
+ uuid: string;
+ }): Promise {
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+
+ return this.sendMessageProtoAndWait(
+ Date.now(),
+ [uuid],
+ plaintext,
+ ContentHint.SUPPLEMENTARY,
+ undefined, // groupId
options
);
}
@@ -1639,6 +1739,8 @@ export default class MessageSender {
providedIdentifiers: Array,
proto: ContentClass,
timestamp = Date.now(),
+ contentHint: number,
+ groupId: string | undefined,
options?: SendOptionsType
): Promise {
const myE164 = window.textsecure.storage.user.getNumber();
@@ -1658,7 +1760,6 @@ export default class MessageSender {
}
return new Promise((resolve, reject) => {
- const silent = true;
const callback = (res: CallbackResultType) => {
res.dataMessage = proto.dataMessage?.toArrayBuffer();
if (res.errors && res.errors.length > 0) {
@@ -1672,21 +1773,17 @@ export default class MessageSender {
timestamp,
providedIdentifiers,
proto,
+ contentHint,
+ groupId,
callback,
- silent,
options
);
});
}
- // The one group send exception - a message that should never be sent via sender key
- async sendSenderKeyDistributionMessage(
- {
- distributionId,
- identifiers,
- }: { distributionId: string; identifiers: Array },
- options?: SendOptionsType
- ): Promise {
+ async getSenderKeyDistributionMessage(
+ distributionId: string
+ ): Promise {
const ourUuid = window.textsecure.storage.user.getUuid();
if (!ourUuid) {
throw new Error(
@@ -1702,7 +1799,7 @@ export default class MessageSender {
const address = `${ourUuid}.${ourDeviceId}`;
const senderKeyStore = new SenderKeys();
- const message = await window.textsecure.storage.protocol.enqueueSenderKeyJob(
+ return window.textsecure.storage.protocol.enqueueSenderKeyJob(
address,
async () =>
SenderKeyDistributionMessage.create(
@@ -1711,13 +1808,40 @@ export default class MessageSender {
senderKeyStore
)
);
+ }
- const proto = new window.textsecure.protobuf.Content();
- proto.senderKeyDistributionMessage = window.dcodeIO.ByteBuffer.wrap(
- typedArrayToArrayBuffer(message.serialize())
+ // The one group send exception - a message that should never be sent via sender key
+ async sendSenderKeyDistributionMessage(
+ {
+ contentHint,
+ distributionId,
+ groupId,
+ identifiers,
+ }: {
+ contentHint: number;
+ distributionId: string;
+ groupId: string | undefined;
+ identifiers: Array;
+ },
+ options?: SendOptionsType
+ ): Promise {
+ const contentMessage = new window.textsecure.protobuf.Content();
+
+ const senderKeyDistributionMessage = await this.getSenderKeyDistributionMessage(
+ distributionId
+ );
+ contentMessage.senderKeyDistributionMessage = window.dcodeIO.ByteBuffer.wrap(
+ typedArrayToArrayBuffer(senderKeyDistributionMessage.serialize())
);
- return this.sendGroupProto(identifiers, proto, Date.now(), options);
+ return this.sendGroupProto(
+ identifiers,
+ contentMessage,
+ Date.now(),
+ contentHint,
+ groupId,
+ options
+ );
}
// GroupV1-only functions; not to be used in the future
@@ -1731,7 +1855,18 @@ export default class MessageSender {
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.QUIT;
- return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options);
+
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+ return this.sendGroupProto(
+ groupIdentifiers,
+ proto,
+ Date.now(),
+ ContentHint.SUPPLEMENTARY,
+ undefined, // only for GV2 ids
+ options
+ );
}
async sendExpirationTimerUpdateToGroup(
@@ -1770,7 +1905,15 @@ export default class MessageSender {
});
}
- return this.sendMessage(attrs, options);
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
+ return this.sendMessage(
+ attrs,
+ ContentHint.SUPPLEMENTARY,
+ undefined, // only for GV2 ids
+ options
+ );
}
// Simple pass-throughs
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index 070f0f6e5..6fbed700a 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -934,14 +934,12 @@ export type WebAPIType = {
destination: string,
messageArray: Array,
timestamp: number,
- silent?: boolean,
online?: boolean
) => Promise;
sendMessagesUnauth: (
destination: string,
messageArray: Array,
timestamp: number,
- silent?: boolean,
online?: boolean,
options?: { accessKey?: string }
) => Promise;
@@ -1446,7 +1444,7 @@ export function initialize({
const capabilities: CapabilitiesUploadType = {
'gv2-3': true,
'gv1-migration': true,
- senderKey: false,
+ senderKey: true,
};
const { accessKey } = options;
@@ -1684,15 +1682,11 @@ export function initialize({
destination: string,
messageArray: Array,
timestamp: number,
- silent?: boolean,
online?: boolean,
{ accessKey }: { accessKey?: string } = {}
) {
const jsonData: any = { messages: messageArray, timestamp };
- if (silent) {
- jsonData.silent = true;
- }
if (online) {
jsonData.online = true;
}
@@ -1712,14 +1706,10 @@ export function initialize({
destination: string,
messageArray: Array,
timestamp: number,
- silent?: boolean,
online?: boolean
) {
const jsonData: any = { messages: messageArray, timestamp };
- if (silent) {
- jsonData.silent = true;
- }
if (online) {
jsonData.online = true;
}
diff --git a/ts/util/index.ts b/ts/util/index.ts
index b2ffaf9d7..937b4a692 100644
--- a/ts/util/index.ts
+++ b/ts/util/index.ts
@@ -36,6 +36,7 @@ import * as zkgroup from './zkgroup';
import { StartupQueue } from './StartupQueue';
import { postLinkExperience } from './postLinkExperience';
import { sendToGroup, sendContentMessageToGroup } from './sendToGroup';
+import { RetryPlaceholders } from './retryPlaceholders';
export {
GoogleChrome,
@@ -62,6 +63,7 @@ export {
parseRemoteClientExpiration,
postLinkExperience,
queueUpdateMessage,
+ RetryPlaceholders,
saveNewMessageBatcher,
sendContentMessageToGroup,
sendToGroup,
diff --git a/ts/util/retryPlaceholders.ts b/ts/util/retryPlaceholders.ts
new file mode 100644
index 000000000..5f8b3f364
--- /dev/null
+++ b/ts/util/retryPlaceholders.ts
@@ -0,0 +1,196 @@
+// Copyright 2021 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { z } from 'zod';
+import { groupBy } from 'lodash';
+
+const retryItemSchema = z
+ .object({
+ conversationId: z.string(),
+ sentAt: z.number(),
+ receivedAt: z.number(),
+ receivedAtCounter: z.number(),
+ senderUuid: z.string(),
+ })
+ .passthrough();
+export type RetryItemType = z.infer;
+
+const retryItemListSchema = z.array(retryItemSchema);
+export type RetryItemListType = z.infer;
+
+export type ByConversationLookupType = {
+ [key: string]: Array;
+};
+export type ByMessageLookupType = Map;
+
+export function getItemId(conversationId: string, sentAt: number): string {
+ return `${conversationId}--${sentAt}`;
+}
+
+const HOUR = 60 * 60 * 1000;
+export const STORAGE_KEY = 'retryPlaceholders';
+
+export function getOneHourAgo(): number {
+ return Date.now() - HOUR;
+}
+
+export class RetryPlaceholders {
+ private items: Array;
+
+ private byConversation: ByConversationLookupType;
+
+ private byMessage: ByMessageLookupType;
+
+ constructor() {
+ if (!window.storage) {
+ throw new Error(
+ 'RetryPlaceholders.constructor: window.storage not available!'
+ );
+ }
+
+ const parsed = retryItemListSchema.safeParse(
+ window.storage.get(STORAGE_KEY) || []
+ );
+ if (!parsed.success) {
+ window.log.warn(
+ `RetryPlaceholders.constructor: Data fetched from storage did not match schema: ${JSON.stringify(
+ parsed.error.flatten()
+ )}`
+ );
+ }
+
+ this.items = parsed.success ? parsed.data : [];
+ window.log.info(
+ `RetryPlaceholders.constructor: Started with ${this.items.length} items`
+ );
+
+ this.sortByExpiresAtAsc();
+ this.byConversation = this.makeByConversationLookup();
+ this.byMessage = this.makeByMessageLookup();
+ }
+
+ // Arranging local data for efficiency
+
+ sortByExpiresAtAsc(): void {
+ this.items.sort(
+ (left: RetryItemType, right: RetryItemType) =>
+ left.receivedAt - right.receivedAt
+ );
+ }
+
+ makeByConversationLookup(): ByConversationLookupType {
+ return groupBy(this.items, item => item.conversationId);
+ }
+
+ makeByMessageLookup(): ByMessageLookupType {
+ const lookup = new Map();
+ this.items.forEach(item => {
+ lookup.set(getItemId(item.conversationId, item.sentAt), item);
+ });
+ return lookup;
+ }
+
+ makeLookups(): void {
+ this.byConversation = this.makeByConversationLookup();
+ this.byMessage = this.makeByMessageLookup();
+ }
+
+ // Basic data management
+
+ async add(item: RetryItemType): Promise {
+ const parsed = retryItemSchema.safeParse(item);
+ if (!parsed.success) {
+ throw new Error(
+ `RetryPlaceholders.add: Item did not match schema ${JSON.stringify(
+ parsed.error.flatten()
+ )}`
+ );
+ }
+
+ this.items.push(item);
+ this.sortByExpiresAtAsc();
+ this.makeLookups();
+ await this.save();
+ }
+
+ async save(): Promise {
+ await window.storage.put(STORAGE_KEY, this.items);
+ }
+
+ // Finding items in different ways
+
+ getCount(): number {
+ return this.items.length;
+ }
+
+ getNextToExpire(): RetryItemType | undefined {
+ return this.items[0];
+ }
+
+ async getExpiredAndRemove(): Promise> {
+ const expiration = getOneHourAgo();
+ const max = this.items.length;
+ const result: Array = [];
+
+ for (let i = 0; i < max; i += 1) {
+ const item = this.items[i];
+ if (item.receivedAt <= expiration) {
+ result.push(item);
+ } else {
+ break;
+ }
+ }
+
+ window.log.info(
+ `RetryPlaceholders.getExpiredAndRemove: Found ${result.length} expired items`
+ );
+
+ this.items.splice(0, result.length);
+ this.makeLookups();
+ await this.save();
+
+ return result;
+ }
+
+ async findByConversationAndRemove(
+ conversationId: string
+ ): Promise> {
+ const result = this.byConversation[conversationId];
+ if (!result) {
+ return [];
+ }
+
+ const items = this.items.filter(
+ item => item.conversationId !== conversationId
+ );
+
+ window.log.info(
+ `RetryPlaceholders.findByConversationAndRemove: Found ${result.length} expired items`
+ );
+
+ this.items = items;
+ this.sortByExpiresAtAsc();
+ this.makeLookups();
+ await this.save();
+
+ return result;
+ }
+
+ async findByMessageAndRemove(
+ conversationId: string,
+ sentAt: number
+ ): Promise {
+ const result = this.byMessage.get(getItemId(conversationId, sentAt));
+ if (!result) {
+ return undefined;
+ }
+
+ const index = this.items.findIndex(item => item === result);
+
+ this.items.splice(index, 1);
+ this.makeLookups();
+ await this.save();
+
+ return result;
+ }
+}
diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts
index 693c83ea8..c3eac7368 100644
--- a/ts/util/sendToGroup.ts
+++ b/ts/util/sendToGroup.ts
@@ -55,6 +55,7 @@ const MAX_RECURSION = 5;
export async function sendToGroup(
groupSendOptions: GroupSendOptionsType,
conversation: ConversationModel,
+ contentHint: number,
sendOptions?: SendOptionsType,
isPartialSend?: boolean
): Promise {
@@ -75,6 +76,7 @@ export async function sendToGroup(
);
return sendContentMessageToGroup({
+ contentHint,
contentMessage,
conversation,
isPartialSend,
@@ -85,6 +87,7 @@ export async function sendToGroup(
}
export async function sendContentMessageToGroup({
+ contentHint,
contentMessage,
conversation,
isPartialSend,
@@ -93,6 +96,7 @@ export async function sendContentMessageToGroup({
sendOptions,
timestamp,
}: {
+ contentHint: number;
contentMessage: ContentClass;
conversation: ConversationModel;
isPartialSend?: boolean;
@@ -110,6 +114,7 @@ export async function sendContentMessageToGroup({
if (conversation.isGroupV2()) {
try {
return await sendToGroupViaSenderKey({
+ contentHint,
contentMessage,
conversation,
isPartialSend,
@@ -127,10 +132,15 @@ export async function sendContentMessageToGroup({
}
}
+ const groupId = conversation.isGroupV2()
+ ? conversation.get('groupId')
+ : undefined;
return window.textsecure.messaging.sendGroupProto(
recipients,
contentMessage,
timestamp,
+ contentHint,
+ groupId,
sendOptions
);
}
@@ -138,6 +148,7 @@ export async function sendContentMessageToGroup({
// The Primary Sender Key workflow
export async function sendToGroupViaSenderKey(options: {
+ contentHint: number;
contentMessage: ContentClass;
conversation: ConversationModel;
isPartialSend?: boolean;
@@ -148,6 +159,7 @@ export async function sendToGroupViaSenderKey(options: {
timestamp: number;
}): Promise {
const {
+ contentHint,
contentMessage,
conversation,
isPartialSend,
@@ -157,6 +169,9 @@ export async function sendToGroupViaSenderKey(options: {
sendOptions,
timestamp,
} = options;
+ const {
+ ContentHint,
+ } = window.textsecure.protobuf.UnidentifiedSenderMessage.Message;
const logId = conversation.idForLogging();
window.log.info(
@@ -176,6 +191,15 @@ export async function sendToGroupViaSenderKey(options: {
);
}
+ if (
+ contentHint !== ContentHint.RESENDABLE &&
+ contentHint !== ContentHint.SUPPLEMENTARY
+ ) {
+ throw new Error(
+ `sendToGroupViaSenderKey/${logId}: Invalid contentHint ${contentHint}`
+ );
+ }
+
assert(
window.textsecure.messaging,
'sendToGroupViaSenderKey: textsecure.messaging not available!'
@@ -293,10 +317,15 @@ export async function sendToGroupViaSenderKey(options: {
newToMemberUuids.length
} members: ${JSON.stringify(newToMemberUuids)}`
);
- await window.textsecure.messaging.sendSenderKeyDistributionMessage({
- distributionId,
- identifiers: newToMemberUuids,
- });
+ await window.textsecure.messaging.sendSenderKeyDistributionMessage(
+ {
+ contentHint: ContentHint.SUPPLEMENTARY,
+ distributionId,
+ groupId,
+ identifiers: newToMemberUuids,
+ },
+ sendOptions
+ );
}
// 9. Update memberDevices with both adds and the removals which didn't require a reset.
@@ -323,6 +352,7 @@ export async function sendToGroupViaSenderKey(options: {
// 10. Send the Sender Key message!
try {
const messageBuffer = await encryptForSenderKey({
+ contentHint,
devices: devicesForSenderKey,
distributionId,
contentMessage: contentMessage.toArrayBuffer(),
@@ -396,6 +426,8 @@ export async function sendToGroupViaSenderKey(options: {
normalRecipients,
contentMessage,
timestamp,
+ contentHint,
+ groupId,
sendOptions
);
@@ -594,14 +626,16 @@ function getXorOfAccessKeys(devices: Array): Buffer {
}
async function encryptForSenderKey({
+ contentHint,
+ contentMessage,
devices,
distributionId,
- contentMessage,
groupId,
}: {
+ contentHint: number;
+ contentMessage: ArrayBuffer;
devices: Array;
distributionId: string;
- contentMessage: ArrayBuffer;
groupId: string;
}): Promise {
const ourUuid = window.textsecure.storage.user.getUuid();
@@ -625,7 +659,6 @@ async function encryptForSenderKey({
() => groupEncrypt(sender, distributionId, senderKeyStore, message)
);
- const contentHint = 1;
const groupIdBuffer = Buffer.from(groupId, 'base64');
const senderCertificateObject = await senderCertificateService.get(
SenderCertificateMode.WithoutE164
@@ -676,8 +709,8 @@ function isValidSenderKeyRecipient(
return false;
}
- const { capabilities } = memberConversation.attributes;
- if (!capabilities.senderKey) {
+ const capabilities = memberConversation.get('capabilities');
+ if (!capabilities?.senderKey) {
window.log.info(
`isValidSenderKeyRecipient: Missing senderKey capability for member ${uuid}`
);
diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts
index 2576661dd..3c7592561 100644
--- a/ts/views/conversation_view.ts
+++ b/ts/views/conversation_view.ts
@@ -394,12 +394,6 @@ Whisper.ConversationView = Whisper.View.extend({
this.model.throttledGetProfiles =
this.model.throttledGetProfiles ||
window._.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES);
- this.model.throttledMaybeMigrateV1Group =
- this.model.throttledMaybeMigrateV1Group ||
- window._.throttle(
- this.model.maybeMigrateV1Group.bind(this.model),
- FIVE_MINUTES
- );
this.debouncedMaybeGrabLinkPreview = window._.debounce(
this.maybeGrabLinkPreview.bind(this),
@@ -2171,6 +2165,8 @@ Whisper.ConversationView = Whisper.View.extend({
},
async onOpened(messageId: any) {
+ const { model }: { model: ConversationModel } = this;
+
if (messageId) {
const message = await getMessageById(messageId, {
Message: Whisper.Message,
@@ -2184,29 +2180,41 @@ Whisper.ConversationView = Whisper.View.extend({
window.log.warn(`onOpened: Did not find message ${messageId}`);
}
+ const { retryPlaceholders } = window.Signal.Services;
+ if (retryPlaceholders) {
+ const placeholders = await retryPlaceholders.findByConversationAndRemove(
+ model.id
+ );
+ window.log.info(`onOpened: Found ${placeholders.length} placeholders`);
+ }
+
this.loadNewestMessages();
- this.model.updateLastMessage();
+ model.updateLastMessage();
this.focusMessageField();
- const quotedMessageId = this.model.get('quotedMessageId');
+ const quotedMessageId = model.get('quotedMessageId');
if (quotedMessageId) {
this.setQuoteMessage(quotedMessageId);
}
- this.model.fetchLatestGroupV2Data();
- this.model.throttledMaybeMigrateV1Group();
+ model.fetchLatestGroupV2Data();
assert(
- this.model.throttledFetchSMSOnlyUUID !== undefined,
+ model.throttledMaybeMigrateV1Group !== undefined,
'Conversation model should be initialized'
);
- this.model.throttledFetchSMSOnlyUUID();
+ model.throttledMaybeMigrateV1Group();
+ assert(
+ model.throttledFetchSMSOnlyUUID !== undefined,
+ 'Conversation model should be initialized'
+ );
+ model.throttledFetchSMSOnlyUUID();
const statusPromise = this.model.throttledGetProfiles();
// eslint-disable-next-line more/no-then
this.statusFetch = statusPromise.then(() =>
// eslint-disable-next-line more/no-then
- this.model.updateVerified().then(() => {
+ model.updateVerified().then(() => {
this.onVerifiedChange();
this.statusFetch = null;
})
diff --git a/ts/window.d.ts b/ts/window.d.ts
index 0cfb65d21..1d9c07c8e 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -315,6 +315,7 @@ declare global {
) => void;
onTimeout: (timestamp: number, cb: () => void, id?: string) => string;
removeTimeout: (uuid: string) => void;
+ retryPlaceholders?: Util.RetryPlaceholders;
runStorageServiceSyncJob: () => Promise;
storageServiceUploadJob: () => void;
};
diff --git a/yarn.lock b/yarn.lock
index 2d20ff122..be35ace0f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1634,10 +1634,10 @@
react-lifecycles-compat "^3.0.4"
warning "^3.0.0"
-"@signalapp/signal-client@0.6.0":
- version "0.6.0"
- resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.6.0.tgz#65b3affe66d73b63daf3494e027470b3d824674a"
- integrity sha512-EhuQeloFqtagd4QxfNsJjKLG0P2bQwv1tB9u5hqLWVsIL8wWUcMYSaPxFAXMbPpmLPu3u3378scr1w861lcHxg==
+"@signalapp/signal-client@0.8.0":
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/@signalapp/signal-client/-/signal-client-0.8.0.tgz#30c3bfafbd32680c8dd7e5417e53b928b1ccdd65"
+ integrity sha512-pchM+cwWdJZSCIceUvq/2lNZr6qJO7qGpQMfxbm9CGrcQaU7t7vtrkR5F0AsHnYO+lfL/3mMOVbBb0Rgl5/IVw==
dependencies:
node-gyp-build "^4.2.3"
uuid "^8.3.0"