diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f5135b0eb..269e2333c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -613,6 +613,10 @@ } } }, + "decryptionErrorToast": { + "message": "Desktop ran into a decryption error. Click to submit a debug log.", + "description": "An error popup when we haven't added an error for decryption error." + }, "oneNonImageAtATimeToast": { "message": "When including a non-image attachment, the limit is one attachment per message.", "description": "An error popup when the user has attempted to add an attachment" diff --git a/js/reactions.js b/js/reactions.js deleted file mode 100644 index 8124ed9b0..000000000 --- a/js/reactions.js +++ /dev/null @@ -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; - } - }, - }))(); -})(); diff --git a/preload.js b/preload.js index ff8a861e1..6e93efcc1 100644 --- a/preload.js +++ b/preload.js @@ -50,6 +50,8 @@ try { window.GV2_MIGRATION_DISABLE_ADD = false; window.GV2_MIGRATION_DISABLE_INVITE = false; + window.RETRY_DELAY = false; + window.platform = process.platform; window.getTitle = () => title; window.getLocale = () => config.locale; @@ -156,6 +158,10 @@ try { window.log.info('shutdown'); ipc.send('shutdown'); }; + window.showDebugLog = () => { + window.log.info('showDebugLog'); + ipc.send('show-debug-log'); + }; window.closeAbout = () => ipc.send('close-about'); window.readyForUpdates = () => ipc.send('ready-for-updates'); diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index ab6b6e00b..2b00a9da3 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -9,16 +9,12 @@ import { ConversationModelCollectionType, ConversationAttributesTypeType, } from './model-types.d'; -import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage'; import { ConversationModel } from './models/conversations'; import { maybeDeriveGroupV2Id } from './groups'; import { assert } from './util/assert'; import { isValidGuid } from './util/isValidGuid'; import { map, reduce } from './util/iterables'; import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; -import { deprecated } from './util/deprecated'; -import { getSendOptions } from './util/getSendOptions'; -import { handleMessageSend } from './util/handleMessageSend'; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; @@ -313,6 +309,25 @@ export class ConversationController { return conversationId; } + getOurConversationOrThrow(): ConversationModel { + const conversationId = this.getOurConversationIdOrThrow(); + const conversation = this.get(conversationId); + if (!conversation) { + throw new Error( + 'getOurConversationOrThrow: Failed to fetch our own conversation' + ); + } + + return conversation; + } + + // eslint-disable-next-line class-methods-use-this + areWePrimaryDevice(): boolean { + const ourDeviceId = window.textsecure.storage.user.getDeviceId(); + + return ourDeviceId === 1; + } + /** * Given a UUID and/or an E164, resolves to a string representing the local * database id of the given contact. In high trust mode, it may create new contacts, @@ -730,25 +745,6 @@ export class ConversationController { return null; } - async prepareForSend( - id: string | undefined, - options?: { syncMessage?: boolean } - ): Promise<{ - wrap: ( - promise: Promise - ) => Promise; - sendOptions: SendOptionsType | undefined; - }> { - deprecated('prepareForSend'); - // id is any valid conversation identifier - const conversation = this.get(id); - const sendOptions = conversation - ? await getSendOptions(conversation.attributes, options) - : undefined; - - return { wrap: handleMessageSend, sendOptions }; - } - async getAllGroupsInvolvingId( conversationId: string ): Promise> { diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index b8c8c4156..070d1d752 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -9,18 +9,19 @@ export type ConfigKeyType = | 'desktop.disableGV1' | 'desktop.groupCalling' | 'desktop.gv2' + | 'desktop.internalUser' | 'desktop.mandatoryProfileSharing' | 'desktop.mediaQuality.levels' | 'desktop.messageRequests' | 'desktop.retryReceiptLifespan' | 'desktop.retryRespondMaxAge' | 'desktop.screensharing2' - | 'desktop.sendSenderKey' + | 'desktop.sendSenderKey2' | 'desktop.storage' | 'desktop.storageWrite3' | 'desktop.worksAtSignal' - | 'global.groupsv2.maxGroupSize' - | 'global.groupsv2.groupSizeHardLimit'; + | 'global.groupsv2.groupSizeHardLimit' + | 'global.groupsv2.maxGroupSize'; type ConfigValueType = { name: ConfigKeyType; enabled: boolean; diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index e69be5483..6c3cac8a8 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -24,6 +24,7 @@ import { typedArrayToArrayBuffer, } from './Crypto'; import { assert } from './util/assert'; +import { handleMessageSend } from './util/handleMessageSend'; import { isNotNil } from './util/isNotNil'; import { Zone } from './util/Zone'; import { isMoreRecentThan } from './util/timestamp'; @@ -590,6 +591,13 @@ export class SignalProtocolStore extends EventsMixin { } } + async clearSenderKeyStore(): Promise { + if (this.senderKeys) { + this.senderKeys.clear(); + } + await window.Signal.Data.removeAllSenderKeys(); + } + // Session Queue async enqueueSessionJob( @@ -1231,7 +1239,14 @@ export class SignalProtocolStore extends EventsMixin { // Send a null message with newly-created session const sendOptions = await getSendOptions(conversation.attributes); - await window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions); + const result = await handleMessageSend( + window.textsecure.messaging.sendNullMessage({ uuid }, sendOptions), + { messageIds: [], sendType: 'nullMessage' } + ); + + if (result && result.errors && result.errors.length) { + throw result.errors[0]; + } } catch (error) { // If we failed to do the session reset, then we'll allow another attempt sooner // than one hour from now. diff --git a/ts/background.ts b/ts/background.ts index 846a49335..afceb3904 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -4,10 +4,6 @@ import { isNumber, noop } from 'lodash'; import { bindActionCreators } from 'redux'; import { render } from 'react-dom'; -import { - DecryptionErrorMessage, - PlaintextContent, -} from '@signalapp/signal-client'; import MessageReceiver from './textsecure/MessageReceiver'; import { SessionResetsType, ProcessedDataMessage } from './textsecure/Types.d'; @@ -17,7 +13,7 @@ import { } from './model-types.d'; import * as Bytes from './Bytes'; import { typedArrayToArrayBuffer } from './Crypto'; -import { WhatIsThis } from './window.d'; +import { WhatIsThis, DeliveryReceiptBatcherItemType } from './window.d'; import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; import { SocketStatus } from './types/SocketStatus'; import { DEFAULT_CONVERSATION_COLOR } from './types/Colors'; @@ -46,15 +42,11 @@ import { TypingEvent, ErrorEvent, DeliveryEvent, - DecryptionErrorEvent, - DecryptionErrorEventData, SentEvent, SentEventData, ProfileKeyUpdateEvent, MessageEvent, MessageEventData, - RetryRequestEvent, - RetryRequestEventData, ReadEvent, ConfigurationEvent, ViewSyncEvent, @@ -72,6 +64,7 @@ import * as universalExpireTimer from './util/universalExpireTimer'; import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation'; import { getSendOptions } from './util/getSendOptions'; import { BackOff, FIBONACCI_TIMEOUTS } from './util/BackOff'; +import { handleMessageSend } from './util/handleMessageSend'; import { AppViewType } from './state/ducks/app'; import { isIncoming } from './state/selectors/message'; import { actionCreators } from './state/actions'; @@ -89,6 +82,7 @@ import { } from './types/SystemTraySetting'; import * as Stickers from './types/Stickers'; import { SignalService as Proto } from './protobuf'; +import { onRetryRequest, onDecryptionError } from './util/handleRetry'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -167,6 +161,7 @@ export async function startApp(): Promise { profileKeyResponseQueue.pause(); const lightSessionResetQueue = new window.PQueue(); + window.Signal.Services.lightSessionResetQueue = lightSessionResetQueue; lightSessionResetQueue.pause(); window.Whisper.deliveryReceiptQueue = new window.PQueue({ @@ -174,57 +169,63 @@ export async function startApp(): Promise { timeout: 1000 * 60 * 2, }); window.Whisper.deliveryReceiptQueue.pause(); - window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher({ - name: 'Whisper.deliveryReceiptBatcher', - wait: 500, - maxSize: 500, - processBatch: async items => { - const byConversationId = window._.groupBy(items, item => - window.ConversationController.ensureContactIds({ - e164: item.source, - uuid: item.sourceUuid, - }) - ); - const ids = Object.keys(byConversationId); - - for (let i = 0, max = ids.length; i < max; i += 1) { - const conversationId = ids[i]; - const timestamps = byConversationId[conversationId].map( - item => item.timestamp + window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher( + { + name: 'Whisper.deliveryReceiptBatcher', + wait: 500, + maxSize: 500, + processBatch: async items => { + const byConversationId = window._.groupBy(items, item => + window.ConversationController.ensureContactIds({ + e164: item.source, + uuid: item.sourceUuid, + }) ); + const ids = Object.keys(byConversationId); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const c = window.ConversationController.get(conversationId)!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const uuid = c.get('uuid')!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const e164 = c.get('e164')!; + for (let i = 0, max = ids.length; i < max; i += 1) { + const conversationId = ids[i]; + const ourItems = byConversationId[conversationId]; + const timestamps = ourItems.map(item => item.timestamp); + const messageIds = ourItems.map(item => item.messageId); - c.queueJob('sendDeliveryReceipt', async () => { - try { - const { - wrap, - sendOptions, - } = await window.ConversationController.prepareForSend(c.get('id')); - // eslint-disable-next-line no-await-in-loop - await wrap( - window.textsecure.messaging.sendDeliveryReceipt({ - e164, - uuid, - timestamps, - options: sendOptions, - }) - ); - } catch (error) { - window.log.error( - `Failed to send delivery receipt to ${e164}/${uuid} for timestamps ${timestamps}:`, - error && error.stack ? error.stack : error + const c = window.ConversationController.get(conversationId); + if (!c) { + window.log.warn( + `deliveryReceiptBatcher: Conversation ${conversationId} does not exist! ` + + `Will not send delivery receipts for timestamps ${timestamps}` ); + continue; } - }); - } - }, - }); + + const uuid = c.get('uuid'); + const e164 = c.get('e164'); + + c.queueJob('sendDeliveryReceipt', async () => { + try { + const sendOptions = await getSendOptions(c.attributes); + + // eslint-disable-next-line no-await-in-loop + await handleMessageSend( + window.textsecure.messaging.sendDeliveryReceipt({ + e164, + uuid, + timestamps, + options: sendOptions, + }), + { messageIds, sendType: 'deliveryReceipt' } + ); + } catch (error) { + window.log.error( + `Failed to send delivery receipt to ${e164}/${uuid} for timestamps ${timestamps}:`, + error && error.stack ? error.stack : error + ); + } + }); + } + }, + } + ); if (getTitleBarVisibility() === TitleBarVisibility.Hidden) { window.addEventListener('dblclick', (event: Event) => { @@ -899,25 +900,47 @@ export async function startApp(): Promise { window.Signal.Services.retryPlaceholders = retryPlaceholders; setInterval(async () => { - const expired = await retryPlaceholders.getExpiredAndRemove(); - window.log.info( - `retryPlaceholders/interval: Found ${expired.length} expired items` - ); - expired.forEach(item => { - const { conversationId, senderUuid } = item; - const conversation = window.ConversationController.get(conversationId); - if (conversation) { - const receivedAt = Date.now(); - const receivedAtCounter = window.Signal.Util.incrementMessageCounter(); - conversation.queueJob('addDeliveryIssue', () => - conversation.addDeliveryIssue({ - receivedAt, - receivedAtCounter, - senderUuid, - }) + const now = Date.now(); + const HOUR = 1000 * 60 * 60; + const DAY = 24 * HOUR; + const oneDayAgo = now - DAY; + try { + await window.Signal.Data.deleteSentProtosOlderThan(oneDayAgo); + } catch (error) { + window.log.error( + 'background/onready/setInterval: Error deleting sent protos: ', + error && error.stack ? error.stack : error + ); + } + + try { + const expired = await retryPlaceholders.getExpiredAndRemove(); + window.log.info( + `retryPlaceholders/interval: Found ${expired.length} expired items` + ); + expired.forEach(item => { + const { conversationId, senderUuid } = item; + const conversation = window.ConversationController.get( + conversationId ); - } - }); + if (conversation) { + const receivedAt = Date.now(); + const receivedAtCounter = window.Signal.Util.incrementMessageCounter(); + conversation.queueJob('addDeliveryIssue', () => + conversation.addDeliveryIssue({ + receivedAt, + receivedAtCounter, + senderUuid, + }) + ); + } + }); + } catch (error) { + window.log.error( + 'background/onready/setInterval: Error getting expired retry placeholders: ', + error && error.stack ? error.stack : error + ); + } }, FIVE_MINUTES); try { @@ -1640,7 +1663,18 @@ export async function startApp(): Promise { function runStorageService() { window.Signal.Services.enableStorageService(); - window.textsecure.messaging.sendRequestKeySyncMessage(); + + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'background/runStorageService: We are primary device; not sending key sync request' + ); + return; + } + + handleMessageSend(window.textsecure.messaging.sendRequestKeySyncMessage(), { + messageIds: [], + sendType: 'otherSync', + }); } let challengeHandler: ChallengeHandler | undefined; @@ -1868,7 +1902,18 @@ export async function startApp(): Promise { } 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 { 'gv2-3': true, 'gv1-migration': true, senderKey: window.Signal.RemoteConfig.isEnabled( - 'desktop.sendSenderKey' + 'desktop.sendSenderKey2' ), }); } catch (error) { @@ -2312,11 +2357,8 @@ export async function startApp(): Promise { runStorageService(); }); - const ourId = window.ConversationController.getOurConversationId(); - const { - wrap, - sendOptions, - } = await window.ConversationController.prepareForSend(ourId, { + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { syncMessage: true, }); @@ -2328,11 +2370,19 @@ export async function startApp(): Promise { installed: true, })); - wrap( + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'background/connect: We are primary device; not sending sticker pack sync' + ); + return; + } + + handleMessageSend( window.textsecure.messaging.sendStickerPackSync( operations, sendOptions - ) + ), + { messageIds: [], sendType: 'otherSync' } ).catch(error => { window.log.error( 'Failed to send installed sticker packs via sync message', @@ -3559,382 +3609,6 @@ export async function startApp(): Promise { window.log.warn('background onError: Doing nothing with incoming error'); } - function isInList( - conversation: ConversationModel, - list: Array | 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 { - 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 { - const { groupId, requesterUuid } = options; - let sentDistributionMessage = false; - window.log.info('sendDistributionMessageOrNullMessage: Starting', { - groupId: groupId ? `groupv2(${groupId})` : undefined, - requesterUuid, - }); - - await archiveSessionOnMatch(options); - - const conversation = window.ConversationController.getOrCreate( - requesterUuid, - 'private' - ); - - if (groupId) { - const group = window.ConversationController.get(groupId); - const distributionId = group?.get('senderKeyInfo')?.distributionId; - - if (group && distributionId) { - window.log.info( - 'sendDistributionMessageOrNullMessage: Found matching group, sending sender key distribution message' - ); - - try { - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - - const result = await window.textsecure.messaging.sendSenderKeyDistributionMessage( - { - contentHint: ContentHint.DEFAULT, - distributionId, - groupId, - identifiers: [requesterUuid], - } - ); - if (result.errors && result.errors.length > 0) { - throw result.errors[0]; - } - sentDistributionMessage = true; - } catch (error) { - window.log.error( - 'sendDistributionMessageOrNullMessage: Failed to send sender key distribution message', - error && error.stack ? error.stack : error - ); - } - } - } - - if (!sentDistributionMessage) { - window.log.info( - 'sendDistributionMessageOrNullMessage: Did not send distribution message, sending null message' - ); - - try { - const sendOptions = await getSendOptions(conversation.attributes); - const result = await window.textsecure.messaging.sendNullMessage( - { uuid: requesterUuid }, - sendOptions - ); - if (result.errors && result.errors.length > 0) { - throw result.errors[0]; - } - } catch (error) { - window.log.error( - 'maybeSendDistributionMessage: Failed to send null message', - error && error.stack ? error.stack : error - ); - } - } - } - - async function onRetryRequest(event: RetryRequestEvent) { - const { retryRequest } = event; - const { - requesterDevice, - requesterUuid, - senderDevice, - sentAt, - } = retryRequest; - const logId = `${requesterUuid}.${requesterDevice} ${sentAt}-${senderDevice}`; - - window.log.info(`onRetryRequest/${logId}: Starting...`); - - const requesterConversation = window.ConversationController.getOrCreate( - requesterUuid, - 'private' - ); - - const messages = await window.Signal.Data.getMessagesBySentAt(sentAt, { - MessageCollection: window.Whisper.MessageCollection, - }); - - const targetMessage = messages.find(message => { - if (message.get('sent_at') !== sentAt) { - return false; - } - - if (message.get('type') !== 'outgoing') { - return false; - } - - if (!isInList(requesterConversation, message.get('sent_to'))) { - return false; - } - - return true; - }); - - if (!targetMessage) { - window.log.info(`onRetryRequest/${logId}: Did not find message`); - await sendDistributionMessageOrNullMessage(retryRequest); - return; - } - - if (targetMessage.isErased()) { - window.log.info( - `onRetryRequest/${logId}: Message is erased, refusing to send again.` - ); - await sendDistributionMessageOrNullMessage(retryRequest); - return; - } - - const HOUR = 60 * 60 * 1000; - const ONE_DAY = 24 * HOUR; - let retryRespondMaxAge = ONE_DAY; - try { - retryRespondMaxAge = parseIntOrThrow( - window.Signal.RemoteConfig.getValue('desktop.retryRespondMaxAge'), - 'retryRespondMaxAge' - ); - } catch (error) { - window.log.warn( - `onRetryRequest/${logId}: Failed to parse integer from desktop.retryRespondMaxAge feature flag`, - error && error.stack ? error.stack : error - ); - } - - if (isOlderThan(sentAt, retryRespondMaxAge)) { - window.log.info( - `onRetryRequest/${logId}: Message is too old, refusing to send again.` - ); - await sendDistributionMessageOrNullMessage(retryRequest); - return; - } - - window.log.info(`onRetryRequest/${logId}: Resending message`); - await archiveSessionOnMatch(retryRequest); - await targetMessage.resend(requesterUuid); - } - - async function onDecryptionError(event: DecryptionErrorEvent) { - const { decryptionError } = event; - const { senderUuid, senderDevice, timestamp } = decryptionError; - const logId = `${senderUuid}.${senderDevice} ${timestamp}`; - - window.log.info(`onDecryptionError/${logId}: Starting...`); - - const conversation = window.ConversationController.getOrCreate( - senderUuid, - 'private' - ); - const capabilities = conversation.get('capabilities'); - if (!capabilities) { - await conversation.getProfiles(); - } - - if (conversation.get('capabilities')?.senderKey) { - await requestResend(decryptionError); - } else { - await startAutomaticSessionReset(decryptionError); - } - - window.log.info(`onDecryptionError/${logId}: ...complete`); - } - - async function requestResend(decryptionError: DecryptionErrorEventData) { - const { - cipherTextBytes, - cipherTextType, - contentHint, - groupId, - receivedAtCounter, - receivedAtDate, - senderDevice, - senderUuid, - timestamp, - } = decryptionError; - const logId = `${senderUuid}.${senderDevice} ${timestamp}`; - - window.log.info(`requestResend/${logId}: Starting...`, { - cipherTextBytesLength: cipherTextBytes?.byteLength, - cipherTextType, - contentHint, - groupId: groupId ? `groupv2(${groupId})` : undefined, - }); - - // 1. Find the target conversation - - const group = groupId - ? window.ConversationController.get(groupId) - : undefined; - const sender = window.ConversationController.getOrCreate( - senderUuid, - 'private' - ); - const conversation = group || sender; - - // 2. Send resend request - - if (!cipherTextBytes || !isNumber(cipherTextType)) { - window.log.warn( - `requestResend/${logId}: Missing cipherText information, failing over to automatic reset` - ); - startAutomaticSessionReset(decryptionError); - return; - } - - try { - const message = DecryptionErrorMessage.forOriginal( - Buffer.from(cipherTextBytes), - cipherTextType, - timestamp, - senderDevice - ); - - const plaintext = PlaintextContent.from(message); - const options = await getSendOptions(conversation.attributes); - const result = await window.textsecure.messaging.sendRetryRequest({ - plaintext, - options, - uuid: senderUuid, - }); - if (result.errors && result.errors.length > 0) { - throw result.errors[0]; - } - } catch (error) { - window.log.error( - `requestResend/${logId}: Failed to send retry request, failing over to automatic reset`, - error && error.stack ? error.stack : error - ); - startAutomaticSessionReset(decryptionError); - return; - } - - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - - // 3. Determine how to represent this to the user. Three different options. - - // We believe that it could be successfully re-sent, so we'll add a placeholder. - if (contentHint === ContentHint.RESENDABLE) { - const { retryPlaceholders } = window.Signal.Services; - assert(retryPlaceholders, 'requestResend: adding placeholder'); - - window.log.info(`requestResend/${logId}: Adding placeholder`); - await retryPlaceholders.add({ - conversationId: conversation.get('id'), - receivedAt: receivedAtDate, - receivedAtCounter, - sentAt: timestamp, - senderUuid, - }); - - return; - } - - // This message cannot be resent. We'll show no error and trust the other side to - // reset their session. - if (contentHint === ContentHint.IMPLICIT) { - return; - } - - window.log.warn( - `requestResend/${logId}: No content hint, adding error immediately` - ); - conversation.queueJob('addDeliveryIssue', async () => { - conversation.addDeliveryIssue({ - receivedAt: receivedAtDate, - receivedAtCounter, - senderUuid, - }); - }); - } - - function scheduleSessionReset(senderUuid: string, senderDevice: number) { - // Postpone sending light session resets until the queue is empty - lightSessionResetQueue.add(() => { - window.textsecure.storage.protocol.lightSessionReset( - senderUuid, - senderDevice - ); - }); - } - - function startAutomaticSessionReset( - decryptionError: DecryptionErrorEventData - ) { - const { senderUuid, senderDevice, timestamp } = decryptionError; - const logId = `${senderUuid}.${senderDevice} ${timestamp}`; - - window.log.info(`startAutomaticSessionReset/${logId}: Starting...`); - - scheduleSessionReset(senderUuid, senderDevice); - - const conversationId = window.ConversationController.ensureContactIds({ - uuid: senderUuid, - }); - - if (!conversationId) { - window.log.warn( - 'onLightSessionReset: No conversation id, cannot add message to timeline' - ); - return; - } - const conversation = window.ConversationController.get(conversationId); - - if (!conversation) { - window.log.warn( - 'onLightSessionReset: No conversation, cannot add message to timeline' - ); - return; - } - - const receivedAt = Date.now(); - const receivedAtCounter = window.Signal.Util.incrementMessageCounter(); - conversation.queueJob('addChatSessionRefreshed', async () => { - conversation.addChatSessionRefreshed({ receivedAt, receivedAtCounter }); - }); - } - async function onViewSync(ev: ViewSyncEvent) { ev.confirm(); @@ -4025,7 +3699,13 @@ export async function startApp(): Promise { } function onReadReceipt(ev: ReadEvent) { - const { envelopeTimestamp, timestamp, source, sourceUuid } = ev.read; + const { + envelopeTimestamp, + timestamp, + source, + sourceUuid, + sourceDevice, + } = ev.read; const readAt = envelopeTimestamp; const reader = window.ConversationController.ensureContactIds({ e164: source, @@ -4036,6 +3716,7 @@ export async function startApp(): Promise { 'read receipt', source, sourceUuid, + sourceDevice, envelopeTimestamp, reader, 'for sent message', @@ -4050,6 +3731,7 @@ export async function startApp(): Promise { const receipt = ReadReceipts.getSingleton().add({ reader, + readerDevice: sourceDevice, timestamp, readAt, }); @@ -4198,6 +3880,7 @@ export async function startApp(): Promise { const receipt = DeliveryReceipts.getSingleton().add({ timestamp, deliveredTo, + deliveredToDevice: sourceDevice, }); // Note: We don't wait for completion here diff --git a/ts/groups.ts b/ts/groups.ts index 4f0236caa..1c7a41680 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -69,7 +69,7 @@ import { isGroupV2 as getIsGroupV2, isMe, } from './util/whatTypeOfConversation'; -import { handleMessageSend } from './util/handleMessageSend'; +import { handleMessageSend, SendTypesType } from './util/handleMessageSend'; import { getSendOptions } from './util/getSendOptions'; import * as Bytes from './Bytes'; import { SignalService as Proto } from './protobuf'; @@ -1309,9 +1309,12 @@ export async function modifyGroupV2({ profileKey, }, conversation, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, + messageId: undefined, sendOptions, - }) + sendType: 'groupChange', + }), + { messageIds: [], sendType: 'groupChange' } ); // We don't save this message; we just use it to ensure that a sync message is @@ -1682,6 +1685,7 @@ export async function createGroupV2({ await wrapWithSyncMessageSend({ conversation, logId: `sendToGroup/${logId}`, + messageIds: [], send: async () => window.Signal.Util.sendToGroup({ groupSendOptions: { @@ -1690,9 +1694,12 @@ export async function createGroupV2({ profileKey, }, conversation, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, + messageId: undefined, sendOptions, + sendType: 'groupChange', }), + sendType: 'groupChange', timestamp, }); @@ -2212,6 +2219,7 @@ export async function initiateMigrationToGroupV2( await wrapWithSyncMessageSend({ conversation, logId: `sendToGroup/${logId}`, + messageIds: [], send: async () => // Minimal message to notify group members about migration window.Signal.Util.sendToGroup({ @@ -2223,9 +2231,12 @@ export async function initiateMigrationToGroupV2( profileKey: ourProfileKey, }, conversation, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, + messageId: undefined, sendOptions, + sendType: 'groupChange', }), + sendType: 'groupChange', timestamp, }); } @@ -2233,12 +2244,16 @@ export async function initiateMigrationToGroupV2( export async function wrapWithSyncMessageSend({ conversation, logId, + messageIds, send, + sendType, timestamp, }: { conversation: ConversationModel; logId: string; - send: (sender: MessageSender) => Promise; + messageIds: Array; + send: (sender: MessageSender) => Promise; + sendType: SendTypesType; timestamp: number; }): Promise { const sender = window.textsecure.messaging; @@ -2250,7 +2265,7 @@ export async function wrapWithSyncMessageSend({ let response: CallbackResultType | undefined; try { - response = await send(sender); + response = await handleMessageSend(send(sender), { messageIds, sendType }); } catch (error) { if (conversation.processSendResponse(error)) { response = error; @@ -2285,15 +2300,27 @@ export async function wrapWithSyncMessageSend({ ); } - await sender.sendSyncMessage({ - encodedDataMessage: dataMessage, - timestamp, - destination: ourConversation.get('e164'), - destinationUuid: ourConversation.get('uuid'), - expirationStartTimestamp: null, - sentTo: [], - unidentifiedDeliveries: [], - }); + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + `wrapWithSyncMessageSend/${logId}: We are primary device; not sync message` + ); + return; + } + + const options = await getSendOptions(ourConversation.attributes); + await handleMessageSend( + sender.sendSyncMessage({ + destination: ourConversation.get('e164'), + destinationUuid: ourConversation.get('uuid'), + encodedDataMessage: dataMessage, + expirationStartTimestamp: null, + options, + sentTo: [], + timestamp, + unidentifiedDeliveries: [], + }), + { messageIds, sendType } + ); } export async function waitThenRespondToGroupV2Migration( diff --git a/ts/messageModifiers/DeliveryReceipts.ts b/ts/messageModifiers/DeliveryReceipts.ts index a0212f8aa..039b809a4 100644 --- a/ts/messageModifiers/DeliveryReceipts.ts +++ b/ts/messageModifiers/DeliveryReceipts.ts @@ -10,10 +10,15 @@ import { ConversationModel } from '../models/conversations'; import { MessageModel } from '../models/messages'; import { MessageModelCollectionType } from '../model-types.d'; import { isIncoming } from '../state/selectors/message'; +import { isDirectConversation } from '../util/whatTypeOfConversation'; +import dataInterface from '../sql/Client'; + +const { deleteSentProtoRecipient } = dataInterface; type DeliveryReceiptAttributesType = { timestamp: number; deliveredTo: string; + deliveredToDevice: number; }; class DeliveryReceiptModel extends Model {} @@ -67,7 +72,7 @@ export class DeliveryReceipts extends Collection { message: MessageModel ): Array { let recipients: Array; - if (conversation.isPrivate()) { + if (isDirectConversation(conversation.attributes)) { recipients = [conversation.id]; } else { recipients = conversation.getMemberIds(); @@ -82,32 +87,29 @@ export class DeliveryReceipts extends Collection { } async onReceipt(receipt: DeliveryReceiptModel): Promise { - try { - const messages = await window.Signal.Data.getMessagesBySentAt( - receipt.get('timestamp'), - { - MessageCollection: window.Whisper.MessageCollection, - } - ); + const timestamp = receipt.get('timestamp'); + const deliveredTo = receipt.get('deliveredTo'); - const message = await getTargetMessage( - receipt.get('deliveredTo'), - messages - ); + try { + const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, { + MessageCollection: window.Whisper.MessageCollection, + }); + + const message = await getTargetMessage(deliveredTo, messages); if (!message) { window.log.info( 'No message for delivery receipt', - receipt.get('deliveredTo'), - receipt.get('timestamp') + deliveredTo, + timestamp ); return; } const deliveries = message.get('delivered') || 0; - const deliveredTo = message.get('delivered_to') || []; + const originalDeliveredTo = message.get('delivered_to') || []; const expirationStartTimestamp = message.get('expirationStartTimestamp'); message.set({ - delivered_to: union(deliveredTo, [receipt.get('deliveredTo')]), + delivered_to: union(originalDeliveredTo, [deliveredTo]), delivered: deliveries + 1, expirationStartTimestamp: expirationStartTimestamp || Date.now(), sent: true, @@ -126,6 +128,33 @@ export class DeliveryReceipts extends Collection { updateLeftPane(); } + const unidentifiedLookup = ( + message.get('unidentifiedDeliveries') || [] + ).reduce((accumulator: Record, identifier: string) => { + const id = window.ConversationController.getConversationId(identifier); + if (id) { + accumulator[id] = true; + } + return accumulator; + }, Object.create(null) as Record); + const recipient = window.ConversationController.get(deliveredTo); + if (recipient && unidentifiedLookup[recipient.id]) { + const recipientUuid = recipient?.get('uuid'); + const deviceId = receipt.get('deliveredToDevice'); + + if (recipientUuid && deviceId) { + await deleteSentProtoRecipient({ + timestamp, + recipientUuid, + deviceId, + }); + } else { + window.log.warn( + `DeliveryReceipts.onReceipt: Missing uuid or deviceId for deliveredTo ${deliveredTo}` + ); + } + } + this.remove(receipt); } catch (error) { window.log.error( diff --git a/ts/messageModifiers/ReadReceipts.ts b/ts/messageModifiers/ReadReceipts.ts index 75c7a5eaf..967251e48 100644 --- a/ts/messageModifiers/ReadReceipts.ts +++ b/ts/messageModifiers/ReadReceipts.ts @@ -9,9 +9,14 @@ import { ConversationModel } from '../models/conversations'; import { MessageModel } from '../models/messages'; import { MessageModelCollectionType } from '../model-types.d'; import { isOutgoing } from '../state/selectors/message'; +import { isDirectConversation } from '../util/whatTypeOfConversation'; +import dataInterface from '../sql/Client'; + +const { deleteSentProtoRecipient } = dataInterface; type ReadReceiptAttributesType = { reader: string; + readerDevice: number; timestamp: number; readAt: number; }; @@ -68,7 +73,7 @@ export class ReadReceipts extends Collection { return []; } let ids: Array; - if (conversation.isPrivate()) { + if (isDirectConversation(conversation.attributes)) { ids = [conversation.id]; } else { ids = conversation.getMemberIds(); @@ -86,29 +91,25 @@ export class ReadReceipts extends Collection { } async onReceipt(receipt: ReadReceiptModel): Promise { - try { - const messages = await window.Signal.Data.getMessagesBySentAt( - receipt.get('timestamp'), - { - MessageCollection: window.Whisper.MessageCollection, - } - ); + const timestamp = receipt.get('timestamp'); + const reader = receipt.get('reader'); - const message = await getTargetMessage(receipt.get('reader'), messages); + try { + const messages = await window.Signal.Data.getMessagesBySentAt(timestamp, { + MessageCollection: window.Whisper.MessageCollection, + }); + + const message = await getTargetMessage(reader, messages); if (!message) { - window.log.info( - 'No message for read receipt', - receipt.get('reader'), - receipt.get('timestamp') - ); + window.log.info('No message for read receipt', reader, timestamp); return; } const readBy = message.get('read_by') || []; const expirationStartTimestamp = message.get('expirationStartTimestamp'); - readBy.push(receipt.get('reader')); + readBy.push(reader); message.set({ read_by: readBy, expirationStartTimestamp: expirationStartTimestamp || Date.now(), @@ -128,6 +129,22 @@ export class ReadReceipts extends Collection { updateLeftPane(); } + const deviceId = receipt.get('readerDevice'); + const recipient = window.ConversationController.get(reader); + const recipientUuid = recipient?.get('uuid'); + + if (recipientUuid && deviceId) { + await deleteSentProtoRecipient({ + timestamp, + recipientUuid, + deviceId, + }); + } else { + window.log.warn( + `ReadReceipts.onReceipt: Missing uuid or deviceId for reader ${reader}` + ); + } + this.remove(receipt); } catch (error) { window.log.error( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index fa3955d84..fa9580301 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -371,5 +371,3 @@ export type ReactionAttributesType = { timestamp: number; fromSync?: boolean; }; - -export declare class ReactionModelType extends Backbone.Model {} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 0b5141fa5..afb451e3f 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -10,7 +10,6 @@ import { MessageAttributesType, MessageModelCollectionType, QuotedMessageType, - ReactionModelType, VerificationOptions, WhatIsThis, } from '../model-types.d'; @@ -64,7 +63,6 @@ import { isGroupV2, isMe, } from '../util/whatTypeOfConversation'; -import { deprecated } from '../util/deprecated'; import { SignalService as Proto } from '../protobuf'; import { hasErrors, @@ -73,7 +71,7 @@ import { getMessagePropStatus, } from '../state/selectors/message'; import { Deletes } from '../messageModifiers/Deletes'; -import { Reactions } from '../messageModifiers/Reactions'; +import { Reactions, ReactionModel } from '../messageModifiers/Reactions'; // TODO: remove once we move away from ArrayBuffers const FIXMEU8 = Uint8Array; @@ -320,11 +318,6 @@ export class ConversationModel extends window.Backbone } } - isPrivate(): boolean { - deprecated('isPrivate()'); - return isDirectConversation(this.attributes); - } - isMemberRequestingToJoin(conversationId: string): boolean { if (!isGroupV2(this.attributes)) { return false; @@ -1200,7 +1193,8 @@ export class ConversationModel extends window.Backbone ...sendOptions, online: true, }, - }) + }), + { messageIds: [], sendType: 'typing' } ); } else { handleMessageSend( @@ -1208,11 +1202,14 @@ export class ConversationModel extends window.Backbone contentHint: ContentHint.IMPLICIT, contentMessage, conversation: this, + messageId: undefined, online: true, recipients: groupMembers, sendOptions, + sendType: 'typing', timestamp, - }) + }), + { messageIds: [], sendType: 'typing' } ); } }); @@ -1577,6 +1574,7 @@ export class ConversationModel extends window.Backbone m => !hasErrors(m.attributes) && isIncoming(m.attributes) ); const receiptSpecs = readMessages.map(m => ({ + messageId: m.id, senderE164: m.get('source'), senderUuid: m.get('sourceUuid'), senderId: window.ConversationController.ensureContactIds({ @@ -1988,22 +1986,22 @@ export class ConversationModel extends window.Backbone // server updates were successful. await this.applyMessageRequestResponse(response); - const { ourNumber, ourUuid } = this; - const { - wrap, - sendOptions, - } = await window.ConversationController.prepareForSend( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ourNumber || ourUuid!, - { - syncMessage: true, - } - ); + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); const groupId = this.getGroupIdBuffer(); + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'syncMessageRequestResponse: We are primary device; not sending message request sync' + ); + return; + } + try { - await wrap( + await handleMessageSend( window.textsecure.messaging.syncMessageRequestResponse( { threadE164: this.get('e164'), @@ -2012,7 +2010,8 @@ export class ConversationModel extends window.Backbone type: response, }, sendOptions - ) + ), + { messageIds: [], sendType: 'otherSync' } ); } catch (result) { this.processSendResponse(result); @@ -2167,10 +2166,8 @@ export class ConversationModel extends window.Backbone } if (!options.viaSyncMessage) { await this.sendVerifySyncMessage( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.get('e164')!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.get('uuid')!, + this.get('e164'), + this.get('uuid'), verified ); } @@ -2179,33 +2176,52 @@ export class ConversationModel extends window.Backbone } async sendVerifySyncMessage( - e164: string, - uuid: string, + e164: string | undefined, + uuid: string | undefined, state: number - ): Promise { + ): Promise { + const identifier = uuid || e164; + if (!identifier) { + throw new Error( + 'sendVerifySyncMessage: Neither e164 nor UUID were provided' + ); + } + + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'sendVerifySyncMessage: We are primary device; not sending sync' + ); + return; + } + // Because syncVerification sends a (null) message to the target of the verify and // a sync message to our own devices, we need to send the accessKeys down for both // contacts. So we merge their sendOptions. - const { sendOptions } = await window.ConversationController.prepareForSend( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.ourNumber || this.ourUuid!, - { syncMessage: true } - ); + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); const contactSendOptions = await getSendOptions(this.attributes); const options = { ...sendOptions, ...contactSendOptions }; - const promise = window.textsecure.storage.protocol.loadIdentityKey(e164); - return promise.then(key => - handleMessageSend( - window.textsecure.messaging.syncVerification( - e164, - uuid, - state, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - key!, - options - ) - ) + const key = await window.textsecure.storage.protocol.loadIdentityKey( + identifier + ); + if (!key) { + throw new Error( + `sendVerifySyncMessage: No identity key found for identifier ${identifier}` + ); + } + + await handleMessageSend( + window.textsecure.messaging.syncVerification( + e164, + uuid, + state, + key, + options + ), + { messageIds: [], sendType: 'verificationSync' } ); } @@ -2214,13 +2230,12 @@ export class ConversationModel extends window.Backbone // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.get('verified') === this.verifiedEnum!.VERIFIED; } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (!this.contactCollection!.length) { + + if (!this.contactCollection?.length) { return false; } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.contactCollection!.every(contact => { + return this.contactCollection?.every(contact => { if (isMe(contact.attributes)) { return true; } @@ -2238,16 +2253,12 @@ export class ConversationModel extends window.Backbone verified !== this.verifiedEnum!.DEFAULT ); } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (!this.contactCollection!.length) { + + if (!this.contactCollection?.length) { return true; } - // Array.any does not exist. This is probably broken. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.contactCollection!.any(contact => { + return this.contactCollection?.some(contact => { if (isMe(contact.attributes)) { return false; } @@ -2262,8 +2273,7 @@ export class ConversationModel extends window.Backbone : new window.Backbone.Collection(); } return new window.Backbone.Collection( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.contactCollection!.filter(contact => { + this.contactCollection?.filter(contact => { if (isMe(contact.attributes)) { return false; } @@ -3158,7 +3168,11 @@ export class ConversationModel extends window.Backbone window.reduxActions.stickers.useSticker(packId, stickerId); } - async sendDeleteForEveryoneMessage(targetTimestamp: number): Promise { + async sendDeleteForEveryoneMessage(options: { + id: string; + timestamp: number; + }): Promise { + const { timestamp: targetTimestamp, id: messageId } = options; const timestamp = Date.now(); if (timestamp - targetTimestamp > THREE_HOURS) { @@ -3224,7 +3238,7 @@ export class ConversationModel extends window.Backbone deletedForEveryoneTimestamp: targetTimestamp, timestamp, expireTimer: undefined, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, groupId: undefined, profileKey, options: sendOptions, @@ -3240,8 +3254,10 @@ export class ConversationModel extends window.Backbone profileKey, }, conversation: this, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, + messageId, sendOptions, + sendType: 'deleteForEveryone', }); })(); @@ -3249,11 +3265,16 @@ export class ConversationModel extends window.Backbone // anything to the database. message.doNotSave = true; - const result = await message.send(handleMessageSend(promise)); + const result = await message.send( + handleMessageSend(promise, { + messageIds: [messageId], + sendType: 'deleteForEveryone', + }) + ); if (!message.hasSuccessfulDelivery()) { // This is handled by `conversation_view` which displays a toast on - // send error. + // send error. throw new Error('No successful delivery for delete for everyone'); } Deletes.getSingleton().onDelete(deleteModel); @@ -3274,10 +3295,12 @@ export class ConversationModel extends window.Backbone async sendReactionMessage( reaction: { emoji: string; remove: boolean }, target: { + messageId: string; targetAuthorUuid: string; targetTimestamp: number; } ): Promise { + const { messageId } = target; const timestamp = Date.now(); const outgoingReaction = { ...reaction, ...target }; @@ -3373,7 +3396,7 @@ export class ConversationModel extends window.Backbone deletedForEveryoneTimestamp: undefined, timestamp, expireTimer, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, groupId: undefined, profileKey, options, @@ -3392,12 +3415,19 @@ export class ConversationModel extends window.Backbone profileKey, }, conversation: this, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, + messageId, sendOptions: options, + sendType: 'reaction', }); })(); - const result = await message.send(handleMessageSend(promise)); + const result = await message.send( + handleMessageSend(promise, { + messageIds: [messageId], + sendType: 'reaction', + }) + ); if (!message.hasSuccessfulDelivery()) { // This is handled by `conversation_view` which displays a toast on @@ -3407,7 +3437,7 @@ export class ConversationModel extends window.Backbone return result; }).catch(() => { - let reverseReaction: ReactionModelType; + let reverseReaction: ReactionModel; if (oldReaction) { // Either restore old reaction reverseReaction = Reactions.getSingleton().add({ @@ -3444,11 +3474,15 @@ export class ConversationModel extends window.Backbone ); return; } - await window.textsecure.messaging.sendProfileKeyUpdate( - profileKey, - recipients, - await getSendOptions(this.attributes), - this.get('groupId') + + await handleMessageSend( + window.textsecure.messaging.sendProfileKeyUpdate( + profileKey, + recipients, + await getSendOptions(this.attributes), + this.get('groupId') + ), + { messageIds: [], sendType: 'profileKeyUpdate' } ); } @@ -3537,6 +3571,7 @@ export class ConversationModel extends window.Backbone await addStickerPackReference(model.id, sticker.packId); } const message = window.MessageController.register(model.id, model); + const messageId = message.id; await window.Signal.Data.saveMessage(message.attributes, { forceSave: true, Message: window.Whisper.Message, @@ -3635,7 +3670,9 @@ export class ConversationModel extends window.Backbone }, conversation: this, contentHint: ContentHint.RESENDABLE, + messageId, sendOptions: options, + sendType: 'message', }); } else { promise = window.textsecure.messaging.sendMessageToIdentifier({ @@ -3656,7 +3693,12 @@ export class ConversationModel extends window.Backbone }); } - return message.send(handleMessageSend(promise)); + return message.send( + handleMessageSend(promise, { + messageIds: [messageId], + sendType: 'message', + }) + ); }); } @@ -4099,7 +4141,12 @@ export class ConversationModel extends window.Backbone ); } - await message.send(handleMessageSend(promise)); + await message.send( + handleMessageSend(promise, { + messageIds: [], + sendType: 'expirationTimerUpdate', + }) + ); return message; } @@ -4220,7 +4267,8 @@ export class ConversationModel extends window.Backbone groupId, groupIdentifiers, options - ) + ), + { messageIds: [], sendType: 'legacyGroupChange' } ) ); } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 58263036d..068f221dc 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -167,7 +167,7 @@ export class MessageModel extends window.Backbone.Model { isSelected?: boolean; - syncPromise?: Promise; + syncPromise?: Promise; initialize(attributes: unknown): void { if (_.isObject(attributes)) { @@ -774,8 +774,10 @@ export class MessageModel extends window.Backbone.Model { } async cleanup(): Promise { - const { messageDeleted } = window.reduxActions.conversations; - messageDeleted(this.id, this.get('conversationId')); + window.reduxActions?.conversations?.messageDeleted( + this.id, + this.get('conversationId') + ); this.getConversation()?.debouncedUpdateLastMessage?.(); @@ -868,26 +870,26 @@ export class MessageModel extends window.Backbone.Model { } const timestamp = this.get('sent_at'); - const ourNumber = window.textsecure.storage.user.getNumber(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const ourUuid = window.textsecure.storage.user.getUuid()!; - const { - wrap, - sendOptions, - } = await window.ConversationController.prepareForSend( - ourNumber || ourUuid, - { - syncMessage: true, - } - ); + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); - await wrap( + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'markViewed: We are primary device; not sending view sync' + ); + return; + } + + await handleMessageSend( window.textsecure.messaging.syncViewOnceOpen( sender, senderUuid, timestamp, sendOptions - ) + ), + { messageIds: [this.id], sendType: 'viewOnceSync' } ); } } @@ -987,6 +989,8 @@ export class MessageModel extends window.Backbone.Model { Message: window.Whisper.Message, }); } + + await window.Signal.Data.deleteSentProtoByMessageId(this.id); } isEmpty(): boolean { @@ -1346,11 +1350,18 @@ export class MessageModel extends window.Backbone.Model { // Important to ensure that we don't consider this recipient list to be the // entire member list. isPartialSend: true, + messageId: this.id, sendOptions: options, + sendType: 'messageRetry', }); } - return this.send(handleMessageSend(promise)); + return this.send( + handleMessageSend(promise, { + messageIds: [this.id], + sendType: 'messageRetry', + }) + ); } // eslint-disable-next-line class-methods-use-this @@ -1429,10 +1440,11 @@ export class MessageModel extends window.Backbone.Model { const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; const parentConversation = this.getConversation(); const groupId = parentConversation?.get('groupId'); - const { - wrap, - sendOptions, - } = await window.ConversationController.prepareForSend(identifier); + + const recipientConversation = window.ConversationController.get(identifier); + const sendOptions = recipientConversation + ? await getSendOptions(recipientConversation.attributes) + : undefined; const group = groupId && isGroupV1(parentConversation?.attributes) ? { @@ -1479,7 +1491,12 @@ export class MessageModel extends window.Backbone.Model { options: sendOptions, }); - return this.send(wrap(promise)); + return this.send( + handleMessageSend(promise, { + messageIds: [this.id], + sendType: 'messageRetry', + }) + ); } removeOutgoingErrors(incomingIdentifier: string): CustomError { @@ -1689,18 +1706,13 @@ export class MessageModel extends window.Backbone.Model { // possible. await this.send( handleMessageSend( - // TODO: DESKTOP-724 - // resetSession returns `Array` which is incompatible with the - // expected promise return values. `[]` is truthy and handleMessageSend - // assumes it's a valid callback result type - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore window.textsecure.messaging.resetSession( options.uuid, options.e164, options.now, sendOptions - ) + ), + { messageIds: [], sendType: 'resetSession' } ) ); @@ -1725,10 +1737,13 @@ export class MessageModel extends window.Backbone.Model { sent: true, expirationStartTimestamp: Date.now(), }); - const result: typeof window.WhatIsThis = await this.sendSyncMessage(); + const result = await this.sendSyncMessage(); this.set({ // We have to do this afterward, since we didn't have a previous send! - unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null, + unidentifiedDeliveries: + result && result.unidentifiedDeliveries + ? result.unidentifiedDeliveries + : undefined, // These are unique to a Note to Self message - immediately read/delivered // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -1751,30 +1766,31 @@ export class MessageModel extends window.Backbone.Model { } } - async sendSyncMessage(): Promise { - const ourNumber = window.textsecure.storage.user.getNumber(); - const ourUuid = window.textsecure.storage.user.getUuid(); - const { - wrap, - sendOptions, - } = await window.ConversationController.prepareForSend( - ourUuid || ourNumber, - { - syncMessage: true, - } - ); + async sendSyncMessage(): Promise { + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); + + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'sendSyncMessage: We are primary device; not sending sync message' + ); + this.set({ dataMessage: undefined }); + return; + } this.syncPromise = this.syncPromise || Promise.resolve(); const next = async () => { const dataMessage = this.get('dataMessage'); if (!dataMessage) { - return Promise.resolve(); + return; } const isUpdate = Boolean(this.get('synced')); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const conv = this.getConversation()!; - return wrap( + return handleMessageSend( window.textsecure.messaging.sendSyncMessage({ encodedDataMessage: dataMessage, timestamp: this.get('sent_at'), @@ -1786,8 +1802,9 @@ export class MessageModel extends window.Backbone.Model { unidentifiedDeliveries: this.get('unidentifiedDeliveries') || [], isUpdate, options: sendOptions, - }) - ).then(async (result: unknown) => { + }), + { messageIds: [this.id], sendType: 'sentSync' } + ).then(async result => { this.set({ synced: true, dataMessage: null, @@ -2504,28 +2521,6 @@ export class MessageModel extends window.Backbone.Model { } } - // Now check for decryption error placeholders - const { retryPlaceholders } = window.Signal.Services; - if (retryPlaceholders) { - const item = await retryPlaceholders.findByMessageAndRemove( - conversationId, - message.get('sent_at') - ); - if (item && item.wasOpened) { - window.log.info( - `handleDataMessage: found retry placeholder for ${message.idForLogging()}, but conversation was opened. No updates made.` - ); - } else if (item) { - window.log.info( - `handleDataMessage: found retry placeholder for ${message.idForLogging()}. Updating received_at/received_at_ms` - ); - message.set({ - received_at: item.receivedAtCounter, - received_at_ms: item.receivedAt, - }); - } - } - // GroupV2 if (initialMessage.groupV2) { @@ -2640,6 +2635,8 @@ export class MessageModel extends window.Backbone.Model { return; } + const messageId = window.getGuid(); + // Send delivery receipts, but only for incoming sealed sender messages // and not for messages from unaccepted conversations if ( @@ -2653,6 +2650,7 @@ export class MessageModel extends window.Backbone.Model { // The queue can be paused easily. window.Whisper.deliveryReceiptQueue.add(() => { window.Whisper.deliveryReceiptBatcher.add({ + messageId, source, sourceUuid, timestamp: this.get('sent_at'), @@ -2689,7 +2687,7 @@ export class MessageModel extends window.Backbone.Model { } message.set({ - id: window.getGuid(), + id: messageId, attachments: dataMessage.attachments, body: dataMessage.body, bodyRanges: dataMessage.bodyRanges, @@ -3270,6 +3268,7 @@ export class MessageModel extends window.Backbone.Model { conversationId: this.get('conversationId'), emoji: reaction.get('emoji'), fromId: reaction.get('fromId'), + messageId: this.id, messageReceivedAt: this.get('received_at'), targetAuthorUuid: reaction.get('targetAuthorUuid'), targetTimestamp: reaction.get('targetTimestamp'), diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 275fe2ce0..5849fed8e 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -57,6 +57,7 @@ import { import { assert } from '../util/assert'; import { dropNull, shallowDropNull } from '../util/dropNull'; import { getOwn } from '../util/getOwn'; +import { handleMessageSend } from '../util/handleMessageSend'; import { fetchMembershipProof, getMembershipList, @@ -937,13 +938,17 @@ export class CallingClass { wrapWithSyncMessageSend({ conversation, logId: `sendToGroup/groupCallUpdate/${conversationId}-${eraId}`, + messageIds: [], send: () => window.Signal.Util.sendToGroup({ groupSendOptions: { groupCallUpdate: { eraId }, groupV2, timestamp }, conversation, contentHint: ContentHint.DEFAULT, + messageId: undefined, sendOptions, + sendType: 'callingMessage', }), + sendType: 'callingMessage', timestamp, }).catch(err => { window.log.error( @@ -1559,12 +1564,19 @@ export class CallingClass { } try { - await window.textsecure.messaging.sendCallingMessage( - remoteUserId, - callingMessageToProto(message), - sendOptions + const result = await handleMessageSend( + window.textsecure.messaging.sendCallingMessage( + remoteUserId, + callingMessageToProto(message), + sendOptions + ), + { messageIds: [], sendType: 'callingMessage' } ); + if (result && result.errors && result.errors.length) { + throw result.errors[0]; + } + window.log.info('handleOutgoingSignaling() completed successfully'); return true; } catch (err) { diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 0989d344d..0ef0c5c14 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -27,6 +27,7 @@ import { import { ConversationModel } from '../models/conversations'; import { strictAssert } from '../util/assert'; import { BackOff } from '../util/BackOff'; +import { handleMessageSend } from '../util/handleMessageSend'; import { storageJobQueue } from '../util/JobQueue'; import { sleep } from '../util/sleep'; import { isMoreRecentThan } from '../util/timestamp'; @@ -531,7 +532,18 @@ async function uploadManifest( window.storage.put('manifestVersion', version); conflictBackOff.reset(); backOff.reset(); - await window.textsecure.messaging.sendFetchManifestSyncMessage(); + + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'uploadManifest: We are primary device; not sending sync manifest' + ); + return; + } + + await handleMessageSend( + window.textsecure.messaging.sendFetchManifestSyncMessage(), + { messageIds: [], sendType: 'otherSync' } + ); } async function stopStorageServiceSync() { @@ -552,7 +564,18 @@ async function stopStorageServiceSync() { if (!window.textsecure.messaging) { throw new Error('storageService.stopStorageServiceSync: We are offline!'); } - window.textsecure.messaging.sendRequestKeySyncMessage(); + + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'stopStorageServiceSync: We are primary device; not sending key sync request' + ); + return; + } + + handleMessageSend(window.textsecure.messaging.sendRequestKeySyncMessage(), { + messageIds: [], + sendType: 'otherSync', + }); }); } @@ -1106,7 +1129,18 @@ async function upload(fromSync = false): Promise { 'storageService.upload: no storageKey, requesting new keys' ); backOff.reset(); - await window.textsecure.messaging.sendRequestKeySyncMessage(); + + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'upload: We are primary device; not sending key sync request' + ); + return; + } + + await handleMessageSend( + window.textsecure.messaging.sendRequestKeySyncMessage(), + { messageIds: [], sendType: 'otherSync' } + ); return; } diff --git a/ts/shims/textsecure.ts b/ts/shims/textsecure.ts index 711df2ed2..4fd7d0e5f 100644 --- a/ts/shims/textsecure.ts +++ b/ts/shims/textsecure.ts @@ -1,19 +1,19 @@ // Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { handleMessageSend } from '../util/handleMessageSend'; +import { getSendOptions } from '../util/getSendOptions'; + export async function sendStickerPackSync( packId: string, packKey: string, installed: boolean ): Promise { const { ConversationController, textsecure, log } = window; - const ourNumber = textsecure.storage.user.getNumber(); - const { wrap, sendOptions } = await ConversationController.prepareForSend( - ourNumber, - { - syncMessage: true, - } - ); + const ourConversation = ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); if (!textsecure.messaging) { log.error( @@ -23,7 +23,14 @@ export async function sendStickerPackSync( return; } - wrap( + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'shims/sendStickerPackSync: We are primary device; not sending sync' + ); + return; + } + + handleMessageSend( textsecure.messaging.sendStickerPackSync( [ { @@ -33,7 +40,8 @@ export async function sendStickerPackSync( }, ], sendOptions - ) + ), + { messageIds: [], sendType: 'otherSync' } ).catch(error => { log.error( 'shim: Error calling sendStickerPackSync:', diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 86665b518..2a49ef115 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -14,7 +14,6 @@ import { cloneDeep, compact, fromPairs, - toPairs, get, groupBy, isFunction, @@ -22,6 +21,8 @@ import { map, omit, set, + toPairs, + uniq, } from 'lodash'; import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto'; @@ -41,8 +42,8 @@ import { StoredJob } from '../jobs/types'; import { AttachmentDownloadJobType, ClientInterface, - ClientSearchResultMessageType, ClientJobType, + ClientSearchResultMessageType, ConversationType, IdentityKeyType, ItemKeyType, @@ -52,6 +53,12 @@ import { PreKeyType, SearchResultMessageType, SenderKeyType, + SentMessageDBType, + SentMessagesType, + SentProtoType, + SentProtoWithMessageIdsType, + SentRecipientsDBType, + SentRecipientsType, ServerInterface, SessionType, SignedPreKeyType, @@ -143,6 +150,17 @@ const dataInterface: ClientInterface = { getAllSenderKeys, removeSenderKeyById, + insertSentProto, + deleteSentProtosOlderThan, + deleteSentProtoByMessageId, + insertProtoRecipients, + deleteSentProtoRecipient, + getSentProtoByRecipient, + removeAllSentProtos, + getAllSentProtos, + _getAllSentProtoRecipients, + _getAllSentProtoMessageIds, + createOrUpdateSession, createOrUpdateSessions, commitSessionsAndUnprocessed, @@ -771,6 +789,66 @@ async function removeSenderKeyById(id: string): Promise { return channels.removeSenderKeyById(id); } +// Sent Protos + +async function insertSentProto( + proto: SentProtoType, + options: { + messageIds: SentMessagesType; + recipients: SentRecipientsType; + } +): Promise { + return channels.insertSentProto(proto, { + ...options, + messageIds: uniq(options.messageIds), + }); +} +async function deleteSentProtosOlderThan(timestamp: number): Promise { + await channels.deleteSentProtosOlderThan(timestamp); +} +async function deleteSentProtoByMessageId(messageId: string): Promise { + await channels.deleteSentProtoByMessageId(messageId); +} + +async function insertProtoRecipients(options: { + id: number; + recipientUuid: string; + deviceIds: Array; +}): Promise { + await channels.insertProtoRecipients(options); +} +async function deleteSentProtoRecipient(options: { + timestamp: number; + recipientUuid: string; + deviceId: number; +}): Promise { + await channels.deleteSentProtoRecipient(options); +} + +async function getSentProtoByRecipient(options: { + now: number; + recipientUuid: string; + timestamp: number; +}): Promise { + return channels.getSentProtoByRecipient(options); +} +async function removeAllSentProtos(): Promise { + await channels.removeAllSentProtos(); +} +async function getAllSentProtos(): Promise> { + return channels.getAllSentProtos(); +} + +// Test-only: +async function _getAllSentProtoRecipients(): Promise< + Array +> { + return channels._getAllSentProtoRecipients(); +} +async function _getAllSentProtoMessageIds(): Promise> { + return channels._getAllSentProtoMessageIds(); +} + // Sessions async function createOrUpdateSession(data: SessionType) { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index c8398a790..6c0c91714 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -17,6 +17,7 @@ import type { ReactionType } from '../types/Reactions'; import type { ConversationColorType, CustomColorType } from '../types/Colors'; import { StorageAccessType } from '../types/Storage.d'; import type { AttachmentType } from '../types/Attachment'; +import { BodyRangesType } from '../types/Util'; export type AttachmentDownloadJobTypeType = | 'long-message' @@ -83,9 +84,32 @@ export type SearchResultMessageType = { }; export type ClientSearchResultMessageType = MessageType & { json: string; - bodyRanges: []; + bodyRanges: BodyRangesType; snippet: string; }; + +export type SentProtoType = { + contentHint: number; + proto: Buffer; + timestamp: number; +}; +export type SentProtoWithMessageIdsType = SentProtoType & { + messageIds: Array; +}; +export type SentRecipientsType = Record>; +export type SentMessagesType = Array; + +// These two are for test only +export type SentRecipientsDBType = { + payloadId: number; + recipientUuid: string; + deviceId: number; +}; +export type SentMessageDBType = { + payloadId: number; + messageId: string; +}; + export type SenderKeyType = { // Primary key id: string; @@ -215,6 +239,36 @@ export type DataInterface = { getAllSenderKeys: () => Promise>; removeSenderKeyById: (id: string) => Promise; + insertSentProto: ( + proto: SentProtoType, + options: { + recipients: SentRecipientsType; + messageIds: SentMessagesType; + } + ) => Promise; + deleteSentProtosOlderThan: (timestamp: number) => Promise; + deleteSentProtoByMessageId: (messageId: string) => Promise; + insertProtoRecipients: (options: { + id: number; + recipientUuid: string; + deviceIds: Array; + }) => Promise; + deleteSentProtoRecipient: (options: { + timestamp: number; + recipientUuid: string; + deviceId: number; + }) => Promise; + getSentProtoByRecipient: (options: { + now: number; + recipientUuid: string; + timestamp: number; + }) => Promise; + removeAllSentProtos: () => Promise; + getAllSentProtos: () => Promise>; + // Test-only + _getAllSentProtoRecipients: () => Promise>; + _getAllSentProtoMessageIds: () => Promise>; + createOrUpdateSession: (data: SessionType) => Promise; createOrUpdateSessions: (array: Array) => Promise; commitSessionsAndUnprocessed(options: { @@ -255,6 +309,36 @@ export type DataInterface = { ) => Promise; getNextTapToViewMessageTimestampToAgeOut: () => Promise; + getUnreadCountForConversation: (conversationId: string) => Promise; + getUnreadByConversationAndMarkRead: ( + conversationId: string, + newestUnreadId: number, + readAt?: number + ) => Promise< + Array< + Pick + > + >; + getUnreadReactionsAndMarkRead: ( + conversationId: string, + newestUnreadId: number + ) => Promise< + Array< + Pick + > + >; + markReactionAsRead: ( + targetAuthorUuid: string, + targetTimestamp: number + ) => Promise; + removeReactionFromConversation: (reaction: { + emoji: string; + fromId: string; + targetAuthorUuid: string; + targetTimestamp: number; + }) => Promise; + addReaction: (reactionObj: ReactionType) => Promise; + getUnprocessedCount: () => Promise; getAllUnprocessed: () => Promise>; updateUnprocessedAttempts: (id: string, attempts: number) => Promise; @@ -391,33 +475,6 @@ export type ServerInterface = DataInterface & { ourConversationId: string; }) => Promise; getTapToViewMessagesNeedingErase: () => Promise>; - getUnreadCountForConversation: (conversationId: string) => Promise; - getUnreadByConversationAndMarkRead: ( - conversationId: string, - newestUnreadId: number, - readAt?: number - ) => Promise< - Array< - Pick - > - >; - getUnreadReactionsAndMarkRead: ( - conversationId: string, - newestUnreadId: number - ) => Promise< - Array> - >; - markReactionAsRead: ( - targetAuthorUuid: string, - targetTimestamp: number - ) => Promise; - removeReactionFromConversation: (reaction: { - emoji: string; - fromId: string; - targetAuthorUuid: string; - targetTimestamp: number; - }) => Promise; - addReaction: (reactionObj: ReactionType) => Promise; removeConversation: (id: Array | string) => Promise; removeMessage: (id: string) => Promise; removeMessages: (ids: Array) => Promise; @@ -530,33 +587,6 @@ export type ClientInterface = DataInterface & { getTapToViewMessagesNeedingErase: (options: { MessageCollection: typeof MessageModelCollectionType; }) => Promise; - getUnreadCountForConversation: (conversationId: string) => Promise; - getUnreadByConversationAndMarkRead: ( - conversationId: string, - newestUnreadId: number, - readAt?: number - ) => Promise< - Array< - Pick - > - >; - getUnreadReactionsAndMarkRead: ( - conversationId: string, - newestUnreadId: number - ) => Promise< - Array> - >; - markReactionAsRead: ( - targetAuthorUuid: string, - targetTimestamp: number - ) => Promise; - removeReactionFromConversation: (reaction: { - emoji: string; - fromId: string; - targetAuthorUuid: string; - targetTimestamp: number; - }) => Promise; - addReaction: (reactionObj: ReactionType) => Promise; removeConversation: ( id: string, options: { Conversation: typeof ConversationModel } diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 2f4d75895..e60d5117d 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -36,23 +36,30 @@ import { combineNames } from '../util/combineNames'; import { dropNull } from '../util/dropNull'; import { isNormalNumber } from '../util/isNormalNumber'; import { isNotNil } from '../util/isNotNil'; +import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { ConversationColorType, CustomColorType } from '../types/Colors'; import { + AllItemsType, AttachmentDownloadJobType, ConversationMetricsType, ConversationType, EmojiType, IdentityKeyType, - AllItemsType, ItemKeyType, ItemType, + MessageMetricsType, MessageType, MessageTypeUnhydrated, - MessageMetricsType, PreKeyType, SearchResultMessageType, SenderKeyType, + SentMessageDBType, + SentMessagesType, + SentProtoType, + SentProtoWithMessageIdsType, + SentRecipientsDBType, + SentRecipientsType, ServerInterface, SessionType, SignedPreKeyType, @@ -63,14 +70,6 @@ import { UnprocessedUpdateType, } from './Interface'; -declare global { - // We want to extend `Function`'s properties, so we need to use an interface. - // eslint-disable-next-line no-restricted-syntax - interface Function { - needsSerial?: boolean; - } -} - type JSONRows = Array<{ readonly json: string }>; type ConversationRow = Readonly<{ json: string; @@ -137,6 +136,17 @@ const dataInterface: ServerInterface = { getAllSenderKeys, removeSenderKeyById, + insertSentProto, + deleteSentProtosOlderThan, + deleteSentProtoByMessageId, + insertProtoRecipients, + deleteSentProtoRecipient, + getSentProtoByRecipient, + removeAllSentProtos, + getAllSentProtos, + _getAllSentProtoRecipients, + _getAllSentProtoMessageIds, + createOrUpdateSession, createOrUpdateSessions, commitSessionsAndUnprocessed, @@ -253,16 +263,16 @@ type DatabaseQueryCache = Map>>; const statementCache = new WeakMap(); -function prepare(db: Database, query: string): Statement { +function prepare(db: Database, query: string): Statement { let dbCache = statementCache.get(db); if (!dbCache) { dbCache = new Map(); statementCache.set(db, dbCache); } - let result = dbCache.get(query); + let result = dbCache.get(query) as Statement; if (!result) { - result = db.prepare(query); + result = db.prepare(query); dbCache.set(query, result); } @@ -1947,6 +1957,84 @@ function updateToSchemaVersion36(currentVersion: number, db: Database) { console.log('updateToSchemaVersion36: success!'); } +function updateToSchemaVersion37(currentVersion: number, db: Database) { + if (currentVersion >= 37) { + return; + } + + db.transaction(() => { + db.exec(` + -- Create send log primary table + + CREATE TABLE sendLogPayloads( + id INTEGER PRIMARY KEY ASC, + + timestamp INTEGER NOT NULL, + contentHint INTEGER NOT NULL, + proto BLOB NOT NULL + ); + + CREATE INDEX sendLogPayloadsByTimestamp ON sendLogPayloads (timestamp); + + -- Create send log recipients table with foreign key relationship to payloads + + CREATE TABLE sendLogRecipients( + payloadId INTEGER NOT NULL, + + recipientUuid STRING NOT NULL, + deviceId INTEGER NOT NULL, + + PRIMARY KEY (payloadId, recipientUuid, deviceId), + + CONSTRAINT sendLogRecipientsForeignKey + FOREIGN KEY (payloadId) + REFERENCES sendLogPayloads(id) + ON DELETE CASCADE + ); + + CREATE INDEX sendLogRecipientsByRecipient + ON sendLogRecipients (recipientUuid, deviceId); + + -- Create send log messages table with foreign key relationship to payloads + + CREATE TABLE sendLogMessageIds( + payloadId INTEGER NOT NULL, + + messageId STRING NOT NULL, + + PRIMARY KEY (payloadId, messageId), + + CONSTRAINT sendLogMessageIdsForeignKey + FOREIGN KEY (payloadId) + REFERENCES sendLogPayloads(id) + ON DELETE CASCADE + ); + + CREATE INDEX sendLogMessageIdsByMessage + ON sendLogMessageIds (messageId); + + -- Recreate messages table delete trigger with send log support + + DROP TRIGGER messages_on_delete; + + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + DELETE FROM sendLogPayloads WHERE id IN ( + SELECT payloadId FROM sendLogMessageIds + WHERE messageId = old.id + ); + END; + + --- Add messageId column to reactions table to properly track proto associations + + ALTER TABLE reactions ADD column messageId STRING; + `); + + db.pragma('user_version = 37'); + })(); + console.log('updateToSchemaVersion37: success!'); +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -1984,6 +2072,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion34, updateToSchemaVersion35, updateToSchemaVersion36, + updateToSchemaVersion37, ]; function updateSchema(db: Database): void { @@ -2350,11 +2439,11 @@ async function getSenderKeyById( } async function removeAllSenderKeys(): Promise { const db = getInstance(); - prepare(db, 'DELETE FROM senderKeys').run({}); + prepare(db, 'DELETE FROM senderKeys').run(); } async function getAllSenderKeys(): Promise> { const db = getInstance(); - const rows = prepare(db, 'SELECT * FROM senderKeys').all({}); + const rows = prepare(db, 'SELECT * FROM senderKeys').all(); return rows; } @@ -2363,6 +2452,317 @@ async function removeSenderKeyById(id: string): Promise { prepare(db, 'DELETE FROM senderKeys WHERE id = $id').run({ id }); } +async function insertSentProto( + proto: SentProtoType, + options: { + recipients: SentRecipientsType; + messageIds: SentMessagesType; + } +): Promise { + 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 { + const db = getInstance(); + + prepare( + db, + ` + DELETE FROM sendLogPayloads + WHERE + timestamp IS NULL OR + timestamp < $timestamp; + ` + ).run({ + timestamp, + }); +} + +async function deleteSentProtoByMessageId(messageId: string): Promise { + 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; +}): Promise { + 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 { + 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 { + 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 { + const db = getInstance(); + prepare(db, 'DELETE FROM sendLogPayloads;').run(); +} +async function getAllSentProtos(): Promise> { + const db = getInstance(); + const rows = prepare(db, 'SELECT * FROM sendLogPayloads;').all(); + + return rows; +} +async function _getAllSentProtoRecipients(): Promise< + Array +> { + const db = getInstance(); + const rows = prepare( + db, + 'SELECT * FROM sendLogRecipients;' + ).all(); + + return rows; +} +async function _getAllSentProtoMessageIds(): Promise> { + const db = getInstance(); + const rows = prepare( + db, + 'SELECT * FROM sendLogMessageIds;' + ).all(); + + return rows; +} + const SESSIONS_TABLE = 'sessions'; function createOrUpdateSessionSync(data: SessionType): void { const db = getInstance(); @@ -2717,8 +3117,7 @@ function updateConversationSync(data: ConversationType): void { ? members.join(' ') : null; - prepare( - db, + db.prepare( ` UPDATE conversations SET json = $json, @@ -3470,13 +3869,18 @@ async function getUnreadByConversationAndMarkRead( async function getUnreadReactionsAndMarkRead( conversationId: string, newestUnreadId: number -): Promise>> { +): Promise< + Array< + Pick + > +> { const db = getInstance(); + return db.transaction(() => { const unreadMessages = db .prepare( ` - SELECT targetAuthorUuid, targetTimestamp + SELECT targetAuthorUuid, targetTimestamp, messageId FROM reactions WHERE unread = 1 AND conversationId = $conversationId AND @@ -3548,6 +3952,7 @@ async function addReaction({ conversationId, emoji, fromId, + messageId, messageReceivedAt, targetAuthorUuid, targetTimestamp, @@ -3559,6 +3964,7 @@ async function addReaction({ conversationId, emoji, fromId, + messageId, messageReceivedAt, targetAuthorUuid, targetTimestamp, @@ -3567,6 +3973,7 @@ async function addReaction({ $conversationId, $emoji, $fromId, + $messageId, $messageReceivedAt, $targetAuthorUuid, $targetTimestamp, @@ -3577,6 +3984,7 @@ async function addReaction({ conversationId, emoji, fromId, + messageId, messageReceivedAt, targetAuthorUuid, targetTimestamp, diff --git a/ts/test-electron/models/messages_test.ts b/ts/test-electron/models/messages_test.ts index 89162514e..6aa62988f 100644 --- a/ts/test-electron/models/messages_test.ts +++ b/ts/test-electron/models/messages_test.ts @@ -5,6 +5,7 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; +import { CallbackResultType } from '../../textsecure/SendMessage'; import { SignalService as Proto } from '../../protobuf'; describe('Message', () => { @@ -71,7 +72,16 @@ describe('Message', () => { it('updates the `sent` attribute', async () => { const message = createMessage({ type: 'outgoing', source, sent: false }); - await message.send(Promise.resolve({})); + const promise: Promise = 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')); }); diff --git a/ts/test-electron/sql/sendLog_test.ts b/ts/test-electron/sql/sendLog_test.ts new file mode 100644 index 000000000..a9e755ee4 --- /dev/null +++ b/ts/test-electron/sql/sendLog_test.ts @@ -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); + }); + }); +}); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 7c317cc8c..560cd072a 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1028,7 +1028,7 @@ class MessageReceiverInner extends EventTarget { } catch (error) { const args = [ 'queueEncryptedEnvelope error handling envelope', - this.getEnvelopeId(envelope), + this.getEnvelopeId(error.envelope || envelope), ':', error && error.extra ? JSON.stringify(error.extra) : '', error && error.stack ? error.stack : error, @@ -1587,7 +1587,10 @@ class MessageReceiverInner extends EventTarget { }); // Avoid deadlocks by scheduling processing on decrypted queue - this.addToQueue(() => this.dispatchAndWait(event), TaskType.Decrypted); + this.addToQueue( + async () => this.dispatchEvent(event), + TaskType.Decrypted + ); } else { const envelopeId = this.getEnvelopeId(newEnvelope); window.log.error( @@ -1803,39 +1806,98 @@ class MessageReceiverInner extends EventTarget { ); assert(envelope.content, 'Should have `content` field'); const result = await this.decrypt(stores, envelope, envelope.content); + if (!result.plaintext) { window.log.warn('decryptContentMessage: plaintext was falsey'); + return result; } - return result; - } - - async innerHandleContentMessage( - envelope: ProcessedEnvelope, - plaintext: Uint8Array - ): Promise { - const content = Proto.Content.decode(plaintext); - - // Note: a distribution message can be tacked on to any other message, so we - // make sure to process it first. If that fails, we still try to process - // the rest of the message. + // Note: we need to process this as part of decryption, because we might need this + // sender key to decrypt the next message in the queue! try { + const content = Proto.Content.decode(result.plaintext); + if ( content.senderKeyDistributionMessage && Bytes.isNotEmpty(content.senderKeyDistributionMessage) ) { await this.handleSenderKeyDistributionMessage( - envelope, + stores, + result.envelope, content.senderKeyDistributionMessage ); } } catch (error) { const errorString = error && error.stack ? error.stack : error; window.log.error( - `innerHandleContentMessage: Failed to process sender key distribution message: ${errorString}` + `decryptContentMessage: Failed to process sender key distribution message: ${errorString}` ); } + return result; + } + + async maybeUpdateTimestamp( + envelope: ProcessedEnvelope + ): Promise { + 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 { + const content = Proto.Content.decode(plaintext); + const envelope = await this.maybeUpdateTimestamp(incomingEnvelope); + if ( content.decryptionErrorMessage && Bytes.isNotEmpty(content.decryptionErrorMessage) @@ -1908,10 +1970,11 @@ class MessageReceiverInner extends EventTarget { senderDevice: request.deviceId(), sentAt: request.timestamp(), }); - await this.dispatchAndWait(event); + await this.dispatchEvent(event); } async handleSenderKeyDistributionMessage( + stores: LockedStores, envelope: ProcessedEnvelope, distributionMessage: Uint8Array ): Promise { @@ -1941,12 +2004,15 @@ class MessageReceiverInner extends EventTarget { const senderKeyStore = new SenderKeys(); const address = `${identifier}.${sourceDevice}`; - await window.textsecure.storage.protocol.enqueueSenderKeyJob(address, () => - processSenderKeyDistributionMessage( - sender, - senderKeyDistributionMessage, - senderKeyStore - ) + await window.textsecure.storage.protocol.enqueueSenderKeyJob( + address, + () => + processSenderKeyDistributionMessage( + sender, + senderKeyDistributionMessage, + senderKeyStore + ), + stores.zone ); } @@ -1989,6 +2055,7 @@ class MessageReceiverInner extends EventTarget { envelopeTimestamp: envelope.timestamp, source: envelope.source, sourceUuid: envelope.sourceUuid, + sourceDevice: envelope.sourceDevice, }, this.removeFromCache.bind(this, envelope) ); diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 17f2bd682..e4dd1dcb3 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -48,6 +48,11 @@ export const enum SenderCertificateMode { WithoutE164, } +export type SendLogCallbackType = (options: { + identifier: string; + deviceIds: Array; +}) => Promise; + type SendMetadata = { type: number; destinationDeviceId: number; @@ -123,11 +128,11 @@ export default class OutgoingMessage { errors: Array; - successfulIdentifiers: Array; + successfulIdentifiers: Array; - failoverIdentifiers: Array; + failoverIdentifiers: Array; - unidentifiedDeliveries: Array; + unidentifiedDeliveries: Array; sendMetadata?: SendMetadataType; @@ -137,16 +142,31 @@ export default class OutgoingMessage { contentHint: number; - constructor( - server: WebAPIType, - timestamp: number, - identifiers: Array, - message: Proto.Content | Proto.DataMessage | PlaintextContent, - contentHint: number, - groupId: string | undefined, - callback: (result: CallbackResultType) => void, - options: OutgoingMessageOptionsType = {} - ) { + recipients: Record>; + + sendLogCallback?: SendLogCallbackType; + + constructor({ + callback, + contentHint, + groupId, + identifiers, + message, + options, + sendLogCallback, + server, + timestamp, + }: { + callback: (result: CallbackResultType) => void; + contentHint: number; + groupId: string | undefined; + identifiers: Array; + message: Proto.Content | Proto.DataMessage | PlaintextContent; + options?: OutgoingMessageOptionsType; + sendLogCallback?: SendLogCallbackType; + server: WebAPIType; + timestamp: number; + }) { if (message instanceof Proto.DataMessage) { const content = new Proto.Content(); content.dataMessage = message; @@ -168,20 +188,29 @@ export default class OutgoingMessage { this.successfulIdentifiers = []; this.failoverIdentifiers = []; this.unidentifiedDeliveries = []; + this.recipients = {}; + this.sendLogCallback = sendLogCallback; - const { sendMetadata, online } = options; - this.sendMetadata = sendMetadata; - this.online = online; + this.sendMetadata = options?.sendMetadata; + this.online = options?.online; } numberCompleted(): void { this.identifiersCompleted += 1; if (this.identifiersCompleted >= this.identifiers.length) { + const contentProto = this.getContentProtoBytes(); + const { timestamp, contentHint, recipients } = this; + this.callback({ successfulIdentifiers: this.successfulIdentifiers, failoverIdentifiers: this.failoverIdentifiers, errors: this.errors, unidentifiedDeliveries: this.unidentifiedDeliveries, + + contentHint, + recipients, + contentProto, + timestamp, }); } } @@ -313,6 +342,14 @@ export default class OutgoingMessage { return toArrayBuffer(this.plaintext); } + getContentProtoBytes(): Uint8Array | undefined { + if (this.message instanceof Proto.Content) { + return new Uint8Array(Proto.Content.encode(this.message).finish()); + } + + return undefined; + } + async getCiphertextMessage({ identityKeyStore, protocolAddress, @@ -455,9 +492,21 @@ export default class OutgoingMessage { accessKey, }).then( () => { + this.recipients[identifier] = deviceIds; this.unidentifiedDeliveries.push(identifier); this.successfulIdentifiers.push(identifier); this.numberCompleted(); + + if (this.sendLogCallback) { + this.sendLogCallback({ + identifier, + deviceIds, + }); + } else if (this.successfulIdentifiers.length > 1) { + window.log.warn( + `OutgoingMessage.doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients` + ); + } }, async (error: Error) => { if (error.code === 401 || error.code === 403) { @@ -481,7 +530,19 @@ export default class OutgoingMessage { return this.transmitMessage(identifier, jsonData, this.timestamp).then( () => { this.successfulIdentifiers.push(identifier); + this.recipients[identifier] = deviceIds; this.numberCompleted(); + + if (this.sendLogCallback) { + this.sendLogCallback({ + identifier, + deviceIds, + }); + } else if (this.successfulIdentifiers.length > 1) { + window.log.warn( + `OutgoingMessage.doSendMessage: no sendLogCallback provided for message ${this.timestamp}, but multiple recipients` + ); + } } ); }) diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index c6e9e5df3..1ad0c1b2d 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -28,7 +28,10 @@ import { MultiRecipient200ResponseType, } from './WebAPI'; import createTaskWithTimeout from './TaskWithTimeout'; -import OutgoingMessage, { SerializedCertificateType } from './OutgoingMessage'; +import OutgoingMessage, { + SerializedCertificateType, + SendLogCallbackType, +} from './OutgoingMessage'; import Crypto from './Crypto'; import * as Bytes from '../Bytes'; import { @@ -48,6 +51,11 @@ import { LinkPreviewMetadata, } from '../linkPreviews/linkPreviewFetch'; import { concat } from '../util/iterables'; +import { + handleMessageSend, + shouldSaveProto, + SendTypesType, +} from '../util/handleMessageSend'; import { SignalService as Proto } from '../protobuf'; export type SendMetadataType = { @@ -68,11 +76,17 @@ export type CustomError = Error & { }; export type CallbackResultType = { - successfulIdentifiers?: Array; - failoverIdentifiers?: Array; + successfulIdentifiers?: Array; + failoverIdentifiers?: Array; errors?: Array; - unidentifiedDeliveries?: Array; + unidentifiedDeliveries?: Array; dataMessage?: ArrayBuffer; + + // Fields necesary for send log save + contentHint?: number; + contentProto?: Uint8Array; + timestamp?: number; + recipients?: Record>; }; type PreviewType = { @@ -593,9 +607,12 @@ export default class MessageSender { try { const { sticker } = message; - if (!sticker || !sticker.data) { + if (!sticker) { return; } + if (!sticker.data) { + throw new Error('uploadSticker: No sticker data to upload!'); + } // eslint-disable-next-line no-param-reassign message.sticker = { @@ -824,21 +841,23 @@ export default class MessageSender { } sendMessageProto({ - timestamp, - recipients, - proto, + callback, contentHint, groupId, - callback, options, + proto, + recipients, + sendLogCallback, + timestamp, }: { - timestamp: number; - recipients: Array; - proto: Proto.Content | Proto.DataMessage | PlaintextContent; + callback: (result: CallbackResultType) => void; contentHint: number; groupId: string | undefined; - callback: (result: CallbackResultType) => void; options?: SendOptionsType; + proto: Proto.Content | Proto.DataMessage | PlaintextContent; + recipients: Array; + sendLogCallback?: SendLogCallbackType; + timestamp: number; }): void { const rejections = window.textsecure.storage.get( 'signedKeyRotationRejected', @@ -848,16 +867,17 @@ export default class MessageSender { throw new SignedPreKeyRotationError(); } - const outgoing = new OutgoingMessage( - this.server, - timestamp, - recipients, - proto, + const outgoing = new OutgoingMessage({ + callback, contentHint, groupId, - callback, - options - ); + identifiers: recipients, + message: proto, + options, + sendLogCallback, + server: this.server, + timestamp, + }); recipients.forEach(identifier => { this.queueJobForIdentifier(identifier, async () => @@ -992,6 +1012,8 @@ export default class MessageSender { // Support for sync messages + // Note: this is used for sending real messages to your other devices after sending a + // message to others. async sendSyncMessage({ encodedDataMessage, timestamp, @@ -1012,14 +1034,9 @@ export default class MessageSender { unidentifiedDeliveries?: Array; isUpdate?: boolean; options?: SendOptionsType; - }): Promise { + }): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - - if (myDevice === 1) { - return Promise.resolve(); - } const dataMessage = Proto.DataMessage.decode( new FIXMEU8(encodedDataMessage) @@ -1082,134 +1099,112 @@ export default class MessageSender { identifier: myUuid || myNumber, proto: contentMessage, timestamp, - contentHint: ContentHint.IMPLICIT, + contentHint: ContentHint.RESENDABLE, options, }); } async sendRequestBlockSyncMessage( options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice !== 1) { - const request = new Proto.SyncMessage.Request(); - request.type = Proto.SyncMessage.Request.Type.BLOCKED; - const syncMessage = this.createSyncMessage(); - syncMessage.request = request; - const contentMessage = new Proto.Content(); - contentMessage.syncMessage = syncMessage; - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + const request = new Proto.SyncMessage.Request(); + request.type = Proto.SyncMessage.Request.Type.BLOCKED; + const syncMessage = this.createSyncMessage(); + syncMessage.request = request; + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto({ - identifier: myUuid || myNumber, - proto: contentMessage, - timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, - options, - }); - } + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - return Promise.resolve(); + return this.sendIndividualProto({ + identifier: myUuid || myNumber, + proto: contentMessage, + timestamp: Date.now(), + contentHint: ContentHint.IMPLICIT, + options, + }); } async sendRequestConfigurationSyncMessage( options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice !== 1) { - const request = new Proto.SyncMessage.Request(); - request.type = Proto.SyncMessage.Request.Type.CONFIGURATION; - const syncMessage = this.createSyncMessage(); - syncMessage.request = request; - const contentMessage = new Proto.Content(); - contentMessage.syncMessage = syncMessage; - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + const request = new Proto.SyncMessage.Request(); + request.type = Proto.SyncMessage.Request.Type.CONFIGURATION; + const syncMessage = this.createSyncMessage(); + syncMessage.request = request; + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto({ - identifier: myUuid || myNumber, - proto: contentMessage, - timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, - options, - }); - } + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - return Promise.resolve(); + return this.sendIndividualProto({ + identifier: myUuid || myNumber, + proto: contentMessage, + timestamp: Date.now(), + contentHint: ContentHint.IMPLICIT, + options, + }); } async sendRequestGroupSyncMessage( options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice !== 1) { - const request = new Proto.SyncMessage.Request(); - request.type = Proto.SyncMessage.Request.Type.GROUPS; - const syncMessage = this.createSyncMessage(); - syncMessage.request = request; - const contentMessage = new Proto.Content(); - contentMessage.syncMessage = syncMessage; - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + const request = new Proto.SyncMessage.Request(); + request.type = Proto.SyncMessage.Request.Type.GROUPS; + const syncMessage = this.createSyncMessage(); + syncMessage.request = request; + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; - return this.sendIndividualProto({ - identifier: myUuid || myNumber, - proto: contentMessage, - timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, - options, - }); - } + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - return Promise.resolve(); + return this.sendIndividualProto({ + identifier: myUuid || myNumber, + proto: contentMessage, + timestamp: Date.now(), + contentHint: ContentHint.IMPLICIT, + options, + }); } async sendRequestContactSyncMessage( options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice !== 1) { - const request = new Proto.SyncMessage.Request(); - request.type = Proto.SyncMessage.Request.Type.CONTACTS; - const syncMessage = this.createSyncMessage(); - syncMessage.request = request; - const contentMessage = new Proto.Content(); - contentMessage.syncMessage = syncMessage; + const request = new Proto.SyncMessage.Request(); + request.type = Proto.SyncMessage.Request.Type.CONTACTS; + const syncMessage = this.createSyncMessage(); + syncMessage.request = request; + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - return this.sendIndividualProto({ - identifier: myUuid || myNumber, - proto: contentMessage, - timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, - options, - }); - } - - return Promise.resolve(); + return this.sendIndividualProto({ + identifier: myUuid || myNumber, + proto: contentMessage, + timestamp: Date.now(), + contentHint: ContentHint.IMPLICIT, + options, + }); } async sendFetchManifestSyncMessage( options?: SendOptionsType - ): Promise { + ): Promise { const myUuid = window.textsecure.storage.user.getUuid(); const myNumber = window.textsecure.storage.user.getNumber(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - - if (myDevice === 1) { - return; - } const fetchLatest = new Proto.SyncMessage.FetchLatest(); fetchLatest.type = Proto.SyncMessage.FetchLatest.Type.STORAGE_MANIFEST; @@ -1221,7 +1216,7 @@ export default class MessageSender { const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - await this.sendIndividualProto({ + return this.sendIndividualProto({ identifier: myUuid || myNumber, proto: contentMessage, timestamp: Date.now(), @@ -1232,14 +1227,9 @@ export default class MessageSender { async sendRequestKeySyncMessage( options?: SendOptionsType - ): Promise { + ): Promise { const myUuid = window.textsecure.storage.user.getUuid(); const myNumber = window.textsecure.storage.user.getNumber(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - - if (myDevice === 1) { - return; - } const request = new Proto.SyncMessage.Request(); request.type = Proto.SyncMessage.Request.Type.KEYS; @@ -1251,7 +1241,7 @@ export default class MessageSender { const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - await this.sendIndividualProto({ + return this.sendIndividualProto({ identifier: myUuid || myNumber, proto: contentMessage, timestamp: Date.now(), @@ -1267,13 +1257,10 @@ export default class MessageSender { timestamp: number; }>, options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice === 1) { - return; - } + const syncMessage = this.createSyncMessage(); syncMessage.read = []; for (let i = 0; i < reads.length; i += 1) { @@ -1290,7 +1277,7 @@ export default class MessageSender { identifier: myUuid || myNumber, proto: contentMessage, timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, + contentHint: ContentHint.RESENDABLE, options, }); } @@ -1300,13 +1287,9 @@ export default class MessageSender { senderUuid: string, timestamp: number, options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice === 1) { - return null; - } const syncMessage = this.createSyncMessage(); @@ -1327,7 +1310,7 @@ export default class MessageSender { identifier: myUuid || myNumber, proto: contentMessage, timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, + contentHint: ContentHint.RESENDABLE, options, }); } @@ -1340,13 +1323,9 @@ export default class MessageSender { type: number; }, options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice === 1) { - return null; - } const syncMessage = this.createSyncMessage(); @@ -1372,7 +1351,7 @@ export default class MessageSender { identifier: myUuid || myNumber, proto: contentMessage, timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, + contentHint: ContentHint.RESENDABLE, options, }); } @@ -1384,12 +1363,7 @@ export default class MessageSender { installed: boolean; }>, options?: SendOptionsType - ): Promise { - const myDevice = window.textsecure.storage.user.getDeviceId(); - if (myDevice === 1) { - return null; - } - + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); const ENUM = Proto.SyncMessage.StickerPackOperation.Type; @@ -1423,57 +1397,60 @@ export default class MessageSender { } async syncVerification( - destinationE164: string, - destinationUuid: string, + destinationE164: string | undefined, + destinationUuid: string | undefined, state: number, identityKey: ArrayBuffer, options?: SendOptionsType - ): Promise { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); const now = Date.now(); - if (myDevice === 1) { - return Promise.resolve(); + if (!destinationE164 && !destinationUuid) { + throw new Error('syncVerification: Neither e164 nor UUID were provided'); } // Get padding which we can share between null message and verified sync const padding = this.getRandomPadding(); // First send a null message to mask the sync message. - const promise = this.sendNullMessage( - { uuid: destinationUuid, e164: destinationE164, padding }, - options + await handleMessageSend( + this.sendNullMessage( + { uuid: destinationUuid, e164: destinationE164, padding }, + options + ), + { + messageIds: [], + sendType: 'nullMessage', + } ); - return promise.then(async () => { - const verified = new Proto.Verified(); - verified.state = state; - if (destinationE164) { - verified.destination = destinationE164; - } - if (destinationUuid) { - verified.destinationUuid = destinationUuid; - } - verified.identityKey = new FIXMEU8(identityKey); - verified.nullMessage = padding; + const verified = new Proto.Verified(); + verified.state = state; + if (destinationE164) { + verified.destination = destinationE164; + } + if (destinationUuid) { + verified.destinationUuid = destinationUuid; + } + verified.identityKey = new FIXMEU8(identityKey); + verified.nullMessage = padding; - const syncMessage = this.createSyncMessage(); - syncMessage.verified = verified; + const syncMessage = this.createSyncMessage(); + syncMessage.verified = verified; - const secondMessage = new Proto.Content(); - secondMessage.syncMessage = syncMessage; + const secondMessage = new Proto.Content(); + secondMessage.syncMessage = syncMessage; - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - await this.sendIndividualProto({ - identifier: myUuid || myNumber, - proto: secondMessage, - timestamp: now, - contentHint: ContentHint.IMPLICIT, - options, - }); + return this.sendIndividualProto({ + identifier: myUuid || myNumber, + proto: secondMessage, + timestamp: now, + contentHint: ContentHint.RESENDABLE, + options, }); } @@ -1512,7 +1489,7 @@ export default class MessageSender { recipientId: string, callingMessage: Proto.ICallingMessage, options?: SendOptionsType - ): Promise { + ): Promise { const recipients = [recipientId]; const finalTimestamp = Date.now(); @@ -1521,7 +1498,7 @@ export default class MessageSender { const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - await this.sendMessageProtoAndWait({ + return this.sendMessageProtoAndWait({ timestamp: finalTimestamp, recipients, proto: contentMessage, @@ -1537,16 +1514,15 @@ export default class MessageSender { timestamps, options, }: { - e164: string; - uuid: string; + e164?: string; + uuid?: string; timestamps: Array; options?: SendOptionsType; - }): Promise { - const myNumber = window.textsecure.storage.user.getNumber(); - const myUuid = window.textsecure.storage.user.getUuid(); - const myDevice = window.textsecure.storage.user.getDeviceId(); - if ((myNumber === e164 || myUuid === uuid) && myDevice === 1) { - return Promise.resolve(); + }): Promise { + if (!uuid && !e164) { + throw new Error( + 'sendDeliveryReceipt: Neither uuid nor e164 was provided!' + ); } const receiptMessage = new Proto.ReceiptMessage(); @@ -1562,7 +1538,7 @@ export default class MessageSender { identifier: uuid || e164, proto: contentMessage, timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, + contentHint: ContentHint.RESENDABLE, options, }); } @@ -1591,7 +1567,7 @@ export default class MessageSender { identifier: senderUuid || senderE164, proto: contentMessage, timestamp: Date.now(), - contentHint: ContentHint.IMPLICIT, + contentHint: ContentHint.RESENDABLE, options, }); } @@ -1634,9 +1610,7 @@ export default class MessageSender { e164: string, timestamp: number, options?: SendOptionsType - ): Promise< - CallbackResultType | void | Array> - > { + ): Promise { window.log.info('resetSession: start'); const proto = new Proto.DataMessage(); proto.body = 'TERMINATE'; @@ -1659,19 +1633,27 @@ export default class MessageSender { window.log.info( 'resetSession: finished closing local sessions, now sending to contact' ); - return this.sendIndividualProto({ - identifier, - proto, - timestamp, - contentHint: ContentHint.DEFAULT, - options, - }).catch(logError('resetSession/sendToContact error:')); + return handleMessageSend( + this.sendIndividualProto({ + identifier, + proto, + timestamp, + contentHint: ContentHint.RESENDABLE, + options, + }), + { + messageIds: [], + sendType: 'resetSession', + } + ).catch(logError('resetSession/sendToContact error:')); }) - .then(async () => - window.textsecure.storage.protocol + .then(async result => { + await window.textsecure.storage.protocol .archiveAllSessions(identifier) - .catch(logError('resetSession/archiveAllSessions2 error:')) - ); + .catch(logError('resetSession/archiveAllSessions2 error:')); + + return result; + }); const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); @@ -1694,7 +1676,12 @@ export default class MessageSender { options, }).catch(logError('resetSession/sendSync error:')); - return Promise.all([sendToContactPromise, sendSyncPromise]); + const responses = await Promise.all([ + sendToContactPromise, + sendSyncPromise, + ]); + + return responses[0]; } async sendExpirationTimerUpdateToIdentifier( @@ -1714,17 +1701,19 @@ export default class MessageSender { profileKey, flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, }, - contentHint: ContentHint.DEFAULT, + contentHint: ContentHint.RESENDABLE, groupId: undefined, options, }); } async sendRetryRequest({ + groupId, options, plaintext, uuid, }: { + groupId?: string; options?: SendOptionsType; plaintext: PlaintextContent; uuid: string; @@ -1735,29 +1724,99 @@ export default class MessageSender { timestamp: Date.now(), recipients: [uuid], proto: plaintext, - contentHint: ContentHint.IMPLICIT, - groupId: undefined, + contentHint: ContentHint.DEFAULT, + groupId, options, }); } // Group sends + // Used to ensure that when we send to a group the old way, we save to the send log as + // we send to each recipient. Then we don't have a long delay between the first send + // and the final save to the database with all recipients. + makeSendLogCallback({ + contentHint, + messageId, + proto, + sendType, + timestamp, + }: { + contentHint: number; + messageId?: string; + proto: Buffer; + sendType: SendTypesType; + timestamp: number; + }): SendLogCallbackType { + let initialSavePromise: Promise; + + return async ({ + identifier, + deviceIds, + }: { + identifier: string; + deviceIds: Array; + }) => { + if (!shouldSaveProto(sendType)) { + return; + } + + const conversation = window.ConversationController.get(identifier); + if (!conversation) { + window.log.warn( + `makeSendLogCallback: Unable to find conversation for identifier ${identifier}` + ); + return; + } + const recipientUuid = conversation.get('uuid'); + if (!recipientUuid) { + window.log.warn( + `makeSendLogCallback: Conversation ${conversation.idForLogging()} had no UUID` + ); + return; + } + + if (!initialSavePromise) { + initialSavePromise = window.Signal.Data.insertSentProto( + { + timestamp, + proto, + contentHint, + }, + { + recipients: { [recipientUuid]: deviceIds }, + messageIds: messageId ? [messageId] : [], + } + ); + await initialSavePromise; + } else { + const id = await initialSavePromise; + await window.Signal.Data.insertProtoRecipients({ + id, + recipientUuid, + deviceIds, + }); + } + }; + } + // No functions should really call this; since most group sends are now via Sender Key async sendGroupProto({ - recipients, - proto, - timestamp = Date.now(), contentHint, groupId, options, + proto, + recipients, + sendLogCallback, + timestamp = Date.now(), }: { - recipients: Array; - proto: Proto.Content; - timestamp: number; contentHint: number; groupId: string | undefined; options?: SendOptionsType; + proto: Proto.Content; + recipients: Array; + sendLogCallback?: SendLogCallbackType; + timestamp: number; }): Promise { const dataMessage = proto.dataMessage ? typedArrayToArrayBuffer( @@ -1790,13 +1849,14 @@ export default class MessageSender { }; this.sendMessageProto({ - timestamp, - recipients: identifiers, - proto, + callback, contentHint, groupId, - callback, options, + proto, + recipients: identifiers, + sendLogCallback, + timestamp, }); }); } @@ -1846,19 +1906,31 @@ export default class MessageSender { options?: SendOptionsType ): Promise { const contentMessage = new Proto.Content(); + const timestamp = Date.now(); const senderKeyDistributionMessage = await this.getSenderKeyDistributionMessage( distributionId ); contentMessage.senderKeyDistributionMessage = senderKeyDistributionMessage.serialize(); + const sendLogCallback = + identifiers.length > 1 + ? this.makeSendLogCallback({ + contentHint, + proto: Buffer.from(Proto.Content.encode(contentMessage).finish()), + sendType: 'senderKeyDistributionMessage', + timestamp, + }) + : undefined; + return this.sendGroupProto({ - recipients: identifiers, - proto: contentMessage, - timestamp: Date.now(), contentHint, groupId, options, + proto: contentMessage, + recipients: identifiers, + sendLogCallback, + timestamp, }); } @@ -1869,6 +1941,7 @@ export default class MessageSender { groupIdentifiers: Array, options?: SendOptionsType ): Promise { + const timestamp = Date.now(); const proto = new Proto.Content({ dataMessage: { group: { @@ -1879,13 +1952,26 @@ export default class MessageSender { }); const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + const contentHint = ContentHint.RESENDABLE; + const sendLogCallback = + groupIdentifiers.length > 1 + ? this.makeSendLogCallback({ + contentHint, + proto: Buffer.from(Proto.Content.encode(proto).finish()), + sendType: 'legacyGroupChange', + timestamp, + }) + : undefined; + return this.sendGroupProto({ - recipients: groupIdentifiers, - proto, - timestamp: Date.now(), - contentHint: ContentHint.DEFAULT, + contentHint, groupId: undefined, // only for GV2 ids options, + proto, + recipients: groupIdentifiers, + sendLogCallback, + timestamp, }); } @@ -1913,6 +1999,7 @@ export default class MessageSender { type: Proto.GroupContext.Type.DELIVER, }, }; + const proto = await this.getContentMessage(messageOptions); if (recipients.length === 0) { return Promise.resolve({ @@ -1925,11 +2012,25 @@ export default class MessageSender { } const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - return this.sendMessage({ - messageOptions, - contentHint: ContentHint.DEFAULT, + const contentHint = ContentHint.RESENDABLE; + const sendLogCallback = + groupIdentifiers.length > 1 + ? this.makeSendLogCallback({ + contentHint, + proto: Buffer.from(Proto.Content.encode(proto).finish()), + sendType: 'expirationTimerUpdate', + timestamp, + }) + : undefined; + + return this.sendGroupProto({ + contentHint, groupId: undefined, // only for GV2 ids options, + proto, + recipients, + sendLogCallback, + timestamp, }); } diff --git a/ts/textsecure/SyncRequest.ts b/ts/textsecure/SyncRequest.ts index 66f8ee980..6043c3c72 100644 --- a/ts/textsecure/SyncRequest.ts +++ b/ts/textsecure/SyncRequest.ts @@ -11,6 +11,8 @@ import MessageReceiver from './MessageReceiver'; import { ContactSyncEvent, GroupSyncEvent } from './messageReceiverEvents'; import MessageSender from './SendMessage'; import { assert } from '../util/assert'; +import { getSendOptions } from '../util/getSendOptions'; +import { handleMessageSend } from '../util/handleMessageSend'; class SyncRequestInner extends EventTarget { private started = false; @@ -61,25 +63,41 @@ class SyncRequestInner extends EventTarget { const { sender } = this; - const ourNumber = window.textsecure.storage.user.getNumber(); - const { - wrap, - sendOptions, - } = await window.ConversationController.prepareForSend(ourNumber, { + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { syncMessage: true, }); + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'SyncRequest.start: We are primary device; returning early' + ); + return; + } + window.log.info('SyncRequest created. Sending config sync request...'); - wrap(sender.sendRequestConfigurationSyncMessage(sendOptions)); + handleMessageSend(sender.sendRequestConfigurationSyncMessage(sendOptions), { + messageIds: [], + sendType: 'otherSync', + }); window.log.info('SyncRequest now sending block sync request...'); - wrap(sender.sendRequestBlockSyncMessage(sendOptions)); + handleMessageSend(sender.sendRequestBlockSyncMessage(sendOptions), { + messageIds: [], + sendType: 'otherSync', + }); window.log.info('SyncRequest now sending contact sync message...'); - wrap(sender.sendRequestContactSyncMessage(sendOptions)) + handleMessageSend(sender.sendRequestContactSyncMessage(sendOptions), { + messageIds: [], + sendType: 'otherSync', + }) .then(() => { window.log.info('SyncRequest now sending group sync message...'); - return wrap(sender.sendRequestGroupSyncMessage(sendOptions)); + return handleMessageSend( + sender.sendRequestGroupSyncMessage(sendOptions), + { messageIds: [], sendType: 'otherSync' } + ); }) .catch((error: Error) => { window.log.error( diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts index 0662fcf41..f3e35e5fa 100644 --- a/ts/textsecure/Types.d.ts +++ b/ts/textsecure/Types.d.ts @@ -75,6 +75,7 @@ export type ProcessedEnvelope = Readonly<{ content?: Uint8Array; serverGuid: string; serverTimestamp: number; + groupId?: string; }>; export type ProcessedAttachment = { diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index d7c8fbfff..511f8f356 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -219,6 +219,7 @@ export type ReadEventData = Readonly<{ envelopeTimestamp: number; source?: string; sourceUuid?: string; + sourceDevice?: number; }>; export class ReadEvent extends ConfirmableEvent { diff --git a/ts/types/Reactions.ts b/ts/types/Reactions.ts index 67996c9e1..9fcee6018 100644 --- a/ts/types/Reactions.ts +++ b/ts/types/Reactions.ts @@ -5,6 +5,7 @@ export type ReactionType = Readonly<{ conversationId: string; emoji: string; fromId: string; + messageId: string | undefined; messageReceivedAt: number; targetAuthorUuid: string; targetTimestamp: number; diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index a0f21bf76..5bf4f167f 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -1,7 +1,11 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { isNumber } from 'lodash'; import { CallbackResultType } from '../textsecure/SendMessage'; +import dataInterface from '../sql/Client'; + +const { insertSentProto } = dataInterface; export const SEALED_SENDER = { UNKNOWN: 0, @@ -10,17 +14,71 @@ export const SEALED_SENDER = { UNRESTRICTED: 3, }; +export type SendTypesType = + | 'callingMessage' // excluded from send log + | 'deleteForEveryone' + | 'deliveryReceipt' + | 'expirationTimerUpdate' + | 'groupChange' + | 'legacyGroupChange' + | 'message' + | 'messageRetry' + | 'nullMessage' // excluded from send log + | 'otherSync' + | 'profileKeyUpdate' + | 'reaction' + | 'readReceipt' + | 'readSync' + | 'resendFromLog' // excluded from send log + | 'resetSession' + | 'retryRequest' // excluded from send log + | 'senderKeyDistributionMessage' + | 'sentSync' + | 'typing' // excluded from send log + | 'verificationSync' + | 'viewOnceSync'; + +export function shouldSaveProto(sendType: SendTypesType): boolean { + if (sendType === 'callingMessage') { + return false; + } + + if (sendType === 'nullMessage') { + return false; + } + + if (sendType === 'resendFromLog') { + return false; + } + + if (sendType === 'retryRequest') { + return false; + } + + if (sendType === 'typing') { + return false; + } + + return true; +} + export async function handleMessageSend( - promise: Promise -): Promise { + promise: Promise, + options: { + messageIds: Array; + sendType: SendTypesType; + } +): Promise { try { const result = await promise; - if (result) { - await handleMessageSendResult( - result.failoverIdentifiers, - result.unidentifiedDeliveries - ); - } + + await maybeSaveToSendLog(result, options); + + await handleMessageSendResult( + result.failoverIdentifiers, + result.unidentifiedDeliveries + ); + return result; } catch (err) { if (err) { @@ -84,3 +142,52 @@ async function handleMessageSendResult( }) ); } + +async function maybeSaveToSendLog( + result: CallbackResultType, + { + messageIds, + sendType, + }: { + messageIds: Array; + sendType: SendTypesType; + } +): Promise { + 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, + } + ); +} diff --git a/ts/util/handleRetry.ts b/ts/util/handleRetry.ts new file mode 100644 index 000000000..e1c46d576 --- /dev/null +++ b/ts/util/handleRetry.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + requestGroupId?: string; +}): Promise { + 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; + 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 }); + }); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 432554a5f..fc19473c7 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14105,20 +14105,6 @@ "updated": "2021-01-21T23:06:13.270Z", "reasonDetail": "Doesn't manipulate the DOM." }, - { - "rule": "jQuery-wrap(", - "path": "ts/shims/textsecure.js", - "line": " wrap(textsecure.messaging.sendStickerPackSync([", - "reasonCategory": "falseMatch", - "updated": "2020-02-07T19:52:28.522Z" - }, - { - "rule": "jQuery-wrap(", - "path": "ts/shims/textsecure.ts", - "line": " wrap(", - "reasonCategory": "falseMatch", - "updated": "2020-02-07T19:52:28.522Z" - }, { "rule": "jQuery-load(", "path": "ts/types/Stickers.js", diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index 7fa8a6f9e..2768aa2b3 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -3,8 +3,10 @@ import { ConversationAttributesType } from '../model-types.d'; import { handleMessageSend } from './handleMessageSend'; +import { getSendOptions } from './getSendOptions'; import { sendReadReceiptsFor } from './sendReadReceiptsFor'; import { hasErrors } from '../state/selectors/message'; +import { isNotNil } from './isNotNil'; export async function markConversationRead( conversationAttrs: ConversationAttributesType, @@ -43,6 +45,7 @@ export async function markConversationRead( const unreadReactionSyncData = new Map< string, { + messageId?: string; senderUuid?: string; senderE164?: string; timestamp: number; @@ -54,6 +57,7 @@ export async function markConversationRead( return; } unreadReactionSyncData.set(targetKey, { + messageId: reaction.messageId, senderE164: undefined, senderUuid: reaction.targetAuthorUuid, timestamp: reaction.targetTimestamp, @@ -68,6 +72,7 @@ export async function markConversationRead( } return { + messageId: messageSyncData.id, senderE164: messageSyncData.source, senderUuid: messageSyncData.sourceUuid, senderId: window.ConversationController.ensureContactIds({ @@ -89,25 +94,39 @@ export async function markConversationRead( item => Boolean(item.senderId) && !item.hasErrors ); - const readSyncs = [ + const readSyncs: Array<{ + messageId?: string; + senderE164?: string; + senderUuid?: string; + senderId?: string; + timestamp: number; + hasErrors?: string; + }> = [ ...unreadMessagesSyncData, ...Array.from(unreadReactionSyncData.values()), ]; + const messageIds = readSyncs.map(item => item.messageId).filter(isNotNil); if (readSyncs.length && options.sendReadReceipts) { window.log.info(`Sending ${readSyncs.length} read syncs`); // Because syncReadMessages sends to our other devices, and sendReadReceipts goes // to a contact, we need accessKeys for both. - const { - sendOptions, - } = await window.ConversationController.prepareForSend( - window.ConversationController.getOurConversationId(), - { syncMessage: true } - ); + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); + + if (window.ConversationController.areWePrimaryDevice()) { + window.log.warn( + 'markConversationRead: We are primary device; not sending read syncs' + ); + } else { + await handleMessageSend( + window.textsecure.messaging.syncReadMessages(readSyncs, sendOptions), + { messageIds, sendType: 'readSync' } + ); + } - await handleMessageSend( - window.textsecure.messaging.syncReadMessages(readSyncs, sendOptions) - ); await sendReadReceiptsFor(conversationAttrs, unreadMessagesSyncData); } diff --git a/ts/util/retryPlaceholders.ts b/ts/util/retryPlaceholders.ts index acab92032..ac5824def 100644 --- a/ts/util/retryPlaceholders.ts +++ b/ts/util/retryPlaceholders.ts @@ -63,14 +63,14 @@ export class RetryPlaceholders { } this.items = parsed.success ? parsed.data : []; - window.log.info( - `RetryPlaceholders.constructor: Started with ${this.items.length} items` - ); - this.sortByExpiresAtAsc(); this.byConversation = this.makeByConversationLookup(); this.byMessage = this.makeByMessageLookup(); this.retryReceiptLifespan = options.retryReceiptLifespan || HOUR; + + window.log.info( + `RetryPlaceholders.constructor: Started with ${this.items.length} items, lifespan of ${this.retryReceiptLifespan}` + ); } // Arranging local data for efficiency diff --git a/ts/util/sendReadReceiptsFor.ts b/ts/util/sendReadReceiptsFor.ts index 526af7f7e..af34bcc00 100644 --- a/ts/util/sendReadReceiptsFor.ts +++ b/ts/util/sendReadReceiptsFor.ts @@ -7,9 +7,18 @@ import { getSendOptions } from './getSendOptions'; import { handleMessageSend } from './handleMessageSend'; import { isConversationAccepted } from './isConversationAccepted'; +type ReceiptSpecType = { + messageId: string; + senderE164?: string; + senderUuid?: string; + senderId?: string; + timestamp: number; + hasErrors: boolean; +}; + export async function sendReadReceiptsFor( conversationAttrs: ConversationAttributesType, - items: Array + items: Array ): Promise { // Only send read receipts for accepted conversations if ( @@ -22,7 +31,8 @@ export async function sendReadReceiptsFor( await Promise.all( map(receiptsBySender, async (receipts, senderId) => { - const timestamps = map(receipts, 'timestamp'); + const timestamps = map(receipts, item => item.timestamp); + const messageIds = map(receipts, item => item.messageId); const conversation = window.ConversationController.get(senderId); if (conversation) { @@ -34,7 +44,8 @@ export async function sendReadReceiptsFor( senderUuid: conversation.get('uuid')!, timestamps, options: sendOptions, - }) + }), + { messageIds, sendType: 'readReceipt' } ); } }) diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index c04545e10..5701c0a99 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { differenceWith, partition } from 'lodash'; +import { differenceWith, omit, partition } from 'lodash'; import PQueue from 'p-queue'; import { @@ -16,6 +16,7 @@ import { senderCertificateService } from '../services/senderCertificate'; import { padMessage, SenderCertificateMode, + SendLogCallbackType, } from '../textsecure/OutgoingMessage'; import { isEnabled } from '../RemoteConfig'; @@ -30,7 +31,12 @@ import { ConversationModel } from '../models/conversations'; import { DeviceType } from '../textsecure/Types.d'; import { getKeysForIdentifier } from '../textsecure/getKeysForIdentifier'; import { ConversationAttributesType } from '../model-types.d'; -import { SEALED_SENDER } from './handleMessageSend'; +import { + handleMessageSend, + SEALED_SENDER, + SendTypesType, + shouldSaveProto, +} from './handleMessageSend'; import { parseIntOrThrow } from './parseIntOrThrow'; import { multiRecipient200ResponseSchema, @@ -59,17 +65,21 @@ const FIXMEU8 = Uint8Array; // Public API: export async function sendToGroup({ - groupSendOptions, - conversation, contentHint, - sendOptions, + conversation, + groupSendOptions, + messageId, isPartialSend, + sendOptions, + sendType, }: { - groupSendOptions: GroupSendOptionsType; - conversation: ConversationModel; contentHint: number; - sendOptions?: SendOptionsType; + conversation: ConversationModel; + groupSendOptions: GroupSendOptionsType; isPartialSend?: boolean; + messageId: string | undefined; + sendOptions?: SendOptionsType; + sendType: SendTypesType; }): Promise { assert( window.textsecure.messaging, @@ -92,8 +102,10 @@ export async function sendToGroup({ contentMessage, conversation, isPartialSend, + messageId, recipients, sendOptions, + sendType, timestamp, }); } @@ -103,18 +115,22 @@ export async function sendContentMessageToGroup({ contentMessage, conversation, isPartialSend, + messageId, online, recipients, sendOptions, + sendType, timestamp, }: { contentHint: number; contentMessage: Proto.Content; conversation: ConversationModel; isPartialSend?: boolean; + messageId: string | undefined; online?: boolean; recipients: Array; sendOptions?: SendOptionsType; + sendType: SendTypesType; timestamp: number; }): Promise { const logId = conversation.idForLogging(); @@ -127,7 +143,7 @@ export async function sendContentMessageToGroup({ const ourConversation = window.ConversationController.get(ourConversationId); if ( - isEnabled('desktop.sendSenderKey') && + isEnabled('desktop.sendSenderKey2') && ourConversation?.get('capabilities')?.senderKey && isGroupV2(conversation.attributes) ) { @@ -137,10 +153,12 @@ export async function sendContentMessageToGroup({ contentMessage, conversation, isPartialSend, + messageId, online, recipients, recursionCount: 0, sendOptions, + sendType, timestamp, }); } catch (error) { @@ -151,16 +169,24 @@ export async function sendContentMessageToGroup({ } } + const sendLogCallback = window.textsecure.messaging.makeSendLogCallback({ + contentHint, + messageId, + proto: Buffer.from(Proto.Content.encode(contentMessage).finish()), + sendType, + timestamp, + }); const groupId = isGroupV2(conversation.attributes) ? conversation.get('groupId') : undefined; return window.textsecure.messaging.sendGroupProto({ - recipients, - proto: contentMessage, - timestamp, contentHint, groupId, options: { ...sendOptions, online }, + proto: contentMessage, + recipients, + sendLogCallback, + timestamp, }); } @@ -171,10 +197,12 @@ export async function sendToGroupViaSenderKey(options: { contentMessage: Proto.Content; conversation: ConversationModel; isPartialSend?: boolean; + messageId: string | undefined; online?: boolean; recipients: Array; recursionCount: number; sendOptions?: SendOptionsType; + sendType: SendTypesType; timestamp: number; }): Promise { const { @@ -182,10 +210,12 @@ export async function sendToGroupViaSenderKey(options: { contentMessage, conversation, isPartialSend, + messageId, online, recursionCount, recipients, sendOptions, + sendType, timestamp, } = options; const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; @@ -287,12 +317,16 @@ export async function sendToGroupViaSenderKey(options: { currentDevices, device => isValidSenderKeyRecipient(conversation, device.identifier) ); + + const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey); + const normalSendRecipients = getUuidsFromDevices(devicesForNormalSend); window.log.info( - `sendToGroupViaSenderKey/${logId}: ${devicesForSenderKey.length} devices for sender key, ${devicesForNormalSend.length} devices for normal send` + `sendToGroupViaSenderKey/${logId}:` + + ` ${senderKeyRecipients.length} accounts for sender key (${devicesForSenderKey.length} devices),` + + ` ${normalSendRecipients.length} accounts for normal send (${devicesForNormalSend.length} devices)` ); // 5. Ensure we have enough recipients - const senderKeyRecipients = getUuidsFromDevices(devicesForSenderKey); if (senderKeyRecipients.length < 2) { throw new Error( `sendToGroupViaSenderKey/${logId}: Not enough recipients for Sender Key message. Failing over.` @@ -335,14 +369,17 @@ export async function sendToGroupViaSenderKey(options: { newToMemberUuids.length } members: ${JSON.stringify(newToMemberUuids)}` ); - await window.textsecure.messaging.sendSenderKeyDistributionMessage( - { - contentHint: ContentHint.DEFAULT, - distributionId, - groupId, - identifiers: newToMemberUuids, - }, - sendOptions + await handleMessageSend( + window.textsecure.messaging.sendSenderKeyDistributionMessage( + { + contentHint: ContentHint.RESENDABLE, + distributionId, + groupId, + identifiers: newToMemberUuids, + }, + sendOptions + ), + { messageIds: [], sendType: 'senderKeyDistributionMessage' } ); } @@ -368,6 +405,14 @@ export async function sendToGroupViaSenderKey(options: { } // 10. Send the Sender Key message! + let sendLogId: number; + let senderKeyRecipientsWithDevices: Record> = {}; + devicesForSenderKey.forEach(item => { + const { id, identifier } = item; + senderKeyRecipientsWithDevices[identifier] ||= []; + senderKeyRecipientsWithDevices[identifier].push(id); + }); + try { const messageBuffer = await encryptForSenderKey({ contentHint, @@ -397,6 +442,11 @@ export async function sendToGroupViaSenderKey(options: { ), }); } + + senderKeyRecipientsWithDevices = omit( + senderKeyRecipientsWithDevices, + uuids404 || [] + ); } else { window.log.error( `sendToGroupViaSenderKey/${logId}: Server returned unexpected 200 response ${JSON.stringify( @@ -404,6 +454,20 @@ export async function sendToGroupViaSenderKey(options: { )}` ); } + + if (shouldSaveProto(sendType)) { + sendLogId = await window.Signal.Data.insertSentProto( + { + contentHint, + proto: Buffer.from(Proto.Content.encode(contentMessage).finish()), + timestamp, + }, + { + recipients: senderKeyRecipientsWithDevices, + messageIds: messageId ? [messageId] : [], + } + ); + } } catch (error) { if (error.code === ERROR_EXPIRED_OR_MISSING_DEVICES) { await handle409Response(logId, error); @@ -426,13 +490,14 @@ export async function sendToGroupViaSenderKey(options: { } throw new Error( - `sendToGroupViaSenderKey/${logId}: Returned unexpected error ${error.code}. Failing over.` + `sendToGroupViaSenderKey/${logId}: Returned unexpected error ${ + error.code + }. Failing over. ${error.stack || error}` ); } // 11. Return early if there are no normal send recipients - const normalRecipients = getUuidsFromDevices(devicesForNormalSend); - if (normalRecipients.length === 0) { + if (normalSendRecipients.length === 0) { return { dataMessage: contentMessage.dataMessage ? toArrayBuffer( @@ -441,18 +506,59 @@ export async function sendToGroupViaSenderKey(options: { : undefined, successfulIdentifiers: senderKeyRecipients, unidentifiedDeliveries: senderKeyRecipients, + + contentHint, + timestamp, + contentProto: Buffer.from(Proto.Content.encode(contentMessage).finish()), + recipients: senderKeyRecipientsWithDevices, }; } // 12. Send normal message to the leftover normal recipients. Then combine normal send // result with result from sender key send for final return value. + + // We don't want to use a normal send log callback here, because the proto has already + // been saved as part of the Sender Key send. We're just adding recipients here. + const sendLogCallback: SendLogCallbackType = async ({ + identifier, + deviceIds, + }: { + identifier: string; + deviceIds: Array; + }) => { + if (!shouldSaveProto(sendType)) { + return; + } + + const sentToConversation = window.ConversationController.get(identifier); + if (!sentToConversation) { + window.log.warn( + `sendToGroupViaSenderKey/callback: Unable to find conversation for identifier ${identifier}` + ); + return; + } + const recipientUuid = sentToConversation.get('uuid'); + if (!recipientUuid) { + window.log.warn( + `sendToGroupViaSenderKey/callback: Conversation ${conversation.idForLogging()} had no UUID` + ); + return; + } + + await window.Signal.Data.insertProtoRecipients({ + id: sendLogId, + recipientUuid, + deviceIds, + }); + }; const normalSendResult = await window.textsecure.messaging.sendGroupProto({ - recipients: normalRecipients, - proto: contentMessage, - timestamp, contentHint, groupId, options: { ...sendOptions, online }, + proto: contentMessage, + recipients: normalSendRecipients, + sendLogCallback, + timestamp, }); return { @@ -471,6 +577,14 @@ export async function sendToGroupViaSenderKey(options: { ...(normalSendResult.unidentifiedDeliveries || []), ...senderKeyRecipients, ], + + contentHint, + timestamp, + contentProto: Buffer.from(Proto.Content.encode(contentMessage).finish()), + recipients: { + ...normalSendResult.recipients, + ...senderKeyRecipientsWithDevices, + }, }; } diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index a8e8b84ed..e5a69978f 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -202,6 +202,24 @@ Whisper.TapToViewExpiredOutgoingToast = Whisper.ToastView.extend({ }, }); +Whisper.DecryptionErrorToast = Whisper.ToastView.extend({ + className: 'toast toast-clickable', + initialize() { + this.timeout = 10000; + }, + events: { + click: 'onClick', + }, + render_attributes() { + return { + toastMessage: window.i18n('decryptionErrorToast'), + }; + }, + onClick() { + window.showDebugLog(); + }, +}); + Whisper.FileSavedToast = Whisper.ToastView.extend({ className: 'toast toast-clickable', initialize(options: any) { @@ -2939,7 +2957,10 @@ Whisper.ConversationView = Whisper.View.extend({ okText: window.i18n('delete'), resolve: async () => { try { - await this.model.sendDeleteForEveryoneMessage(message.get('sent_at')); + await this.model.sendDeleteForEveryoneMessage({ + id: message.id, + timestamp: message.get('sent_at'), + }); } catch (error) { window.log.error( 'Error sending delete-for-everyone', @@ -3673,6 +3694,7 @@ Whisper.ConversationView = Whisper.View.extend({ } await this.model.sendReactionMessage(reaction, { + messageId, targetAuthorUuid: messageModel.getSourceUuid(), targetTimestamp: messageModel.get('sent_at'), }); diff --git a/ts/window.d.ts b/ts/window.d.ts index 8f928056f..44d5fe562 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -16,7 +16,6 @@ import { MessageModelCollectionType, MessageAttributesType, ReactionAttributesType, - ReactionModelType, } from './model-types.d'; import { TextSecureType } from './textsecure.d'; import { Storage } from './textsecure/Storage'; @@ -241,6 +240,7 @@ declare global { showWindow: () => void; showSettings: () => void; shutdown: () => void; + showDebugLog: () => void; sendChallengeRequest: (request: IPCChallengeRequest) => void; setAutoHideMenuBar: (value: WhatIsThis) => void; setBadgeCount: (count: number) => void; @@ -290,6 +290,7 @@ declare global { onTimeout: (timestamp: number, cb: () => void, id?: string) => string; removeTimeout: (uuid: string) => void; retryPlaceholders?: Util.RetryPlaceholders; + lightSessionResetQueue?: PQueue; runStorageServiceSyncJob: () => Promise; storageServiceUploadJob: () => void; }; @@ -494,6 +495,7 @@ declare global { GV2_ENABLE_STATE_PROCESSING: boolean; GV2_MIGRATION_DISABLE_ADD: boolean; GV2_MIGRATION_DISABLE_INVITE: boolean; + RETRY_DELAY: boolean; } // We want to extend `Error`, so we need an interface. @@ -536,6 +538,13 @@ export class CanvasVideoRenderer { constructor(canvas: Ref); } +export type DeliveryReceiptBatcherItemType = { + messageId: string; + source?: string; + sourceUuid?: string; + timestamp: number; +}; + export type LoggerType = { fatal: LogFunctionType; info: LogFunctionType; @@ -614,12 +623,8 @@ export type WhisperType = { ExpiringMessagesListener: WhatIsThis; TapToViewMessagesListener: WhatIsThis; - deliveryReceiptQueue: PQueue; - deliveryReceiptBatcher: BatcherType<{ - source?: string; - sourceUuid?: string; - timestamp: number; - }>; + deliveryReceiptQueue: PQueue; + deliveryReceiptBatcher: BatcherType; RotateSignedPreKeyListener: WhatIsThis; AlreadyGroupMemberToast: typeof window.Whisper.ToastView; @@ -630,6 +635,7 @@ export type WhisperType = { CaptchaSolvedToast: typeof window.Whisper.ToastView; CaptchaFailedToast: typeof window.Whisper.ToastView; DangerousFileTypeToast: typeof window.Whisper.ToastView; + DecryptionErrorToast: typeof window.Whisper.ToastView; ExpiredToast: typeof window.Whisper.ToastView; FileSavedToast: typeof window.Whisper.ToastView; FileSizeToast: any;