Message Send Log to enable comprehensive resend

This commit is contained in:
Scott Nonnenberg 2021-07-15 16:48:09 -07:00 committed by GitHub
parent 0fe68b57b1
commit a42c41ed01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 3154 additions and 1266 deletions

View File

@ -613,6 +613,10 @@
}
}
},
"decryptionErrorToast": {
"message": "Desktop ran into a decryption error. Click to submit a debug log.",
"description": "An error popup when we haven't added an error for decryption error."
},
"oneNonImageAtATimeToast": {
"message": "When including a non-image attachment, the limit is one attachment per message.",
"description": "An error popup when the user has attempted to add an attachment"

View File

@ -1,141 +0,0 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global
Backbone,
Whisper,
MessageController,
ConversationController
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function () {
window.Whisper = window.Whisper || {};
Whisper.Reactions = new (Backbone.Collection.extend({
forMessage(message) {
if (message.isOutgoing()) {
const outgoingReactions = this.filter({
targetTimestamp: message.get('sent_at'),
});
if (outgoingReactions.length > 0) {
window.log.info('Found early reaction for outgoing message');
this.remove(outgoingReactions);
return outgoingReactions;
}
}
const senderId = message.getContactId();
const sentAt = message.get('sent_at');
const reactionsBySource = this.filter(re => {
const targetSenderId = ConversationController.ensureContactIds({
uuid: re.get('targetAuthorUuid'),
});
const targetTimestamp = re.get('targetTimestamp');
return targetSenderId === senderId && targetTimestamp === sentAt;
});
if (reactionsBySource.length > 0) {
window.log.info('Found early reaction for message');
this.remove(reactionsBySource);
return reactionsBySource;
}
return [];
},
async onReaction(reaction) {
try {
// The conversation the target message was in; we have to find it in the database
// to to figure that out.
const targetConversation = await ConversationController.getConversationForTargetMessage(
ConversationController.ensureContactIds({
uuid: reaction.get('targetAuthorUuid'),
}),
reaction.get('targetTimestamp')
);
if (!targetConversation) {
window.log.info(
'No target conversation for reaction',
reaction.get('targetAuthorUuid'),
reaction.get('targetTimestamp')
);
return undefined;
}
// awaiting is safe since `onReaction` is never called from inside the queue
return await targetConversation.queueJob(
'Reactions.onReaction',
async () => {
window.log.info(
'Handling reaction for',
reaction.get('targetTimestamp')
);
const messages = await window.Signal.Data.getMessagesBySentAt(
reaction.get('targetTimestamp'),
{
MessageCollection: Whisper.MessageCollection,
}
);
// Message is fetched inside the conversation queue so we have the
// most recent data
const targetMessage = messages.find(m => {
const contact = m.getContact();
if (!contact) {
return false;
}
const mcid = contact.get('id');
const recid = ConversationController.ensureContactIds({
uuid: reaction.get('targetAuthorUuid'),
});
return mcid === recid;
});
if (!targetMessage) {
window.log.info(
'No message for reaction',
reaction.get('targetAuthorUuid'),
reaction.get('targetTimestamp')
);
// Since we haven't received the message for which we are removing a
// reaction, we can just remove those pending reactions
if (reaction.get('remove')) {
this.remove(reaction);
const oldReaction = this.where({
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
emoji: reaction.get('emoji'),
});
oldReaction.forEach(r => this.remove(r));
}
return undefined;
}
const message = MessageController.register(
targetMessage.id,
targetMessage
);
const oldReaction = await message.handleReaction(reaction);
this.remove(reaction);
return oldReaction;
}
);
} catch (error) {
window.log.error(
'Reactions.onReaction error:',
error && error.stack ? error.stack : error
);
return undefined;
}
},
}))();
})();

View File

@ -50,6 +50,8 @@ try {
window.GV2_MIGRATION_DISABLE_ADD = false;
window.GV2_MIGRATION_DISABLE_INVITE = false;
window.RETRY_DELAY = false;
window.platform = process.platform;
window.getTitle = () => title;
window.getLocale = () => config.locale;
@ -156,6 +158,10 @@ try {
window.log.info('shutdown');
ipc.send('shutdown');
};
window.showDebugLog = () => {
window.log.info('showDebugLog');
ipc.send('show-debug-log');
};
window.closeAbout = () => ipc.send('close-about');
window.readyForUpdates = () => ipc.send('ready-for-updates');

View File

@ -9,16 +9,12 @@ import {
ConversationModelCollectionType,
ConversationAttributesTypeType,
} from './model-types.d';
import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage';
import { ConversationModel } from './models/conversations';
import { maybeDeriveGroupV2Id } from './groups';
import { assert } from './util/assert';
import { isValidGuid } from './util/isValidGuid';
import { map, reduce } from './util/iterables';
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
import { deprecated } from './util/deprecated';
import { getSendOptions } from './util/getSendOptions';
import { handleMessageSend } from './util/handleMessageSend';
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
@ -313,6 +309,25 @@ export class ConversationController {
return conversationId;
}
getOurConversationOrThrow(): ConversationModel {
const conversationId = this.getOurConversationIdOrThrow();
const conversation = this.get(conversationId);
if (!conversation) {
throw new Error(
'getOurConversationOrThrow: Failed to fetch our own conversation'
);
}
return conversation;
}
// eslint-disable-next-line class-methods-use-this
areWePrimaryDevice(): boolean {
const ourDeviceId = window.textsecure.storage.user.getDeviceId();
return ourDeviceId === 1;
}
/**
* Given a UUID and/or an E164, resolves to a string representing the local
* database id of the given contact. In high trust mode, it may create new contacts,
@ -730,25 +745,6 @@ export class ConversationController {
return null;
}
async prepareForSend(
id: string | undefined,
options?: { syncMessage?: boolean }
): Promise<{
wrap: (
promise: Promise<CallbackResultType | void | null>
) => Promise<CallbackResultType | void | null>;
sendOptions: SendOptionsType | undefined;
}> {
deprecated('prepareForSend');
// id is any valid conversation identifier
const conversation = this.get(id);
const sendOptions = conversation
? await getSendOptions(conversation.attributes, options)
: undefined;
return { wrap: handleMessageSend, sendOptions };
}
async getAllGroupsInvolvingId(
conversationId: string
): Promise<Array<ConversationModel>> {

View File

@ -9,18 +9,19 @@ export type ConfigKeyType =
| 'desktop.disableGV1'
| 'desktop.groupCalling'
| 'desktop.gv2'
| 'desktop.internalUser'
| 'desktop.mandatoryProfileSharing'
| 'desktop.mediaQuality.levels'
| 'desktop.messageRequests'
| 'desktop.retryReceiptLifespan'
| 'desktop.retryRespondMaxAge'
| 'desktop.screensharing2'
| 'desktop.sendSenderKey'
| 'desktop.sendSenderKey2'
| 'desktop.storage'
| 'desktop.storageWrite3'
| 'desktop.worksAtSignal'
| 'global.groupsv2.maxGroupSize'
| 'global.groupsv2.groupSizeHardLimit';
| 'global.groupsv2.groupSizeHardLimit'
| 'global.groupsv2.maxGroupSize';
type ConfigValueType = {
name: ConfigKeyType;
enabled: boolean;

View File

@ -24,6 +24,7 @@ import {
typedArrayToArrayBuffer,
} from './Crypto';
import { assert } from './util/assert';
import { handleMessageSend } from './util/handleMessageSend';
import { isNotNil } from './util/isNotNil';
import { Zone } from './util/Zone';
import { isMoreRecentThan } from './util/timestamp';
@ -590,6 +591,13 @@ export class SignalProtocolStore extends EventsMixin {
}
}
async clearSenderKeyStore(): Promise<void> {
if (this.senderKeys) {
this.senderKeys.clear();
}
await window.Signal.Data.removeAllSenderKeys();
}
// Session Queue
async enqueueSessionJob<T>(
@ -1231,7 +1239,14 @@ export class SignalProtocolStore extends EventsMixin {
// Send a null message with newly-created session
const sendOptions = await getSendOptions(conversation.attributes);
await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions);
const result = await handleMessageSend(
window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions),
{ messageIds: [], sendType: 'nullMessage' }
);
if (result && result.errors && result.errors.length) {
throw result.errors[0];
}
} catch (error) {
// If we failed to do the session reset, then we'll allow another attempt sooner
// than one hour from now.

View File

@ -4,10 +4,6 @@
import { isNumber, noop } from 'lodash';
import { bindActionCreators } from 'redux';
import { render } from 'react-dom';
import {
DecryptionErrorMessage,
PlaintextContent,
} from '@signalapp/signal-client';
import MessageReceiver from './textsecure/MessageReceiver';
import { SessionResetsType, ProcessedDataMessage } from './textsecure/Types.d';
@ -17,7 +13,7 @@ import {
} from './model-types.d';
import * as Bytes from './Bytes';
import { typedArrayToArrayBuffer } from './Crypto';
import { WhatIsThis } from './window.d';
import { WhatIsThis, DeliveryReceiptBatcherItemType } from './window.d';
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
import { SocketStatus } from './types/SocketStatus';
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
@ -46,15 +42,11 @@ import {
TypingEvent,
ErrorEvent,
DeliveryEvent,
DecryptionErrorEvent,
DecryptionErrorEventData,
SentEvent,
SentEventData,
ProfileKeyUpdateEvent,
MessageEvent,
MessageEventData,
RetryRequestEvent,
RetryRequestEventData,
ReadEvent,
ConfigurationEvent,
ViewSyncEvent,
@ -72,6 +64,7 @@ import * as universalExpireTimer from './util/universalExpireTimer';
import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation';
import { getSendOptions } from './util/getSendOptions';
import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff';
import { handleMessageSend } from './util/handleMessageSend';
import { AppViewType } from './state/ducks/app';
import { isIncoming } from './state/selectors/message';
import { actionCreators } from './state/actions';
@ -89,6 +82,7 @@ import {
} from './types/SystemTraySetting';
import * as Stickers from './types/Stickers';
import { SignalService as Proto } from './protobuf';
import { onRetryRequest, onDecryptionError } from './util/handleRetry';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -167,6 +161,7 @@ export async function startApp(): Promise<void> {
profileKeyResponseQueue.pause();
const lightSessionResetQueue = new window.PQueue();
window.Signal.Services.lightSessionResetQueue = lightSessionResetQueue;
lightSessionResetQueue.pause();
window.Whisper.deliveryReceiptQueue = new window.PQueue({
@ -174,57 +169,63 @@ export async function startApp(): Promise<void> {
timeout: 1000 * 60 * 2,
});
window.Whisper.deliveryReceiptQueue.pause();
window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher({
name: 'Whisper.deliveryReceiptBatcher',
wait: 500,
maxSize: 500,
processBatch: async items => {
const byConversationId = window._.groupBy(items, item =>
window.ConversationController.ensureContactIds({
e164: item.source,
uuid: item.sourceUuid,
})
);
const ids = Object.keys(byConversationId);
for (let i = 0, max = ids.length; i < max; i += 1) {
const conversationId = ids[i];
const timestamps = byConversationId[conversationId].map(
item => item.timestamp
window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher<DeliveryReceiptBatcherItemType>(
{
name: 'Whisper.deliveryReceiptBatcher',
wait: 500,
maxSize: 500,
processBatch: async items => {
const byConversationId = window._.groupBy(items, item =>
window.ConversationController.ensureContactIds({
e164: item.source,
uuid: item.sourceUuid,
})
);
const ids = Object.keys(byConversationId);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const c = window.ConversationController.get(conversationId)!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const uuid = c.get('uuid')!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const e164 = c.get('e164')!;
for (let i = 0, max = ids.length; i < max; i += 1) {
const conversationId = ids[i];
const ourItems = byConversationId[conversationId];
const timestamps = ourItems.map(item => item.timestamp);
const messageIds = ourItems.map(item => item.messageId);
c.queueJob('sendDeliveryReceipt', async () => {
try {
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(c.get('id'));
// eslint-disable-next-line no-await-in-loop
await wrap(
window.textsecure.messaging.sendDeliveryReceipt({
e164,
uuid,
timestamps,
options: sendOptions,
})
);
} catch (error) {
window.log.error(
`Failed to send delivery receipt to ${e164}/${uuid} for timestamps ${timestamps}:`,
error && error.stack ? error.stack : error
const c = window.ConversationController.get(conversationId);
if (!c) {
window.log.warn(
`deliveryReceiptBatcher: Conversation ${conversationId} does not exist! ` +
`Will not send delivery receipts for timestamps ${timestamps}`
);
continue;
}
});
}
},
});
const uuid = c.get('uuid');
const e164 = c.get('e164');
c.queueJob('sendDeliveryReceipt', async () => {
try {
const sendOptions = await getSendOptions(c.attributes);
// eslint-disable-next-line no-await-in-loop
await handleMessageSend(
window.textsecure.messaging.sendDeliveryReceipt({
e164,
uuid,
timestamps,
options: sendOptions,
}),
{ messageIds, sendType: 'deliveryReceipt' }
);
} catch (error) {
window.log.error(
`Failed to send delivery receipt to ${e164}/${uuid} for timestamps ${timestamps}:`,
error && error.stack ? error.stack : error
);
}
});
}
},
}
);
if (getTitleBarVisibility() === TitleBarVisibility.Hidden) {
window.addEventListener('dblclick', (event: Event) => {
@ -899,25 +900,47 @@ export async function startApp(): Promise<void> {
window.Signal.Services.retryPlaceholders = retryPlaceholders;
setInterval(async () => {
const expired = await retryPlaceholders.getExpiredAndRemove();
window.log.info(
`retryPlaceholders/interval: Found ${expired.length} expired items`
);
expired.forEach(item => {
const { conversationId, senderUuid } = item;
const conversation = window.ConversationController.get(conversationId);
if (conversation) {
const receivedAt = Date.now();
const receivedAtCounter = window.Signal.Util.incrementMessageCounter();
conversation.queueJob('addDeliveryIssue', () =>
conversation.addDeliveryIssue({
receivedAt,
receivedAtCounter,
senderUuid,
})
const now = Date.now();
const HOUR = 1000 * 60 * 60;
const DAY = 24 * HOUR;
const oneDayAgo = now - DAY;
try {
await window.Signal.Data.deleteSentProtosOlderThan(oneDayAgo);
} catch (error) {
window.log.error(
'background/onready/setInterval: Error deleting sent protos: ',
error && error.stack ? error.stack : error
);
}
try {
const expired = await retryPlaceholders.getExpiredAndRemove();
window.log.info(
`retryPlaceholders/interval: Found ${expired.length} expired items`
);
expired.forEach(item => {
const { conversationId, senderUuid } = item;
const conversation = window.ConversationController.get(
conversationId
);
}
});
if (conversation) {
const receivedAt = Date.now();
const receivedAtCounter = window.Signal.Util.incrementMessageCounter();
conversation.queueJob('addDeliveryIssue', () =>
conversation.addDeliveryIssue({
receivedAt,
receivedAtCounter,
senderUuid,
})
);
}
});
} catch (error) {
window.log.error(
'background/onready/setInterval: Error getting expired retry placeholders: ',
error && error.stack ? error.stack : error
);
}
}, FIVE_MINUTES);
try {
@ -1640,7 +1663,18 @@ export async function startApp(): Promise<void> {
function runStorageService() {
window.Signal.Services.enableStorageService();
window.textsecure.messaging.sendRequestKeySyncMessage();
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'background/runStorageService: We are primary device; not sending key sync request'
);
return;
}
handleMessageSend(window.textsecure.messaging.sendRequestKeySyncMessage(), {
messageIds: [],
sendType: 'otherSync',
});
}
let challengeHandler: ChallengeHandler | undefined;
@ -1868,7 +1902,18 @@ export async function startApp(): Promise<void> {
}
await window.storage.remove('manifestVersion');
await window.textsecure.messaging.sendRequestKeySyncMessage();
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'onChange/desktop.storage: We are primary device; not sending key sync request'
);
return;
}
await handleMessageSend(
window.textsecure.messaging.sendRequestKeySyncMessage(),
{ messageIds: [], sendType: 'otherSync' }
);
}
);
@ -2275,7 +2320,7 @@ export async function startApp(): Promise<void> {
'gv2-3': true,
'gv1-migration': true,
senderKey: window.Signal.RemoteConfig.isEnabled(
'desktop.sendSenderKey'
'desktop.sendSenderKey2'
),
});
} catch (error) {
@ -2312,11 +2357,8 @@ export async function startApp(): Promise<void> {
runStorageService();
});
const ourId = window.ConversationController.getOurConversationId();
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(ourId, {
const ourConversation = window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
@ -2328,11 +2370,19 @@ export async function startApp(): Promise<void> {
installed: true,
}));
wrap(
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'background/connect: We are primary device; not sending sticker pack sync'
);
return;
}
handleMessageSend(
window.textsecure.messaging.sendStickerPackSync(
operations,
sendOptions
)
),
{ messageIds: [], sendType: 'otherSync' }
).catch(error => {
window.log.error(
'Failed to send installed sticker packs via sync message',
@ -3559,382 +3609,6 @@ export async function startApp(): Promise<void> {
window.log.warn('background onError: Doing nothing with incoming error');
}
function isInList(
conversation: ConversationModel,
list: Array<string | undefined | null> | undefined
): boolean {
const uuid = conversation.get('uuid');
const e164 = conversation.get('e164');
const id = conversation.get('id');
if (!list) {
return false;
}
if (list.includes(id)) {
return true;
}
if (uuid && list.includes(uuid)) {
return true;
}
if (e164 && list.includes(e164)) {
return true;
}
return false;
}
async function archiveSessionOnMatch({
requesterUuid,
requesterDevice,
senderDevice,
}: RetryRequestEventData): Promise<void> {
const ourDeviceId = parseIntOrThrow(
window.textsecure.storage.user.getDeviceId(),
'archiveSessionOnMatch/getDeviceId'
);
if (ourDeviceId === senderDevice) {
const address = `${requesterUuid}.${requesterDevice}`;
window.log.info(
'archiveSessionOnMatch: Devices match, archiving session'
);
await window.textsecure.storage.protocol.archiveSession(address);
}
}
async function sendDistributionMessageOrNullMessage(
options: RetryRequestEventData
): Promise<void> {
const { groupId, requesterUuid } = options;
let sentDistributionMessage = false;
window.log.info('sendDistributionMessageOrNullMessage: Starting', {
groupId: groupId ? `groupv2(${groupId})` : undefined,
requesterUuid,
});
await archiveSessionOnMatch(options);
const conversation = window.ConversationController.getOrCreate(
requesterUuid,
'private'
);
if (groupId) {
const group = window.ConversationController.get(groupId);
const distributionId = group?.get('senderKeyInfo')?.distributionId;
if (group && distributionId) {
window.log.info(
'sendDistributionMessageOrNullMessage: Found matching group, sending sender key distribution message'
);
try {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const result = await window.textsecure.messaging.sendSenderKeyDistributionMessage(
{
contentHint: ContentHint.DEFAULT,
distributionId,
groupId,
identifiers: [requesterUuid],
}
);
if (result.errors && result.errors.length > 0) {
throw result.errors[0];
}
sentDistributionMessage = true;
} catch (error) {
window.log.error(
'sendDistributionMessageOrNullMessage: Failed to send sender key distribution message',
error && error.stack ? error.stack : error
);
}
}
}
if (!sentDistributionMessage) {
window.log.info(
'sendDistributionMessageOrNullMessage: Did not send distribution message, sending null message'
);
try {
const sendOptions = await getSendOptions(conversation.attributes);
const result = await window.textsecure.messaging.sendNullMessage(
{ uuid: requesterUuid },
sendOptions
);
if (result.errors && result.errors.length > 0) {
throw result.errors[0];
}
} catch (error) {
window.log.error(
'maybeSendDistributionMessage: Failed to send null message',
error && error.stack ? error.stack : error
);
}
}
}
async function onRetryRequest(event: RetryRequestEvent) {
const { retryRequest } = event;
const {
requesterDevice,
requesterUuid,
senderDevice,
sentAt,
} = retryRequest;
const logId = `${requesterUuid}.${requesterDevice} ${sentAt}-${senderDevice}`;
window.log.info(`onRetryRequest/${logId}: Starting...`);
const requesterConversation = window.ConversationController.getOrCreate(
requesterUuid,
'private'
);
const messages = await window.Signal.Data.getMessagesBySentAt(sentAt, {
MessageCollection: window.Whisper.MessageCollection,
});
const targetMessage = messages.find(message => {
if (message.get('sent_at') !== sentAt) {
return false;
}
if (message.get('type') !== 'outgoing') {
return false;
}
if (!isInList(requesterConversation, message.get('sent_to'))) {
return false;
}
return true;
});
if (!targetMessage) {
window.log.info(`onRetryRequest/${logId}: Did not find message`);
await sendDistributionMessageOrNullMessage(retryRequest);
return;
}
if (targetMessage.isErased()) {
window.log.info(
`onRetryRequest/${logId}: Message is erased, refusing to send again.`
);
await sendDistributionMessageOrNullMessage(retryRequest);
return;
}
const HOUR = 60 * 60 * 1000;
const ONE_DAY = 24 * HOUR;
let retryRespondMaxAge = ONE_DAY;
try {
retryRespondMaxAge = parseIntOrThrow(
window.Signal.RemoteConfig.getValue('desktop.retryRespondMaxAge'),
'retryRespondMaxAge'
);
} catch (error) {
window.log.warn(
`onRetryRequest/${logId}: Failed to parse integer from desktop.retryRespondMaxAge feature flag`,
error && error.stack ? error.stack : error
);
}
if (isOlderThan(sentAt, retryRespondMaxAge)) {
window.log.info(
`onRetryRequest/${logId}: Message is too old, refusing to send again.`
);
await sendDistributionMessageOrNullMessage(retryRequest);
return;
}
window.log.info(`onRetryRequest/${logId}: Resending message`);
await archiveSessionOnMatch(retryRequest);
await targetMessage.resend(requesterUuid);
}
async function onDecryptionError(event: DecryptionErrorEvent) {
const { decryptionError } = event;
const { senderUuid, senderDevice, timestamp } = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
window.log.info(`onDecryptionError/${logId}: Starting...`);
const conversation = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
const capabilities = conversation.get('capabilities');
if (!capabilities) {
await conversation.getProfiles();
}
if (conversation.get('capabilities')?.senderKey) {
await requestResend(decryptionError);
} else {
await startAutomaticSessionReset(decryptionError);
}
window.log.info(`onDecryptionError/${logId}: ...complete`);
}
async function requestResend(decryptionError: DecryptionErrorEventData) {
const {
cipherTextBytes,
cipherTextType,
contentHint,
groupId,
receivedAtCounter,
receivedAtDate,
senderDevice,
senderUuid,
timestamp,
} = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
window.log.info(`requestResend/${logId}: Starting...`, {
cipherTextBytesLength: cipherTextBytes?.byteLength,
cipherTextType,
contentHint,
groupId: groupId ? `groupv2(${groupId})` : undefined,
});
// 1. Find the target conversation
const group = groupId
? window.ConversationController.get(groupId)
: undefined;
const sender = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
const conversation = group || sender;
// 2. Send resend request
if (!cipherTextBytes || !isNumber(cipherTextType)) {
window.log.warn(
`requestResend/${logId}: Missing cipherText information, failing over to automatic reset`
);
startAutomaticSessionReset(decryptionError);
return;
}
try {
const message = DecryptionErrorMessage.forOriginal(
Buffer.from(cipherTextBytes),
cipherTextType,
timestamp,
senderDevice
);
const plaintext = PlaintextContent.from(message);
const options = await getSendOptions(conversation.attributes);
const result = await window.textsecure.messaging.sendRetryRequest({
plaintext,
options,
uuid: senderUuid,
});
if (result.errors && result.errors.length > 0) {
throw result.errors[0];
}
} catch (error) {
window.log.error(
`requestResend/${logId}: Failed to send retry request, failing over to automatic reset`,
error && error.stack ? error.stack : error
);
startAutomaticSessionReset(decryptionError);
return;
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
// 3. Determine how to represent this to the user. Three different options.
// We believe that it could be successfully re-sent, so we'll add a placeholder.
if (contentHint === ContentHint.RESENDABLE) {
const { retryPlaceholders } = window.Signal.Services;
assert(retryPlaceholders, 'requestResend: adding placeholder');
window.log.info(`requestResend/${logId}: Adding placeholder`);
await retryPlaceholders.add({
conversationId: conversation.get('id'),
receivedAt: receivedAtDate,
receivedAtCounter,
sentAt: timestamp,
senderUuid,
});
return;
}
// This message cannot be resent. We'll show no error and trust the other side to
// reset their session.
if (contentHint === ContentHint.IMPLICIT) {
return;
}
window.log.warn(
`requestResend/${logId}: No content hint, adding error immediately`
);
conversation.queueJob('addDeliveryIssue', async () => {
conversation.addDeliveryIssue({
receivedAt: receivedAtDate,
receivedAtCounter,
senderUuid,
});
});
}
function scheduleSessionReset(senderUuid: string, senderDevice: number) {
// Postpone sending light session resets until the queue is empty
lightSessionResetQueue.add(() => {
window.textsecure.storage.protocol.lightSessionReset(
senderUuid,
senderDevice
);
});
}
function startAutomaticSessionReset(
decryptionError: DecryptionErrorEventData
) {
const { senderUuid, senderDevice, timestamp } = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
window.log.info(`startAutomaticSessionReset/${logId}: Starting...`);
scheduleSessionReset(senderUuid, senderDevice);
const conversationId = window.ConversationController.ensureContactIds({
uuid: senderUuid,
});
if (!conversationId) {
window.log.warn(
'onLightSessionReset: No conversation id, cannot add message to timeline'
);
return;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
window.log.warn(
'onLightSessionReset: No conversation, cannot add message to timeline'
);
return;
}
const receivedAt = Date.now();
const receivedAtCounter = window.Signal.Util.incrementMessageCounter();
conversation.queueJob('addChatSessionRefreshed', async () => {
conversation.addChatSessionRefreshed({ receivedAt, receivedAtCounter });
});
}
async function onViewSync(ev: ViewSyncEvent) {
ev.confirm();
@ -4025,7 +3699,13 @@ export async function startApp(): Promise<void> {
}
function onReadReceipt(ev: ReadEvent) {
const { envelopeTimestamp, timestamp, source, sourceUuid } = ev.read;
const {
envelopeTimestamp,
timestamp,
source,
sourceUuid,
sourceDevice,
} = ev.read;
const readAt = envelopeTimestamp;
const reader = window.ConversationController.ensureContactIds({
e164: source,
@ -4036,6 +3716,7 @@ export async function startApp(): Promise<void> {
'read receipt',
source,
sourceUuid,
sourceDevice,
envelopeTimestamp,
reader,
'for sent message',
@ -4050,6 +3731,7 @@ export async function startApp(): Promise<void> {
const receipt = ReadReceipts.getSingleton().add({
reader,
readerDevice: sourceDevice,
timestamp,
readAt,
});
@ -4198,6 +3880,7 @@ export async function startApp(): Promise<void> {
const receipt = DeliveryReceipts.getSingleton().add({
timestamp,
deliveredTo,
deliveredToDevice: sourceDevice,
});
// Note: We don't wait for completion here

View File

@ -69,7 +69,7 @@ import {
isGroupV2 as getIsGroupV2,
isMe,
} from './util/whatTypeOfConversation';
import { handleMessageSend } from './util/handleMessageSend';
import { handleMessageSend, SendTypesType } from './util/handleMessageSend';
import { getSendOptions } from './util/getSendOptions';
import * as Bytes from './Bytes';
import { SignalService as Proto } from './protobuf';
@ -1309,9 +1309,12 @@ export async function modifyGroupV2({
profileKey,
},
conversation,
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
messageId: undefined,
sendOptions,
})
sendType: 'groupChange',
}),
{ messageIds: [], sendType: 'groupChange' }
);
// We don't save this message; we just use it to ensure that a sync message is
@ -1682,6 +1685,7 @@ export async function createGroupV2({
await wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/${logId}`,
messageIds: [],
send: async () =>
window.Signal.Util.sendToGroup({
groupSendOptions: {
@ -1690,9 +1694,12 @@ export async function createGroupV2({
profileKey,
},
conversation,
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
messageId: undefined,
sendOptions,
sendType: 'groupChange',
}),
sendType: 'groupChange',
timestamp,
});
@ -2212,6 +2219,7 @@ export async function initiateMigrationToGroupV2(
await wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/${logId}`,
messageIds: [],
send: async () =>
// Minimal message to notify group members about migration
window.Signal.Util.sendToGroup({
@ -2223,9 +2231,12 @@ export async function initiateMigrationToGroupV2(
profileKey: ourProfileKey,
},
conversation,
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
messageId: undefined,
sendOptions,
sendType: 'groupChange',
}),
sendType: 'groupChange',
timestamp,
});
}
@ -2233,12 +2244,16 @@ export async function initiateMigrationToGroupV2(
export async function wrapWithSyncMessageSend({
conversation,
logId,
messageIds,
send,
sendType,
timestamp,
}: {
conversation: ConversationModel;
logId: string;
send: (sender: MessageSender) => Promise<CallbackResultType | undefined>;
messageIds: Array<string>;
send: (sender: MessageSender) => Promise<CallbackResultType>;
sendType: SendTypesType;
timestamp: number;
}): Promise<void> {
const sender = window.textsecure.messaging;
@ -2250,7 +2265,7 @@ export async function wrapWithSyncMessageSend({
let response: CallbackResultType | undefined;
try {
response = await send(sender);
response = await handleMessageSend(send(sender), { messageIds, sendType });
} catch (error) {
if (conversation.processSendResponse(error)) {
response = error;
@ -2285,15 +2300,27 @@ export async function wrapWithSyncMessageSend({
);
}
await sender.sendSyncMessage({
encodedDataMessage: dataMessage,
timestamp,
destination: ourConversation.get('e164'),
destinationUuid: ourConversation.get('uuid'),
expirationStartTimestamp: null,
sentTo: [],
unidentifiedDeliveries: [],
});
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
`wrapWithSyncMessageSend/${logId}: We are primary device; not sync message`
);
return;
}
const options = await getSendOptions(ourConversation.attributes);
await handleMessageSend(
sender.sendSyncMessage({
destination: ourConversation.get('e164'),
destinationUuid: ourConversation.get('uuid'),
encodedDataMessage: dataMessage,
expirationStartTimestamp: null,
options,
sentTo: [],
timestamp,
unidentifiedDeliveries: [],
}),
{ messageIds, sendType }
);
}
export async function waitThenRespondToGroupV2Migration(

View File

@ -10,10 +10,15 @@ import { ConversationModel } from '../models/conversations';
import { MessageModel } from '../models/messages';
import { MessageModelCollectionType } from '../model-types.d';
import { isIncoming } from '../state/selectors/message';
import { isDirectConversation } from '../util/whatTypeOfConversation';
import dataInterface from '../sql/Client';
const { deleteSentProtoRecipient } = dataInterface;
type DeliveryReceiptAttributesType = {
timestamp: number;
deliveredTo: string;
deliveredToDevice: number;
};
class DeliveryReceiptModel extends Model<DeliveryReceiptAttributesType> {}
@ -67,7 +72,7 @@ export class DeliveryReceipts extends Collection<DeliveryReceiptModel> {
message: MessageModel
): Array<DeliveryReceiptModel> {
let recipients: Array<string>;
if (conversation.isPrivate()) {
if (isDirectConversation(conversation.attributes)) {
recipients = [conversation.id];
} else {
recipients = conversation.getMemberIds();
@ -82,32 +87,29 @@ export class DeliveryReceipts extends Collection<DeliveryReceiptModel> {
}
async onReceipt(receipt: DeliveryReceiptModel): Promise<void> {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.get('timestamp'),
{
MessageCollection: window.Whisper.MessageCollection,
}
);
const timestamp = receipt.get('timestamp');
const deliveredTo = receipt.get('deliveredTo');
const message = await getTargetMessage(
receipt.get('deliveredTo'),
messages
);
try {
const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, {
MessageCollection: window.Whisper.MessageCollection,
});
const message = await getTargetMessage(deliveredTo, messages);
if (!message) {
window.log.info(
'No message for delivery receipt',
receipt.get('deliveredTo'),
receipt.get('timestamp')
deliveredTo,
timestamp
);
return;
}
const deliveries = message.get('delivered') || 0;
const deliveredTo = message.get('delivered_to') || [];
const originalDeliveredTo = message.get('delivered_to') || [];
const expirationStartTimestamp = message.get('expirationStartTimestamp');
message.set({
delivered_to: union(deliveredTo, [receipt.get('deliveredTo')]),
delivered_to: union(originalDeliveredTo, [deliveredTo]),
delivered: deliveries + 1,
expirationStartTimestamp: expirationStartTimestamp || Date.now(),
sent: true,
@ -126,6 +128,33 @@ export class DeliveryReceipts extends Collection<DeliveryReceiptModel> {
updateLeftPane();
}
const unidentifiedLookup = (
message.get('unidentifiedDeliveries') || []
).reduce((accumulator: Record<string, boolean>, identifier: string) => {
const id = window.ConversationController.getConversationId(identifier);
if (id) {
accumulator[id] = true;
}
return accumulator;
}, Object.create(null) as Record<string, boolean>);
const recipient = window.ConversationController.get(deliveredTo);
if (recipient && unidentifiedLookup[recipient.id]) {
const recipientUuid = recipient?.get('uuid');
const deviceId = receipt.get('deliveredToDevice');
if (recipientUuid && deviceId) {
await deleteSentProtoRecipient({
timestamp,
recipientUuid,
deviceId,
});
} else {
window.log.warn(
`DeliveryReceipts.onReceipt: Missing uuid or deviceId for deliveredTo ${deliveredTo}`
);
}
}
this.remove(receipt);
} catch (error) {
window.log.error(

View File

@ -9,9 +9,14 @@ import { ConversationModel } from '../models/conversations';
import { MessageModel } from '../models/messages';
import { MessageModelCollectionType } from '../model-types.d';
import { isOutgoing } from '../state/selectors/message';
import { isDirectConversation } from '../util/whatTypeOfConversation';
import dataInterface from '../sql/Client';
const { deleteSentProtoRecipient } = dataInterface;
type ReadReceiptAttributesType = {
reader: string;
readerDevice: number;
timestamp: number;
readAt: number;
};
@ -68,7 +73,7 @@ export class ReadReceipts extends Collection<ReadReceiptModel> {
return [];
}
let ids: Array<string>;
if (conversation.isPrivate()) {
if (isDirectConversation(conversation.attributes)) {
ids = [conversation.id];
} else {
ids = conversation.getMemberIds();
@ -86,29 +91,25 @@ export class ReadReceipts extends Collection<ReadReceiptModel> {
}
async onReceipt(receipt: ReadReceiptModel): Promise<void> {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.get('timestamp'),
{
MessageCollection: window.Whisper.MessageCollection,
}
);
const timestamp = receipt.get('timestamp');
const reader = receipt.get('reader');
const message = await getTargetMessage(receipt.get('reader'), messages);
try {
const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, {
MessageCollection: window.Whisper.MessageCollection,
});
const message = await getTargetMessage(reader, messages);
if (!message) {
window.log.info(
'No message for read receipt',
receipt.get('reader'),
receipt.get('timestamp')
);
window.log.info('No message for read receipt', reader, timestamp);
return;
}
const readBy = message.get('read_by') || [];
const expirationStartTimestamp = message.get('expirationStartTimestamp');
readBy.push(receipt.get('reader'));
readBy.push(reader);
message.set({
read_by: readBy,
expirationStartTimestamp: expirationStartTimestamp || Date.now(),
@ -128,6 +129,22 @@ export class ReadReceipts extends Collection<ReadReceiptModel> {
updateLeftPane();
}
const deviceId = receipt.get('readerDevice');
const recipient = window.ConversationController.get(reader);
const recipientUuid = recipient?.get('uuid');
if (recipientUuid && deviceId) {
await deleteSentProtoRecipient({
timestamp,
recipientUuid,
deviceId,
});
} else {
window.log.warn(
`ReadReceipts.onReceipt: Missing uuid or deviceId for reader ${reader}`
);
}
this.remove(receipt);
} catch (error) {
window.log.error(

2
ts/model-types.d.ts vendored
View File

@ -371,5 +371,3 @@ export type ReactionAttributesType = {
timestamp: number;
fromSync?: boolean;
};
export declare class ReactionModelType extends Backbone.Model<ReactionAttributesType> {}

View File

@ -10,7 +10,6 @@ import {
MessageAttributesType,
MessageModelCollectionType,
QuotedMessageType,
ReactionModelType,
VerificationOptions,
WhatIsThis,
} from '../model-types.d';
@ -64,7 +63,6 @@ import {
isGroupV2,
isMe,
} from '../util/whatTypeOfConversation';
import { deprecated } from '../util/deprecated';
import { SignalService as Proto } from '../protobuf';
import {
hasErrors,
@ -73,7 +71,7 @@ import {
getMessagePropStatus,
} from '../state/selectors/message';
import { Deletes } from '../messageModifiers/Deletes';
import { Reactions } from '../messageModifiers/Reactions';
import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
// TODO: remove once we move away from ArrayBuffers
const FIXMEU8 = Uint8Array;
@ -320,11 +318,6 @@ export class ConversationModel extends window.Backbone
}
}
isPrivate(): boolean {
deprecated('isPrivate()');
return isDirectConversation(this.attributes);
}
isMemberRequestingToJoin(conversationId: string): boolean {
if (!isGroupV2(this.attributes)) {
return false;
@ -1200,7 +1193,8 @@ export class ConversationModel extends window.Backbone
...sendOptions,
online: true,
},
})
}),
{ messageIds: [], sendType: 'typing' }
);
} else {
handleMessageSend(
@ -1208,11 +1202,14 @@ export class ConversationModel extends window.Backbone
contentHint: ContentHint.IMPLICIT,
contentMessage,
conversation: this,
messageId: undefined,
online: true,
recipients: groupMembers,
sendOptions,
sendType: 'typing',
timestamp,
})
}),
{ messageIds: [], sendType: 'typing' }
);
}
});
@ -1577,6 +1574,7 @@ export class ConversationModel extends window.Backbone
m => !hasErrors(m.attributes) && isIncoming(m.attributes)
);
const receiptSpecs = readMessages.map(m => ({
messageId: m.id,
senderE164: m.get('source'),
senderUuid: m.get('sourceUuid'),
senderId: window.ConversationController.ensureContactIds({
@ -1988,22 +1986,22 @@ export class ConversationModel extends window.Backbone
// server updates were successful.
await this.applyMessageRequestResponse(response);
const { ourNumber, ourUuid } = this;
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
ourNumber || ourUuid!,
{
syncMessage: true,
}
);
const ourConversation = window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
const groupId = this.getGroupIdBuffer();
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'syncMessageRequestResponse: We are primary device; not sending message request sync'
);
return;
}
try {
await wrap(
await handleMessageSend(
window.textsecure.messaging.syncMessageRequestResponse(
{
threadE164: this.get('e164'),
@ -2012,7 +2010,8 @@ export class ConversationModel extends window.Backbone
type: response,
},
sendOptions
)
),
{ messageIds: [], sendType: 'otherSync' }
);
} catch (result) {
this.processSendResponse(result);
@ -2167,10 +2166,8 @@ export class ConversationModel extends window.Backbone
}
if (!options.viaSyncMessage) {
await this.sendVerifySyncMessage(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.get('e164')!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.get('uuid')!,
this.get('e164'),
this.get('uuid'),
verified
);
}
@ -2179,33 +2176,52 @@ export class ConversationModel extends window.Backbone
}
async sendVerifySyncMessage(
e164: string,
uuid: string,
e164: string | undefined,
uuid: string | undefined,
state: number
): Promise<WhatIsThis> {
): Promise<CallbackResultType | void> {
const identifier = uuid || e164;
if (!identifier) {
throw new Error(
'sendVerifySyncMessage: Neither e164 nor UUID were provided'
);
}
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'sendVerifySyncMessage: We are primary device; not sending sync'
);
return;
}
// Because syncVerification sends a (null) message to the target of the verify and
// a sync message to our own devices, we need to send the accessKeys down for both
// contacts. So we merge their sendOptions.
const { sendOptions } = await window.ConversationController.prepareForSend(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.ourNumber || this.ourUuid!,
{ syncMessage: true }
);
const ourConversation = window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
const contactSendOptions = await getSendOptions(this.attributes);
const options = { ...sendOptions, ...contactSendOptions };
const promise = window.textsecure.storage.protocol.loadIdentityKey(e164);
return promise.then(key =>
handleMessageSend(
window.textsecure.messaging.syncVerification(
e164,
uuid,
state,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
key!,
options
)
)
const key = await window.textsecure.storage.protocol.loadIdentityKey(
identifier
);
if (!key) {
throw new Error(
`sendVerifySyncMessage: No identity key found for identifier ${identifier}`
);
}
await handleMessageSend(
window.textsecure.messaging.syncVerification(
e164,
uuid,
state,
key,
options
),
{ messageIds: [], sendType: 'verificationSync' }
);
}
@ -2214,13 +2230,12 @@ export class ConversationModel extends window.Backbone
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.get('verified') === this.verifiedEnum!.VERIFIED;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (!this.contactCollection!.length) {
if (!this.contactCollection?.length) {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.contactCollection!.every(contact => {
return this.contactCollection?.every(contact => {
if (isMe(contact.attributes)) {
return true;
}
@ -2238,16 +2253,12 @@ export class ConversationModel extends window.Backbone
verified !== this.verifiedEnum!.DEFAULT
);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (!this.contactCollection!.length) {
if (!this.contactCollection?.length) {
return true;
}
// Array.any does not exist. This is probably broken.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.contactCollection!.any(contact => {
return this.contactCollection?.some(contact => {
if (isMe(contact.attributes)) {
return false;
}
@ -2262,8 +2273,7 @@ export class ConversationModel extends window.Backbone
: new window.Backbone.Collection();
}
return new window.Backbone.Collection(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.contactCollection!.filter(contact => {
this.contactCollection?.filter(contact => {
if (isMe(contact.attributes)) {
return false;
}
@ -3158,7 +3168,11 @@ export class ConversationModel extends window.Backbone
window.reduxActions.stickers.useSticker(packId, stickerId);
}
async sendDeleteForEveryoneMessage(targetTimestamp: number): Promise<void> {
async sendDeleteForEveryoneMessage(options: {
id: string;
timestamp: number;
}): Promise<void> {
const { timestamp: targetTimestamp, id: messageId } = options;
const timestamp = Date.now();
if (timestamp - targetTimestamp > THREE_HOURS) {
@ -3224,7 +3238,7 @@ export class ConversationModel extends window.Backbone
deletedForEveryoneTimestamp: targetTimestamp,
timestamp,
expireTimer: undefined,
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options: sendOptions,
@ -3240,8 +3254,10 @@ export class ConversationModel extends window.Backbone
profileKey,
},
conversation: this,
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
messageId,
sendOptions,
sendType: 'deleteForEveryone',
});
})();
@ -3249,11 +3265,16 @@ export class ConversationModel extends window.Backbone
// anything to the database.
message.doNotSave = true;
const result = await message.send(handleMessageSend(promise));
const result = await message.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'deleteForEveryone',
})
);
if (!message.hasSuccessfulDelivery()) {
// This is handled by `conversation_view` which displays a toast on
// send error.
// send error.
throw new Error('No successful delivery for delete for everyone');
}
Deletes.getSingleton().onDelete(deleteModel);
@ -3274,10 +3295,12 @@ export class ConversationModel extends window.Backbone
async sendReactionMessage(
reaction: { emoji: string; remove: boolean },
target: {
messageId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}
): Promise<WhatIsThis> {
const { messageId } = target;
const timestamp = Date.now();
const outgoingReaction = { ...reaction, ...target };
@ -3373,7 +3396,7 @@ export class ConversationModel extends window.Backbone
deletedForEveryoneTimestamp: undefined,
timestamp,
expireTimer,
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options,
@ -3392,12 +3415,19 @@ export class ConversationModel extends window.Backbone
profileKey,
},
conversation: this,
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
messageId,
sendOptions: options,
sendType: 'reaction',
});
})();
const result = await message.send(handleMessageSend(promise));
const result = await message.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'reaction',
})
);
if (!message.hasSuccessfulDelivery()) {
// This is handled by `conversation_view` which displays a toast on
@ -3407,7 +3437,7 @@ export class ConversationModel extends window.Backbone
return result;
}).catch(() => {
let reverseReaction: ReactionModelType;
let reverseReaction: ReactionModel;
if (oldReaction) {
// Either restore old reaction
reverseReaction = Reactions.getSingleton().add({
@ -3444,11 +3474,15 @@ export class ConversationModel extends window.Backbone
);
return;
}
await window.textsecure.messaging.sendProfileKeyUpdate(
profileKey,
recipients,
await getSendOptions(this.attributes),
this.get('groupId')
await handleMessageSend(
window.textsecure.messaging.sendProfileKeyUpdate(
profileKey,
recipients,
await getSendOptions(this.attributes),
this.get('groupId')
),
{ messageIds: [], sendType: 'profileKeyUpdate' }
);
}
@ -3537,6 +3571,7 @@ export class ConversationModel extends window.Backbone
await addStickerPackReference(model.id, sticker.packId);
}
const message = window.MessageController.register(model.id, model);
const messageId = message.id;
await window.Signal.Data.saveMessage(message.attributes, {
forceSave: true,
Message: window.Whisper.Message,
@ -3635,7 +3670,9 @@ export class ConversationModel extends window.Backbone
},
conversation: this,
contentHint: ContentHint.RESENDABLE,
messageId,
sendOptions: options,
sendType: 'message',
});
} else {
promise = window.textsecure.messaging.sendMessageToIdentifier({
@ -3656,7 +3693,12 @@ export class ConversationModel extends window.Backbone
});
}
return message.send(handleMessageSend(promise));
return message.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'message',
})
);
});
}
@ -4099,7 +4141,12 @@ export class ConversationModel extends window.Backbone
);
}
await message.send(handleMessageSend(promise));
await message.send(
handleMessageSend(promise, {
messageIds: [],
sendType: 'expirationTimerUpdate',
})
);
return message;
}
@ -4220,7 +4267,8 @@ export class ConversationModel extends window.Backbone
groupId,
groupIdentifiers,
options
)
),
{ messageIds: [], sendType: 'legacyGroupChange' }
)
);
}

View File

@ -167,7 +167,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
isSelected?: boolean;
syncPromise?: Promise<unknown>;
syncPromise?: Promise<CallbackResultType | void>;
initialize(attributes: unknown): void {
if (_.isObject(attributes)) {
@ -774,8 +774,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
async cleanup(): Promise<void> {
const { messageDeleted } = window.reduxActions.conversations;
messageDeleted(this.id, this.get('conversationId'));
window.reduxActions?.conversations?.messageDeleted(
this.id,
this.get('conversationId')
);
this.getConversation()?.debouncedUpdateLastMessage?.();
@ -868,26 +870,26 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
const timestamp = this.get('sent_at');
const ourNumber = window.textsecure.storage.user.getNumber();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ourUuid = window.textsecure.storage.user.getUuid()!;
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(
ourNumber || ourUuid,
{
syncMessage: true,
}
);
const ourConversation = window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
await wrap(
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'markViewed: We are primary device; not sending view sync'
);
return;
}
await handleMessageSend(
window.textsecure.messaging.syncViewOnceOpen(
sender,
senderUuid,
timestamp,
sendOptions
)
),
{ messageIds: [this.id], sendType: 'viewOnceSync' }
);
}
}
@ -987,6 +989,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
Message: window.Whisper.Message,
});
}
await window.Signal.Data.deleteSentProtoByMessageId(this.id);
}
isEmpty(): boolean {
@ -1346,11 +1350,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Important to ensure that we don't consider this recipient list to be the
// entire member list.
isPartialSend: true,
messageId: this.id,
sendOptions: options,
sendType: 'messageRetry',
});
}
return this.send(handleMessageSend(promise));
return this.send(
handleMessageSend(promise, {
messageIds: [this.id],
sendType: 'messageRetry',
})
);
}
// eslint-disable-next-line class-methods-use-this
@ -1429,10 +1440,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const parentConversation = this.getConversation();
const groupId = parentConversation?.get('groupId');
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(identifier);
const recipientConversation = window.ConversationController.get(identifier);
const sendOptions = recipientConversation
? await getSendOptions(recipientConversation.attributes)
: undefined;
const group =
groupId && isGroupV1(parentConversation?.attributes)
? {
@ -1479,7 +1491,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
options: sendOptions,
});
return this.send(wrap(promise));
return this.send(
handleMessageSend(promise, {
messageIds: [this.id],
sendType: 'messageRetry',
})
);
}
removeOutgoingErrors(incomingIdentifier: string): CustomError {
@ -1689,18 +1706,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// possible.
await this.send(
handleMessageSend(
// TODO: DESKTOP-724
// resetSession returns `Array<void>` which is incompatible with the
// expected promise return values. `[]` is truthy and handleMessageSend
// assumes it's a valid callback result type
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.textsecure.messaging.resetSession(
options.uuid,
options.e164,
options.now,
sendOptions
)
),
{ messageIds: [], sendType: 'resetSession' }
)
);
@ -1725,10 +1737,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
sent: true,
expirationStartTimestamp: Date.now(),
});
const result: typeof window.WhatIsThis = await this.sendSyncMessage();
const result = await this.sendSyncMessage();
this.set({
// We have to do this afterward, since we didn't have a previous send!
unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null,
unidentifiedDeliveries:
result && result.unidentifiedDeliveries
? result.unidentifiedDeliveries
: undefined,
// These are unique to a Note to Self message - immediately read/delivered
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -1751,30 +1766,31 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
}
async sendSyncMessage(): Promise<WhatIsThis> {
const ourNumber = window.textsecure.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid();
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(
ourUuid || ourNumber,
{
syncMessage: true,
}
);
async sendSyncMessage(): Promise<CallbackResultType | void> {
const ourConversation = window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'sendSyncMessage: We are primary device; not sending sync message'
);
this.set({ dataMessage: undefined });
return;
}
this.syncPromise = this.syncPromise || Promise.resolve();
const next = async () => {
const dataMessage = this.get('dataMessage');
if (!dataMessage) {
return Promise.resolve();
return;
}
const isUpdate = Boolean(this.get('synced'));
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conv = this.getConversation()!;
return wrap(
return handleMessageSend(
window.textsecure.messaging.sendSyncMessage({
encodedDataMessage: dataMessage,
timestamp: this.get('sent_at'),
@ -1786,8 +1802,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
unidentifiedDeliveries: this.get('unidentifiedDeliveries') || [],
isUpdate,
options: sendOptions,
})
).then(async (result: unknown) => {
}),
{ messageIds: [this.id], sendType: 'sentSync' }
).then(async result => {
this.set({
synced: true,
dataMessage: null,
@ -2504,28 +2521,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
}
// 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 && item.wasOpened) {
window.log.info(
`handleDataMessage: found retry placeholder for ${message.idForLogging()}, but conversation was opened. No updates made.`
);
} else if (item) {
window.log.info(
`handleDataMessage: found retry placeholder for ${message.idForLogging()}. Updating received_at/received_at_ms`
);
message.set({
received_at: item.receivedAtCounter,
received_at_ms: item.receivedAt,
});
}
}
// GroupV2
if (initialMessage.groupV2) {
@ -2640,6 +2635,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return;
}
const messageId = window.getGuid();
// Send delivery receipts, but only for incoming sealed sender messages
// and not for messages from unaccepted conversations
if (
@ -2653,6 +2650,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// The queue can be paused easily.
window.Whisper.deliveryReceiptQueue.add(() => {
window.Whisper.deliveryReceiptBatcher.add({
messageId,
source,
sourceUuid,
timestamp: this.get('sent_at'),
@ -2689,7 +2687,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
message.set({
id: window.getGuid(),
id: messageId,
attachments: dataMessage.attachments,
body: dataMessage.body,
bodyRanges: dataMessage.bodyRanges,
@ -3270,6 +3268,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
conversationId: this.get('conversationId'),
emoji: reaction.get('emoji'),
fromId: reaction.get('fromId'),
messageId: this.id,
messageReceivedAt: this.get('received_at'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),

View File

@ -57,6 +57,7 @@ import {
import { assert } from '../util/assert';
import { dropNull, shallowDropNull } from '../util/dropNull';
import { getOwn } from '../util/getOwn';
import { handleMessageSend } from '../util/handleMessageSend';
import {
fetchMembershipProof,
getMembershipList,
@ -937,13 +938,17 @@ export class CallingClass {
wrapWithSyncMessageSend({
conversation,
logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`,
messageIds: [],
send: () =>
window.Signal.Util.sendToGroup({
groupSendOptions: { groupCallUpdate: { eraId }, groupV2, timestamp },
conversation,
contentHint: ContentHint.DEFAULT,
messageId: undefined,
sendOptions,
sendType: 'callingMessage',
}),
sendType: 'callingMessage',
timestamp,
}).catch(err => {
window.log.error(
@ -1559,12 +1564,19 @@ export class CallingClass {
}
try {
await window.textsecure.messaging.sendCallingMessage(
remoteUserId,
callingMessageToProto(message),
sendOptions
const result = await handleMessageSend(
window.textsecure.messaging.sendCallingMessage(
remoteUserId,
callingMessageToProto(message),
sendOptions
),
{ messageIds: [], sendType: 'callingMessage' }
);
if (result && result.errors && result.errors.length) {
throw result.errors[0];
}
window.log.info('handleOutgoingSignaling() completed successfully');
return true;
} catch (err) {

View File

@ -27,6 +27,7 @@ import {
import { ConversationModel } from '../models/conversations';
import { strictAssert } from '../util/assert';
import { BackOff } from '../util/BackOff';
import { handleMessageSend } from '../util/handleMessageSend';
import { storageJobQueue } from '../util/JobQueue';
import { sleep } from '../util/sleep';
import { isMoreRecentThan } from '../util/timestamp';
@ -531,7 +532,18 @@ async function uploadManifest(
window.storage.put('manifestVersion', version);
conflictBackOff.reset();
backOff.reset();
await window.textsecure.messaging.sendFetchManifestSyncMessage();
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'uploadManifest: We are primary device; not sending sync manifest'
);
return;
}
await handleMessageSend(
window.textsecure.messaging.sendFetchManifestSyncMessage(),
{ messageIds: [], sendType: 'otherSync' }
);
}
async function stopStorageServiceSync() {
@ -552,7 +564,18 @@ async function stopStorageServiceSync() {
if (!window.textsecure.messaging) {
throw new Error('storageService.stopStorageServiceSync: We are offline!');
}
window.textsecure.messaging.sendRequestKeySyncMessage();
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'stopStorageServiceSync: We are primary device; not sending key sync request'
);
return;
}
handleMessageSend(window.textsecure.messaging.sendRequestKeySyncMessage(), {
messageIds: [],
sendType: 'otherSync',
});
});
}
@ -1106,7 +1129,18 @@ async function upload(fromSync = false): Promise<void> {
'storageService.upload: no storageKey, requesting new keys'
);
backOff.reset();
await window.textsecure.messaging.sendRequestKeySyncMessage();
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'upload: We are primary device; not sending key sync request'
);
return;
}
await handleMessageSend(
window.textsecure.messaging.sendRequestKeySyncMessage(),
{ messageIds: [], sendType: 'otherSync' }
);
return;
}

View File

@ -1,19 +1,19 @@
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { handleMessageSend } from '../util/handleMessageSend';
import { getSendOptions } from '../util/getSendOptions';
export async function sendStickerPackSync(
packId: string,
packKey: string,
installed: boolean
): Promise<void> {
const { ConversationController, textsecure, log } = window;
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = await ConversationController.prepareForSend(
ourNumber,
{
syncMessage: true,
}
);
const ourConversation = ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
if (!textsecure.messaging) {
log.error(
@ -23,7 +23,14 @@ export async function sendStickerPackSync(
return;
}
wrap(
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'shims/sendStickerPackSync: We are primary device; not sending sync'
);
return;
}
handleMessageSend(
textsecure.messaging.sendStickerPackSync(
[
{
@ -33,7 +40,8 @@ export async function sendStickerPackSync(
},
],
sendOptions
)
),
{ messageIds: [], sendType: 'otherSync' }
).catch(error => {
log.error(
'shim: Error calling sendStickerPackSync:',

View File

@ -14,7 +14,6 @@ import {
cloneDeep,
compact,
fromPairs,
toPairs,
get,
groupBy,
isFunction,
@ -22,6 +21,8 @@ import {
map,
omit,
set,
toPairs,
uniq,
} from 'lodash';
import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto';
@ -41,8 +42,8 @@ import { StoredJob } from '../jobs/types';
import {
AttachmentDownloadJobType,
ClientInterface,
ClientSearchResultMessageType,
ClientJobType,
ClientSearchResultMessageType,
ConversationType,
IdentityKeyType,
ItemKeyType,
@ -52,6 +53,12 @@ import {
PreKeyType,
SearchResultMessageType,
SenderKeyType,
SentMessageDBType,
SentMessagesType,
SentProtoType,
SentProtoWithMessageIdsType,
SentRecipientsDBType,
SentRecipientsType,
ServerInterface,
SessionType,
SignedPreKeyType,
@ -143,6 +150,17 @@ const dataInterface: ClientInterface = {
getAllSenderKeys,
removeSenderKeyById,
insertSentProto,
deleteSentProtosOlderThan,
deleteSentProtoByMessageId,
insertProtoRecipients,
deleteSentProtoRecipient,
getSentProtoByRecipient,
removeAllSentProtos,
getAllSentProtos,
_getAllSentProtoRecipients,
_getAllSentProtoMessageIds,
createOrUpdateSession,
createOrUpdateSessions,
commitSessionsAndUnprocessed,
@ -771,6 +789,66 @@ async function removeSenderKeyById(id: string): Promise<void> {
return channels.removeSenderKeyById(id);
}
// Sent Protos
async function insertSentProto(
proto: SentProtoType,
options: {
messageIds: SentMessagesType;
recipients: SentRecipientsType;
}
): Promise<number> {
return channels.insertSentProto(proto, {
...options,
messageIds: uniq(options.messageIds),
});
}
async function deleteSentProtosOlderThan(timestamp: number): Promise<void> {
await channels.deleteSentProtosOlderThan(timestamp);
}
async function deleteSentProtoByMessageId(messageId: string): Promise<void> {
await channels.deleteSentProtoByMessageId(messageId);
}
async function insertProtoRecipients(options: {
id: number;
recipientUuid: string;
deviceIds: Array<number>;
}): Promise<void> {
await channels.insertProtoRecipients(options);
}
async function deleteSentProtoRecipient(options: {
timestamp: number;
recipientUuid: string;
deviceId: number;
}): Promise<void> {
await channels.deleteSentProtoRecipient(options);
}
async function getSentProtoByRecipient(options: {
now: number;
recipientUuid: string;
timestamp: number;
}): Promise<SentProtoWithMessageIdsType | undefined> {
return channels.getSentProtoByRecipient(options);
}
async function removeAllSentProtos(): Promise<void> {
await channels.removeAllSentProtos();
}
async function getAllSentProtos(): Promise<Array<SentProtoType>> {
return channels.getAllSentProtos();
}
// Test-only:
async function _getAllSentProtoRecipients(): Promise<
Array<SentRecipientsDBType>
> {
return channels._getAllSentProtoRecipients();
}
async function _getAllSentProtoMessageIds(): Promise<Array<SentMessageDBType>> {
return channels._getAllSentProtoMessageIds();
}
// Sessions
async function createOrUpdateSession(data: SessionType) {

View File

@ -17,6 +17,7 @@ import type { ReactionType } from '../types/Reactions';
import type { ConversationColorType, CustomColorType } from '../types/Colors';
import { StorageAccessType } from '../types/Storage.d';
import type { AttachmentType } from '../types/Attachment';
import { BodyRangesType } from '../types/Util';
export type AttachmentDownloadJobTypeType =
| 'long-message'
@ -83,9 +84,32 @@ export type SearchResultMessageType = {
};
export type ClientSearchResultMessageType = MessageType & {
json: string;
bodyRanges: [];
bodyRanges: BodyRangesType;
snippet: string;
};
export type SentProtoType = {
contentHint: number;
proto: Buffer;
timestamp: number;
};
export type SentProtoWithMessageIdsType = SentProtoType & {
messageIds: Array<string>;
};
export type SentRecipientsType = Record<string, Array<number>>;
export type SentMessagesType = Array<string>;
// These two are for test only
export type SentRecipientsDBType = {
payloadId: number;
recipientUuid: string;
deviceId: number;
};
export type SentMessageDBType = {
payloadId: number;
messageId: string;
};
export type SenderKeyType = {
// Primary key
id: string;
@ -215,6 +239,36 @@ export type DataInterface = {
getAllSenderKeys: () => Promise<Array<SenderKeyType>>;
removeSenderKeyById: (id: string) => Promise<void>;
insertSentProto: (
proto: SentProtoType,
options: {
recipients: SentRecipientsType;
messageIds: SentMessagesType;
}
) => Promise<number>;
deleteSentProtosOlderThan: (timestamp: number) => Promise<void>;
deleteSentProtoByMessageId: (messageId: string) => Promise<void>;
insertProtoRecipients: (options: {
id: number;
recipientUuid: string;
deviceIds: Array<number>;
}) => Promise<void>;
deleteSentProtoRecipient: (options: {
timestamp: number;
recipientUuid: string;
deviceId: number;
}) => Promise<void>;
getSentProtoByRecipient: (options: {
now: number;
recipientUuid: string;
timestamp: number;
}) => Promise<SentProtoWithMessageIdsType | undefined>;
removeAllSentProtos: () => Promise<void>;
getAllSentProtos: () => Promise<Array<SentProtoType>>;
// Test-only
_getAllSentProtoRecipients: () => Promise<Array<SentRecipientsDBType>>;
_getAllSentProtoMessageIds: () => Promise<Array<SentMessageDBType>>;
createOrUpdateSession: (data: SessionType) => Promise<void>;
createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>;
commitSessionsAndUnprocessed(options: {
@ -255,6 +309,36 @@ export type DataInterface = {
) => Promise<void>;
getNextTapToViewMessageTimestampToAgeOut: () => Promise<undefined | number>;
getUnreadCountForConversation: (conversationId: string) => Promise<number>;
getUnreadByConversationAndMarkRead: (
conversationId: string,
newestUnreadId: number,
readAt?: number
) => Promise<
Array<
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
>
>;
getUnreadReactionsAndMarkRead: (
conversationId: string,
newestUnreadId: number
) => Promise<
Array<
Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp' | 'messageId'>
>
>;
markReactionAsRead: (
targetAuthorUuid: string,
targetTimestamp: number
) => Promise<ReactionType | undefined>;
removeReactionFromConversation: (reaction: {
emoji: string;
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}) => Promise<void>;
addReaction: (reactionObj: ReactionType) => Promise<void>;
getUnprocessedCount: () => Promise<number>;
getAllUnprocessed: () => Promise<Array<UnprocessedType>>;
updateUnprocessedAttempts: (id: string, attempts: number) => Promise<void>;
@ -391,33 +475,6 @@ export type ServerInterface = DataInterface & {
ourConversationId: string;
}) => Promise<MessageType | undefined>;
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>;
getUnreadCountForConversation: (conversationId: string) => Promise<number>;
getUnreadByConversationAndMarkRead: (
conversationId: string,
newestUnreadId: number,
readAt?: number
) => Promise<
Array<
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
>
>;
getUnreadReactionsAndMarkRead: (
conversationId: string,
newestUnreadId: number
) => Promise<
Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>
>;
markReactionAsRead: (
targetAuthorUuid: string,
targetTimestamp: number
) => Promise<ReactionType | undefined>;
removeReactionFromConversation: (reaction: {
emoji: string;
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}) => Promise<void>;
addReaction: (reactionObj: ReactionType) => Promise<void>;
removeConversation: (id: Array<string> | string) => Promise<void>;
removeMessage: (id: string) => Promise<void>;
removeMessages: (ids: Array<string>) => Promise<void>;
@ -530,33 +587,6 @@ export type ClientInterface = DataInterface & {
getTapToViewMessagesNeedingErase: (options: {
MessageCollection: typeof MessageModelCollectionType;
}) => Promise<MessageModelCollectionType>;
getUnreadCountForConversation: (conversationId: string) => Promise<number>;
getUnreadByConversationAndMarkRead: (
conversationId: string,
newestUnreadId: number,
readAt?: number
) => Promise<
Array<
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
>
>;
getUnreadReactionsAndMarkRead: (
conversationId: string,
newestUnreadId: number
) => Promise<
Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>
>;
markReactionAsRead: (
targetAuthorUuid: string,
targetTimestamp: number
) => Promise<ReactionType | undefined>;
removeReactionFromConversation: (reaction: {
emoji: string;
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}) => Promise<void>;
addReaction: (reactionObj: ReactionType) => Promise<void>;
removeConversation: (
id: string,
options: { Conversation: typeof ConversationModel }

View File

@ -36,23 +36,30 @@ import { combineNames } from '../util/combineNames';
import { dropNull } from '../util/dropNull';
import { isNormalNumber } from '../util/isNormalNumber';
import { isNotNil } from '../util/isNotNil';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { ConversationColorType, CustomColorType } from '../types/Colors';
import {
AllItemsType,
AttachmentDownloadJobType,
ConversationMetricsType,
ConversationType,
EmojiType,
IdentityKeyType,
AllItemsType,
ItemKeyType,
ItemType,
MessageMetricsType,
MessageType,
MessageTypeUnhydrated,
MessageMetricsType,
PreKeyType,
SearchResultMessageType,
SenderKeyType,
SentMessageDBType,
SentMessagesType,
SentProtoType,
SentProtoWithMessageIdsType,
SentRecipientsDBType,
SentRecipientsType,
ServerInterface,
SessionType,
SignedPreKeyType,
@ -63,14 +70,6 @@ import {
UnprocessedUpdateType,
} from './Interface';
declare global {
// We want to extend `Function`'s properties, so we need to use an interface.
// eslint-disable-next-line no-restricted-syntax
interface Function {
needsSerial?: boolean;
}
}
type JSONRows = Array<{ readonly json: string }>;
type ConversationRow = Readonly<{
json: string;
@ -137,6 +136,17 @@ const dataInterface: ServerInterface = {
getAllSenderKeys,
removeSenderKeyById,
insertSentProto,
deleteSentProtosOlderThan,
deleteSentProtoByMessageId,
insertProtoRecipients,
deleteSentProtoRecipient,
getSentProtoByRecipient,
removeAllSentProtos,
getAllSentProtos,
_getAllSentProtoRecipients,
_getAllSentProtoMessageIds,
createOrUpdateSession,
createOrUpdateSessions,
commitSessionsAndUnprocessed,
@ -253,16 +263,16 @@ type DatabaseQueryCache = Map<string, Statement<Array<any>>>;
const statementCache = new WeakMap<Database, DatabaseQueryCache>();
function prepare(db: Database, query: string): Statement<Query> {
function prepare<T>(db: Database, query: string): Statement<T> {
let dbCache = statementCache.get(db);
if (!dbCache) {
dbCache = new Map();
statementCache.set(db, dbCache);
}
let result = dbCache.get(query);
let result = dbCache.get(query) as Statement<T>;
if (!result) {
result = db.prepare(query);
result = db.prepare<T>(query);
dbCache.set(query, result);
}
@ -1947,6 +1957,84 @@ function updateToSchemaVersion36(currentVersion: number, db: Database) {
console.log('updateToSchemaVersion36: success!');
}
function updateToSchemaVersion37(currentVersion: number, db: Database) {
if (currentVersion >= 37) {
return;
}
db.transaction(() => {
db.exec(`
-- Create send log primary table
CREATE TABLE sendLogPayloads(
id INTEGER PRIMARY KEY ASC,
timestamp INTEGER NOT NULL,
contentHint INTEGER NOT NULL,
proto BLOB NOT NULL
);
CREATE INDEX sendLogPayloadsByTimestamp ON sendLogPayloads (timestamp);
-- Create send log recipients table with foreign key relationship to payloads
CREATE TABLE sendLogRecipients(
payloadId INTEGER NOT NULL,
recipientUuid STRING NOT NULL,
deviceId INTEGER NOT NULL,
PRIMARY KEY (payloadId, recipientUuid, deviceId),
CONSTRAINT sendLogRecipientsForeignKey
FOREIGN KEY (payloadId)
REFERENCES sendLogPayloads(id)
ON DELETE CASCADE
);
CREATE INDEX sendLogRecipientsByRecipient
ON sendLogRecipients (recipientUuid, deviceId);
-- Create send log messages table with foreign key relationship to payloads
CREATE TABLE sendLogMessageIds(
payloadId INTEGER NOT NULL,
messageId STRING NOT NULL,
PRIMARY KEY (payloadId, messageId),
CONSTRAINT sendLogMessageIdsForeignKey
FOREIGN KEY (payloadId)
REFERENCES sendLogPayloads(id)
ON DELETE CASCADE
);
CREATE INDEX sendLogMessageIdsByMessage
ON sendLogMessageIds (messageId);
-- Recreate messages table delete trigger with send log support
DROP TRIGGER messages_on_delete;
CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.rowid;
DELETE FROM sendLogPayloads WHERE id IN (
SELECT payloadId FROM sendLogMessageIds
WHERE messageId = old.id
);
END;
--- Add messageId column to reactions table to properly track proto associations
ALTER TABLE reactions ADD column messageId STRING;
`);
db.pragma('user_version = 37');
})();
console.log('updateToSchemaVersion37: success!');
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -1984,6 +2072,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion34,
updateToSchemaVersion35,
updateToSchemaVersion36,
updateToSchemaVersion37,
];
function updateSchema(db: Database): void {
@ -2350,11 +2439,11 @@ async function getSenderKeyById(
}
async function removeAllSenderKeys(): Promise<void> {
const db = getInstance();
prepare(db, 'DELETE FROM senderKeys').run({});
prepare<EmptyQuery>(db, 'DELETE FROM senderKeys').run();
}
async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
const db = getInstance();
const rows = prepare(db, 'SELECT * FROM senderKeys').all({});
const rows = prepare<EmptyQuery>(db, 'SELECT * FROM senderKeys').all();
return rows;
}
@ -2363,6 +2452,317 @@ async function removeSenderKeyById(id: string): Promise<void> {
prepare(db, 'DELETE FROM senderKeys WHERE id = $id').run({ id });
}
async function insertSentProto(
proto: SentProtoType,
options: {
recipients: SentRecipientsType;
messageIds: SentMessagesType;
}
): Promise<number> {
const db = getInstance();
const { recipients, messageIds } = options;
// Note: we use `pluck` in this function to fetch only the first column of returned row.
return db.transaction(() => {
// 1. Insert the payload, fetching its primary key id
const info = prepare(
db,
`
INSERT INTO sendLogPayloads (
contentHint,
proto,
timestamp
) VALUES (
$contentHint,
$proto,
$timestamp
);
`
).run(proto);
const id = parseIntOrThrow(
info.lastInsertRowid,
'insertSentProto/lastInsertRowid'
);
// 2. Insert a record for each recipient device.
const recipientStatement = prepare(
db,
`
INSERT INTO sendLogRecipients (
payloadId,
recipientUuid,
deviceId
) VALUES (
$id,
$recipientUuid,
$deviceId
);
`
);
const recipientUuids = Object.keys(recipients);
for (const recipientUuid of recipientUuids) {
const deviceIds = recipients[recipientUuid];
for (const deviceId of deviceIds) {
recipientStatement.run({
id,
recipientUuid,
deviceId,
});
}
}
// 2. Insert a record for each message referenced by this payload.
const messageStatement = prepare(
db,
`
INSERT INTO sendLogMessageIds (
payloadId,
messageId
) VALUES (
$id,
$messageId
);
`
);
for (const messageId of messageIds) {
messageStatement.run({
id,
messageId,
});
}
return id;
})();
}
async function deleteSentProtosOlderThan(timestamp: number): Promise<void> {
const db = getInstance();
prepare(
db,
`
DELETE FROM sendLogPayloads
WHERE
timestamp IS NULL OR
timestamp < $timestamp;
`
).run({
timestamp,
});
}
async function deleteSentProtoByMessageId(messageId: string): Promise<void> {
const db = getInstance();
prepare(
db,
`
DELETE FROM sendLogPayloads WHERE id IN (
SELECT payloadId FROM sendLogMessageIds
WHERE messageId = $messageId
);
`
).run({
messageId,
});
}
async function insertProtoRecipients({
id,
recipientUuid,
deviceIds,
}: {
id: number;
recipientUuid: string;
deviceIds: Array<number>;
}): Promise<void> {
const db = getInstance();
db.transaction(() => {
const statement = prepare(
db,
`
INSERT INTO sendLogRecipients (
payloadId,
recipientUuid,
deviceId
) VALUES (
$id,
$recipientUuid,
$deviceId
);
`
);
for (const deviceId of deviceIds) {
statement.run({
id,
recipientUuid,
deviceId,
});
}
})();
}
async function deleteSentProtoRecipient({
timestamp,
recipientUuid,
deviceId,
}: {
timestamp: number;
recipientUuid: string;
deviceId: number;
}): Promise<void> {
const db = getInstance();
// Note: we use `pluck` in this function to fetch only the first column of returned row.
db.transaction(() => {
// 1. Figure out what payload we're talking about.
const rows = prepare(
db,
`
SELECT sendLogPayloads.id FROM sendLogPayloads
INNER JOIN sendLogRecipients
ON sendLogRecipients.payloadId = sendLogPayloads.id
WHERE
sendLogPayloads.timestamp = $timestamp AND
sendLogRecipients.recipientUuid = $recipientUuid AND
sendLogRecipients.deviceId = $deviceId;
`
).all({ timestamp, recipientUuid, deviceId });
if (!rows.length) {
return;
}
if (rows.length > 1) {
console.warn(
`deleteSentProtoRecipient: More than one payload matches recipient and timestamp ${timestamp}. Using the first.`
);
return;
}
const { id } = rows[0];
// 2. Delete the recipient/device combination in question.
prepare(
db,
`
DELETE FROM sendLogRecipients
WHERE
payloadId = $id AND
recipientUuid = $recipientUuid AND
deviceId = $deviceId;
`
).run({ id, recipientUuid, deviceId });
// 3. See how many more recipient devices there were for this payload.
const remaining = prepare(
db,
'SELECT count(*) FROM sendLogRecipients WHERE payloadId = $id;'
)
.pluck(true)
.get({ id });
if (!isNumber(remaining)) {
throw new Error(
'deleteSentProtoRecipient: select count() returned non-number!'
);
}
if (remaining > 0) {
return;
}
// 4. Delete the entire payload if there are no more recipients left.
console.info(
`deleteSentProtoRecipient: Deleting proto payload for timestamp ${timestamp}`
);
prepare(db, 'DELETE FROM sendLogPayloads WHERE id = $id;').run({
id,
});
})();
}
async function getSentProtoByRecipient({
now,
recipientUuid,
timestamp,
}: {
now: number;
recipientUuid: string;
timestamp: number;
}): Promise<SentProtoWithMessageIdsType | undefined> {
const db = getInstance();
const HOUR = 1000 * 60 * 60;
const oneDayAgo = now - HOUR * 24;
await deleteSentProtosOlderThan(oneDayAgo);
const row = prepare(
db,
`
SELECT
sendLogPayloads.*,
GROUP_CONCAT(DISTINCT sendLogMessageIds.messageId) AS messageIds
FROM sendLogPayloads
INNER JOIN sendLogRecipients ON sendLogRecipients.payloadId = sendLogPayloads.id
LEFT JOIN sendLogMessageIds ON sendLogMessageIds.payloadId = sendLogPayloads.id
WHERE
sendLogPayloads.timestamp = $timestamp AND
sendLogRecipients.recipientUuid = $recipientUuid
GROUP BY sendLogPayloads.id;
`
).get({
timestamp,
recipientUuid,
});
if (!row) {
return undefined;
}
const { messageIds } = row;
return {
...row,
messageIds: messageIds ? messageIds.split(',') : [],
};
}
async function removeAllSentProtos(): Promise<void> {
const db = getInstance();
prepare<EmptyQuery>(db, 'DELETE FROM sendLogPayloads;').run();
}
async function getAllSentProtos(): Promise<Array<SentProtoType>> {
const db = getInstance();
const rows = prepare<EmptyQuery>(db, 'SELECT * FROM sendLogPayloads;').all();
return rows;
}
async function _getAllSentProtoRecipients(): Promise<
Array<SentRecipientsDBType>
> {
const db = getInstance();
const rows = prepare<EmptyQuery>(
db,
'SELECT * FROM sendLogRecipients;'
).all();
return rows;
}
async function _getAllSentProtoMessageIds(): Promise<Array<SentMessageDBType>> {
const db = getInstance();
const rows = prepare<EmptyQuery>(
db,
'SELECT * FROM sendLogMessageIds;'
).all();
return rows;
}
const SESSIONS_TABLE = 'sessions';
function createOrUpdateSessionSync(data: SessionType): void {
const db = getInstance();
@ -2717,8 +3117,7 @@ function updateConversationSync(data: ConversationType): void {
? members.join(' ')
: null;
prepare(
db,
db.prepare(
`
UPDATE conversations SET
json = $json,
@ -3470,13 +3869,18 @@ async function getUnreadByConversationAndMarkRead(
async function getUnreadReactionsAndMarkRead(
conversationId: string,
newestUnreadId: number
): Promise<Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>> {
): Promise<
Array<
Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp' | 'messageId'>
>
> {
const db = getInstance();
return db.transaction(() => {
const unreadMessages = db
.prepare<Query>(
`
SELECT targetAuthorUuid, targetTimestamp
SELECT targetAuthorUuid, targetTimestamp, messageId
FROM reactions WHERE
unread = 1 AND
conversationId = $conversationId AND
@ -3548,6 +3952,7 @@ async function addReaction({
conversationId,
emoji,
fromId,
messageId,
messageReceivedAt,
targetAuthorUuid,
targetTimestamp,
@ -3559,6 +3964,7 @@ async function addReaction({
conversationId,
emoji,
fromId,
messageId,
messageReceivedAt,
targetAuthorUuid,
targetTimestamp,
@ -3567,6 +3973,7 @@ async function addReaction({
$conversationId,
$emoji,
$fromId,
$messageId,
$messageReceivedAt,
$targetAuthorUuid,
$targetTimestamp,
@ -3577,6 +3984,7 @@ async function addReaction({
conversationId,
emoji,
fromId,
messageId,
messageReceivedAt,
targetAuthorUuid,
targetTimestamp,

View File

@ -5,6 +5,7 @@ import { assert } from 'chai';
import * as sinon from 'sinon';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { CallbackResultType } from '../../textsecure/SendMessage';
import { SignalService as Proto } from '../../protobuf';
describe('Message', () => {
@ -71,7 +72,16 @@ describe('Message', () => {
it('updates the `sent` attribute', async () => {
const message = createMessage({ type: 'outgoing', source, sent: false });
await message.send(Promise.resolve({}));
const promise: Promise<CallbackResultType> = Promise.resolve({
successfulIdentifiers: [window.getGuid(), window.getGuid()],
errors: [
Object.assign(new Error('failed'), {
identifier: window.getGuid(),
}),
],
});
await message.send(promise);
assert.isTrue(message.get('sent'));
});

View File

@ -0,0 +1,591 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as getGuid } from 'uuid';
import { assert } from 'chai';
import dataInterface from '../../sql/Client';
import {
constantTimeEqual,
getRandomBytes,
typedArrayToArrayBuffer,
} from '../../Crypto';
const {
_getAllSentProtoMessageIds,
_getAllSentProtoRecipients,
deleteSentProtoByMessageId,
deleteSentProtoRecipient,
deleteSentProtosOlderThan,
getAllSentProtos,
getSentProtoByRecipient,
insertProtoRecipients,
insertSentProto,
removeAllSentProtos,
removeMessage,
saveMessage,
} = dataInterface;
describe('sendLog', () => {
beforeEach(async () => {
await removeAllSentProtos();
});
it('roundtrips with insertSentProto/getAllSentProtos', async () => {
const bytes = Buffer.from(getRandomBytes(128));
const timestamp = Date.now();
const proto = {
contentHint: 1,
proto: bytes,
timestamp,
};
await insertSentProto(proto, {
messageIds: [getGuid()],
recipients: {
[getGuid()]: [1, 2],
},
});
const allProtos = await getAllSentProtos();
assert.lengthOf(allProtos, 1);
const actual = allProtos[0];
assert.strictEqual(actual.contentHint, proto.contentHint);
assert.isTrue(
constantTimeEqual(
typedArrayToArrayBuffer(actual.proto),
typedArrayToArrayBuffer(proto.proto)
)
);
assert.strictEqual(actual.timestamp, proto.timestamp);
await removeAllSentProtos();
assert.lengthOf(await getAllSentProtos(), 0);
});
it('cascades deletes into both tables with foreign keys', async () => {
assert.lengthOf(await getAllSentProtos(), 0);
assert.lengthOf(await _getAllSentProtoMessageIds(), 0);
assert.lengthOf(await _getAllSentProtoRecipients(), 0);
const bytes = Buffer.from(getRandomBytes(128));
const timestamp = Date.now();
const proto = {
contentHint: 1,
proto: bytes,
timestamp,
};
await insertSentProto(proto, {
messageIds: [getGuid(), getGuid()],
recipients: {
[getGuid()]: [1, 2],
[getGuid()]: [1],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoMessageIds(), 2);
assert.lengthOf(await _getAllSentProtoRecipients(), 3);
await removeAllSentProtos();
assert.lengthOf(await getAllSentProtos(), 0);
assert.lengthOf(await _getAllSentProtoMessageIds(), 0);
assert.lengthOf(await _getAllSentProtoRecipients(), 0);
});
it('trigger deletes payload when referenced message is deleted', async () => {
const id = getGuid();
const timestamp = Date.now();
await saveMessage(
{
id,
body: 'some text',
conversationId: getGuid(),
received_at: timestamp,
sent_at: timestamp,
timestamp,
type: 'outgoing',
},
{ forceSave: true, Message: window.Whisper.Message }
);
const bytes = Buffer.from(getRandomBytes(128));
const proto = {
contentHint: 1,
proto: bytes,
timestamp,
};
await insertSentProto(proto, {
messageIds: [id],
recipients: {
[getGuid()]: [1, 2],
},
});
const allProtos = await getAllSentProtos();
assert.lengthOf(allProtos, 1);
const actual = allProtos[0];
assert.strictEqual(actual.timestamp, proto.timestamp);
await removeMessage(id, { Message: window.Whisper.Message });
assert.lengthOf(await getAllSentProtos(), 0);
});
describe('#insertSentProto', () => {
it('supports adding duplicates', async () => {
const timestamp = Date.now();
const messageIds = [getGuid()];
const recipients = {
[getGuid()]: [1],
};
const proto1 = {
contentHint: 7,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
const proto2 = {
contentHint: 9,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
assert.lengthOf(await getAllSentProtos(), 0);
assert.lengthOf(await _getAllSentProtoMessageIds(), 0);
assert.lengthOf(await _getAllSentProtoRecipients(), 0);
await insertSentProto(proto1, { messageIds, recipients });
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoMessageIds(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 1);
await insertSentProto(proto2, { messageIds, recipients });
assert.lengthOf(await getAllSentProtos(), 2);
assert.lengthOf(await _getAllSentProtoMessageIds(), 2);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
});
});
describe('#insertProtoRecipients', () => {
it('handles duplicates, adding new recipients if needed', async () => {
const timestamp = Date.now();
const messageIds = [getGuid()];
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
assert.lengthOf(await getAllSentProtos(), 0);
assert.lengthOf(await _getAllSentProtoMessageIds(), 0);
assert.lengthOf(await _getAllSentProtoRecipients(), 0);
const id = await insertSentProto(proto, {
messageIds,
recipients: {
[getGuid()]: [1],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoMessageIds(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 1);
const recipientUuid = getGuid();
await insertProtoRecipients({
id,
recipientUuid,
deviceIds: [1, 2],
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoMessageIds(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 3);
});
});
describe('#deleteSentProtosOlderThan', () => {
it('deletes all older timestamps', async () => {
const timestamp = Date.now();
const proto1 = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp: timestamp + 10,
};
const proto2 = {
contentHint: 2,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
const proto3 = {
contentHint: 0,
proto: Buffer.from(getRandomBytes(128)),
timestamp: timestamp - 15,
};
await insertSentProto(proto1, {
messageIds: [getGuid()],
recipients: {
[getGuid()]: [1],
},
});
await insertSentProto(proto2, {
messageIds: [getGuid()],
recipients: {
[getGuid()]: [1, 2],
},
});
await insertSentProto(proto3, {
messageIds: [getGuid()],
recipients: {
[getGuid()]: [1, 2, 3],
},
});
assert.lengthOf(await getAllSentProtos(), 3);
await deleteSentProtosOlderThan(timestamp);
const allProtos = await getAllSentProtos();
assert.lengthOf(allProtos, 2);
const actual1 = allProtos[0];
assert.strictEqual(actual1.contentHint, proto1.contentHint);
assert.isTrue(
constantTimeEqual(
typedArrayToArrayBuffer(actual1.proto),
typedArrayToArrayBuffer(proto1.proto)
)
);
assert.strictEqual(actual1.timestamp, proto1.timestamp);
const actual2 = allProtos[1];
assert.strictEqual(actual2.contentHint, proto2.contentHint);
assert.isTrue(
constantTimeEqual(
typedArrayToArrayBuffer(actual2.proto),
typedArrayToArrayBuffer(proto2.proto)
)
);
assert.strictEqual(actual2.timestamp, proto2.timestamp);
});
});
describe('#deleteSentProtoByMessageId', () => {
it('deletes all records releated to that messageId', async () => {
assert.lengthOf(await getAllSentProtos(), 0);
assert.lengthOf(await _getAllSentProtoMessageIds(), 0);
assert.lengthOf(await _getAllSentProtoRecipients(), 0);
const messageId = getGuid();
const timestamp = Date.now();
const proto1 = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
const proto2 = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp: timestamp - 10,
};
const proto3 = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp: timestamp - 20,
};
await insertSentProto(proto1, {
messageIds: [messageId, getGuid()],
recipients: {
[getGuid()]: [1, 2],
[getGuid()]: [1],
},
});
await insertSentProto(proto2, {
messageIds: [messageId],
recipients: {
[getGuid()]: [1],
},
});
await insertSentProto(proto3, {
messageIds: [getGuid()],
recipients: {
[getGuid()]: [1],
},
});
assert.lengthOf(await getAllSentProtos(), 3);
assert.lengthOf(await _getAllSentProtoMessageIds(), 4);
assert.lengthOf(await _getAllSentProtoRecipients(), 5);
await deleteSentProtoByMessageId(messageId);
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoMessageIds(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 1);
});
});
describe('#deleteSentProtoRecipient', () => {
it('does not delete payload if recipient remains', async () => {
const timestamp = Date.now();
const recipientUuid1 = getGuid();
const recipientUuid2 = getGuid();
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
await insertSentProto(proto, {
messageIds: [getGuid()],
recipients: {
[recipientUuid1]: [1, 2],
[recipientUuid2]: [1],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 3);
await deleteSentProtoRecipient({
timestamp,
recipientUuid: recipientUuid1,
deviceId: 1,
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
});
it('deletes payload if no recipients remain', async () => {
const timestamp = Date.now();
const recipientUuid1 = getGuid();
const recipientUuid2 = getGuid();
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
await insertSentProto(proto, {
messageIds: [getGuid()],
recipients: {
[recipientUuid1]: [1, 2],
[recipientUuid2]: [1],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 3);
await deleteSentProtoRecipient({
timestamp,
recipientUuid: recipientUuid1,
deviceId: 1,
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
await deleteSentProtoRecipient({
timestamp,
recipientUuid: recipientUuid1,
deviceId: 2,
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 1);
await deleteSentProtoRecipient({
timestamp,
recipientUuid: recipientUuid2,
deviceId: 1,
});
assert.lengthOf(await getAllSentProtos(), 0);
assert.lengthOf(await _getAllSentProtoRecipients(), 0);
});
});
describe('#getSentProtoByRecipient', () => {
it('returns matching payload', async () => {
const timestamp = Date.now();
const recipientUuid = getGuid();
const messageIds = [getGuid(), getGuid()];
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
await insertSentProto(proto, {
messageIds,
recipients: {
[recipientUuid]: [1, 2],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
assert.lengthOf(await _getAllSentProtoMessageIds(), 2);
const actual = await getSentProtoByRecipient({
now: timestamp,
timestamp,
recipientUuid,
});
if (!actual) {
throw new Error('Failed to fetch proto!');
}
assert.strictEqual(actual.contentHint, proto.contentHint);
assert.isTrue(
constantTimeEqual(
typedArrayToArrayBuffer(actual.proto),
typedArrayToArrayBuffer(proto.proto)
)
);
assert.strictEqual(actual.timestamp, proto.timestamp);
assert.sameMembers(actual.messageIds, messageIds);
});
it('returns matching payload with no messageIds', async () => {
const timestamp = Date.now();
const recipientUuid = getGuid();
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
await insertSentProto(proto, {
messageIds: [],
recipients: {
[recipientUuid]: [1, 2],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
assert.lengthOf(await _getAllSentProtoMessageIds(), 0);
const actual = await getSentProtoByRecipient({
now: timestamp,
timestamp,
recipientUuid,
});
if (!actual) {
throw new Error('Failed to fetch proto!');
}
assert.strictEqual(actual.contentHint, proto.contentHint);
assert.isTrue(
constantTimeEqual(
typedArrayToArrayBuffer(actual.proto),
typedArrayToArrayBuffer(proto.proto)
)
);
assert.strictEqual(actual.timestamp, proto.timestamp);
assert.deepEqual(actual.messageIds, []);
});
it('returns nothing if payload does not have recipient', async () => {
const timestamp = Date.now();
const recipientUuid = getGuid();
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
await insertSentProto(proto, {
messageIds: [getGuid()],
recipients: {
[recipientUuid]: [1, 2],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
const actual = await getSentProtoByRecipient({
now: timestamp,
timestamp,
recipientUuid: getGuid(),
});
assert.isUndefined(actual);
});
it('returns nothing if timestamp does not match', async () => {
const timestamp = Date.now();
const recipientUuid = getGuid();
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
await insertSentProto(proto, {
messageIds: [getGuid()],
recipients: {
[recipientUuid]: [1, 2],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
const actual = await getSentProtoByRecipient({
now: timestamp,
timestamp: timestamp + 1,
recipientUuid,
});
assert.isUndefined(actual);
});
it('returns nothing if timestamp proto is too old', async () => {
const TWO_DAYS = 2 * 24 * 60 * 60 * 1000;
const timestamp = Date.now();
const recipientUuid = getGuid();
const proto = {
contentHint: 1,
proto: Buffer.from(getRandomBytes(128)),
timestamp,
};
await insertSentProto(proto, {
messageIds: [getGuid()],
recipients: {
[recipientUuid]: [1, 2],
},
});
assert.lengthOf(await getAllSentProtos(), 1);
assert.lengthOf(await _getAllSentProtoRecipients(), 2);
const actual = await getSentProtoByRecipient({
now: timestamp + TWO_DAYS,
timestamp,
recipientUuid,
});
assert.isUndefined(actual);
assert.lengthOf(await getAllSentProtos(), 0);
assert.lengthOf(await _getAllSentProtoRecipients(), 0);
});
});
});

View File

@ -1028,7 +1028,7 @@ class MessageReceiverInner extends EventTarget {
} catch (error) {
const args = [
'queueEncryptedEnvelope error handling envelope',
this.getEnvelopeId(envelope),
this.getEnvelopeId(error.envelope || envelope),
':',
error && error.extra ? JSON.stringify(error.extra) : '',
error && error.stack ? error.stack : error,
@ -1587,7 +1587,10 @@ class MessageReceiverInner extends EventTarget {
});
// Avoid deadlocks by scheduling processing on decrypted queue
this.addToQueue(() => this.dispatchAndWait(event), TaskType.Decrypted);
this.addToQueue(
async () => this.dispatchEvent(event),
TaskType.Decrypted
);
} else {
const envelopeId = this.getEnvelopeId(newEnvelope);
window.log.error(
@ -1803,39 +1806,98 @@ class MessageReceiverInner extends EventTarget {
);
assert(envelope.content, 'Should have `content` field');
const result = await this.decrypt(stores, envelope, envelope.content);
if (!result.plaintext) {
window.log.warn('decryptContentMessage: plaintext was falsey');
return result;
}
return result;
}
async innerHandleContentMessage(
envelope: ProcessedEnvelope,
plaintext: Uint8Array
): Promise<void> {
const content = Proto.Content.decode(plaintext);
// Note: a distribution message can be tacked on to any other message, so we
// make sure to process it first. If that fails, we still try to process
// the rest of the message.
// Note: we need to process this as part of decryption, because we might need this
// sender key to decrypt the next message in the queue!
try {
const content = Proto.Content.decode(result.plaintext);
if (
content.senderKeyDistributionMessage &&
Bytes.isNotEmpty(content.senderKeyDistributionMessage)
) {
await this.handleSenderKeyDistributionMessage(
envelope,
stores,
result.envelope,
content.senderKeyDistributionMessage
);
}
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`innerHandleContentMessage: Failed to process sender key distribution message: ${errorString}`
`decryptContentMessage: Failed to process sender key distribution message: ${errorString}`
);
}
return result;
}
async maybeUpdateTimestamp(
envelope: ProcessedEnvelope
): Promise<ProcessedEnvelope> {
const { retryPlaceholders } = window.Signal.Services;
if (!retryPlaceholders) {
window.log.warn(
'maybeUpdateTimestamp: retry placeholders not available!'
);
return envelope;
}
const { timestamp } = envelope;
const identifier =
envelope.groupId || envelope.sourceUuid || envelope.source;
const conversation = window.ConversationController.get(identifier);
try {
if (!conversation) {
window.log.info(
`maybeUpdateTimestamp/${timestamp}: No conversation found for identifier ${identifier}`
);
return envelope;
}
const logId = `${conversation.idForLogging()}/${timestamp}`;
const item = await retryPlaceholders.findByMessageAndRemove(
conversation.id,
timestamp
);
if (item && item.wasOpened) {
window.log.info(
`maybeUpdateTimestamp/${logId}: found retry placeholder, but conversation was opened. No updates made.`
);
} else if (item) {
window.log.info(
`maybeUpdateTimestamp/${logId}: found retry placeholder. Updating receivedAtCounter/receivedAtDate`
);
return {
...envelope,
receivedAtCounter: item.receivedAtCounter,
receivedAtDate: item.receivedAt,
};
}
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`maybeUpdateTimestamp/${timestamp}: Failed to process sender key distribution message: ${errorString}`
);
}
return envelope;
}
async innerHandleContentMessage(
incomingEnvelope: ProcessedEnvelope,
plaintext: Uint8Array
): Promise<void> {
const content = Proto.Content.decode(plaintext);
const envelope = await this.maybeUpdateTimestamp(incomingEnvelope);
if (
content.decryptionErrorMessage &&
Bytes.isNotEmpty(content.decryptionErrorMessage)
@ -1908,10 +1970,11 @@ class MessageReceiverInner extends EventTarget {
senderDevice: request.deviceId(),
sentAt: request.timestamp(),
});
await this.dispatchAndWait(event);
await this.dispatchEvent(event);
}
async handleSenderKeyDistributionMessage(
stores: LockedStores,
envelope: ProcessedEnvelope,
distributionMessage: Uint8Array
): Promise<void> {
@ -1941,12 +2004,15 @@ class MessageReceiverInner extends EventTarget {
const senderKeyStore = new SenderKeys();
const address = `${identifier}.${sourceDevice}`;
await window.textsecure.storage.protocol.enqueueSenderKeyJob(address, () =>
processSenderKeyDistributionMessage(
sender,
senderKeyDistributionMessage,
senderKeyStore
)
await window.textsecure.storage.protocol.enqueueSenderKeyJob(
address,
() =>
processSenderKeyDistributionMessage(
sender,
senderKeyDistributionMessage,
senderKeyStore
),
stores.zone
);
}
@ -1989,6 +2055,7 @@ class MessageReceiverInner extends EventTarget {
envelopeTimestamp: envelope.timestamp,
source: envelope.source,
sourceUuid: envelope.sourceUuid,
sourceDevice: envelope.sourceDevice,
},
this.removeFromCache.bind(this, envelope)
);

View File

@ -48,6 +48,11 @@ export const enum SenderCertificateMode {
WithoutE164,
}
export type SendLogCallbackType = (options: {
identifier: string;
deviceIds: Array<number>;
}) => Promise<void>;
type SendMetadata = {
type: number;
destinationDeviceId: number;
@ -123,11 +128,11 @@ export default class OutgoingMessage {
errors: Array<CustomError>;
successfulIdentifiers: Array<unknown>;
successfulIdentifiers: Array<string>;
failoverIdentifiers: Array<unknown>;
failoverIdentifiers: Array<string>;
unidentifiedDeliveries: Array<unknown>;
unidentifiedDeliveries: Array<string>;
sendMetadata?: SendMetadataType;
@ -137,16 +142,31 @@ export default class OutgoingMessage {
contentHint: number;
constructor(
server: WebAPIType,
timestamp: number,
identifiers: Array<string>,
message: Proto.Content | Proto.DataMessage | PlaintextContent,
contentHint: number,
groupId: string | undefined,
callback: (result: CallbackResultType) => void,
options: OutgoingMessageOptionsType = {}
) {
recipients: Record<string, Array<number>>;
sendLogCallback?: SendLogCallbackType;
constructor({
callback,
contentHint,
groupId,
identifiers,
message,
options,
sendLogCallback,
server,
timestamp,
}: {
callback: (result: CallbackResultType) => void;
contentHint: number;
groupId: string | undefined;
identifiers: Array<string>;
message: Proto.Content | Proto.DataMessage | PlaintextContent;
options?: OutgoingMessageOptionsType;
sendLogCallback?: SendLogCallbackType;
server: WebAPIType;
timestamp: number;
}) {
if (message instanceof Proto.DataMessage) {
const content = new Proto.Content();
content.dataMessage = message;
@ -168,20 +188,29 @@ export default class OutgoingMessage {
this.successfulIdentifiers = [];
this.failoverIdentifiers = [];
this.unidentifiedDeliveries = [];
this.recipients = {};
this.sendLogCallback = sendLogCallback;
const { sendMetadata, online } = options;
this.sendMetadata = sendMetadata;
this.online = online;
this.sendMetadata = options?.sendMetadata;
this.online = options?.online;
}
numberCompleted(): void {
this.identifiersCompleted += 1;
if (this.identifiersCompleted >= this.identifiers.length) {
const contentProto = this.getContentProtoBytes();
const { timestamp, contentHint, recipients } = this;
this.callback({
successfulIdentifiers: this.successfulIdentifiers,
failoverIdentifiers: this.failoverIdentifiers,
errors: this.errors,
unidentifiedDeliveries: this.unidentifiedDeliveries,
contentHint,
recipients,
contentProto,
timestamp,
});
}
}
@ -313,6 +342,14 @@ export default class OutgoingMessage {
return toArrayBuffer(this.plaintext);
}
getContentProtoBytes(): Uint8Array | undefined {
if (this.message instanceof Proto.Content) {
return new Uint8Array(Proto.Content.encode(this.message).finish());
}
return undefined;
}
async getCiphertextMessage({
identityKeyStore,
protocolAddress,
@ -455,9 +492,21 @@ export default class OutgoingMessage {
accessKey,
}).then(
() => {
this.recipients[identifier] = deviceIds;
this.unidentifiedDeliveries.push(identifier);
this.successfulIdentifiers.push(identifier);
this.numberCompleted();
if (this.sendLogCallback) {
this.sendLogCallback({
identifier,
deviceIds,
});
} else if (this.successfulIdentifiers.length > 1) {
window.log.warn(
`OutgoingMessage.doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients`
);
}
},
async (error: Error) => {
if (error.code === 401 || error.code === 403) {
@ -481,7 +530,19 @@ export default class OutgoingMessage {
return this.transmitMessage(identifier, jsonData, this.timestamp).then(
() => {
this.successfulIdentifiers.push(identifier);
this.recipients[identifier] = deviceIds;
this.numberCompleted();
if (this.sendLogCallback) {
this.sendLogCallback({
identifier,
deviceIds,
});
} else if (this.successfulIdentifiers.length > 1) {
window.log.warn(
`OutgoingMessage.doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients`
);
}
}
);
})

View File

@ -28,7 +28,10 @@ import {
MultiRecipient200ResponseType,
} from './WebAPI';
import createTaskWithTimeout from './TaskWithTimeout';
import OutgoingMessage, { SerializedCertificateType } from './OutgoingMessage';
import OutgoingMessage, {
SerializedCertificateType,
SendLogCallbackType,
} from './OutgoingMessage';
import Crypto from './Crypto';
import * as Bytes from '../Bytes';
import {
@ -48,6 +51,11 @@ import {
LinkPreviewMetadata,
} from '../linkPreviews/linkPreviewFetch';
import { concat } from '../util/iterables';
import {
handleMessageSend,
shouldSaveProto,
SendTypesType,
} from '../util/handleMessageSend';
import { SignalService as Proto } from '../protobuf';
export type SendMetadataType = {
@ -68,11 +76,17 @@ export type CustomError = Error & {
};
export type CallbackResultType = {
successfulIdentifiers?: Array<any>;
failoverIdentifiers?: Array<any>;
successfulIdentifiers?: Array<string>;
failoverIdentifiers?: Array<string>;
errors?: Array<CustomError>;
unidentifiedDeliveries?: Array<any>;
unidentifiedDeliveries?: Array<string>;
dataMessage?: ArrayBuffer;
// Fields necesary for send log save
contentHint?: number;
contentProto?: Uint8Array;
timestamp?: number;
recipients?: Record<string, Array<number>>;
};
type PreviewType = {
@ -593,9 +607,12 @@ export default class MessageSender {
try {
const { sticker } = message;
if (!sticker || !sticker.data) {
if (!sticker) {
return;
}
if (!sticker.data) {
throw new Error('uploadSticker: No sticker data to upload!');
}
// eslint-disable-next-line no-param-reassign
message.sticker = {
@ -824,21 +841,23 @@ export default class MessageSender {
}
sendMessageProto({
timestamp,
recipients,
proto,
callback,
contentHint,
groupId,
callback,
options,
proto,
recipients,
sendLogCallback,
timestamp,
}: {
timestamp: number;
recipients: Array<string>;
proto: Proto.Content | Proto.DataMessage | PlaintextContent;
callback: (result: CallbackResultType) => void;
contentHint: number;
groupId: string | undefined;
callback: (result: CallbackResultType) => void;
options?: SendOptionsType;
proto: Proto.Content | Proto.DataMessage | PlaintextContent;
recipients: Array<string>;
sendLogCallback?: SendLogCallbackType;
timestamp: number;
}): void {
const rejections = window.textsecure.storage.get(
'signedKeyRotationRejected',
@ -848,16 +867,17 @@ export default class MessageSender {
throw new SignedPreKeyRotationError();
}
const outgoing = new OutgoingMessage(
this.server,
timestamp,
recipients,
proto,
const outgoing = new OutgoingMessage({
callback,
contentHint,
groupId,
callback,
options
);
identifiers: recipients,
message: proto,
options,
sendLogCallback,
server: this.server,
timestamp,
});
recipients.forEach(identifier => {
this.queueJobForIdentifier(identifier, async () =>
@ -992,6 +1012,8 @@ export default class MessageSender {
// Support for sync messages
// Note: this is used for sending real messages to your other devices after sending a
// message to others.
async sendSyncMessage({
encodedDataMessage,
timestamp,
@ -1012,14 +1034,9 @@ export default class MessageSender {
unidentifiedDeliveries?: Array<string>;
isUpdate?: boolean;
options?: SendOptionsType;
}): Promise<CallbackResultType | void> {
}): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1) {
return Promise.resolve();
}
const dataMessage = Proto.DataMessage.decode(
new FIXMEU8(encodedDataMessage)
@ -1082,134 +1099,112 @@ export default class MessageSender {
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp,
contentHint: ContentHint.IMPLICIT,
contentHint: ContentHint.RESENDABLE,
options,
});
}
async sendRequestBlockSyncMessage(
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice !== 1) {
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.BLOCKED;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.BLOCKED;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return Promise.resolve();
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
async sendRequestConfigurationSyncMessage(
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice !== 1) {
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.CONFIGURATION;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.CONFIGURATION;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return Promise.resolve();
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
async sendRequestGroupSyncMessage(
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice !== 1) {
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.GROUPS;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.GROUPS;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return Promise.resolve();
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
async sendRequestContactSyncMessage(
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice !== 1) {
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.CONTACTS;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.CONTACTS;
const syncMessage = this.createSyncMessage();
syncMessage.request = request;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
return Promise.resolve();
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
options,
});
}
async sendFetchManifestSyncMessage(
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myUuid = window.textsecure.storage.user.getUuid();
const myNumber = window.textsecure.storage.user.getNumber();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1) {
return;
}
const fetchLatest = new Proto.SyncMessage.FetchLatest();
fetchLatest.type = Proto.SyncMessage.FetchLatest.Type.STORAGE_MANIFEST;
@ -1221,7 +1216,7 @@ export default class MessageSender {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
await this.sendIndividualProto({
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
@ -1232,14 +1227,9 @@ export default class MessageSender {
async sendRequestKeySyncMessage(
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myUuid = window.textsecure.storage.user.getUuid();
const myNumber = window.textsecure.storage.user.getNumber();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1) {
return;
}
const request = new Proto.SyncMessage.Request();
request.type = Proto.SyncMessage.Request.Type.KEYS;
@ -1251,7 +1241,7 @@ export default class MessageSender {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
await this.sendIndividualProto({
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
@ -1267,13 +1257,10 @@ export default class MessageSender {
timestamp: number;
}>,
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1) {
return;
}
const syncMessage = this.createSyncMessage();
syncMessage.read = [];
for (let i = 0; i < reads.length; i += 1) {
@ -1290,7 +1277,7 @@ export default class MessageSender {
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
contentHint: ContentHint.RESENDABLE,
options,
});
}
@ -1300,13 +1287,9 @@ export default class MessageSender {
senderUuid: string,
timestamp: number,
options?: SendOptionsType
): Promise<CallbackResultType | null> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1) {
return null;
}
const syncMessage = this.createSyncMessage();
@ -1327,7 +1310,7 @@ export default class MessageSender {
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
contentHint: ContentHint.RESENDABLE,
options,
});
}
@ -1340,13 +1323,9 @@ export default class MessageSender {
type: number;
},
options?: SendOptionsType
): Promise<CallbackResultType | null> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1) {
return null;
}
const syncMessage = this.createSyncMessage();
@ -1372,7 +1351,7 @@ export default class MessageSender {
identifier: myUuid || myNumber,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
contentHint: ContentHint.RESENDABLE,
options,
});
}
@ -1384,12 +1363,7 @@ export default class MessageSender {
installed: boolean;
}>,
options?: SendOptionsType
): Promise<CallbackResultType | null> {
const myDevice = window.textsecure.storage.user.getDeviceId();
if (myDevice === 1) {
return null;
}
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const ENUM = Proto.SyncMessage.StickerPackOperation.Type;
@ -1423,57 +1397,60 @@ export default class MessageSender {
}
async syncVerification(
destinationE164: string,
destinationUuid: string,
destinationE164: string | undefined,
destinationUuid: string | undefined,
state: number,
identityKey: ArrayBuffer,
options?: SendOptionsType
): Promise<CallbackResultType | void> {
): Promise<CallbackResultType> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
const now = Date.now();
if (myDevice === 1) {
return Promise.resolve();
if (!destinationE164 && !destinationUuid) {
throw new Error('syncVerification: Neither e164 nor UUID were provided');
}
// Get padding which we can share between null message and verified sync
const padding = this.getRandomPadding();
// First send a null message to mask the sync message.
const promise = this.sendNullMessage(
{ uuid: destinationUuid, e164: destinationE164, padding },
options
await handleMessageSend(
this.sendNullMessage(
{ uuid: destinationUuid, e164: destinationE164, padding },
options
),
{
messageIds: [],
sendType: 'nullMessage',
}
);
return promise.then(async () => {
const verified = new Proto.Verified();
verified.state = state;
if (destinationE164) {
verified.destination = destinationE164;
}
if (destinationUuid) {
verified.destinationUuid = destinationUuid;
}
verified.identityKey = new FIXMEU8(identityKey);
verified.nullMessage = padding;
const verified = new Proto.Verified();
verified.state = state;
if (destinationE164) {
verified.destination = destinationE164;
}
if (destinationUuid) {
verified.destinationUuid = destinationUuid;
}
verified.identityKey = new FIXMEU8(identityKey);
verified.nullMessage = padding;
const syncMessage = this.createSyncMessage();
syncMessage.verified = verified;
const syncMessage = this.createSyncMessage();
syncMessage.verified = verified;
const secondMessage = new Proto.Content();
secondMessage.syncMessage = syncMessage;
const secondMessage = new Proto.Content();
secondMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
await this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: secondMessage,
timestamp: now,
contentHint: ContentHint.IMPLICIT,
options,
});
return this.sendIndividualProto({
identifier: myUuid || myNumber,
proto: secondMessage,
timestamp: now,
contentHint: ContentHint.RESENDABLE,
options,
});
}
@ -1512,7 +1489,7 @@ export default class MessageSender {
recipientId: string,
callingMessage: Proto.ICallingMessage,
options?: SendOptionsType
): Promise<void> {
): Promise<CallbackResultType> {
const recipients = [recipientId];
const finalTimestamp = Date.now();
@ -1521,7 +1498,7 @@ export default class MessageSender {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
await this.sendMessageProtoAndWait({
return this.sendMessageProtoAndWait({
timestamp: finalTimestamp,
recipients,
proto: contentMessage,
@ -1537,16 +1514,15 @@ export default class MessageSender {
timestamps,
options,
}: {
e164: string;
uuid: string;
e164?: string;
uuid?: string;
timestamps: Array<number>;
options?: SendOptionsType;
}): Promise<CallbackResultType | void> {
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
const myDevice = window.textsecure.storage.user.getDeviceId();
if ((myNumber === e164 || myUuid === uuid) && myDevice === 1) {
return Promise.resolve();
}): Promise<CallbackResultType> {
if (!uuid && !e164) {
throw new Error(
'sendDeliveryReceipt: Neither uuid nor e164 was provided!'
);
}
const receiptMessage = new Proto.ReceiptMessage();
@ -1562,7 +1538,7 @@ export default class MessageSender {
identifier: uuid || e164,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
contentHint: ContentHint.RESENDABLE,
options,
});
}
@ -1591,7 +1567,7 @@ export default class MessageSender {
identifier: senderUuid || senderE164,
proto: contentMessage,
timestamp: Date.now(),
contentHint: ContentHint.IMPLICIT,
contentHint: ContentHint.RESENDABLE,
options,
});
}
@ -1634,9 +1610,7 @@ export default class MessageSender {
e164: string,
timestamp: number,
options?: SendOptionsType
): Promise<
CallbackResultType | void | Array<CallbackResultType | void | Array<void>>
> {
): Promise<CallbackResultType> {
window.log.info('resetSession: start');
const proto = new Proto.DataMessage();
proto.body = 'TERMINATE';
@ -1659,19 +1633,27 @@ export default class MessageSender {
window.log.info(
'resetSession: finished closing local sessions, now sending to contact'
);
return this.sendIndividualProto({
identifier,
proto,
timestamp,
contentHint: ContentHint.DEFAULT,
options,
}).catch(logError('resetSession/sendToContact error:'));
return handleMessageSend(
this.sendIndividualProto({
identifier,
proto,
timestamp,
contentHint: ContentHint.RESENDABLE,
options,
}),
{
messageIds: [],
sendType: 'resetSession',
}
).catch(logError('resetSession/sendToContact error:'));
})
.then(async () =>
window.textsecure.storage.protocol
.then(async result => {
await window.textsecure.storage.protocol
.archiveAllSessions(identifier)
.catch(logError('resetSession/archiveAllSessions2 error:'))
);
.catch(logError('resetSession/archiveAllSessions2 error:'));
return result;
});
const myNumber = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getUuid();
@ -1694,7 +1676,12 @@ export default class MessageSender {
options,
}).catch(logError('resetSession/sendSync error:'));
return Promise.all([sendToContactPromise, sendSyncPromise]);
const responses = await Promise.all([
sendToContactPromise,
sendSyncPromise,
]);
return responses[0];
}
async sendExpirationTimerUpdateToIdentifier(
@ -1714,17 +1701,19 @@ export default class MessageSender {
profileKey,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
},
contentHint: ContentHint.DEFAULT,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
options,
});
}
async sendRetryRequest({
groupId,
options,
plaintext,
uuid,
}: {
groupId?: string;
options?: SendOptionsType;
plaintext: PlaintextContent;
uuid: string;
@ -1735,29 +1724,99 @@ export default class MessageSender {
timestamp: Date.now(),
recipients: [uuid],
proto: plaintext,
contentHint: ContentHint.IMPLICIT,
groupId: undefined,
contentHint: ContentHint.DEFAULT,
groupId,
options,
});
}
// Group sends
// Used to ensure that when we send to a group the old way, we save to the send log as
// we send to each recipient. Then we don't have a long delay between the first send
// and the final save to the database with all recipients.
makeSendLogCallback({
contentHint,
messageId,
proto,
sendType,
timestamp,
}: {
contentHint: number;
messageId?: string;
proto: Buffer;
sendType: SendTypesType;
timestamp: number;
}): SendLogCallbackType {
let initialSavePromise: Promise<number>;
return async ({
identifier,
deviceIds,
}: {
identifier: string;
deviceIds: Array<number>;
}) => {
if (!shouldSaveProto(sendType)) {
return;
}
const conversation = window.ConversationController.get(identifier);
if (!conversation) {
window.log.warn(
`makeSendLogCallback: Unable to find conversation for identifier ${identifier}`
);
return;
}
const recipientUuid = conversation.get('uuid');
if (!recipientUuid) {
window.log.warn(
`makeSendLogCallback: Conversation ${conversation.idForLogging()} had no UUID`
);
return;
}
if (!initialSavePromise) {
initialSavePromise = window.Signal.Data.insertSentProto(
{
timestamp,
proto,
contentHint,
},
{
recipients: { [recipientUuid]: deviceIds },
messageIds: messageId ? [messageId] : [],
}
);
await initialSavePromise;
} else {
const id = await initialSavePromise;
await window.Signal.Data.insertProtoRecipients({
id,
recipientUuid,
deviceIds,
});
}
};
}
// No functions should really call this; since most group sends are now via Sender Key
async sendGroupProto({
recipients,
proto,
timestamp = Date.now(),
contentHint,
groupId,
options,
proto,
recipients,
sendLogCallback,
timestamp = Date.now(),
}: {
recipients: Array<string>;
proto: Proto.Content;
timestamp: number;
contentHint: number;
groupId: string | undefined;
options?: SendOptionsType;
proto: Proto.Content;
recipients: Array<string>;
sendLogCallback?: SendLogCallbackType;
timestamp: number;
}): Promise<CallbackResultType> {
const dataMessage = proto.dataMessage
? typedArrayToArrayBuffer(
@ -1790,13 +1849,14 @@ export default class MessageSender {
};
this.sendMessageProto({
timestamp,
recipients: identifiers,
proto,
callback,
contentHint,
groupId,
callback,
options,
proto,
recipients: identifiers,
sendLogCallback,
timestamp,
});
});
}
@ -1846,19 +1906,31 @@ export default class MessageSender {
options?: SendOptionsType
): Promise<CallbackResultType> {
const contentMessage = new Proto.Content();
const timestamp = Date.now();
const senderKeyDistributionMessage = await this.getSenderKeyDistributionMessage(
distributionId
);
contentMessage.senderKeyDistributionMessage = senderKeyDistributionMessage.serialize();
const sendLogCallback =
identifiers.length > 1
? this.makeSendLogCallback({
contentHint,
proto: Buffer.from(Proto.Content.encode(contentMessage).finish()),
sendType: 'senderKeyDistributionMessage',
timestamp,
})
: undefined;
return this.sendGroupProto({
recipients: identifiers,
proto: contentMessage,
timestamp: Date.now(),
contentHint,
groupId,
options,
proto: contentMessage,
recipients: identifiers,
sendLogCallback,
timestamp,
});
}
@ -1869,6 +1941,7 @@ export default class MessageSender {
groupIdentifiers: Array<string>,
options?: SendOptionsType
): Promise<CallbackResultType> {
const timestamp = Date.now();
const proto = new Proto.Content({
dataMessage: {
group: {
@ -1879,13 +1952,26 @@ export default class MessageSender {
});
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const contentHint = ContentHint.RESENDABLE;
const sendLogCallback =
groupIdentifiers.length > 1
? this.makeSendLogCallback({
contentHint,
proto: Buffer.from(Proto.Content.encode(proto).finish()),
sendType: 'legacyGroupChange',
timestamp,
})
: undefined;
return this.sendGroupProto({
recipients: groupIdentifiers,
proto,
timestamp: Date.now(),
contentHint: ContentHint.DEFAULT,
contentHint,
groupId: undefined, // only for GV2 ids
options,
proto,
recipients: groupIdentifiers,
sendLogCallback,
timestamp,
});
}
@ -1913,6 +1999,7 @@ export default class MessageSender {
type: Proto.GroupContext.Type.DELIVER,
},
};
const proto = await this.getContentMessage(messageOptions);
if (recipients.length === 0) {
return Promise.resolve({
@ -1925,11 +2012,25 @@ export default class MessageSender {
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return this.sendMessage({
messageOptions,
contentHint: ContentHint.DEFAULT,
const contentHint = ContentHint.RESENDABLE;
const sendLogCallback =
groupIdentifiers.length > 1
? this.makeSendLogCallback({
contentHint,
proto: Buffer.from(Proto.Content.encode(proto).finish()),
sendType: 'expirationTimerUpdate',
timestamp,
})
: undefined;
return this.sendGroupProto({
contentHint,
groupId: undefined, // only for GV2 ids
options,
proto,
recipients,
sendLogCallback,
timestamp,
});
}

View File

@ -11,6 +11,8 @@ import MessageReceiver from './MessageReceiver';
import { ContactSyncEvent, GroupSyncEvent } from './messageReceiverEvents';
import MessageSender from './SendMessage';
import { assert } from '../util/assert';
import { getSendOptions } from '../util/getSendOptions';
import { handleMessageSend } from '../util/handleMessageSend';
class SyncRequestInner extends EventTarget {
private started = false;
@ -61,25 +63,41 @@ class SyncRequestInner extends EventTarget {
const { sender } = this;
const ourNumber = window.textsecure.storage.user.getNumber();
const {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(ourNumber, {
const ourConversation = window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'SyncRequest.start: We are primary device; returning early'
);
return;
}
window.log.info('SyncRequest created. Sending config sync request...');
wrap(sender.sendRequestConfigurationSyncMessage(sendOptions));
handleMessageSend(sender.sendRequestConfigurationSyncMessage(sendOptions), {
messageIds: [],
sendType: 'otherSync',
});
window.log.info('SyncRequest now sending block sync request...');
wrap(sender.sendRequestBlockSyncMessage(sendOptions));
handleMessageSend(sender.sendRequestBlockSyncMessage(sendOptions), {
messageIds: [],
sendType: 'otherSync',
});
window.log.info('SyncRequest now sending contact sync message...');
wrap(sender.sendRequestContactSyncMessage(sendOptions))
handleMessageSend(sender.sendRequestContactSyncMessage(sendOptions), {
messageIds: [],
sendType: 'otherSync',
})
.then(() => {
window.log.info('SyncRequest now sending group sync message...');
return wrap(sender.sendRequestGroupSyncMessage(sendOptions));
return handleMessageSend(
sender.sendRequestGroupSyncMessage(sendOptions),
{ messageIds: [], sendType: 'otherSync' }
);
})
.catch((error: Error) => {
window.log.error(

View File

@ -75,6 +75,7 @@ export type ProcessedEnvelope = Readonly<{
content?: Uint8Array;
serverGuid: string;
serverTimestamp: number;
groupId?: string;
}>;
export type ProcessedAttachment = {

View File

@ -219,6 +219,7 @@ export type ReadEventData = Readonly<{
envelopeTimestamp: number;
source?: string;
sourceUuid?: string;
sourceDevice?: number;
}>;
export class ReadEvent extends ConfirmableEvent {

View File

@ -5,6 +5,7 @@ export type ReactionType = Readonly<{
conversationId: string;
emoji: string;
fromId: string;
messageId: string | undefined;
messageReceivedAt: number;
targetAuthorUuid: string;
targetTimestamp: number;

View File

@ -1,7 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import { CallbackResultType } from '../textsecure/SendMessage';
import dataInterface from '../sql/Client';
const { insertSentProto } = dataInterface;
export const SEALED_SENDER = {
UNKNOWN: 0,
@ -10,17 +14,71 @@ export const SEALED_SENDER = {
UNRESTRICTED: 3,
};
export type SendTypesType =
| 'callingMessage' // excluded from send log
| 'deleteForEveryone'
| 'deliveryReceipt'
| 'expirationTimerUpdate'
| 'groupChange'
| 'legacyGroupChange'
| 'message'
| 'messageRetry'
| 'nullMessage' // excluded from send log
| 'otherSync'
| 'profileKeyUpdate'
| 'reaction'
| 'readReceipt'
| 'readSync'
| 'resendFromLog' // excluded from send log
| 'resetSession'
| 'retryRequest' // excluded from send log
| 'senderKeyDistributionMessage'
| 'sentSync'
| 'typing' // excluded from send log
| 'verificationSync'
| 'viewOnceSync';
export function shouldSaveProto(sendType: SendTypesType): boolean {
if (sendType === 'callingMessage') {
return false;
}
if (sendType === 'nullMessage') {
return false;
}
if (sendType === 'resendFromLog') {
return false;
}
if (sendType === 'retryRequest') {
return false;
}
if (sendType === 'typing') {
return false;
}
return true;
}
export async function handleMessageSend(
promise: Promise<CallbackResultType | void | null>
): Promise<CallbackResultType | void | null> {
promise: Promise<CallbackResultType>,
options: {
messageIds: Array<string>;
sendType: SendTypesType;
}
): Promise<CallbackResultType> {
try {
const result = await promise;
if (result) {
await handleMessageSendResult(
result.failoverIdentifiers,
result.unidentifiedDeliveries
);
}
await maybeSaveToSendLog(result, options);
await handleMessageSendResult(
result.failoverIdentifiers,
result.unidentifiedDeliveries
);
return result;
} catch (err) {
if (err) {
@ -84,3 +142,52 @@ async function handleMessageSendResult(
})
);
}
async function maybeSaveToSendLog(
result: CallbackResultType,
{
messageIds,
sendType,
}: {
messageIds: Array<string>;
sendType: SendTypesType;
}
): Promise<void> {
const { contentHint, contentProto, recipients, timestamp } = result;
if (!shouldSaveProto(sendType)) {
return;
}
if (!isNumber(contentHint) || !contentProto || !recipients || !timestamp) {
window.log.warn(
`handleMessageSend: Missing necessary information to save to log for ${sendType} message ${timestamp}`
);
return;
}
const identifiers = Object.keys(recipients);
if (identifiers.length === 0) {
window.log.warn(
`handleMessageSend: ${sendType} message ${timestamp} had no recipients`
);
return;
}
// If the identifier count is greater than one, we've done the save elsewhere
if (identifiers.length > 1) {
return;
}
await insertSentProto(
{
timestamp,
proto: Buffer.from(contentProto),
contentHint,
},
{
messageIds,
recipients,
}
);
}

520
ts/util/handleRetry.ts Normal file
View File

@ -0,0 +1,520 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
DecryptionErrorMessage,
PlaintextContent,
} from '@signalapp/signal-client';
import { isNumber } from 'lodash';
import { assert } from './assert';
import { getSendOptions } from './getSendOptions';
import { handleMessageSend } from './handleMessageSend';
import { isGroupV2 } from './whatTypeOfConversation';
import { isOlderThan } from './timestamp';
import { parseIntOrThrow } from './parseIntOrThrow';
import * as RemoteConfig from '../RemoteConfig';
import { ConversationModel } from '../models/conversations';
import {
DecryptionErrorEvent,
DecryptionErrorEventData,
RetryRequestEvent,
RetryRequestEventData,
} from '../textsecure/messageReceiverEvents';
import { SignalService as Proto } from '../protobuf';
// Entrypoints
export async function onRetryRequest(event: RetryRequestEvent): Promise<void> {
const { retryRequest } = event;
const {
groupId: requestGroupId,
requesterDevice,
requesterUuid,
senderDevice,
sentAt,
} = retryRequest;
const logId = `${requesterUuid}.${requesterDevice} ${sentAt}-${senderDevice}`;
window.log.info(`onRetryRequest/${logId}: Starting...`);
if (window.RETRY_DELAY) {
window.log.warn(
`onRetryRequest/${logId}: Delaying because RETRY_DELAY is set...`
);
await new Promise(resolve => setTimeout(resolve, 5000));
}
const HOUR = 60 * 60 * 1000;
const ONE_DAY = 24 * HOUR;
let retryRespondMaxAge = ONE_DAY;
try {
retryRespondMaxAge = parseIntOrThrow(
RemoteConfig.getValue('desktop.retryRespondMaxAge'),
'retryRespondMaxAge'
);
} catch (error) {
window.log.warn(
`onRetryRequest/${logId}: Failed to parse integer from desktop.retryRespondMaxAge feature flag`,
error && error.stack ? error.stack : error
);
}
if (isOlderThan(sentAt, retryRespondMaxAge)) {
window.log.info(
`onRetryRequest/${logId}: Message is too old, refusing to send again.`
);
await sendDistributionMessageOrNullMessage(logId, retryRequest);
return;
}
const sentProto = await window.Signal.Data.getSentProtoByRecipient({
now: Date.now(),
recipientUuid: requesterUuid,
timestamp: sentAt,
});
if (!sentProto) {
window.log.info(`onRetryRequest/${logId}: Did not find sent proto`);
await sendDistributionMessageOrNullMessage(logId, retryRequest);
return;
}
window.log.info(`onRetryRequest/${logId}: Resending message`);
await archiveSessionOnMatch(retryRequest);
const { contentHint, messageIds, proto, timestamp } = sentProto;
const { contentProto, groupId } = await maybeAddSenderKeyDistributionMessage({
contentProto: Proto.Content.decode(proto),
logId,
messageIds,
requestGroupId,
requesterUuid,
});
const recipientConversation = window.ConversationController.getOrCreate(
requesterUuid,
'private'
);
const sendOptions = await getSendOptions(recipientConversation.attributes);
const promise = window.textsecure.messaging.sendMessageProtoAndWait({
timestamp,
recipients: [requesterUuid],
proto: new Proto.Content(contentProto),
contentHint,
groupId,
options: sendOptions,
});
await handleMessageSend(promise, {
messageIds: [],
sendType: 'resendFromLog',
});
}
function maybeShowDecryptionToast(logId: string) {
if (!RemoteConfig.isEnabled('desktop.internalUser')) {
return;
}
window.log.info(
`onDecryptionError/${logId}: Showing toast for internal user`
);
window.Whisper.ToastView.show(
window.Whisper.DecryptionErrorToast,
document.getElementsByClassName('conversation-stack')[0]
);
}
export async function onDecryptionError(
event: DecryptionErrorEvent
): Promise<void> {
const { decryptionError } = event;
const { senderUuid, senderDevice, timestamp } = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
window.log.info(`onDecryptionError/${logId}: Starting...`);
const conversation = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
if (!conversation.get('capabilities')?.senderKey) {
await conversation.getProfiles();
}
if (conversation.get('capabilities')?.senderKey) {
await requestResend(decryptionError);
} else {
await startAutomaticSessionReset(decryptionError);
}
window.log.info(`onDecryptionError/${logId}: ...complete`);
}
// Helpers
async function archiveSessionOnMatch({
requesterUuid,
requesterDevice,
senderDevice,
}: RetryRequestEventData): Promise<void> {
const ourDeviceId = parseIntOrThrow(
window.textsecure.storage.user.getDeviceId(),
'archiveSessionOnMatch/getDeviceId'
);
if (ourDeviceId === senderDevice) {
const address = `${requesterUuid}.${requesterDevice}`;
window.log.info('archiveSessionOnMatch: Devices match, archiving session');
await window.textsecure.storage.protocol.archiveSession(address);
}
}
async function sendDistributionMessageOrNullMessage(
logId: string,
options: RetryRequestEventData
): Promise<void> {
const { groupId, requesterUuid } = options;
let sentDistributionMessage = false;
window.log.info(`sendDistributionMessageOrNullMessage/${logId}: Starting...`);
await archiveSessionOnMatch(options);
const conversation = window.ConversationController.getOrCreate(
requesterUuid,
'private'
);
if (groupId) {
const group = window.ConversationController.get(groupId);
const distributionId = group?.get('senderKeyInfo')?.distributionId;
if (group && !group.hasMember(requesterUuid)) {
throw new Error(
`sendDistributionMessageOrNullMessage/${logId}: Requester ${requesterUuid} is not a member of ${conversation.idForLogging()}`
);
}
if (group && distributionId) {
window.log.info(
`sendDistributionMessageOrNullMessage/${logId}: Found matching group, sending sender key distribution message'`
);
try {
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const result = await handleMessageSend(
window.textsecure.messaging.sendSenderKeyDistributionMessage({
contentHint: ContentHint.RESENDABLE,
distributionId,
groupId,
identifiers: [requesterUuid],
}),
{ messageIds: [], sendType: 'senderKeyDistributionMessage' }
);
if (result && result.errors && result.errors.length > 0) {
throw result.errors[0];
}
sentDistributionMessage = true;
} catch (error) {
window.log.error(
`sendDistributionMessageOrNullMessage/${logId}: Failed to send sender key distribution message`,
error && error.stack ? error.stack : error
);
}
}
}
if (!sentDistributionMessage) {
window.log.info(
`sendDistributionMessageOrNullMessage/${logId}: Did not send distribution message, sending null message`
);
try {
const sendOptions = await getSendOptions(conversation.attributes);
const result = await handleMessageSend(
window.textsecure.messaging.sendNullMessage(
{ uuid: requesterUuid },
sendOptions
),
{ messageIds: [], sendType: 'nullMessage' }
);
if (result && result.errors && result.errors.length > 0) {
throw result.errors[0];
}
} catch (error) {
window.log.error(
`maybeSendDistributionMessage/${logId}: Failed to send null message`,
error && error.stack ? error.stack : error
);
}
}
}
async function getRetryConversation({
logId,
messageIds,
requestGroupId,
}: {
logId: string;
messageIds: Array<string>;
requestGroupId?: string;
}): Promise<ConversationModel | undefined> {
if (messageIds.length !== 1) {
// Fail over to requested groupId
return window.ConversationController.get(requestGroupId);
}
const [messageId] = messageIds;
const message = await window.Signal.Data.getMessageById(messageId, {
Message: window.Whisper.Message,
});
if (!message) {
window.log.warn(
`maybeAddSenderKeyDistributionMessage/${logId}: Unable to find message ${messageId}`
);
// Fail over to requested groupId
return window.ConversationController.get(requestGroupId);
}
const conversationId = message.get('conversationId');
return window.ConversationController.get(conversationId);
}
async function maybeAddSenderKeyDistributionMessage({
contentProto,
logId,
messageIds,
requestGroupId,
requesterUuid,
}: {
contentProto: Proto.IContent;
logId: string;
messageIds: Array<string>;
requestGroupId?: string;
requesterUuid: string;
}): Promise<{ contentProto: Proto.IContent; groupId?: string }> {
const conversation = await getRetryConversation({
logId,
messageIds,
requestGroupId,
});
if (!conversation) {
window.log.warn(
`maybeAddSenderKeyDistributionMessage/${logId}: Unable to find conversation`
);
return {
contentProto,
};
}
if (!conversation.hasMember(requesterUuid)) {
throw new Error(
`maybeAddSenderKeyDistributionMessage/${logId}: Recipient ${requesterUuid} is not a member of ${conversation.idForLogging()}`
);
}
if (!isGroupV2(conversation.attributes)) {
return {
contentProto,
};
}
const senderKeyInfo = conversation.get('senderKeyInfo');
if (senderKeyInfo && senderKeyInfo.distributionId) {
const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage(
senderKeyInfo.distributionId
);
return {
contentProto: {
...contentProto,
senderKeyDistributionMessage: senderKeyDistributionMessage.serialize(),
},
groupId: conversation.get('groupId'),
};
}
return {
contentProto,
groupId: conversation.get('groupId'),
};
}
async function requestResend(decryptionError: DecryptionErrorEventData) {
const {
cipherTextBytes,
cipherTextType,
contentHint,
groupId,
receivedAtCounter,
receivedAtDate,
senderDevice,
senderUuid,
timestamp,
} = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
window.log.info(`requestResend/${logId}: Starting...`, {
cipherTextBytesLength: cipherTextBytes?.byteLength,
cipherTextType,
contentHint,
groupId: groupId ? `groupv2(${groupId})` : undefined,
});
// 1. Find the target conversation
const group = groupId
? window.ConversationController.get(groupId)
: undefined;
const sender = window.ConversationController.getOrCreate(
senderUuid,
'private'
);
const conversation = group || sender;
// 2. Send resend request
if (!cipherTextBytes || !isNumber(cipherTextType)) {
window.log.warn(
`requestResend/${logId}: Missing cipherText information, failing over to automatic reset`
);
startAutomaticSessionReset(decryptionError);
return;
}
try {
const message = DecryptionErrorMessage.forOriginal(
Buffer.from(cipherTextBytes),
cipherTextType,
timestamp,
senderDevice
);
const plaintext = PlaintextContent.from(message);
const options = await getSendOptions(conversation.attributes);
const result = await handleMessageSend(
window.textsecure.messaging.sendRetryRequest({
plaintext,
options,
groupId,
uuid: senderUuid,
}),
{ messageIds: [], sendType: 'retryRequest' }
);
if (result && result.errors && result.errors.length > 0) {
throw result.errors[0];
}
} catch (error) {
window.log.error(
`requestResend/${logId}: Failed to send retry request, failing over to automatic reset`,
error && error.stack ? error.stack : error
);
startAutomaticSessionReset(decryptionError);
return;
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
// 3. Determine how to represent this to the user. Three different options.
// We believe that it could be successfully re-sent, so we'll add a placeholder.
if (contentHint === ContentHint.RESENDABLE) {
const { retryPlaceholders } = window.Signal.Services;
assert(retryPlaceholders, 'requestResend: adding placeholder');
window.log.info(`requestResend/${logId}: Adding placeholder`);
const state = window.reduxStore.getState();
const selectedId = state.conversations.selectedConversationId;
const wasOpened = selectedId === conversation.id;
await retryPlaceholders.add({
conversationId: conversation.get('id'),
receivedAt: receivedAtDate,
receivedAtCounter,
sentAt: timestamp,
senderUuid,
wasOpened,
});
maybeShowDecryptionToast(logId);
return;
}
// This message cannot be resent. We'll show no error and trust the other side to
// reset their session.
if (contentHint === ContentHint.IMPLICIT) {
maybeShowDecryptionToast(logId);
return;
}
window.log.warn(
`requestResend/${logId}: No content hint, adding error immediately`
);
conversation.queueJob('addDeliveryIssue', async () => {
conversation.addDeliveryIssue({
receivedAt: receivedAtDate,
receivedAtCounter,
senderUuid,
});
});
}
function scheduleSessionReset(senderUuid: string, senderDevice: number) {
// Postpone sending light session resets until the queue is empty
const { lightSessionResetQueue } = window.Signal.Services;
if (!lightSessionResetQueue) {
throw new Error(
'scheduleSessionReset: lightSessionResetQueue is not available!'
);
}
lightSessionResetQueue.add(() => {
window.textsecure.storage.protocol.lightSessionReset(
senderUuid,
senderDevice
);
});
}
function startAutomaticSessionReset(decryptionError: DecryptionErrorEventData) {
const { senderUuid, senderDevice, timestamp } = decryptionError;
const logId = `${senderUuid}.${senderDevice} ${timestamp}`;
window.log.info(`startAutomaticSessionReset/${logId}: Starting...`);
scheduleSessionReset(senderUuid, senderDevice);
const conversationId = window.ConversationController.ensureContactIds({
uuid: senderUuid,
});
if (!conversationId) {
window.log.warn(
'onLightSessionReset: No conversation id, cannot add message to timeline'
);
return;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
window.log.warn(
'onLightSessionReset: No conversation, cannot add message to timeline'
);
return;
}
const receivedAt = Date.now();
const receivedAtCounter = window.Signal.Util.incrementMessageCounter();
conversation.queueJob('addChatSessionRefreshed', async () => {
conversation.addChatSessionRefreshed({ receivedAt, receivedAtCounter });
});
}

View File

@ -14105,20 +14105,6 @@
"updated": "2021-01-21T23:06:13.270Z",
"reasonDetail": "Doesn't manipulate the DOM."
},
{
"rule": "jQuery-wrap(",
"path": "ts/shims/textsecure.js",
"line": " wrap(textsecure.messaging.sendStickerPackSync([",
"reasonCategory": "falseMatch",
"updated": "2020-02-07T19:52:28.522Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/shims/textsecure.ts",
"line": " wrap(",
"reasonCategory": "falseMatch",
"updated": "2020-02-07T19:52:28.522Z"
},
{
"rule": "jQuery-load(",
"path": "ts/types/Stickers.js",

View File

@ -3,8 +3,10 @@
import { ConversationAttributesType } from '../model-types.d';
import { handleMessageSend } from './handleMessageSend';
import { getSendOptions } from './getSendOptions';
import { sendReadReceiptsFor } from './sendReadReceiptsFor';
import { hasErrors } from '../state/selectors/message';
import { isNotNil } from './isNotNil';
export async function markConversationRead(
conversationAttrs: ConversationAttributesType,
@ -43,6 +45,7 @@ export async function markConversationRead(
const unreadReactionSyncData = new Map<
string,
{
messageId?: string;
senderUuid?: string;
senderE164?: string;
timestamp: number;
@ -54,6 +57,7 @@ export async function markConversationRead(
return;
}
unreadReactionSyncData.set(targetKey, {
messageId: reaction.messageId,
senderE164: undefined,
senderUuid: reaction.targetAuthorUuid,
timestamp: reaction.targetTimestamp,
@ -68,6 +72,7 @@ export async function markConversationRead(
}
return {
messageId: messageSyncData.id,
senderE164: messageSyncData.source,
senderUuid: messageSyncData.sourceUuid,
senderId: window.ConversationController.ensureContactIds({
@ -89,25 +94,39 @@ export async function markConversationRead(
item => Boolean(item.senderId) && !item.hasErrors
);
const readSyncs = [
const readSyncs: Array<{
messageId?: string;
senderE164?: string;
senderUuid?: string;
senderId?: string;
timestamp: number;
hasErrors?: string;
}> = [
...unreadMessagesSyncData,
...Array.from(unreadReactionSyncData.values()),
];
const messageIds = readSyncs.map(item => item.messageId).filter(isNotNil);
if (readSyncs.length && options.sendReadReceipts) {
window.log.info(`Sending ${readSyncs.length} read syncs`);
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
// to a contact, we need accessKeys for both.
const {
sendOptions,
} = await window.ConversationController.prepareForSend(
window.ConversationController.getOurConversationId(),
{ syncMessage: true }
);
const ourConversation = window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
if (window.ConversationController.areWePrimaryDevice()) {
window.log.warn(
'markConversationRead: We are primary device; not sending read syncs'
);
} else {
await handleMessageSend(
window.textsecure.messaging.syncReadMessages(readSyncs, sendOptions),
{ messageIds, sendType: 'readSync' }
);
}
await handleMessageSend(
window.textsecure.messaging.syncReadMessages(readSyncs, sendOptions)
);
await sendReadReceiptsFor(conversationAttrs, unreadMessagesSyncData);
}

View File

@ -63,14 +63,14 @@ export class RetryPlaceholders {
}
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();
this.retryReceiptLifespan = options.retryReceiptLifespan || HOUR;
window.log.info(
`RetryPlaceholders.constructor: Started with ${this.items.length} items, lifespan of ${this.retryReceiptLifespan}`
);
}
// Arranging local data for efficiency

View File

@ -7,9 +7,18 @@ import { getSendOptions } from './getSendOptions';
import { handleMessageSend } from './handleMessageSend';
import { isConversationAccepted } from './isConversationAccepted';
type ReceiptSpecType = {
messageId: string;
senderE164?: string;
senderUuid?: string;
senderId?: string;
timestamp: number;
hasErrors: boolean;
};
export async function sendReadReceiptsFor(
conversationAttrs: ConversationAttributesType,
items: Array<unknown>
items: Array<ReceiptSpecType>
): Promise<void> {
// Only send read receipts for accepted conversations
if (
@ -22,7 +31,8 @@ export async function sendReadReceiptsFor(
await Promise.all(
map(receiptsBySender, async (receipts, senderId) => {
const timestamps = map(receipts, 'timestamp');
const timestamps = map(receipts, item => item.timestamp);
const messageIds = map(receipts, item => item.messageId);
const conversation = window.ConversationController.get(senderId);
if (conversation) {
@ -34,7 +44,8 @@ export async function sendReadReceiptsFor(
senderUuid: conversation.get('uuid')!,
timestamps,
options: sendOptions,
})
}),
{ messageIds, sendType: 'readReceipt' }
);
}
})

View File

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { differenceWith, partition } from 'lodash';
import { differenceWith, omit, partition } from 'lodash';
import PQueue from 'p-queue';
import {
@ -16,6 +16,7 @@ import { senderCertificateService } from '../services/senderCertificate';
import {
padMessage,
SenderCertificateMode,
SendLogCallbackType,
} from '../textsecure/OutgoingMessage';
import { isEnabled } from '../RemoteConfig';
@ -30,7 +31,12 @@ import { ConversationModel } from '../models/conversations';
import { DeviceType } from '../textsecure/Types.d';
import { getKeysForIdentifier } from '../textsecure/getKeysForIdentifier';
import { ConversationAttributesType } from '../model-types.d';
import { SEALED_SENDER } from './handleMessageSend';
import {
handleMessageSend,
SEALED_SENDER,
SendTypesType,
shouldSaveProto,
} from './handleMessageSend';
import { parseIntOrThrow } from './parseIntOrThrow';
import {
multiRecipient200ResponseSchema,
@ -59,17 +65,21 @@ const FIXMEU8 = Uint8Array;
// Public API:
export async function sendToGroup({
groupSendOptions,
conversation,
contentHint,
sendOptions,
conversation,
groupSendOptions,
messageId,
isPartialSend,
sendOptions,
sendType,
}: {
groupSendOptions: GroupSendOptionsType;
conversation: ConversationModel;
contentHint: number;
sendOptions?: SendOptionsType;
conversation: ConversationModel;
groupSendOptions: GroupSendOptionsType;
isPartialSend?: boolean;
messageId: string | undefined;
sendOptions?: SendOptionsType;
sendType: SendTypesType;
}): Promise<CallbackResultType> {
assert(
window.textsecure.messaging,
@ -92,8 +102,10 @@ export async function sendToGroup({
contentMessage,
conversation,
isPartialSend,
messageId,
recipients,
sendOptions,
sendType,
timestamp,
});
}
@ -103,18 +115,22 @@ export async function sendContentMessageToGroup({
contentMessage,
conversation,
isPartialSend,
messageId,
online,
recipients,
sendOptions,
sendType,
timestamp,
}: {
contentHint: number;
contentMessage: Proto.Content;
conversation: ConversationModel;
isPartialSend?: boolean;
messageId: string | undefined;
online?: boolean;
recipients: Array<string>;
sendOptions?: SendOptionsType;
sendType: SendTypesType;
timestamp: number;
}): Promise<CallbackResultType> {
const logId = conversation.idForLogging();
@ -127,7 +143,7 @@ export async function sendContentMessageToGroup({
const ourConversation = window.ConversationController.get(ourConversationId);
if (
isEnabled('desktop.sendSenderKey') &&
isEnabled('desktop.sendSenderKey2') &&
ourConversation?.get('capabilities')?.senderKey &&
isGroupV2(conversation.attributes)
) {
@ -137,10 +153,12 @@ export async function sendContentMessageToGroup({
contentMessage,
conversation,
isPartialSend,
messageId,
online,
recipients,
recursionCount: 0,
sendOptions,
sendType,
timestamp,
});
} catch (error) {
@ -151,16 +169,24 @@ export async function sendContentMessageToGroup({
}
}
const sendLogCallback = window.textsecure.messaging.makeSendLogCallback({
contentHint,
messageId,
proto: Buffer.from(Proto.Content.encode(contentMessage).finish()),
sendType,
timestamp,
});
const groupId = isGroupV2(conversation.attributes)
? conversation.get('groupId')
: undefined;
return window.textsecure.messaging.sendGroupProto({
recipients,
proto: contentMessage,
timestamp,
contentHint,
groupId,
options: { ...sendOptions, online },
proto: contentMessage,
recipients,
sendLogCallback,
timestamp,
});
}
@ -171,10 +197,12 @@ export async function sendToGroupViaSenderKey(options: {
contentMessage: Proto.Content;
conversation: ConversationModel;
isPartialSend?: boolean;
messageId: string | undefined;
online?: boolean;
recipients: Array<string>;
recursionCount: number;
sendOptions?: SendOptionsType;
sendType: SendTypesType;
timestamp: number;
}): Promise<CallbackResultType> {
const {
@ -182,10 +210,12 @@ export async function sendToGroupViaSenderKey(options: {
contentMessage,
conversation,
isPartialSend,
messageId,
online,
recursionCount,
recipients,
sendOptions,
sendType,
timestamp,
} = options;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
@ -287,12 +317,16 @@ export async function sendToGroupViaSenderKey(options: {
currentDevices,
device => isValidSenderKeyRecipient(conversation, device.identifier)
);
const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey);
const normalSendRecipients = getUuidsFromDevices(devicesForNormalSend);
window.log.info(
`sendToGroupViaSenderKey/${logId}: ${devicesForSenderKey.length} devices for sender key, ${devicesForNormalSend.length} devices for normal send`
`sendToGroupViaSenderKey/${logId}:` +
` ${senderKeyRecipients.length} accounts for sender key (${devicesForSenderKey.length} devices),` +
` ${normalSendRecipients.length} accounts for normal send (${devicesForNormalSend.length} devices)`
);
// 5. Ensure we have enough recipients
const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey);
if (senderKeyRecipients.length < 2) {
throw new Error(
`sendToGroupViaSenderKey/${logId}: Not enough recipients for Sender Key message. Failing over.`
@ -335,14 +369,17 @@ export async function sendToGroupViaSenderKey(options: {
newToMemberUuids.length
} members: ${JSON.stringify(newToMemberUuids)}`
);
await window.textsecure.messaging.sendSenderKeyDistributionMessage(
{
contentHint: ContentHint.DEFAULT,
distributionId,
groupId,
identifiers: newToMemberUuids,
},
sendOptions
await handleMessageSend(
window.textsecure.messaging.sendSenderKeyDistributionMessage(
{
contentHint: ContentHint.RESENDABLE,
distributionId,
groupId,
identifiers: newToMemberUuids,
},
sendOptions
),
{ messageIds: [], sendType: 'senderKeyDistributionMessage' }
);
}
@ -368,6 +405,14 @@ export async function sendToGroupViaSenderKey(options: {
}
// 10. Send the Sender Key message!
let sendLogId: number;
let senderKeyRecipientsWithDevices: Record<string, Array<number>> = {};
devicesForSenderKey.forEach(item => {
const { id, identifier } = item;
senderKeyRecipientsWithDevices[identifier] ||= [];
senderKeyRecipientsWithDevices[identifier].push(id);
});
try {
const messageBuffer = await encryptForSenderKey({
contentHint,
@ -397,6 +442,11 @@ export async function sendToGroupViaSenderKey(options: {
),
});
}
senderKeyRecipientsWithDevices = omit(
senderKeyRecipientsWithDevices,
uuids404 || []
);
} else {
window.log.error(
`sendToGroupViaSenderKey/${logId}: Server returned unexpected 200 response ${JSON.stringify(
@ -404,6 +454,20 @@ export async function sendToGroupViaSenderKey(options: {
)}`
);
}
if (shouldSaveProto(sendType)) {
sendLogId = await window.Signal.Data.insertSentProto(
{
contentHint,
proto: Buffer.from(Proto.Content.encode(contentMessage).finish()),
timestamp,
},
{
recipients: senderKeyRecipientsWithDevices,
messageIds: messageId ? [messageId] : [],
}
);
}
} catch (error) {
if (error.code === ERROR_EXPIRED_OR_MISSING_DEVICES) {
await handle409Response(logId, error);
@ -426,13 +490,14 @@ export async function sendToGroupViaSenderKey(options: {
}
throw new Error(
`sendToGroupViaSenderKey/${logId}: Returned unexpected error ${error.code}. Failing over.`
`sendToGroupViaSenderKey/${logId}: Returned unexpected error ${
error.code
}. Failing over. ${error.stack || error}`
);
}
// 11. Return early if there are no normal send recipients
const normalRecipients = getUuidsFromDevices(devicesForNormalSend);
if (normalRecipients.length === 0) {
if (normalSendRecipients.length === 0) {
return {
dataMessage: contentMessage.dataMessage
? toArrayBuffer(
@ -441,18 +506,59 @@ export async function sendToGroupViaSenderKey(options: {
: undefined,
successfulIdentifiers: senderKeyRecipients,
unidentifiedDeliveries: senderKeyRecipients,
contentHint,
timestamp,
contentProto: Buffer.from(Proto.Content.encode(contentMessage).finish()),
recipients: senderKeyRecipientsWithDevices,
};
}
// 12. Send normal message to the leftover normal recipients. Then combine normal send
// result with result from sender key send for final return value.
// We don't want to use a normal send log callback here, because the proto has already
// been saved as part of the Sender Key send. We're just adding recipients here.
const sendLogCallback: SendLogCallbackType = async ({
identifier,
deviceIds,
}: {
identifier: string;
deviceIds: Array<number>;
}) => {
if (!shouldSaveProto(sendType)) {
return;
}
const sentToConversation = window.ConversationController.get(identifier);
if (!sentToConversation) {
window.log.warn(
`sendToGroupViaSenderKey/callback: Unable to find conversation for identifier ${identifier}`
);
return;
}
const recipientUuid = sentToConversation.get('uuid');
if (!recipientUuid) {
window.log.warn(
`sendToGroupViaSenderKey/callback: Conversation ${conversation.idForLogging()} had no UUID`
);
return;
}
await window.Signal.Data.insertProtoRecipients({
id: sendLogId,
recipientUuid,
deviceIds,
});
};
const normalSendResult = await window.textsecure.messaging.sendGroupProto({
recipients: normalRecipients,
proto: contentMessage,
timestamp,
contentHint,
groupId,
options: { ...sendOptions, online },
proto: contentMessage,
recipients: normalSendRecipients,
sendLogCallback,
timestamp,
});
return {
@ -471,6 +577,14 @@ export async function sendToGroupViaSenderKey(options: {
...(normalSendResult.unidentifiedDeliveries || []),
...senderKeyRecipients,
],
contentHint,
timestamp,
contentProto: Buffer.from(Proto.Content.encode(contentMessage).finish()),
recipients: {
...normalSendResult.recipients,
...senderKeyRecipientsWithDevices,
},
};
}

View File

@ -202,6 +202,24 @@ Whisper.TapToViewExpiredOutgoingToast = Whisper.ToastView.extend({
},
});
Whisper.DecryptionErrorToast = Whisper.ToastView.extend({
className: 'toast toast-clickable',
initialize() {
this.timeout = 10000;
},
events: {
click: 'onClick',
},
render_attributes() {
return {
toastMessage: window.i18n('decryptionErrorToast'),
};
},
onClick() {
window.showDebugLog();
},
});
Whisper.FileSavedToast = Whisper.ToastView.extend({
className: 'toast toast-clickable',
initialize(options: any) {
@ -2939,7 +2957,10 @@ Whisper.ConversationView = Whisper.View.extend({
okText: window.i18n('delete'),
resolve: async () => {
try {
await this.model.sendDeleteForEveryoneMessage(message.get('sent_at'));
await this.model.sendDeleteForEveryoneMessage({
id: message.id,
timestamp: message.get('sent_at'),
});
} catch (error) {
window.log.error(
'Error sending delete-for-everyone',
@ -3673,6 +3694,7 @@ Whisper.ConversationView = Whisper.View.extend({
}
await this.model.sendReactionMessage(reaction, {
messageId,
targetAuthorUuid: messageModel.getSourceUuid(),
targetTimestamp: messageModel.get('sent_at'),
});

20
ts/window.d.ts vendored
View File

@ -16,7 +16,6 @@ import {
MessageModelCollectionType,
MessageAttributesType,
ReactionAttributesType,
ReactionModelType,
} from './model-types.d';
import { TextSecureType } from './textsecure.d';
import { Storage } from './textsecure/Storage';
@ -241,6 +240,7 @@ declare global {
showWindow: () => void;
showSettings: () => void;
shutdown: () => void;
showDebugLog: () => void;
sendChallengeRequest: (request: IPCChallengeRequest) => void;
setAutoHideMenuBar: (value: WhatIsThis) => void;
setBadgeCount: (count: number) => void;
@ -290,6 +290,7 @@ declare global {
onTimeout: (timestamp: number, cb: () => void, id?: string) => string;
removeTimeout: (uuid: string) => void;
retryPlaceholders?: Util.RetryPlaceholders;
lightSessionResetQueue?: PQueue;
runStorageServiceSyncJob: () => Promise<void>;
storageServiceUploadJob: () => void;
};
@ -494,6 +495,7 @@ declare global {
GV2_ENABLE_STATE_PROCESSING: boolean;
GV2_MIGRATION_DISABLE_ADD: boolean;
GV2_MIGRATION_DISABLE_INVITE: boolean;
RETRY_DELAY: boolean;
}
// We want to extend `Error`, so we need an interface.
@ -536,6 +538,13 @@ export class CanvasVideoRenderer {
constructor(canvas: Ref<HTMLCanvasElement>);
}
export type DeliveryReceiptBatcherItemType = {
messageId: string;
source?: string;
sourceUuid?: string;
timestamp: number;
};
export type LoggerType = {
fatal: LogFunctionType;
info: LogFunctionType;
@ -614,12 +623,8 @@ export type WhisperType = {
ExpiringMessagesListener: WhatIsThis;
TapToViewMessagesListener: WhatIsThis;
deliveryReceiptQueue: PQueue<WhatIsThis>;
deliveryReceiptBatcher: BatcherType<{
source?: string;
sourceUuid?: string;
timestamp: number;
}>;
deliveryReceiptQueue: PQueue;
deliveryReceiptBatcher: BatcherType<DeliveryReceiptBatcherItemType>;
RotateSignedPreKeyListener: WhatIsThis;
AlreadyGroupMemberToast: typeof window.Whisper.ToastView;
@ -630,6 +635,7 @@ export type WhisperType = {
CaptchaSolvedToast: typeof window.Whisper.ToastView;
CaptchaFailedToast: typeof window.Whisper.ToastView;
DangerousFileTypeToast: typeof window.Whisper.ToastView;
DecryptionErrorToast: typeof window.Whisper.ToastView;
ExpiredToast: typeof window.Whisper.ToastView;
FileSavedToast: typeof window.Whisper.ToastView;
FileSizeToast: any;