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": { "oneNonImageAtATimeToast": {
"message": "When including a non-image attachment, the limit is one attachment per message.", "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" "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_ADD = false;
window.GV2_MIGRATION_DISABLE_INVITE = false; window.GV2_MIGRATION_DISABLE_INVITE = false;
window.RETRY_DELAY = false;
window.platform = process.platform; window.platform = process.platform;
window.getTitle = () => title; window.getTitle = () => title;
window.getLocale = () => config.locale; window.getLocale = () => config.locale;
@ -156,6 +158,10 @@ try {
window.log.info('shutdown'); window.log.info('shutdown');
ipc.send('shutdown'); ipc.send('shutdown');
}; };
window.showDebugLog = () => {
window.log.info('showDebugLog');
ipc.send('show-debug-log');
};
window.closeAbout = () => ipc.send('close-about'); window.closeAbout = () => ipc.send('close-about');
window.readyForUpdates = () => ipc.send('ready-for-updates'); window.readyForUpdates = () => ipc.send('ready-for-updates');

View file

@ -9,16 +9,12 @@ import {
ConversationModelCollectionType, ConversationModelCollectionType,
ConversationAttributesTypeType, ConversationAttributesTypeType,
} from './model-types.d'; } from './model-types.d';
import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage';
import { ConversationModel } from './models/conversations'; import { ConversationModel } from './models/conversations';
import { maybeDeriveGroupV2Id } from './groups'; import { maybeDeriveGroupV2Id } from './groups';
import { assert } from './util/assert'; import { assert } from './util/assert';
import { isValidGuid } from './util/isValidGuid'; import { isValidGuid } from './util/isValidGuid';
import { map, reduce } from './util/iterables'; import { map, reduce } from './util/iterables';
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; 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; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
@ -313,6 +309,25 @@ export class ConversationController {
return conversationId; 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 * 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, * database id of the given contact. In high trust mode, it may create new contacts,
@ -730,25 +745,6 @@ export class ConversationController {
return null; 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( async getAllGroupsInvolvingId(
conversationId: string conversationId: string
): Promise<Array<ConversationModel>> { ): Promise<Array<ConversationModel>> {

View file

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

View file

@ -24,6 +24,7 @@ import {
typedArrayToArrayBuffer, typedArrayToArrayBuffer,
} from './Crypto'; } from './Crypto';
import { assert } from './util/assert'; import { assert } from './util/assert';
import { handleMessageSend } from './util/handleMessageSend';
import { isNotNil } from './util/isNotNil'; import { isNotNil } from './util/isNotNil';
import { Zone } from './util/Zone'; import { Zone } from './util/Zone';
import { isMoreRecentThan } from './util/timestamp'; 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 // Session Queue
async enqueueSessionJob<T>( async enqueueSessionJob<T>(
@ -1231,7 +1239,14 @@ export class SignalProtocolStore extends EventsMixin {
// Send a null message with newly-created session // Send a null message with newly-created session
const sendOptions = await getSendOptions(conversation.attributes); 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) { } catch (error) {
// If we failed to do the session reset, then we'll allow another attempt sooner // If we failed to do the session reset, then we'll allow another attempt sooner
// than one hour from now. // than one hour from now.

View file

@ -4,10 +4,6 @@
import { isNumber, noop } from 'lodash'; import { isNumber, noop } from 'lodash';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { render } from 'react-dom'; import { render } from 'react-dom';
import {
DecryptionErrorMessage,
PlaintextContent,
} from '@signalapp/signal-client';
import MessageReceiver from './textsecure/MessageReceiver'; import MessageReceiver from './textsecure/MessageReceiver';
import { SessionResetsType, ProcessedDataMessage } from './textsecure/Types.d'; import { SessionResetsType, ProcessedDataMessage } from './textsecure/Types.d';
@ -17,7 +13,7 @@ import {
} from './model-types.d'; } from './model-types.d';
import * as Bytes from './Bytes'; import * as Bytes from './Bytes';
import { typedArrayToArrayBuffer } from './Crypto'; import { typedArrayToArrayBuffer } from './Crypto';
import { WhatIsThis } from './window.d'; import { WhatIsThis, DeliveryReceiptBatcherItemType } from './window.d';
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
import { SocketStatus } from './types/SocketStatus'; import { SocketStatus } from './types/SocketStatus';
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors'; import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
@ -46,15 +42,11 @@ import {
TypingEvent, TypingEvent,
ErrorEvent, ErrorEvent,
DeliveryEvent, DeliveryEvent,
DecryptionErrorEvent,
DecryptionErrorEventData,
SentEvent, SentEvent,
SentEventData, SentEventData,
ProfileKeyUpdateEvent, ProfileKeyUpdateEvent,
MessageEvent, MessageEvent,
MessageEventData, MessageEventData,
RetryRequestEvent,
RetryRequestEventData,
ReadEvent, ReadEvent,
ConfigurationEvent, ConfigurationEvent,
ViewSyncEvent, ViewSyncEvent,
@ -72,6 +64,7 @@ import * as universalExpireTimer from './util/universalExpireTimer';
import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation'; import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation';
import { getSendOptions } from './util/getSendOptions'; import { getSendOptions } from './util/getSendOptions';
import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff'; import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff';
import { handleMessageSend } from './util/handleMessageSend';
import { AppViewType } from './state/ducks/app'; import { AppViewType } from './state/ducks/app';
import { isIncoming } from './state/selectors/message'; import { isIncoming } from './state/selectors/message';
import { actionCreators } from './state/actions'; import { actionCreators } from './state/actions';
@ -89,6 +82,7 @@ import {
} from './types/SystemTraySetting'; } from './types/SystemTraySetting';
import * as Stickers from './types/Stickers'; import * as Stickers from './types/Stickers';
import { SignalService as Proto } from './protobuf'; import { SignalService as Proto } from './protobuf';
import { onRetryRequest, onDecryptionError } from './util/handleRetry';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -167,6 +161,7 @@ export async function startApp(): Promise<void> {
profileKeyResponseQueue.pause(); profileKeyResponseQueue.pause();
const lightSessionResetQueue = new window.PQueue(); const lightSessionResetQueue = new window.PQueue();
window.Signal.Services.lightSessionResetQueue = lightSessionResetQueue;
lightSessionResetQueue.pause(); lightSessionResetQueue.pause();
window.Whisper.deliveryReceiptQueue = new window.PQueue({ window.Whisper.deliveryReceiptQueue = new window.PQueue({
@ -174,57 +169,63 @@ export async function startApp(): Promise<void> {
timeout: 1000 * 60 * 2, timeout: 1000 * 60 * 2,
}); });
window.Whisper.deliveryReceiptQueue.pause(); window.Whisper.deliveryReceiptQueue.pause();
window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher({ window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher<DeliveryReceiptBatcherItemType>(
name: 'Whisper.deliveryReceiptBatcher', {
wait: 500, name: 'Whisper.deliveryReceiptBatcher',
maxSize: 500, wait: 500,
processBatch: async items => { maxSize: 500,
const byConversationId = window._.groupBy(items, item => processBatch: async items => {
window.ConversationController.ensureContactIds({ const byConversationId = window._.groupBy(items, item =>
e164: item.source, window.ConversationController.ensureContactIds({
uuid: item.sourceUuid, 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
); );
const ids = Object.keys(byConversationId);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion for (let i = 0, max = ids.length; i < max; i += 1) {
const c = window.ConversationController.get(conversationId)!; const conversationId = ids[i];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const ourItems = byConversationId[conversationId];
const uuid = c.get('uuid')!; const timestamps = ourItems.map(item => item.timestamp);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const messageIds = ourItems.map(item => item.messageId);
const e164 = c.get('e164')!;
c.queueJob('sendDeliveryReceipt', async () => { const c = window.ConversationController.get(conversationId);
try { if (!c) {
const { window.log.warn(
wrap, `deliveryReceiptBatcher: Conversation ${conversationId} does not exist! ` +
sendOptions, `Will not send delivery receipts for timestamps ${timestamps}`
} = 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
); );
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) { if (getTitleBarVisibility() === TitleBarVisibility.Hidden) {
window.addEventListener('dblclick', (event: Event) => { window.addEventListener('dblclick', (event: Event) => {
@ -899,25 +900,47 @@ export async function startApp(): Promise<void> {
window.Signal.Services.retryPlaceholders = retryPlaceholders; window.Signal.Services.retryPlaceholders = retryPlaceholders;
setInterval(async () => { setInterval(async () => {
const expired = await retryPlaceholders.getExpiredAndRemove(); const now = Date.now();
window.log.info( const HOUR = 1000 * 60 * 60;
`retryPlaceholders/interval: Found ${expired.length} expired items` const DAY = 24 * HOUR;
); const oneDayAgo = now - DAY;
expired.forEach(item => { try {
const { conversationId, senderUuid } = item; await window.Signal.Data.deleteSentProtosOlderThan(oneDayAgo);
const conversation = window.ConversationController.get(conversationId); } catch (error) {
if (conversation) { window.log.error(
const receivedAt = Date.now(); 'background/onready/setInterval: Error deleting sent protos: ',
const receivedAtCounter = window.Signal.Util.incrementMessageCounter(); error && error.stack ? error.stack : error
conversation.queueJob('addDeliveryIssue', () => );
conversation.addDeliveryIssue({ }
receivedAt,
receivedAtCounter, try {
senderUuid, 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); }, FIVE_MINUTES);
try { try {
@ -1640,7 +1663,18 @@ export async function startApp(): Promise<void> {
function runStorageService() { function runStorageService() {
window.Signal.Services.enableStorageService(); 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; let challengeHandler: ChallengeHandler | undefined;
@ -1868,7 +1902,18 @@ export async function startApp(): Promise<void> {
} }
await window.storage.remove('manifestVersion'); 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, 'gv2-3': true,
'gv1-migration': true, 'gv1-migration': true,
senderKey: window.Signal.RemoteConfig.isEnabled( senderKey: window.Signal.RemoteConfig.isEnabled(
'desktop.sendSenderKey' 'desktop.sendSenderKey2'
), ),
}); });
} catch (error) { } catch (error) {
@ -2312,11 +2357,8 @@ export async function startApp(): Promise<void> {
runStorageService(); runStorageService();
}); });
const ourId = window.ConversationController.getOurConversationId(); const ourConversation = window.ConversationController.getOurConversationOrThrow();
const { const sendOptions = await getSendOptions(ourConversation.attributes, {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(ourId, {
syncMessage: true, syncMessage: true,
}); });
@ -2328,11 +2370,19 @@ export async function startApp(): Promise<void> {
installed: true, 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( window.textsecure.messaging.sendStickerPackSync(
operations, operations,
sendOptions sendOptions
) ),
{ messageIds: [], sendType: 'otherSync' }
).catch(error => { ).catch(error => {
window.log.error( window.log.error(
'Failed to send installed sticker packs via sync message', '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'); 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) { async function onViewSync(ev: ViewSyncEvent) {
ev.confirm(); ev.confirm();
@ -4025,7 +3699,13 @@ export async function startApp(): Promise<void> {
} }
function onReadReceipt(ev: ReadEvent) { function onReadReceipt(ev: ReadEvent) {
const { envelopeTimestamp, timestamp, source, sourceUuid } = ev.read; const {
envelopeTimestamp,
timestamp,
source,
sourceUuid,
sourceDevice,
} = ev.read;
const readAt = envelopeTimestamp; const readAt = envelopeTimestamp;
const reader = window.ConversationController.ensureContactIds({ const reader = window.ConversationController.ensureContactIds({
e164: source, e164: source,
@ -4036,6 +3716,7 @@ export async function startApp(): Promise<void> {
'read receipt', 'read receipt',
source, source,
sourceUuid, sourceUuid,
sourceDevice,
envelopeTimestamp, envelopeTimestamp,
reader, reader,
'for sent message', 'for sent message',
@ -4050,6 +3731,7 @@ export async function startApp(): Promise<void> {
const receipt = ReadReceipts.getSingleton().add({ const receipt = ReadReceipts.getSingleton().add({
reader, reader,
readerDevice: sourceDevice,
timestamp, timestamp,
readAt, readAt,
}); });
@ -4198,6 +3880,7 @@ export async function startApp(): Promise<void> {
const receipt = DeliveryReceipts.getSingleton().add({ const receipt = DeliveryReceipts.getSingleton().add({
timestamp, timestamp,
deliveredTo, deliveredTo,
deliveredToDevice: sourceDevice,
}); });
// Note: We don't wait for completion here // Note: We don't wait for completion here

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -27,6 +27,7 @@ import {
import { ConversationModel } from '../models/conversations'; import { ConversationModel } from '../models/conversations';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { BackOff } from '../util/BackOff'; import { BackOff } from '../util/BackOff';
import { handleMessageSend } from '../util/handleMessageSend';
import { storageJobQueue } from '../util/JobQueue'; import { storageJobQueue } from '../util/JobQueue';
import { sleep } from '../util/sleep'; import { sleep } from '../util/sleep';
import { isMoreRecentThan } from '../util/timestamp'; import { isMoreRecentThan } from '../util/timestamp';
@ -531,7 +532,18 @@ async function uploadManifest(
window.storage.put('manifestVersion', version); window.storage.put('manifestVersion', version);
conflictBackOff.reset(); conflictBackOff.reset();
backOff.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() { async function stopStorageServiceSync() {
@ -552,7 +564,18 @@ async function stopStorageServiceSync() {
if (!window.textsecure.messaging) { if (!window.textsecure.messaging) {
throw new Error('storageService.stopStorageServiceSync: We are offline!'); 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' 'storageService.upload: no storageKey, requesting new keys'
); );
backOff.reset(); 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; return;
} }

View file

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

View file

@ -14,7 +14,6 @@ import {
cloneDeep, cloneDeep,
compact, compact,
fromPairs, fromPairs,
toPairs,
get, get,
groupBy, groupBy,
isFunction, isFunction,
@ -22,6 +21,8 @@ import {
map, map,
omit, omit,
set, set,
toPairs,
uniq,
} from 'lodash'; } from 'lodash';
import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto'; import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto';
@ -41,8 +42,8 @@ import { StoredJob } from '../jobs/types';
import { import {
AttachmentDownloadJobType, AttachmentDownloadJobType,
ClientInterface, ClientInterface,
ClientSearchResultMessageType,
ClientJobType, ClientJobType,
ClientSearchResultMessageType,
ConversationType, ConversationType,
IdentityKeyType, IdentityKeyType,
ItemKeyType, ItemKeyType,
@ -52,6 +53,12 @@ import {
PreKeyType, PreKeyType,
SearchResultMessageType, SearchResultMessageType,
SenderKeyType, SenderKeyType,
SentMessageDBType,
SentMessagesType,
SentProtoType,
SentProtoWithMessageIdsType,
SentRecipientsDBType,
SentRecipientsType,
ServerInterface, ServerInterface,
SessionType, SessionType,
SignedPreKeyType, SignedPreKeyType,
@ -143,6 +150,17 @@ const dataInterface: ClientInterface = {
getAllSenderKeys, getAllSenderKeys,
removeSenderKeyById, removeSenderKeyById,
insertSentProto,
deleteSentProtosOlderThan,
deleteSentProtoByMessageId,
insertProtoRecipients,
deleteSentProtoRecipient,
getSentProtoByRecipient,
removeAllSentProtos,
getAllSentProtos,
_getAllSentProtoRecipients,
_getAllSentProtoMessageIds,
createOrUpdateSession, createOrUpdateSession,
createOrUpdateSessions, createOrUpdateSessions,
commitSessionsAndUnprocessed, commitSessionsAndUnprocessed,
@ -771,6 +789,66 @@ async function removeSenderKeyById(id: string): Promise<void> {
return channels.removeSenderKeyById(id); 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 // Sessions
async function createOrUpdateSession(data: SessionType) { 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 type { ConversationColorType, CustomColorType } from '../types/Colors';
import { StorageAccessType } from '../types/Storage.d'; import { StorageAccessType } from '../types/Storage.d';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import { BodyRangesType } from '../types/Util';
export type AttachmentDownloadJobTypeType = export type AttachmentDownloadJobTypeType =
| 'long-message' | 'long-message'
@ -83,9 +84,32 @@ export type SearchResultMessageType = {
}; };
export type ClientSearchResultMessageType = MessageType & { export type ClientSearchResultMessageType = MessageType & {
json: string; json: string;
bodyRanges: []; bodyRanges: BodyRangesType;
snippet: string; 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 = { export type SenderKeyType = {
// Primary key // Primary key
id: string; id: string;
@ -215,6 +239,36 @@ export type DataInterface = {
getAllSenderKeys: () => Promise<Array<SenderKeyType>>; getAllSenderKeys: () => Promise<Array<SenderKeyType>>;
removeSenderKeyById: (id: string) => Promise<void>; 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>; createOrUpdateSession: (data: SessionType) => Promise<void>;
createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>; createOrUpdateSessions: (array: Array<SessionType>) => Promise<void>;
commitSessionsAndUnprocessed(options: { commitSessionsAndUnprocessed(options: {
@ -255,6 +309,36 @@ export type DataInterface = {
) => Promise<void>; ) => Promise<void>;
getNextTapToViewMessageTimestampToAgeOut: () => Promise<undefined | number>; 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>; getUnprocessedCount: () => Promise<number>;
getAllUnprocessed: () => Promise<Array<UnprocessedType>>; getAllUnprocessed: () => Promise<Array<UnprocessedType>>;
updateUnprocessedAttempts: (id: string, attempts: number) => Promise<void>; updateUnprocessedAttempts: (id: string, attempts: number) => Promise<void>;
@ -391,33 +475,6 @@ export type ServerInterface = DataInterface & {
ourConversationId: string; ourConversationId: string;
}) => Promise<MessageType | undefined>; }) => Promise<MessageType | undefined>;
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>; 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>; removeConversation: (id: Array<string> | string) => Promise<void>;
removeMessage: (id: string) => Promise<void>; removeMessage: (id: string) => Promise<void>;
removeMessages: (ids: Array<string>) => Promise<void>; removeMessages: (ids: Array<string>) => Promise<void>;
@ -530,33 +587,6 @@ export type ClientInterface = DataInterface & {
getTapToViewMessagesNeedingErase: (options: { getTapToViewMessagesNeedingErase: (options: {
MessageCollection: typeof MessageModelCollectionType; MessageCollection: typeof MessageModelCollectionType;
}) => Promise<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: ( removeConversation: (
id: string, id: string,
options: { Conversation: typeof ConversationModel } options: { Conversation: typeof ConversationModel }

View file

@ -36,23 +36,30 @@ import { combineNames } from '../util/combineNames';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { isNormalNumber } from '../util/isNormalNumber'; import { isNormalNumber } from '../util/isNormalNumber';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { ConversationColorType, CustomColorType } from '../types/Colors'; import { ConversationColorType, CustomColorType } from '../types/Colors';
import { import {
AllItemsType,
AttachmentDownloadJobType, AttachmentDownloadJobType,
ConversationMetricsType, ConversationMetricsType,
ConversationType, ConversationType,
EmojiType, EmojiType,
IdentityKeyType, IdentityKeyType,
AllItemsType,
ItemKeyType, ItemKeyType,
ItemType, ItemType,
MessageMetricsType,
MessageType, MessageType,
MessageTypeUnhydrated, MessageTypeUnhydrated,
MessageMetricsType,
PreKeyType, PreKeyType,
SearchResultMessageType, SearchResultMessageType,
SenderKeyType, SenderKeyType,
SentMessageDBType,
SentMessagesType,
SentProtoType,
SentProtoWithMessageIdsType,
SentRecipientsDBType,
SentRecipientsType,
ServerInterface, ServerInterface,
SessionType, SessionType,
SignedPreKeyType, SignedPreKeyType,
@ -63,14 +70,6 @@ import {
UnprocessedUpdateType, UnprocessedUpdateType,
} from './Interface'; } 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 JSONRows = Array<{ readonly json: string }>;
type ConversationRow = Readonly<{ type ConversationRow = Readonly<{
json: string; json: string;
@ -137,6 +136,17 @@ const dataInterface: ServerInterface = {
getAllSenderKeys, getAllSenderKeys,
removeSenderKeyById, removeSenderKeyById,
insertSentProto,
deleteSentProtosOlderThan,
deleteSentProtoByMessageId,
insertProtoRecipients,
deleteSentProtoRecipient,
getSentProtoByRecipient,
removeAllSentProtos,
getAllSentProtos,
_getAllSentProtoRecipients,
_getAllSentProtoMessageIds,
createOrUpdateSession, createOrUpdateSession,
createOrUpdateSessions, createOrUpdateSessions,
commitSessionsAndUnprocessed, commitSessionsAndUnprocessed,
@ -253,16 +263,16 @@ type DatabaseQueryCache = Map<string, Statement<Array<any>>>;
const statementCache = new WeakMap<Database, DatabaseQueryCache>(); 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); let dbCache = statementCache.get(db);
if (!dbCache) { if (!dbCache) {
dbCache = new Map(); dbCache = new Map();
statementCache.set(db, dbCache); statementCache.set(db, dbCache);
} }
let result = dbCache.get(query); let result = dbCache.get(query) as Statement<T>;
if (!result) { if (!result) {
result = db.prepare(query); result = db.prepare<T>(query);
dbCache.set(query, result); dbCache.set(query, result);
} }
@ -1947,6 +1957,84 @@ function updateToSchemaVersion36(currentVersion: number, db: Database) {
console.log('updateToSchemaVersion36: success!'); 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 = [ const SCHEMA_VERSIONS = [
updateToSchemaVersion1, updateToSchemaVersion1,
updateToSchemaVersion2, updateToSchemaVersion2,
@ -1984,6 +2072,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion34, updateToSchemaVersion34,
updateToSchemaVersion35, updateToSchemaVersion35,
updateToSchemaVersion36, updateToSchemaVersion36,
updateToSchemaVersion37,
]; ];
function updateSchema(db: Database): void { function updateSchema(db: Database): void {
@ -2350,11 +2439,11 @@ async function getSenderKeyById(
} }
async function removeAllSenderKeys(): Promise<void> { async function removeAllSenderKeys(): Promise<void> {
const db = getInstance(); const db = getInstance();
prepare(db, 'DELETE FROM senderKeys').run({}); prepare<EmptyQuery>(db, 'DELETE FROM senderKeys').run();
} }
async function getAllSenderKeys(): Promise<Array<SenderKeyType>> { async function getAllSenderKeys(): Promise<Array<SenderKeyType>> {
const db = getInstance(); const db = getInstance();
const rows = prepare(db, 'SELECT * FROM senderKeys').all({}); const rows = prepare<EmptyQuery>(db, 'SELECT * FROM senderKeys').all();
return rows; return rows;
} }
@ -2363,6 +2452,317 @@ async function removeSenderKeyById(id: string): Promise<void> {
prepare(db, 'DELETE FROM senderKeys WHERE id = $id').run({ id }); 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'; const SESSIONS_TABLE = 'sessions';
function createOrUpdateSessionSync(data: SessionType): void { function createOrUpdateSessionSync(data: SessionType): void {
const db = getInstance(); const db = getInstance();
@ -2717,8 +3117,7 @@ function updateConversationSync(data: ConversationType): void {
? members.join(' ') ? members.join(' ')
: null; : null;
prepare( db.prepare(
db,
` `
UPDATE conversations SET UPDATE conversations SET
json = $json, json = $json,
@ -3470,13 +3869,18 @@ async function getUnreadByConversationAndMarkRead(
async function getUnreadReactionsAndMarkRead( async function getUnreadReactionsAndMarkRead(
conversationId: string, conversationId: string,
newestUnreadId: number newestUnreadId: number
): Promise<Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>> { ): Promise<
Array<
Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp' | 'messageId'>
>
> {
const db = getInstance(); const db = getInstance();
return db.transaction(() => { return db.transaction(() => {
const unreadMessages = db const unreadMessages = db
.prepare<Query>( .prepare<Query>(
` `
SELECT targetAuthorUuid, targetTimestamp SELECT targetAuthorUuid, targetTimestamp, messageId
FROM reactions WHERE FROM reactions WHERE
unread = 1 AND unread = 1 AND
conversationId = $conversationId AND conversationId = $conversationId AND
@ -3548,6 +3952,7 @@ async function addReaction({
conversationId, conversationId,
emoji, emoji,
fromId, fromId,
messageId,
messageReceivedAt, messageReceivedAt,
targetAuthorUuid, targetAuthorUuid,
targetTimestamp, targetTimestamp,
@ -3559,6 +3964,7 @@ async function addReaction({
conversationId, conversationId,
emoji, emoji,
fromId, fromId,
messageId,
messageReceivedAt, messageReceivedAt,
targetAuthorUuid, targetAuthorUuid,
targetTimestamp, targetTimestamp,
@ -3567,6 +3973,7 @@ async function addReaction({
$conversationId, $conversationId,
$emoji, $emoji,
$fromId, $fromId,
$messageId,
$messageReceivedAt, $messageReceivedAt,
$targetAuthorUuid, $targetAuthorUuid,
$targetTimestamp, $targetTimestamp,
@ -3577,6 +3984,7 @@ async function addReaction({
conversationId, conversationId,
emoji, emoji,
fromId, fromId,
messageId,
messageReceivedAt, messageReceivedAt,
targetAuthorUuid, targetAuthorUuid,
targetTimestamp, targetTimestamp,

View file

@ -5,6 +5,7 @@ import { assert } from 'chai';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import { CallbackResultType } from '../../textsecure/SendMessage';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
describe('Message', () => { describe('Message', () => {
@ -71,7 +72,16 @@ describe('Message', () => {
it('updates the `sent` attribute', async () => { it('updates the `sent` attribute', async () => {
const message = createMessage({ type: 'outgoing', source, sent: false }); 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')); 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) { } catch (error) {
const args = [ const args = [
'queueEncryptedEnvelope error handling envelope', 'queueEncryptedEnvelope error handling envelope',
this.getEnvelopeId(envelope), this.getEnvelopeId(error.envelope || envelope),
':', ':',
error && error.extra ? JSON.stringify(error.extra) : '', error && error.extra ? JSON.stringify(error.extra) : '',
error && error.stack ? error.stack : error, error && error.stack ? error.stack : error,
@ -1587,7 +1587,10 @@ class MessageReceiverInner extends EventTarget {
}); });
// Avoid deadlocks by scheduling processing on decrypted queue // Avoid deadlocks by scheduling processing on decrypted queue
this.addToQueue(() => this.dispatchAndWait(event), TaskType.Decrypted); this.addToQueue(
async () => this.dispatchEvent(event),
TaskType.Decrypted
);
} else { } else {
const envelopeId = this.getEnvelopeId(newEnvelope); const envelopeId = this.getEnvelopeId(newEnvelope);
window.log.error( window.log.error(
@ -1803,39 +1806,98 @@ class MessageReceiverInner extends EventTarget {
); );
assert(envelope.content, 'Should have `content` field'); assert(envelope.content, 'Should have `content` field');
const result = await this.decrypt(stores, envelope, envelope.content); const result = await this.decrypt(stores, envelope, envelope.content);
if (!result.plaintext) { if (!result.plaintext) {
window.log.warn('decryptContentMessage: plaintext was falsey'); window.log.warn('decryptContentMessage: plaintext was falsey');
return result;
} }
return result; // 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!
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.
try { try {
const content = Proto.Content.decode(result.plaintext);
if ( if (
content.senderKeyDistributionMessage && content.senderKeyDistributionMessage &&
Bytes.isNotEmpty(content.senderKeyDistributionMessage) Bytes.isNotEmpty(content.senderKeyDistributionMessage)
) { ) {
await this.handleSenderKeyDistributionMessage( await this.handleSenderKeyDistributionMessage(
envelope, stores,
result.envelope,
content.senderKeyDistributionMessage content.senderKeyDistributionMessage
); );
} }
} catch (error) { } catch (error) {
const errorString = error && error.stack ? error.stack : error; const errorString = error && error.stack ? error.stack : error;
window.log.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 ( if (
content.decryptionErrorMessage && content.decryptionErrorMessage &&
Bytes.isNotEmpty(content.decryptionErrorMessage) Bytes.isNotEmpty(content.decryptionErrorMessage)
@ -1908,10 +1970,11 @@ class MessageReceiverInner extends EventTarget {
senderDevice: request.deviceId(), senderDevice: request.deviceId(),
sentAt: request.timestamp(), sentAt: request.timestamp(),
}); });
await this.dispatchAndWait(event); await this.dispatchEvent(event);
} }
async handleSenderKeyDistributionMessage( async handleSenderKeyDistributionMessage(
stores: LockedStores,
envelope: ProcessedEnvelope, envelope: ProcessedEnvelope,
distributionMessage: Uint8Array distributionMessage: Uint8Array
): Promise<void> { ): Promise<void> {
@ -1941,12 +2004,15 @@ class MessageReceiverInner extends EventTarget {
const senderKeyStore = new SenderKeys(); const senderKeyStore = new SenderKeys();
const address = `${identifier}.${sourceDevice}`; const address = `${identifier}.${sourceDevice}`;
await window.textsecure.storage.protocol.enqueueSenderKeyJob(address, () => await window.textsecure.storage.protocol.enqueueSenderKeyJob(
processSenderKeyDistributionMessage( address,
sender, () =>
senderKeyDistributionMessage, processSenderKeyDistributionMessage(
senderKeyStore sender,
) senderKeyDistributionMessage,
senderKeyStore
),
stores.zone
); );
} }
@ -1989,6 +2055,7 @@ class MessageReceiverInner extends EventTarget {
envelopeTimestamp: envelope.timestamp, envelopeTimestamp: envelope.timestamp,
source: envelope.source, source: envelope.source,
sourceUuid: envelope.sourceUuid, sourceUuid: envelope.sourceUuid,
sourceDevice: envelope.sourceDevice,
}, },
this.removeFromCache.bind(this, envelope) this.removeFromCache.bind(this, envelope)
); );

View file

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

View file

@ -11,6 +11,8 @@ import MessageReceiver from './MessageReceiver';
import { ContactSyncEvent, GroupSyncEvent } from './messageReceiverEvents'; import { ContactSyncEvent, GroupSyncEvent } from './messageReceiverEvents';
import MessageSender from './SendMessage'; import MessageSender from './SendMessage';
import { assert } from '../util/assert'; import { assert } from '../util/assert';
import { getSendOptions } from '../util/getSendOptions';
import { handleMessageSend } from '../util/handleMessageSend';
class SyncRequestInner extends EventTarget { class SyncRequestInner extends EventTarget {
private started = false; private started = false;
@ -61,25 +63,41 @@ class SyncRequestInner extends EventTarget {
const { sender } = this; const { sender } = this;
const ourNumber = window.textsecure.storage.user.getNumber(); const ourConversation = window.ConversationController.getOurConversationOrThrow();
const { const sendOptions = await getSendOptions(ourConversation.attributes, {
wrap,
sendOptions,
} = await window.ConversationController.prepareForSend(ourNumber, {
syncMessage: true, 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...'); 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...'); 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...'); window.log.info('SyncRequest now sending contact sync message...');
wrap(sender.sendRequestContactSyncMessage(sendOptions)) handleMessageSend(sender.sendRequestContactSyncMessage(sendOptions), {
messageIds: [],
sendType: 'otherSync',
})
.then(() => { .then(() => {
window.log.info('SyncRequest now sending group sync message...'); 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) => { .catch((error: Error) => {
window.log.error( window.log.error(

View file

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

View file

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

View file

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

View file

@ -1,7 +1,11 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
import { CallbackResultType } from '../textsecure/SendMessage'; import { CallbackResultType } from '../textsecure/SendMessage';
import dataInterface from '../sql/Client';
const { insertSentProto } = dataInterface;
export const SEALED_SENDER = { export const SEALED_SENDER = {
UNKNOWN: 0, UNKNOWN: 0,
@ -10,17 +14,71 @@ export const SEALED_SENDER = {
UNRESTRICTED: 3, 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( export async function handleMessageSend(
promise: Promise<CallbackResultType | void | null> promise: Promise<CallbackResultType>,
): Promise<CallbackResultType | void | null> { options: {
messageIds: Array<string>;
sendType: SendTypesType;
}
): Promise<CallbackResultType> {
try { try {
const result = await promise; const result = await promise;
if (result) {
await handleMessageSendResult( await maybeSaveToSendLog(result, options);
result.failoverIdentifiers,
result.unidentifiedDeliveries await handleMessageSendResult(
); result.failoverIdentifiers,
} result.unidentifiedDeliveries
);
return result; return result;
} catch (err) { } catch (err) {
if (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", "updated": "2021-01-21T23:06:13.270Z",
"reasonDetail": "Doesn't manipulate the DOM." "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(", "rule": "jQuery-load(",
"path": "ts/types/Stickers.js", "path": "ts/types/Stickers.js",

View file

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

View file

@ -63,14 +63,14 @@ export class RetryPlaceholders {
} }
this.items = parsed.success ? parsed.data : []; this.items = parsed.success ? parsed.data : [];
window.log.info(
`RetryPlaceholders.constructor: Started with ${this.items.length} items`
);
this.sortByExpiresAtAsc(); this.sortByExpiresAtAsc();
this.byConversation = this.makeByConversationLookup(); this.byConversation = this.makeByConversationLookup();
this.byMessage = this.makeByMessageLookup(); this.byMessage = this.makeByMessageLookup();
this.retryReceiptLifespan = options.retryReceiptLifespan || HOUR; 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 // Arranging local data for efficiency

View file

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

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { differenceWith, partition } from 'lodash'; import { differenceWith, omit, partition } from 'lodash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { import {
@ -16,6 +16,7 @@ import { senderCertificateService } from '../services/senderCertificate';
import { import {
padMessage, padMessage,
SenderCertificateMode, SenderCertificateMode,
SendLogCallbackType,
} from '../textsecure/OutgoingMessage'; } from '../textsecure/OutgoingMessage';
import { isEnabled } from '../RemoteConfig'; import { isEnabled } from '../RemoteConfig';
@ -30,7 +31,12 @@ import { ConversationModel } from '../models/conversations';
import { DeviceType } from '../textsecure/Types.d'; import { DeviceType } from '../textsecure/Types.d';
import { getKeysForIdentifier } from '../textsecure/getKeysForIdentifier'; import { getKeysForIdentifier } from '../textsecure/getKeysForIdentifier';
import { ConversationAttributesType } from '../model-types.d'; import { ConversationAttributesType } from '../model-types.d';
import { SEALED_SENDER } from './handleMessageSend'; import {
handleMessageSend,
SEALED_SENDER,
SendTypesType,
shouldSaveProto,
} from './handleMessageSend';
import { parseIntOrThrow } from './parseIntOrThrow'; import { parseIntOrThrow } from './parseIntOrThrow';
import { import {
multiRecipient200ResponseSchema, multiRecipient200ResponseSchema,
@ -59,17 +65,21 @@ const FIXMEU8 = Uint8Array;
// Public API: // Public API:
export async function sendToGroup({ export async function sendToGroup({
groupSendOptions,
conversation,
contentHint, contentHint,
sendOptions, conversation,
groupSendOptions,
messageId,
isPartialSend, isPartialSend,
sendOptions,
sendType,
}: { }: {
groupSendOptions: GroupSendOptionsType;
conversation: ConversationModel;
contentHint: number; contentHint: number;
sendOptions?: SendOptionsType; conversation: ConversationModel;
groupSendOptions: GroupSendOptionsType;
isPartialSend?: boolean; isPartialSend?: boolean;
messageId: string | undefined;
sendOptions?: SendOptionsType;
sendType: SendTypesType;
}): Promise<CallbackResultType> { }): Promise<CallbackResultType> {
assert( assert(
window.textsecure.messaging, window.textsecure.messaging,
@ -92,8 +102,10 @@ export async function sendToGroup({
contentMessage, contentMessage,
conversation, conversation,
isPartialSend, isPartialSend,
messageId,
recipients, recipients,
sendOptions, sendOptions,
sendType,
timestamp, timestamp,
}); });
} }
@ -103,18 +115,22 @@ export async function sendContentMessageToGroup({
contentMessage, contentMessage,
conversation, conversation,
isPartialSend, isPartialSend,
messageId,
online, online,
recipients, recipients,
sendOptions, sendOptions,
sendType,
timestamp, timestamp,
}: { }: {
contentHint: number; contentHint: number;
contentMessage: Proto.Content; contentMessage: Proto.Content;
conversation: ConversationModel; conversation: ConversationModel;
isPartialSend?: boolean; isPartialSend?: boolean;
messageId: string | undefined;
online?: boolean; online?: boolean;
recipients: Array<string>; recipients: Array<string>;
sendOptions?: SendOptionsType; sendOptions?: SendOptionsType;
sendType: SendTypesType;
timestamp: number; timestamp: number;
}): Promise<CallbackResultType> { }): Promise<CallbackResultType> {
const logId = conversation.idForLogging(); const logId = conversation.idForLogging();
@ -127,7 +143,7 @@ export async function sendContentMessageToGroup({
const ourConversation = window.ConversationController.get(ourConversationId); const ourConversation = window.ConversationController.get(ourConversationId);
if ( if (
isEnabled('desktop.sendSenderKey') && isEnabled('desktop.sendSenderKey2') &&
ourConversation?.get('capabilities')?.senderKey && ourConversation?.get('capabilities')?.senderKey &&
isGroupV2(conversation.attributes) isGroupV2(conversation.attributes)
) { ) {
@ -137,10 +153,12 @@ export async function sendContentMessageToGroup({
contentMessage, contentMessage,
conversation, conversation,
isPartialSend, isPartialSend,
messageId,
online, online,
recipients, recipients,
recursionCount: 0, recursionCount: 0,
sendOptions, sendOptions,
sendType,
timestamp, timestamp,
}); });
} catch (error) { } 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) const groupId = isGroupV2(conversation.attributes)
? conversation.get('groupId') ? conversation.get('groupId')
: undefined; : undefined;
return window.textsecure.messaging.sendGroupProto({ return window.textsecure.messaging.sendGroupProto({
recipients,
proto: contentMessage,
timestamp,
contentHint, contentHint,
groupId, groupId,
options: { ...sendOptions, online }, options: { ...sendOptions, online },
proto: contentMessage,
recipients,
sendLogCallback,
timestamp,
}); });
} }
@ -171,10 +197,12 @@ export async function sendToGroupViaSenderKey(options: {
contentMessage: Proto.Content; contentMessage: Proto.Content;
conversation: ConversationModel; conversation: ConversationModel;
isPartialSend?: boolean; isPartialSend?: boolean;
messageId: string | undefined;
online?: boolean; online?: boolean;
recipients: Array<string>; recipients: Array<string>;
recursionCount: number; recursionCount: number;
sendOptions?: SendOptionsType; sendOptions?: SendOptionsType;
sendType: SendTypesType;
timestamp: number; timestamp: number;
}): Promise<CallbackResultType> { }): Promise<CallbackResultType> {
const { const {
@ -182,10 +210,12 @@ export async function sendToGroupViaSenderKey(options: {
contentMessage, contentMessage,
conversation, conversation,
isPartialSend, isPartialSend,
messageId,
online, online,
recursionCount, recursionCount,
recipients, recipients,
sendOptions, sendOptions,
sendType,
timestamp, timestamp,
} = options; } = options;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
@ -287,12 +317,16 @@ export async function sendToGroupViaSenderKey(options: {
currentDevices, currentDevices,
device => isValidSenderKeyRecipient(conversation, device.identifier) device => isValidSenderKeyRecipient(conversation, device.identifier)
); );
const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey);
const normalSendRecipients = getUuidsFromDevices(devicesForNormalSend);
window.log.info( 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 // 5. Ensure we have enough recipients
const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey);
if (senderKeyRecipients.length < 2) { if (senderKeyRecipients.length < 2) {
throw new Error( throw new Error(
`sendToGroupViaSenderKey/${logId}: Not enough recipients for Sender Key message. Failing over.` `sendToGroupViaSenderKey/${logId}: Not enough recipients for Sender Key message. Failing over.`
@ -335,14 +369,17 @@ export async function sendToGroupViaSenderKey(options: {
newToMemberUuids.length newToMemberUuids.length
} members: ${JSON.stringify(newToMemberUuids)}` } members: ${JSON.stringify(newToMemberUuids)}`
); );
await window.textsecure.messaging.sendSenderKeyDistributionMessage( await handleMessageSend(
{ window.textsecure.messaging.sendSenderKeyDistributionMessage(
contentHint: ContentHint.DEFAULT, {
distributionId, contentHint: ContentHint.RESENDABLE,
groupId, distributionId,
identifiers: newToMemberUuids, groupId,
}, identifiers: newToMemberUuids,
sendOptions },
sendOptions
),
{ messageIds: [], sendType: 'senderKeyDistributionMessage' }
); );
} }
@ -368,6 +405,14 @@ export async function sendToGroupViaSenderKey(options: {
} }
// 10. Send the Sender Key message! // 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 { try {
const messageBuffer = await encryptForSenderKey({ const messageBuffer = await encryptForSenderKey({
contentHint, contentHint,
@ -397,6 +442,11 @@ export async function sendToGroupViaSenderKey(options: {
), ),
}); });
} }
senderKeyRecipientsWithDevices = omit(
senderKeyRecipientsWithDevices,
uuids404 || []
);
} else { } else {
window.log.error( window.log.error(
`sendToGroupViaSenderKey/${logId}: Server returned unexpected 200 response ${JSON.stringify( `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) { } catch (error) {
if (error.code === ERROR_EXPIRED_OR_MISSING_DEVICES) { if (error.code === ERROR_EXPIRED_OR_MISSING_DEVICES) {
await handle409Response(logId, error); await handle409Response(logId, error);
@ -426,13 +490,14 @@ export async function sendToGroupViaSenderKey(options: {
} }
throw new Error( 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 // 11. Return early if there are no normal send recipients
const normalRecipients = getUuidsFromDevices(devicesForNormalSend); if (normalSendRecipients.length === 0) {
if (normalRecipients.length === 0) {
return { return {
dataMessage: contentMessage.dataMessage dataMessage: contentMessage.dataMessage
? toArrayBuffer( ? toArrayBuffer(
@ -441,18 +506,59 @@ export async function sendToGroupViaSenderKey(options: {
: undefined, : undefined,
successfulIdentifiers: senderKeyRecipients, successfulIdentifiers: senderKeyRecipients,
unidentifiedDeliveries: 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 // 12. Send normal message to the leftover normal recipients. Then combine normal send
// result with result from sender key send for final return value. // 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({ const normalSendResult = await window.textsecure.messaging.sendGroupProto({
recipients: normalRecipients,
proto: contentMessage,
timestamp,
contentHint, contentHint,
groupId, groupId,
options: { ...sendOptions, online }, options: { ...sendOptions, online },
proto: contentMessage,
recipients: normalSendRecipients,
sendLogCallback,
timestamp,
}); });
return { return {
@ -471,6 +577,14 @@ export async function sendToGroupViaSenderKey(options: {
...(normalSendResult.unidentifiedDeliveries || []), ...(normalSendResult.unidentifiedDeliveries || []),
...senderKeyRecipients, ...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({ Whisper.FileSavedToast = Whisper.ToastView.extend({
className: 'toast toast-clickable', className: 'toast toast-clickable',
initialize(options: any) { initialize(options: any) {
@ -2939,7 +2957,10 @@ Whisper.ConversationView = Whisper.View.extend({
okText: window.i18n('delete'), okText: window.i18n('delete'),
resolve: async () => { resolve: async () => {
try { try {
await this.model.sendDeleteForEveryoneMessage(message.get('sent_at')); await this.model.sendDeleteForEveryoneMessage({
id: message.id,
timestamp: message.get('sent_at'),
});
} catch (error) { } catch (error) {
window.log.error( window.log.error(
'Error sending delete-for-everyone', 'Error sending delete-for-everyone',
@ -3673,6 +3694,7 @@ Whisper.ConversationView = Whisper.View.extend({
} }
await this.model.sendReactionMessage(reaction, { await this.model.sendReactionMessage(reaction, {
messageId,
targetAuthorUuid: messageModel.getSourceUuid(), targetAuthorUuid: messageModel.getSourceUuid(),
targetTimestamp: messageModel.get('sent_at'), targetTimestamp: messageModel.get('sent_at'),
}); });

20
ts/window.d.ts vendored
View file

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