Attachment backfill

This commit is contained in:
Fedor Indutny
2025-03-24 23:34:58 -07:00
committed by GitHub
parent c94849d3a1
commit b3c7b48d1c
40 changed files with 1793 additions and 357 deletions

View File

@@ -1602,6 +1602,18 @@
"messageformat": "Cant download view once media. {name} will need to send it again.", "messageformat": "Cant download view once media. {name} will need to send it again.",
"description": "Body text for info dialog for messages with old view once media which are no longer available for download." "description": "Body text for info dialog for messages with old view once media which are no longer available for download."
}, },
"icu:BackfillFailureModal__title": {
"messageformat": "Cant download media",
"description": "Title for modal saying that message's attachments cannot be downloaded from the primary device"
},
"icu:BackfillFailureModal__body--timeout": {
"messageformat": "Check your desktop and phones internet connection. Open Signal on your phone, then try downloading again.",
"description": "Body for modal saying that message's attachments cannot be downloaded from the primary device due to timeout"
},
"icu:BackfillFailureModal__body--not-found": {
"messageformat": "This media is no longer available on your phone and cant be downloaded.",
"description": "Body for modal saying that message's attachments cannot be downloaded from the primary device due to message not being available anymore"
},
"icu:save": { "icu:save": {
"messageformat": "Save", "messageformat": "Save",
"description": "Used on save buttons" "description": "Used on save buttons"

View File

@@ -222,7 +222,7 @@
"@indutny/parallel-prettier": "3.0.0", "@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1", "@indutny/rezip-electron": "2.0.1",
"@napi-rs/canvas": "0.1.61", "@napi-rs/canvas": "0.1.61",
"@signalapp/mock-server": "11.1.0", "@signalapp/mock-server": "11.2.0",
"@storybook/addon-a11y": "8.4.4", "@storybook/addon-a11y": "8.4.4",
"@storybook/addon-actions": "8.4.4", "@storybook/addon-actions": "8.4.4",
"@storybook/addon-controls": "8.4.4", "@storybook/addon-controls": "8.4.4",

10
pnpm-lock.yaml generated
View File

@@ -435,8 +435,8 @@ importers:
specifier: 0.1.61 specifier: 0.1.61
version: 0.1.61 version: 0.1.61
'@signalapp/mock-server': '@signalapp/mock-server':
specifier: 11.1.0 specifier: 11.2.0
version: 11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) version: 11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y': '@storybook/addon-a11y':
specifier: 8.4.4 specifier: 8.4.4
version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10)) version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10))
@@ -2543,8 +2543,8 @@ packages:
'@signalapp/libsignal-client@0.67.4': '@signalapp/libsignal-client@0.67.4':
resolution: {integrity: sha512-nenGxomG2zH0uCoFSwBzofqSAHnJRdbIbLr8libGy9y3rCL2z62nHL79Kh1o46ZnzxgAA7Ay3/qMhwPcXq7Iig==} resolution: {integrity: sha512-nenGxomG2zH0uCoFSwBzofqSAHnJRdbIbLr8libGy9y3rCL2z62nHL79Kh1o46ZnzxgAA7Ay3/qMhwPcXq7Iig==}
'@signalapp/mock-server@11.1.0': '@signalapp/mock-server@11.2.0':
resolution: {integrity: sha512-SYYek3QCh57vZZGNVQW+fSXMr+xHjnO8sMAFfj8DovjqW4HIBWbt3itGyyjxSoIXnaCMK346O6I/R79w8xY/aw==} resolution: {integrity: sha512-y8bueRcXVulyXRRVm2M/qT7YmxGpUbiwQsRSi7a+DDI4aUeZIDW9z7KgjElv1CN1/n9O6M1bYO+TLy4ys+7U6w==}
'@signalapp/parchment-cjs@3.0.1': '@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==} resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@@ -12260,7 +12260,7 @@ snapshots:
type-fest: 4.26.1 type-fest: 4.26.1
uuid: 8.3.2 uuid: 8.3.2
'@signalapp/mock-server@11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': '@signalapp/mock-server@11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies: dependencies:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3) '@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.60.2 '@signalapp/libsignal-client': 0.60.2

View File

@@ -631,22 +631,6 @@ message SyncMessage {
} }
message DeleteForMe { message DeleteForMe {
message ConversationIdentifier {
oneof identifier {
string threadServiceId = 1;
bytes threadGroupId = 2;
string threadE164 = 3;
}
}
message AddressableMessage {
oneof author {
string authorServiceId = 1;
string authorE164 = 2;
}
optional uint64 sentTimestamp = 3;
}
message MessageDeletes { message MessageDeletes {
optional ConversationIdentifier conversation = 1; optional ConversationIdentifier conversation = 1;
repeated AddressableMessage messages = 2; repeated AddressableMessage messages = 2;
@@ -685,6 +669,42 @@ message SyncMessage {
optional uint32 deviceId = 2; optional uint32 deviceId = 2;
} }
message AttachmentBackfillRequest {
optional AddressableMessage targetMessage = 1;
optional ConversationIdentifier targetConversation = 2;
}
message AttachmentBackfillResponse {
message AttachmentData {
enum Status {
PENDING = 0;
TERMINAL_ERROR = 1;
}
oneof data {
AttachmentPointer attachment = 1;
Status status = 2;
}
}
enum Error {
MESSAGE_NOT_FOUND = 0;
}
message AttachmentDataList {
repeated AttachmentData attachments = 1;
optional AttachmentData longText = 2;
}
optional AddressableMessage targetMessage = 1;
optional ConversationIdentifier targetConversation = 2;
oneof data {
AttachmentDataList attachments = 3;
Error error = 4;
}
}
optional Sent sent = 1; optional Sent sent = 1;
optional Contacts contacts = 2; optional Contacts contacts = 2;
reserved /*groups*/ 3; reserved /*groups*/ 3;
@@ -707,6 +727,8 @@ message SyncMessage {
optional CallLogEvent callLogEvent = 21; optional CallLogEvent callLogEvent = 21;
optional DeleteForMe deleteForMe = 22; optional DeleteForMe deleteForMe = 22;
optional DeviceNameChange deviceNameChange = 23; optional DeviceNameChange deviceNameChange = 23;
optional AttachmentBackfillRequest attachmentBackfillRequest = 24;
optional AttachmentBackfillResponse attachmentBackfillResponse = 25;
} }
message AttachmentPointer { message AttachmentPointer {
@@ -807,3 +829,19 @@ message BodyRange {
Style style = 4; Style style = 4;
} }
} }
message AddressableMessage {
oneof author {
string authorServiceId = 1;
string authorE164 = 2;
}
optional uint64 sentTimestamp = 3;
}
message ConversationIdentifier {
oneof identifier {
string threadServiceId = 1;
bytes threadGroupId = 2;
string threadE164 = 3;
}
}

View File

@@ -0,0 +1,22 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
@use '../variables';
.BackfillFailureModal__width-container {
max-width: 440px;
}
.BackfillFailureModal__body {
padding-block: 16px 0;
padding-inline: 16px;
}
.BackfillFailureModal .module-Button {
padding-inline: 24px;
}
.BackfillFailureModal .module-Modal__button-footer {
padding-block-start: 18px;
}

View File

@@ -34,6 +34,7 @@
@use 'components/AvatarModalButtons.scss'; @use 'components/AvatarModalButtons.scss';
@use 'components/AvatarPreview.scss'; @use 'components/AvatarPreview.scss';
@use 'components/AvatarTextEditor.scss'; @use 'components/AvatarTextEditor.scss';
@use 'components/BackfillFailureModal.scss';
@use 'components/BackupMediaDownloadProgress.scss'; @use 'components/BackupMediaDownloadProgress.scss';
@use 'components/BadgeCarouselIndex.scss'; @use 'components/BadgeCarouselIndex.scss';
@use 'components/BadgeDialog.scss'; @use 'components/BadgeDialog.scss';

View File

@@ -76,6 +76,7 @@ import { LatestQueue } from './util/LatestQueue';
import { parseIntOrThrow } from './util/parseIntOrThrow'; import { parseIntOrThrow } from './util/parseIntOrThrow';
import { getProfile } from './util/getProfile'; import { getProfile } from './util/getProfile';
import type { import type {
AttachmentBackfillResponseSyncEvent,
ConfigurationEvent, ConfigurationEvent,
DeliveryEvent, DeliveryEvent,
EnvelopeQueuedEvent, EnvelopeQueuedEvent,
@@ -721,6 +722,10 @@ export async function startApp(): Promise<void> {
'deleteForMeSync', 'deleteForMeSync',
queuedEventListener(onDeleteForMeSync, false) queuedEventListener(onDeleteForMeSync, false)
); );
messageReceiver.addEventListener(
'attachmentBackfillResponseSync',
queuedEventListener(onAttachmentBackfillResponseSync, false)
);
messageReceiver.addEventListener( messageReceiver.addEventListener(
'deviceNameChangeSync', 'deviceNameChangeSync',
queuedEventListener(onDeviceNameChangeSync, false) queuedEventListener(onDeviceNameChangeSync, false)
@@ -1917,6 +1922,7 @@ export async function startApp(): Promise<void> {
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true, versionedExpirationTimer: true,
ssre2: true, ssre2: true,
attachmentBackfill: true,
}); });
} catch (error) { } catch (error) {
log.error( log.error(
@@ -3689,6 +3695,13 @@ export async function startApp(): Promise<void> {
log.info(`${logId}: Done`); log.info(`${logId}: Done`);
} }
async function onAttachmentBackfillResponseSync(
ev: AttachmentBackfillResponseSyncEvent
) {
const { confirm } = ev;
await AttachmentDownloadManager.handleBackfillResponse(ev);
confirm();
}
} }
window.startApp = startApp; window.startApp = startApp;

View File

@@ -0,0 +1,31 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './BackfillFailureModal';
import {
BackfillFailureModal,
BackfillFailureKind,
} from './BackfillFailureModal';
import type { ComponentMeta } from '../storybook/types';
const { i18n } = window.SignalContext;
export default {
title: 'Components/BackfillFailureModal',
component: BackfillFailureModal,
args: {
i18n,
onClose: action('onClose'),
kind: BackfillFailureKind.Timeout,
},
} satisfies ComponentMeta<PropsType>;
export function Timeout(args: PropsType): JSX.Element {
return <BackfillFailureModal {...args} />;
}
export function NotFound(args: PropsType): JSX.Element {
return <BackfillFailureModal {...args} kind={BackfillFailureKind.NotFound} />;
}

View File

@@ -0,0 +1,64 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import type { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { Modal } from './Modal';
import { Button, ButtonVariant } from './Button';
export enum BackfillFailureKind {
Timeout = 'Timeout',
NotFound = 'NotFound',
}
export type DataPropsType = Readonly<{
kind: BackfillFailureKind;
}>;
export type HousekeepingPropsType = Readonly<{
i18n: LocalizerType;
onClose: () => void;
}>;
export type PropsType = DataPropsType & HousekeepingPropsType;
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export function BackfillFailureModal(props: PropsType): JSX.Element {
const { i18n, kind, onClose } = props;
const footer = (
<Button onClick={onClose} ref={focusRef} variant={ButtonVariant.Primary}>
{i18n('icu:Confirmation--confirm')}
</Button>
);
let body: string;
if (kind === BackfillFailureKind.Timeout) {
body = i18n('icu:BackfillFailureModal__body--timeout');
} else if (kind === BackfillFailureKind.NotFound) {
body = i18n('icu:BackfillFailureModal__body--not-found');
} else {
throw missingCaseError(kind);
}
return (
<Modal
modalName="BackfillFailureModal"
moduleClassName="BackfillFailureModal"
title={i18n('icu:BackfillFailureModal__title')}
i18n={i18n}
onClose={onClose}
modalFooter={footer}
padded={false}
>
<div className="module-error-modal__description">{body}</div>
</Modal>
);
}

View File

@@ -27,6 +27,10 @@ import {
TapToViewNotAvailableModal, TapToViewNotAvailableModal,
type DataPropsType as TapToViewNotAvailablePropsType, type DataPropsType as TapToViewNotAvailablePropsType,
} from './TapToViewNotAvailableModal'; } from './TapToViewNotAvailableModal';
import {
BackfillFailureModal,
type DataPropsType as BackfillFailureModalPropsType,
} from './BackfillFailureModal';
// NOTE: All types should be required for this component so that the smart // NOTE: All types should be required for this component so that the smart
// component gives you type errors when adding/removing props. // component gives you type errors when adding/removing props.
@@ -124,6 +128,9 @@ export type PropsType = {
// TapToViewNotAvailableModal // TapToViewNotAvailableModal
tapToViewNotAvailableModalProps: TapToViewNotAvailablePropsType | undefined; tapToViewNotAvailableModalProps: TapToViewNotAvailablePropsType | undefined;
hideTapToViewNotAvailableModal: () => void; hideTapToViewNotAvailableModal: () => void;
// BackfillFailureModal
backfillFailureModalProps: BackfillFailureModalPropsType | undefined;
hideBackfillFailureModal: () => void;
// UserNotFoundModal // UserNotFoundModal
hideUserNotFoundModal: () => unknown; hideUserNotFoundModal: () => unknown;
userNotFoundModalState: UserNotFoundModalStateType | undefined; userNotFoundModalState: UserNotFoundModalStateType | undefined;
@@ -214,6 +221,9 @@ export function GlobalModalContainer({
// TapToViewNotAvailableModal // TapToViewNotAvailableModal
tapToViewNotAvailableModalProps, tapToViewNotAvailableModalProps,
hideTapToViewNotAvailableModal, hideTapToViewNotAvailableModal,
// BackfillFailureModal
backfillFailureModalProps,
hideBackfillFailureModal,
// UserNotFoundModal // UserNotFoundModal
hideUserNotFoundModal, hideUserNotFoundModal,
userNotFoundModalState, userNotFoundModalState,
@@ -394,5 +404,15 @@ export function GlobalModalContainer({
); );
} }
if (backfillFailureModalProps != null) {
return (
<BackfillFailureModal
i18n={i18n}
onClose={hideBackfillFailureModal}
{...backfillFailureModalProps}
/>
);
}
return null; return null;
} }

View File

@@ -1205,6 +1205,11 @@ export class Message extends React.PureComponent<Props, State> {
this.openGenericAttachment(); this.openGenericAttachment();
}} }}
tabIndex={tabIndex} tabIndex={tabIndex}
aria-label={
isDownloading(firstAttachment)
? i18n('icu:cancelDownload')
: i18n('icu:startDownload')
}
> >
<AttachmentStatusIcon <AttachmentStatusIcon
key={id} key={id}

View File

@@ -4,10 +4,12 @@ import { noop, omit, throttle } from 'lodash';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
import * as log from '../logging/log'; import * as log from '../logging/log';
import type { AttachmentBackfillResponseSyncEvent } from '../textsecure/messageReceiverEvents';
import { import {
type AttachmentDownloadJobTypeType, type AttachmentDownloadJobTypeType,
type AttachmentDownloadJobType, type AttachmentDownloadJobType,
type CoreAttachmentDownloadJobType, type CoreAttachmentDownloadJobType,
AttachmentDownloadUrgency,
coreAttachmentDownloadJobSchema, coreAttachmentDownloadJobSchema,
} from '../types/AttachmentDownload'; } from '../types/AttachmentDownload';
import { import {
@@ -24,6 +26,7 @@ import {
AttachmentVariant, AttachmentVariant,
mightBeOnBackupTier, mightBeOnBackupTier,
} from '../types/Attachment'; } from '../types/Attachment';
import { type ReadonlyMessageAttributesType } from '../model-types.d';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
import { import {
KIBIBYTE, KIBIBYTE,
@@ -53,13 +56,9 @@ import {
import { safeParsePartial } from '../util/schemas'; import { safeParsePartial } from '../util/schemas';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue'; import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
import { createBatcher } from '../util/batcher'; import { createBatcher } from '../util/batcher';
import { isOlderThan } from '../util/timestamp'; import { showDownloadFailedToast } from '../util/showDownloadFailedToast';
import { ToastType } from '../types/Toast'; import { markAttachmentAsPermanentlyErrored } from '../util/attachments/markAttachmentAsPermanentlyErrored';
import { AttachmentBackfill } from './helpers/attachmentBackfill';
export enum AttachmentDownloadUrgency {
IMMEDIATE = 'immediate',
STANDARD = 'standard',
}
// Type for adding a new job // Type for adding a new job
export type NewAttachmentDownloadJobType = { export type NewAttachmentDownloadJobType = {
@@ -74,7 +73,6 @@ export type NewAttachmentDownloadJobType = {
}; };
const MAX_CONCURRENT_JOBS = 3; const MAX_CONCURRENT_JOBS = 3;
const DOWNLOAD_FAILED_TIMESTAMP_REST = 10 * durations.SECOND;
const DEFAULT_RETRY_CONFIG = { const DEFAULT_RETRY_CONFIG = {
maxAttempts: 5, maxAttempts: 5,
@@ -134,6 +132,8 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
}, },
}); });
#attachmentBackfill = new AttachmentBackfill();
private static _instance: AttachmentDownloadManager | undefined; private static _instance: AttachmentDownloadManager | undefined;
override logPrefix = 'AttachmentDownloadManager'; override logPrefix = 'AttachmentDownloadManager';
@@ -310,6 +310,18 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
callback(); callback();
} }
} }
static async requestBackfill(
message: ReadonlyMessageAttributesType
): Promise<void> {
return this.instance.#attachmentBackfill.request(message);
}
static async handleBackfillResponse(
event: AttachmentBackfillResponseSyncEvent
): Promise<void> {
return this.instance.#attachmentBackfill.handleResponse(event);
}
} }
type DependenciesType = { type DependenciesType = {
@@ -412,9 +424,21 @@ async function runDownloadAttachmentJob({
} }
if (error instanceof AttachmentPermanentlyUndownloadableError) { if (error instanceof AttachmentPermanentlyUndownloadableError) {
if (
job.isManualDownload &&
job.source !== AttachmentDownloadSource.BACKFILL &&
AttachmentBackfill.isEnabledForJob(
job.attachmentType,
message.attributes
)
) {
await AttachmentDownloadManager.requestBackfill(message.attributes);
return { status: 'finished' };
}
await addAttachmentToMessage( await addAttachmentToMessage(
message.id, message.id,
_markAttachmentAsPermanentlyErrored(job.attachment), markAttachmentAsPermanentlyErrored(job.attachment),
logId, logId,
{ type: job.attachmentType } { type: job.attachmentType }
); );
@@ -654,7 +678,26 @@ export async function runDownloadAttachmentJobInner({
} }
} }
let showToast = false;
// Show toast if manual download failed
if (!abortSignal.aborted && job.isManualDownload) { if (!abortSignal.aborted && job.isManualDownload) {
if (job.source === AttachmentDownloadSource.BACKFILL) {
// ...and it was already a backfill request
showToast = true;
} else {
// ...or we didn't backfill the download
const message = await getMessageById(job.messageId);
showToast =
message != null &&
!AttachmentBackfill.isEnabledForJob(
attachmentType,
message.attributes
);
}
}
if (showToast) {
showDownloadFailedToast(messageId); showDownloadFailedToast(messageId);
} }
@@ -662,27 +705,6 @@ export async function runDownloadAttachmentJobInner({
} }
} }
export const lastErrorsByMessageId = new Map<string, number>();
function showDownloadFailedToast(messageId: string): void {
const now = Date.now();
for (const [id, timestamp] of lastErrorsByMessageId) {
if (isOlderThan(timestamp, DOWNLOAD_FAILED_TIMESTAMP_REST)) {
lastErrorsByMessageId.delete(id);
}
}
const existing = lastErrorsByMessageId.get(messageId);
if (!existing) {
window.reduxActions.toast.showToast({
toastType: ToastType.AttachmentDownloadFailed,
parameters: { messageId },
});
lastErrorsByMessageId.set(messageId, now);
}
}
async function downloadBackupThumbnail({ async function downloadBackupThumbnail({
attachment, attachment,
abortSignal, abortSignal,
@@ -711,17 +733,11 @@ async function downloadBackupThumbnail({
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType { function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
return { return {
..._markAttachmentAsPermanentlyErrored(attachment), ...markAttachmentAsPermanentlyErrored(attachment),
wasTooBig: true, wasTooBig: true,
}; };
} }
function _markAttachmentAsPermanentlyErrored(
attachment: AttachmentType
): AttachmentType {
return { ...omit(attachment, ['key', 'id']), pending: false, error: true };
}
function _markAttachmentAsTransientlyErrored( function _markAttachmentAsTransientlyErrored(
attachment: AttachmentType attachment: AttachmentType
): AttachmentType { ): AttachmentType {

View File

@@ -0,0 +1,434 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentBackfillResponseSyncEvent } from '../../textsecure/messageReceiverEvents';
import MessageSender from '../../textsecure/SendMessage';
import * as log from '../../logging/log';
import type { ReadonlyMessageAttributesType } from '../../model-types.d';
import {
type AttachmentType,
isDownloading,
isDownloaded,
} from '../../types/Attachment';
import {
type AttachmentDownloadJobTypeType,
AttachmentDownloadUrgency,
} from '../../types/AttachmentDownload';
import { AttachmentDownloadSource } from '../../sql/Interface';
import { APPLICATION_OCTET_STREAM } from '../../types/MIME';
import {
getConversationIdentifier,
getAddressableMessage,
getConversationFromTarget,
getMessageQueryFromTarget,
findMatchingMessage,
} from '../../util/syncIdentifiers';
import { strictAssert } from '../../util/assert';
import { drop } from '../../util/drop';
import { missingCaseError } from '../../util/missingCaseError';
import { isStagingServer } from '../../util/isStagingServer';
import {
ensureBodyAttachmentsAreSeparated,
queueAttachmentDownloadsForMessage,
} from '../../util/queueAttachmentDownloads';
import { SECOND } from '../../util/durations';
import { showDownloadFailedToast } from '../../util/showDownloadFailedToast';
import { markAttachmentAsPermanentlyErrored } from '../../util/attachments/markAttachmentAsPermanentlyErrored';
import { singleProtoJobQueue } from '../singleProtoJobQueue';
import { MessageModel } from '../../models/messages';
import { getMessageById } from '../../messages/getMessageById';
import { addAttachmentToMessage } from '../../messageModifiers/AttachmentDownloads';
import { SignalService as Proto } from '../../protobuf';
import * as RemoteConfig from '../../RemoteConfig';
import { isTestOrMockEnvironment } from '../../environment';
import { BackfillFailureKind } from '../../components/BackfillFailureModal';
const REQUEST_TIMEOUT = isTestOrMockEnvironment() ? 5 * SECOND : 10 * SECOND;
const PLACEHOLDER_ATTACHMENT: AttachmentType = {
error: true,
contentType: APPLICATION_OCTET_STREAM,
size: 0,
};
function isBackfillEnabled(): boolean {
if (isStagingServer() || isTestOrMockEnvironment()) {
return true;
}
if (RemoteConfig.isEnabled('desktop.internalUser')) {
return true;
}
const ourConversation = window.ConversationController.getOurConversation();
return ourConversation?.get('capabilities')?.attachmentBackfill === true;
}
export class AttachmentBackfill {
#pendingRequests = new Map<
ReadonlyMessageAttributesType['id'],
NodeJS.Timeout
>();
public async request(message: ReadonlyMessageAttributesType): Promise<void> {
const existingTimer = this.#pendingRequests.get(message.id);
if (existingTimer != null) {
return;
}
const addr = getAddressableMessage(message);
if (addr == null) {
throw new Error('No address for message');
}
const convo = window.ConversationController.get(message.conversationId);
strictAssert(convo != null, 'Missing message conversation');
this.#pendingRequests.set(
message.id,
setTimeout(() => drop(this.#onTimeout(message.id)), REQUEST_TIMEOUT)
);
await singleProtoJobQueue.add(
MessageSender.getAttachmentBackfillSyncMessage(
getConversationIdentifier(convo.attributes),
addr
)
);
}
public async handleResponse({
timestamp,
response,
}: AttachmentBackfillResponseSyncEvent): Promise<void> {
const logId = `onAttachmentBackfillResponseSync(${timestamp})`;
if (!isBackfillEnabled()) {
log.info(`${logId}: disabled`);
return;
}
log.info(`${logId}: start`);
const {
Error: ErrorEnum,
AttachmentData: { Status },
} = Proto.SyncMessage.AttachmentBackfillResponse;
if ('error' in response) {
if (response.error === ErrorEnum.MESSAGE_NOT_FOUND) {
window.reduxActions.globalModals.showBackfillFailureModal({
kind: BackfillFailureKind.NotFound,
});
} else {
throw missingCaseError(response.error);
}
return;
}
const { targetMessage, targetConversation } = response;
const convo = getConversationFromTarget(targetConversation);
if (convo == null) {
log.error(`${logId}: conversation not found`);
return;
}
const query = getMessageQueryFromTarget(targetMessage);
const attributes = await findMatchingMessage(convo.id, query);
if (attributes == null) {
log.error(`${logId}: message not found`);
return;
}
const message = window.MessageCache.register(new MessageModel(attributes));
// IMPORTANT: no awaits until we finish modifying attachments
const timer = this.#pendingRequests.get(message.id);
if (timer != null) {
clearTimeout(timer);
}
// Since we are matching remote attachments with local attachments we need
// to make sure things are normalized before starting.
message.set(
ensureBodyAttachmentsAreSeparated(message.attributes, {
logId,
logger: log,
})
);
// We will be potentially modifying these attachments below
let updatedSticker = message.get('sticker');
let updatedBodyAttachment = message.get('bodyAttachment');
const updatedAttachments = message.get('attachments')?.slice() ?? [];
let changeCount = 0;
// If `true` - show a toast at the end of the process
let showToast = false;
// If `true` - queue downloads at the end of the process
let shouldDownload = false;
// Track number of pending attachments to decide when the request is
// fully processed by the phone.
let pendingCount = 0;
const remoteAttachments = response.attachments.slice();
if (updatedSticker?.data != null) {
const remoteSticker = remoteAttachments.shift();
if (remoteSticker == null) {
log.error(`${logId}: no attachment for sticker`);
return;
}
const existing = updatedSticker.data;
if (isDownloaded(existing)) {
log.info(`${logId}: not updating, sticker downloaded`);
} else if ('status' in remoteSticker) {
if (remoteSticker.status === Status.PENDING) {
pendingCount += 1;
// Keep sticker as is
} else if (remoteSticker.status === Status.TERMINAL_ERROR) {
changeCount += 1;
updatedSticker = {
...updatedSticker,
data: markAttachmentAsPermanentlyErrored(existing),
};
showToast = true;
} else {
throw missingCaseError(remoteSticker.status);
}
} else {
// If the attachment is not in pending state - we got a response for
// other device's backfill request. Update the CDN info without queueing
// a download.
if (isDownloading(updatedSticker.data)) {
shouldDownload = true;
}
updatedSticker = {
...updatedSticker,
data: remoteSticker.attachment,
};
changeCount += 1;
}
}
// Pad local attachments until they match remote
let padding = 0;
while (updatedAttachments.length < remoteAttachments.length) {
updatedAttachments.push(PLACEHOLDER_ATTACHMENT);
changeCount += 1;
padding += 1;
}
if (padding !== 0) {
log.warn(`${logId}: padded with ${padding} attachments`);
}
if (response.longText != null) {
if (updatedBodyAttachment == null) {
updatedBodyAttachment = PLACEHOLDER_ATTACHMENT;
changeCount += 1;
log.warn(`${logId}: padded with a body attachment`);
}
if (isDownloaded(updatedBodyAttachment)) {
log.info(`${logId}: not updating long body`);
} else if ('status' in response.longText) {
if (response.longText.status === Status.PENDING) {
// Keep attachment as is
pendingCount += 1;
} else if (response.longText.status === Status.TERMINAL_ERROR) {
changeCount += 1;
updatedBodyAttachment = markAttachmentAsPermanentlyErrored(
updatedBodyAttachment
);
showToast = true;
} else {
throw missingCaseError(response.longText.status);
}
} else {
// See sticker handling code above for the reasoning
if (isDownloading(updatedBodyAttachment)) {
shouldDownload = true;
}
updatedBodyAttachment = response.longText.attachment;
changeCount += 1;
}
}
for (const [index, entry] of remoteAttachments.entries()) {
const existing = updatedAttachments[index];
if (isDownloaded(existing)) {
log.info(`${logId}: not updating ${index}, downloaded`);
continue;
}
if ('status' in entry) {
if (entry.status === Status.PENDING) {
// Keep attachment as is
pendingCount += 1;
} else if (entry.status === Status.TERMINAL_ERROR) {
showToast = true;
changeCount += 1;
updatedAttachments[index] =
markAttachmentAsPermanentlyErrored(existing);
} else {
throw missingCaseError(entry.status);
}
continue;
}
changeCount += 1;
// See sticker handling code above for the reasoning
if (isDownloading(existing)) {
shouldDownload = true;
}
updatedAttachments[index] = entry.attachment;
}
if (showToast) {
log.warn(`${logId}: showing toast`);
showDownloadFailedToast(message.id);
}
if (pendingCount === 0) {
log.info(`${logId}: no pending attachments, fulfilled`);
this.#pendingRequests.delete(message.id);
}
if (changeCount === 0) {
log.info(`${logId}: no changes`);
return;
}
log.info(`${logId}: updating ${changeCount} attachments`);
message.set({
attachments: updatedAttachments,
bodyAttachment: updatedBodyAttachment,
sticker: updatedSticker,
editHistory: message.get('editHistory')?.map(edit => ({
...edit,
attachments: updatedAttachments,
bodyAttachment: updatedBodyAttachment,
})),
});
// It is fine to await below this line
if (shouldDownload) {
log.info(`${logId}: queueing downloads`);
await queueAttachmentDownloadsForMessage(message, {
source: AttachmentDownloadSource.BACKFILL,
urgency: AttachmentDownloadUrgency.IMMEDIATE,
isManualDownload: true,
});
}
// Save after queueing because queuing might update attributes
await window.MessageCache.saveMessage(message.attributes);
}
public static isEnabledForJob(
jobType: AttachmentDownloadJobTypeType,
message: Pick<ReadonlyMessageAttributesType, 'type'>
): boolean {
if (message.type === 'story') {
return false;
}
switch (jobType) {
// Supported
case 'long-message':
break;
case 'attachment':
break;
case 'sticker':
break;
// Not supported
case 'contact':
return false;
case 'preview':
return false;
case 'quote':
return false;
default:
throw missingCaseError(jobType);
}
return isBackfillEnabled();
}
async #onTimeout(messageId: string): Promise<void> {
const message = await getMessageById(messageId);
this.#pendingRequests.delete(messageId);
// Message already removed
if (message == null) {
return;
}
const logId = `attachmentBackfill.onTimeout(${message.get('sent_at')})`;
log.info(`${logId}: onTimeout`);
const bodyAttachment = message.get('bodyAttachment');
if (bodyAttachment != null && isDownloading(bodyAttachment)) {
log.info(`${logId}: clearing long text download`);
await addAttachmentToMessage(
message.id,
{
...bodyAttachment,
pending: false,
},
'attachmentBackfillTimeout',
{ type: 'long-message' }
);
}
const sticker = message.get('sticker');
if (sticker?.data != null && isDownloading(sticker.data)) {
log.info(`${logId}: clearing long text download`);
await addAttachmentToMessage(
message.id,
{
...sticker.data,
pending: false,
},
'attachmentBackfillTimeout',
{ type: 'sticker' }
);
}
const pendingAttachments = (message.get('attachments') ?? []).filter(
isDownloading
);
if (pendingAttachments.length !== 0) {
log.info(
`${logId}: clearing attachment downloads ${pendingAttachments.length}`
);
await Promise.all(
pendingAttachments.map(attachment => {
return addAttachmentToMessage(
message.id,
{
...attachment,
pending: false,
},
'attachmentBackfillTimeout',
{ type: 'attachment' }
);
})
);
}
window.reduxActions.globalModals.showBackfillFailureModal({
kind: BackfillFailureKind.Timeout,
});
}
}

View File

@@ -8,29 +8,31 @@ import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
import type { MessageAttributesType } from '../model-types'; import type { MessageAttributesType } from '../model-types';
import type { import type {
ConversationToDelete, ConversationIdentifier,
MessageToDelete, AddressableMessage,
} from '../textsecure/messageReceiverEvents'; } from '../textsecure/messageReceiverEvents';
import { import {
deleteAttachmentFromMessage, deleteAttachmentFromMessage,
deleteMessage, deleteMessage,
} from '../util/deleteForMe';
import {
doesMessageMatch, doesMessageMatch,
getConversationFromTarget, getConversationFromTarget,
getMessageQueryFromTarget, getMessageQueryFromTarget,
} from '../util/deleteForMe'; } from '../util/syncIdentifiers';
import { DataWriter } from '../sql/Client'; import { DataWriter } from '../sql/Client';
const { removeSyncTaskById } = DataWriter; const { removeSyncTaskById } = DataWriter;
export type DeleteForMeAttributesType = { export type DeleteForMeAttributesType = {
conversation: ConversationToDelete; conversation: ConversationIdentifier;
deleteAttachmentData?: { deleteAttachmentData?: {
clientUuid?: string; clientUuid?: string;
fallbackDigest?: string; fallbackDigest?: string;
fallbackPlaintextHash?: string; fallbackPlaintextHash?: string;
}; };
envelopeId: string; envelopeId: string;
message: MessageToDelete; message: AddressableMessage;
syncTaskId: string; syncTaskId: string;
timestamp: number; timestamp: number;
}; };

View File

@@ -179,11 +179,11 @@ import { getMessageAuthorText } from '../util/getMessageAuthorText';
import { downscaleOutgoingAttachment } from '../util/attachments'; import { downscaleOutgoingAttachment } from '../util/attachments';
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent'; import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
import { hasExpiration } from '../types/Message2'; import { hasExpiration } from '../types/Message2';
import type { MessageToDelete } from '../textsecure/messageReceiverEvents'; import type { AddressableMessage } from '../textsecure/messageReceiverEvents';
import { import {
getConversationToDelete, getConversationIdentifier,
getMessageToDelete, getAddressableMessage,
} from '../util/deleteForMe'; } from '../util/syncIdentifiers';
import { explodePromise } from '../util/explodePromise'; import { explodePromise } from '../util/explodePromise';
import { getCallHistorySelector } from '../state/selectors/callHistory'; import { getCallHistorySelector } from '../state/selectors/callHistory';
import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus'; import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus';
@@ -5195,8 +5195,8 @@ export class ConversationModel extends window.Backbone
const addressableMessages = await getMostRecentAddressableMessages( const addressableMessages = await getMostRecentAddressableMessages(
this.id this.id
); );
const mostRecentMessages: Array<MessageToDelete> = addressableMessages const mostRecentMessages: Array<AddressableMessage> = addressableMessages
.map(getMessageToDelete) .map(getAddressableMessage)
.filter(isNotNil) .filter(isNotNil)
.slice(0, 5); .slice(0, 5);
log.info( log.info(
@@ -5207,12 +5207,12 @@ export class ConversationModel extends window.Backbone
item => item.expireTimer item => item.expireTimer
); );
let mostRecentNonExpiringMessages: Array<MessageToDelete> | undefined; let mostRecentNonExpiringMessages: Array<AddressableMessage> | undefined;
if (areAnyDisappearing) { if (areAnyDisappearing) {
const nondisappearingAddressableMessages = const nondisappearingAddressableMessages =
await getMostRecentAddressableNondisappearingMessages(this.id); await getMostRecentAddressableNondisappearingMessages(this.id);
mostRecentNonExpiringMessages = nondisappearingAddressableMessages mostRecentNonExpiringMessages = nondisappearingAddressableMessages
.map(getMessageToDelete) .map(getAddressableMessage)
.filter(isNotNil) .filter(isNotNil)
.slice(0, 5); .slice(0, 5);
log.info( log.info(
@@ -5225,7 +5225,7 @@ export class ConversationModel extends window.Backbone
MessageSender.getDeleteForMeSyncMessage([ MessageSender.getDeleteForMeSyncMessage([
{ {
type: 'delete-conversation', type: 'delete-conversation',
conversation: getConversationToDelete(this.attributes), conversation: getConversationIdentifier(this.attributes),
isFullDelete: true, isFullDelete: true,
mostRecentMessages, mostRecentMessages,
mostRecentNonExpiringMessages, mostRecentNonExpiringMessages,
@@ -5238,7 +5238,7 @@ export class ConversationModel extends window.Backbone
MessageSender.getDeleteForMeSyncMessage([ MessageSender.getDeleteForMeSyncMessage([
{ {
type: 'delete-local-conversation', type: 'delete-local-conversation',
conversation: getConversationToDelete(this.attributes), conversation: getConversationIdentifier(this.attributes),
timestamp, timestamp,
}, },
]) ])

View File

@@ -70,6 +70,7 @@ type JobType = {
const OBSERVED_CAPABILITY_KEYS = Object.keys({ const OBSERVED_CAPABILITY_KEYS = Object.keys({
deleteSync: true, deleteSync: true,
ssre2: true, ssre2: true,
attachmentBackfill: true,
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>; } satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
export class ProfileService { export class ProfileService {

View File

@@ -532,6 +532,7 @@ export type GetRecentStoryRepliesOptionsType = {
export enum AttachmentDownloadSource { export enum AttachmentDownloadSource {
BACKUP_IMPORT = 'backup_import', BACKUP_IMPORT = 'backup_import',
STANDARD = 'standard', STANDARD = 'standard',
BACKFILL = 'backfill',
} }
type ReadableInterface = { type ReadableInterface = {

View File

@@ -29,6 +29,7 @@ import { drop } from '../../util/drop';
import type { DurationInSeconds } from '../../util/durations'; import type { DurationInSeconds } from '../../util/durations';
import * as universalExpireTimer from '../../util/universalExpireTimer'; import * as universalExpireTimer from '../../util/universalExpireTimer';
import * as Attachment from '../../types/Attachment'; import * as Attachment from '../../types/Attachment';
import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
import { isFileDangerous } from '../../util/isFileDangerous'; import { isFileDangerous } from '../../util/isFileDangerous';
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl'; import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
import { instance as libphonenumberInstance } from '../../util/libphonenumberInstance'; import { instance as libphonenumberInstance } from '../../util/libphonenumberInstance';
@@ -191,18 +192,15 @@ import {
} from '../../util/idForLogging'; } from '../../util/idForLogging';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage'; import MessageSender from '../../textsecure/SendMessage';
import { import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
AttachmentDownloadManager,
AttachmentDownloadUrgency,
} from '../../jobs/AttachmentDownloadManager';
import type { import type {
DeleteForMeSyncEventData, DeleteForMeSyncEventData,
MessageToDelete, AddressableMessage,
} from '../../textsecure/messageReceiverEvents'; } from '../../textsecure/messageReceiverEvents';
import { import {
getConversationToDelete, getConversationIdentifier,
getMessageToDelete, getAddressableMessage,
} from '../../util/deleteForMe'; } from '../../util/syncIdentifiers';
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types'; import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
import { markCallHistoryReadInConversation } from './callHistory'; import { markCallHistoryReadInConversation } from './callHistory';
import type { CapabilitiesType } from '../../textsecure/WebAPI'; import type { CapabilitiesType } from '../../textsecure/WebAPI';
@@ -1782,7 +1780,7 @@ function deleteMessages({
const messages = ( const messages = (
await Promise.all( await Promise.all(
messageIds.map( messageIds.map(
async (messageId): Promise<MessageToDelete | undefined> => { async (messageId): Promise<AddressableMessage | undefined> => {
const message = await getMessageById(messageId); const message = await getMessageById(messageId);
if (!message) { if (!message) {
throw new Error(`deleteMessages: Message ${messageId} missing!`); throw new Error(`deleteMessages: Message ${messageId} missing!`);
@@ -1795,7 +1793,7 @@ function deleteMessages({
); );
} }
return getMessageToDelete(message.attributes); return getAddressableMessage(message.attributes);
} }
) )
) )
@@ -1839,7 +1837,7 @@ function deleteMessages({
} }
const chunks = chunk(messages, MAX_MESSAGE_COUNT); const chunks = chunk(messages, MAX_MESSAGE_COUNT);
const conversationToDelete = getConversationToDelete( const conversationToDelete = getConversationIdentifier(
conversation.attributes conversation.attributes
); );
const timestamp = Date.now(); const timestamp = Date.now();

View File

@@ -58,6 +58,7 @@ import type { StartCallData } from '../../components/ConfirmLeaveCallModal';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import type { AttachmentNotAvailableModalType } from '../../components/AttachmentNotAvailableModal'; import type { AttachmentNotAvailableModalType } from '../../components/AttachmentNotAvailableModal';
import type { DataPropsType as TapToViewNotAvailablePropsType } from '../../components/TapToViewNotAvailableModal'; import type { DataPropsType as TapToViewNotAvailablePropsType } from '../../components/TapToViewNotAvailableModal';
import type { DataPropsType as BackfillFailureModalPropsType } from '../../components/BackfillFailureModal';
// State // State
@@ -102,6 +103,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
addUserToAnotherGroupModalContactId?: string; addUserToAnotherGroupModalContactId?: string;
aboutContactModalContactId?: string; aboutContactModalContactId?: string;
attachmentNotAvailableModalType: AttachmentNotAvailableModalType | undefined; attachmentNotAvailableModalType: AttachmentNotAvailableModalType | undefined;
backfillFailureModalProps: BackfillFailureModalPropsType | undefined;
callLinkAddNameModalRoomId: string | null; callLinkAddNameModalRoomId: string | null;
callLinkEditModalRoomId: string | null; callLinkEditModalRoomId: string | null;
callLinkPendingParticipantContactId: string | undefined; callLinkPendingParticipantContactId: string | undefined;
@@ -152,6 +154,8 @@ const SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL =
'globalModals/SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL'; 'globalModals/SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL';
const HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL = const HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL =
'globalModals/HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL'; 'globalModals/HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL';
const SHOW_BACKFILL_FAILURE_MODAL = 'globalModals/SHOW_BACKFILL_FAILURE_MODAL';
const HIDE_BACKFILL_FAILURE_MODAL = 'globalModals/HIDE_BACKFILL_FAILURE_MODAL';
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL'; const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL'; const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL'; const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
@@ -242,6 +246,15 @@ type ShowTapToViewNotAvailableModalActionType = ReadonlyDeep<{
payload: TapToViewNotAvailablePropsType; payload: TapToViewNotAvailablePropsType;
}>; }>;
type HideBackfillFailureModalActionType = ReadonlyDeep<{
type: typeof HIDE_BACKFILL_FAILURE_MODAL;
}>;
type ShowBackfillFailureModalActionType = ReadonlyDeep<{
type: typeof SHOW_BACKFILL_FAILURE_MODAL;
payload: BackfillFailureModalPropsType;
}>;
type HideContactModalActionType = ReadonlyDeep<{ type HideContactModalActionType = ReadonlyDeep<{
type: typeof HIDE_CONTACT_MODAL; type: typeof HIDE_CONTACT_MODAL;
}>; }>;
@@ -449,6 +462,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| CloseShortcutGuideModalActionType | CloseShortcutGuideModalActionType
| CloseStickerPackPreviewActionType | CloseStickerPackPreviewActionType
| HideAttachmentNotAvailableModalActionType | HideAttachmentNotAvailableModalActionType
| HideBackfillFailureModalActionType
| HideContactModalActionType | HideContactModalActionType
| HideSendAnywayDialogActiontype | HideSendAnywayDialogActiontype
| HideStoriesSettingsActionType | HideStoriesSettingsActionType
@@ -459,6 +473,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| MessageDeletedActionType | MessageDeletedActionType
| MessageExpiredActionType | MessageExpiredActionType
| ShowAttachmentNotAvailableModalActionType | ShowAttachmentNotAvailableModalActionType
| ShowBackfillFailureModalActionType
| ShowContactModalActionType | ShowContactModalActionType
| ShowEditHistoryModalActionType | ShowEditHistoryModalActionType
| ShowErrorModalActionType | ShowErrorModalActionType
@@ -502,6 +517,7 @@ export const actions = {
closeMediaPermissionsModal, closeMediaPermissionsModal,
ensureSystemMediaPermissions, ensureSystemMediaPermissions,
hideAttachmentNotAvailableModal, hideAttachmentNotAvailableModal,
hideBackfillFailureModal,
hideBlockingSafetyNumberChangeDialog, hideBlockingSafetyNumberChangeDialog,
hideContactModal, hideContactModal,
hideStoriesSettings, hideStoriesSettings,
@@ -509,6 +525,7 @@ export const actions = {
hideUserNotFoundModal, hideUserNotFoundModal,
hideWhatsNewModal, hideWhatsNewModal,
showAttachmentNotAvailableModal, showAttachmentNotAvailableModal,
showBackfillFailureModal,
showBlockingSafetyNumberChangeDialog, showBlockingSafetyNumberChangeDialog,
showContactModal, showContactModal,
showEditHistoryModal, showEditHistoryModal,
@@ -575,6 +592,21 @@ function showTapToViewNotAvailableModal(
}; };
} }
function showBackfillFailureModal(
payload: BackfillFailureModalPropsType
): ShowBackfillFailureModalActionType {
return {
type: SHOW_BACKFILL_FAILURE_MODAL,
payload,
};
}
function hideBackfillFailureModal(): HideBackfillFailureModalActionType {
return {
type: HIDE_BACKFILL_FAILURE_MODAL,
};
}
function hideContactModal(): HideContactModalActionType { function hideContactModal(): HideContactModalActionType {
return { return {
type: HIDE_CONTACT_MODAL, type: HIDE_CONTACT_MODAL,
@@ -1185,6 +1217,7 @@ function copyOverMessageAttributesIntoForwardMessages(
export function getEmptyState(): GlobalModalsStateType { export function getEmptyState(): GlobalModalsStateType {
return { return {
attachmentNotAvailableModalType: undefined, attachmentNotAvailableModalType: undefined,
backfillFailureModalProps: undefined,
hasConfirmationModal: false, hasConfirmationModal: false,
callLinkAddNameModalRoomId: null, callLinkAddNameModalRoomId: null,
callLinkEditModalRoomId: null, callLinkEditModalRoomId: null,
@@ -1315,6 +1348,20 @@ export function reducer(
}; };
} }
if (action.type === SHOW_BACKFILL_FAILURE_MODAL) {
return {
...state,
backfillFailureModalProps: action.payload,
};
}
if (action.type === HIDE_BACKFILL_FAILURE_MODAL) {
return {
...state,
backfillFailureModalProps: undefined,
};
}
if (action.type === SHOW_CONTACT_MODAL) { if (action.type === SHOW_CONTACT_MODAL) {
const ourId = window.ConversationController.getOurConversationIdOrThrow(); const ourId = window.ConversationController.getOurConversationIdOrThrow();
if (action.payload.contactId === ourId) { if (action.payload.contactId === ourId) {

View File

@@ -41,7 +41,7 @@ import { showStickerPackPreview } from './globalModals';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import { DataReader } from '../../sql/Client'; import { DataReader } from '../../sql/Client';
import { deleteDownloadsJobQueue } from '../../jobs/deleteDownloadsJobQueue'; import { deleteDownloadsJobQueue } from '../../jobs/deleteDownloadsJobQueue';
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager'; import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { getMessageIdForLogging } from '../../util/idForLogging'; import { getMessageIdForLogging } from '../../util/idForLogging';
import { markViewOnceMessageViewed } from '../../services/MessageUpdater'; import { markViewOnceMessageViewed } from '../../services/MessageUpdater';

View File

@@ -70,7 +70,7 @@ import {
} from '../../jobs/conversationJobQueue'; } from '../../jobs/conversationJobQueue';
import { ReceiptType } from '../../types/Receipt'; import { ReceiptType } from '../../types/Receipt';
import { cleanupMessages } from '../../util/cleanup'; import { cleanupMessages } from '../../util/cleanup';
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager'; import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
export type StoryDataType = ReadonlyDeep< export type StoryDataType = ReadonlyDeep<
{ {

View File

@@ -121,6 +121,7 @@ export const SmartGlobalModalContainer = memo(
aboutContactModalContactId, aboutContactModalContactId,
addUserToAnotherGroupModalContactId, addUserToAnotherGroupModalContactId,
attachmentNotAvailableModalType, attachmentNotAvailableModalType,
backfillFailureModalProps,
callLinkAddNameModalRoomId, callLinkAddNameModalRoomId,
callLinkEditModalRoomId, callLinkEditModalRoomId,
callLinkPendingParticipantContactId, callLinkPendingParticipantContactId,
@@ -155,6 +156,7 @@ export const SmartGlobalModalContainer = memo(
hideTapToViewNotAvailableModal, hideTapToViewNotAvailableModal,
hideUserNotFoundModal, hideUserNotFoundModal,
hideWhatsNewModal, hideWhatsNewModal,
hideBackfillFailureModal,
toggleSignalConnectionsModal, toggleSignalConnectionsModal,
} = useGlobalModalActions(); } = useGlobalModalActions();
@@ -210,6 +212,7 @@ export const SmartGlobalModalContainer = memo(
addUserToAnotherGroupModalContactId={ addUserToAnotherGroupModalContactId={
addUserToAnotherGroupModalContactId addUserToAnotherGroupModalContactId
} }
backfillFailureModalProps={backfillFailureModalProps}
callLinkAddNameModalRoomId={callLinkAddNameModalRoomId} callLinkAddNameModalRoomId={callLinkAddNameModalRoomId}
callLinkEditModalRoomId={callLinkEditModalRoomId} callLinkEditModalRoomId={callLinkEditModalRoomId}
callLinkPendingParticipantContactId={ callLinkPendingParticipantContactId={
@@ -230,6 +233,7 @@ export const SmartGlobalModalContainer = memo(
openSystemMediaPermissions={window.IPC.openSystemMediaPermissions} openSystemMediaPermissions={window.IPC.openSystemMediaPermissions}
notePreviewModalProps={notePreviewModalProps} notePreviewModalProps={notePreviewModalProps}
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal} hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
hideBackfillFailureModal={hideBackfillFailureModal}
hideUserNotFoundModal={hideUserNotFoundModal} hideUserNotFoundModal={hideUserNotFoundModal}
hideWhatsNewModal={hideWhatsNewModal} hideWhatsNewModal={hideWhatsNewModal}
hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal} hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal}

View File

@@ -10,11 +10,13 @@ import { omit } from 'lodash';
import * as MIME from '../../types/MIME'; import * as MIME from '../../types/MIME';
import { import {
AttachmentDownloadManager, AttachmentDownloadManager,
AttachmentDownloadUrgency,
runDownloadAttachmentJobInner, runDownloadAttachmentJobInner,
type NewAttachmentDownloadJobType, type NewAttachmentDownloadJobType,
} from '../../jobs/AttachmentDownloadManager'; } from '../../jobs/AttachmentDownloadManager';
import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload'; import {
type AttachmentDownloadJobType,
AttachmentDownloadUrgency,
} from '../../types/AttachmentDownload';
import { DataReader, DataWriter } from '../../sql/Client'; import { DataReader, DataWriter } from '../../sql/Client';
import { MINUTE } from '../../util/durations'; import { MINUTE } from '../../util/durations';
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment'; import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';

View File

@@ -146,13 +146,19 @@ export function sendTextMessage({
to, to,
text, text,
attachments, attachments,
sticker,
preview,
quote,
desktop, desktop,
timestamp = Date.now(), timestamp = Date.now(),
}: { }: {
from: PrimaryDevice; from: PrimaryDevice;
to: PrimaryDevice | Device | GroupInfo; to: PrimaryDevice | Device | GroupInfo;
text: string; text: string | undefined;
attachments?: Array<Proto.IAttachmentPointer>; attachments?: Array<Proto.IAttachmentPointer>;
sticker?: Proto.DataMessage.ISticker;
preview?: Proto.IPreview;
quote?: Proto.DataMessage.IQuote;
desktop: Device; desktop: Device;
timestamp?: number; timestamp?: number;
}): Promise<void> { }): Promise<void> {
@@ -167,6 +173,9 @@ export function sendTextMessage({
dataMessage: { dataMessage: {
body: text, body: text,
attachments, attachments,
sticker,
preview: preview == null ? null : [preview],
quote,
timestamp: Long.fromNumber(timestamp), timestamp: Long.fromNumber(timestamp),
groupV2: groupInfo groupV2: groupInfo
? { ? {
@@ -209,7 +218,7 @@ export function sendReaction({
reaction: { reaction: {
emoji, emoji,
targetAuthorAci: getDevice(targetAuthor).aci, targetAuthorAci: getDevice(targetAuthor).aci,
targetTimestamp: Long.fromNumber(targetMessageTimestamp), targetSentTimestamp: Long.fromNumber(targetMessageTimestamp),
}, },
}, },
}), }),

View File

@@ -0,0 +1,475 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { PrimaryDevice } from '@signalapp/mock-server';
import { Proto } from '@signalapp/mock-server';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import createDebug from 'debug';
import type { Page } from 'playwright';
import assert from 'assert';
import Long from 'long';
import { LONG_MESSAGE, IMAGE_JPEG } from '../../types/MIME';
import * as durations from '../../util/durations';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import { sendTextMessage, getTimelineMessageWithText } from '../helpers';
export const debug = createDebug('mock:test:edit');
const FIXTURES_PATH = join(__dirname, '..', '..', '..', 'fixtures');
const CAT_PATH = join(FIXTURES_PATH, 'cat-screenshot.png');
const SNOW_PATH = join(FIXTURES_PATH, 'snow.jpg');
const { Status } = Proto.SyncMessage.AttachmentBackfillResponse.AttachmentData;
function createResponse(
response: Proto.SyncMessage.IAttachmentBackfillResponse
): Proto.IContent {
return { syncMessage: { attachmentBackfillResponse: response } };
}
describe('attachment backfill', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
let bootstrap: Bootstrap;
let app: App;
let page: Page;
let unknownContact: PrimaryDevice;
let textAttachment: Proto.IAttachmentPointer;
let catAttachment: Proto.IAttachmentPointer;
let snowAttachment: Proto.IAttachmentPointer;
beforeEach(async () => {
bootstrap = new Bootstrap({ contactCount: 1, unknownContactCount: 1 });
await bootstrap.init();
app = await bootstrap.link();
page = await app.getWindow();
const { unknownContacts } = bootstrap;
[unknownContact] = unknownContacts;
textAttachment = await bootstrap.storeAttachmentOnCDN(
Buffer.from('look at this pic, it is gorgeous!'),
LONG_MESSAGE
);
const plaintextCat = await readFile(CAT_PATH);
catAttachment = await bootstrap.storeAttachmentOnCDN(
plaintextCat,
IMAGE_JPEG
);
const plaintextSnow = await readFile(SNOW_PATH);
snowAttachment = await bootstrap.storeAttachmentOnCDN(
plaintextSnow,
IMAGE_JPEG
);
});
afterEach(async function (this: Mocha.Context) {
if (!bootstrap) {
return;
}
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
it('should be requested on manual download', async () => {
const { phone, desktop } = bootstrap;
debug('sending a message with attachment that is 404 on CDN');
const timestamp = bootstrap.getTimestamp();
await sendTextMessage({
from: unknownContact,
to: desktop,
desktop,
text: 'look at this pic!',
attachments: [
{ ...textAttachment, cdnKey: 'text-not-found' },
{ ...catAttachment, cdnKey: 'cat-not-found' },
{ ...snowAttachment, cdnKey: 'snow-not-found' },
],
timestamp,
});
debug('opening conversation');
const leftPane = page.locator('#LeftPane');
const conversationListItem = leftPane.getByRole('button', {
name: 'Chat with Unknown contact',
});
await conversationListItem.getByText('Message Request').click();
debug('dowloading attachment');
const conversationStack = page.locator('.Inbox__conversation-stack');
const startDownload = conversationStack.getByRole('button', {
name: 'Start Download',
});
await startDownload.click();
debug('waiting for backfill request');
const {
syncMessage: { attachmentBackfillRequest: request },
} = await phone.waitForSyncMessage(entry => {
return entry.syncMessage.attachmentBackfillRequest != null;
});
assert.strictEqual(
request?.targetConversation?.threadServiceId,
unknownContact.device.aci
);
assert.strictEqual(
request?.targetMessage?.authorServiceId,
unknownContact.device.aci
);
assert.strictEqual(
request?.targetMessage?.sentTimestamp?.toNumber(),
timestamp
);
// No download buttons
debug('waiting for spinner to become visible');
await startDownload.waitFor({ state: 'detached' });
const cancelDownload = conversationStack.getByRole('button', {
name: 'Cancel Download',
});
await cancelDownload.waitFor();
debug('sending pending backfill response');
await phone.sendRaw(
desktop,
createResponse({
targetConversation: request.targetConversation,
targetMessage: request.targetMessage,
attachments: {
longText: { status: Status.PENDING },
attachments: [{ status: Status.PENDING }, { status: Status.PENDING }],
},
}),
{
timestamp: bootstrap.getTimestamp(),
}
);
debug('resolving long text');
await phone.sendRaw(
desktop,
createResponse({
targetConversation: request.targetConversation,
targetMessage: request.targetMessage,
attachments: {
longText: { attachment: textAttachment },
attachments: [{ status: Status.PENDING }, { status: Status.PENDING }],
},
}),
{
timestamp: bootstrap.getTimestamp(),
}
);
await getTimelineMessageWithText(page, 'gorgeous').waitFor();
debug('resolving first attachment');
await phone.sendRaw(
desktop,
createResponse({
targetConversation: request.targetConversation,
targetMessage: request.targetMessage,
attachments: {
longText: { attachment: textAttachment },
attachments: [
{ attachment: catAttachment },
{ status: Status.PENDING },
],
},
}),
{
timestamp: bootstrap.getTimestamp(),
}
);
await conversationStack
.getByRole('button', {
name: 'Open this attachment in a larger view',
})
.waitFor();
debug('failing second attachment');
await phone.sendRaw(
desktop,
createResponse({
targetConversation: request.targetConversation,
targetMessage: request.targetMessage,
attachments: {
longText: { attachment: textAttachment },
attachments: [
{ attachment: catAttachment },
{ status: Status.TERMINAL_ERROR },
],
},
}),
{
timestamp: bootstrap.getTimestamp(),
}
);
await page.locator('.Toast >> "Download failed"').waitFor();
await cancelDownload.waitFor({ state: 'detached' });
await conversationStack
.getByRole('button', {
name: 'This media is not available',
})
.waitFor();
});
it('should show modal on timeout', async () => {
const { desktop } = bootstrap;
debug('sending a message with attachment that is 404 on CDN');
const timestamp = bootstrap.getTimestamp();
await sendTextMessage({
from: unknownContact,
to: desktop,
desktop,
text: undefined,
attachments: [{ ...catAttachment, cdnKey: 'cat-not-found' }],
timestamp,
});
debug('opening conversation');
const leftPane = page.locator('#LeftPane');
const conversationListItem = leftPane.getByRole('button', {
name: 'Chat with Unknown contact',
});
await conversationListItem.getByText('Message Request').click();
debug('dowloading attachment');
const conversationStack = page.locator('.Inbox__conversation-stack');
const startDownload = conversationStack.getByRole('button', {
name: 'Start Download',
});
await startDownload.click();
debug('waiting for modal');
const modal = page.getByTestId('BackfillFailureModal');
await modal.waitFor();
await modal.locator('text=/internet connection/').waitFor();
});
it('should show modal on missing message', async () => {
const { phone, desktop } = bootstrap;
debug('sending a message with attachment that is 404 on CDN');
const timestamp = bootstrap.getTimestamp();
await sendTextMessage({
from: unknownContact,
to: desktop,
desktop,
text: undefined,
attachments: [{ ...catAttachment, cdnKey: 'cat-not-found' }],
timestamp,
});
debug('opening conversation');
const leftPane = page.locator('#LeftPane');
const conversationListItem = leftPane.getByRole('button', {
name: 'Chat with Unknown contact',
});
await conversationListItem.getByText('Message Request').click();
debug('dowloading attachment');
const conversationStack = page.locator('.Inbox__conversation-stack');
const startDownload = conversationStack.getByRole('button', {
name: 'Start Download',
});
await startDownload.click();
debug('waiting for request');
const {
syncMessage: { attachmentBackfillRequest: request },
} = await phone.waitForSyncMessage(entry => {
return entry.syncMessage.attachmentBackfillRequest != null;
});
assert.strictEqual(
request?.targetConversation?.threadServiceId,
unknownContact.device.aci
);
assert.strictEqual(
request?.targetMessage?.authorServiceId,
unknownContact.device.aci
);
assert.strictEqual(
request?.targetMessage?.sentTimestamp?.toNumber(),
timestamp
);
debug('sending not found response');
await phone.sendRaw(
desktop,
createResponse({
targetConversation: request.targetConversation,
targetMessage: request.targetMessage,
error:
Proto.SyncMessage.AttachmentBackfillResponse.Error.MESSAGE_NOT_FOUND,
}),
{
timestamp: bootstrap.getTimestamp(),
}
);
debug('waiting for modal');
const modal = page.getByTestId('BackfillFailureModal');
await modal.waitFor();
await modal.locator('text=/no longer available/').waitFor();
});
it('should resolve sticker', async () => {
const { phone, desktop } = bootstrap;
debug('sending a message with attachment that is 404 on CDN');
const timestamp = bootstrap.getTimestamp();
await sendTextMessage({
from: unknownContact,
to: desktop,
desktop,
text: undefined,
sticker: {
packId: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'),
packKey: Buffer.from(
'53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca',
'hex'
),
stickerId: 1,
emoji: '🐈',
data: { ...catAttachment, cdnKey: 'cat-not-found' },
},
timestamp,
});
debug('opening conversation');
const leftPane = page.locator('#LeftPane');
const conversationListItem = leftPane.getByRole('button', {
name: 'Chat with Unknown contact',
});
await conversationListItem.getByText('Message Request').click();
debug('dowloading attachment');
const conversationStack = page.locator('.Inbox__conversation-stack');
const startDownload = conversationStack.getByRole('button', {
name: 'Start Download',
});
await startDownload.click();
debug('waiting for backfill request');
const {
syncMessage: { attachmentBackfillRequest: request },
} = await phone.waitForSyncMessage(entry => {
return entry.syncMessage.attachmentBackfillRequest != null;
});
assert.strictEqual(
request?.targetConversation?.threadServiceId,
unknownContact.device.aci
);
assert.strictEqual(
request?.targetMessage?.authorServiceId,
unknownContact.device.aci
);
assert.strictEqual(
request?.targetMessage?.sentTimestamp?.toNumber(),
timestamp
);
debug('sending pending backfill response');
await phone.sendRaw(
desktop,
createResponse({
targetConversation: request.targetConversation,
targetMessage: request.targetMessage,
attachments: {
attachments: [{ status: Status.PENDING }],
},
}),
{
timestamp: bootstrap.getTimestamp(),
}
);
debug('resolving sticker');
await phone.sendRaw(
desktop,
createResponse({
targetConversation: request.targetConversation,
targetMessage: request.targetMessage,
attachments: {
attachments: [{ attachment: catAttachment }],
},
}),
{
timestamp: bootstrap.getTimestamp(),
}
);
await conversationStack
.locator('.module-image-grid--with-sticker')
.waitFor();
});
it('should not request backfill on quote/preview', async () => {
const { desktop } = bootstrap;
debug('sending a message with attachment that is 404 on CDN');
const timestamp = bootstrap.getTimestamp();
await sendTextMessage({
from: unknownContact,
to: desktop,
desktop,
quote: {
id: Long.fromNumber(bootstrap.getTimestamp()),
authorAci: unknownContact.device.aci,
text: 'quote text',
attachments: [
{
contentType: IMAGE_JPEG,
fileName: 'snow.jpg',
thumbnail: { ...snowAttachment, cdnKey: 'snow-not-found' },
},
],
type: Proto.DataMessage.Quote.Type.NORMAL,
},
preview: {
url: 'https://signal.org',
title: 'Signal',
image: { ...catAttachment, cdnKey: 'cat-not-found' },
},
text: 'https://signal.org',
timestamp,
});
debug('opening conversation');
const leftPane = page.locator('#LeftPane');
const conversationListItem = leftPane.getByRole('button', {
name: 'Chat with Unknown contact',
});
await conversationListItem.getByText('Message Request').click();
debug('dowloading attachment');
const conversationStack = page.locator('.Inbox__conversation-stack');
const startDownload = conversationStack.getByRole('button', {
name: 'Start Download',
});
await startDownload.click();
await page.locator('.Toast >> "Download failed"').waitFor();
});
});

View File

@@ -47,10 +47,10 @@ describe('unknown contacts', function (this: Mocha.Suite) {
debug('sending calling offer message'); debug('sending calling offer message');
await unknownContact.sendRaw(desktop, { await unknownContact.sendRaw(desktop, {
callingMessage: { callMessage: {
offer: { offer: {
callId: new Long(Math.floor(Math.random() * 1e10)), id: new Long(Math.floor(Math.random() * 1e10)),
type: Proto.CallingMessage.Offer.Type.OFFER_AUDIO_CALL, type: Proto.CallMessage.Offer.Type.OFFER_AUDIO_CALL,
opaque: new Uint8Array(0), opaque: new Uint8Array(0),
}, },
}, },

View File

@@ -260,7 +260,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
debug('Send a PNI sync message'); debug('Send a PNI sync message');
const timestamp = bootstrap.getTimestamp(); const timestamp = bootstrap.getTimestamp();
const destinationServiceId = stranger.device.pni; const destinationServiceId = stranger.device.pni;
const destination = stranger.device.number; const destinationE164 = stranger.device.number;
const destinationPniIdentityKey = await stranger.device.getIdentityKey( const destinationPniIdentityKey = await stranger.device.getIdentityKey(
ServiceIdKind.PNI ServiceIdKind.PNI
); );
@@ -272,7 +272,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
syncMessage: { syncMessage: {
sent: { sent: {
destinationServiceId, destinationServiceId,
destination, destinationE164,
timestamp: Long.fromNumber(timestamp), timestamp: Long.fromNumber(timestamp),
message: originalDataMessage, message: originalDataMessage,
unidentifiedStatus: [ unidentifiedStatus: [
@@ -322,7 +322,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
.innerText(); .innerText();
assert.equal( assert.equal(
strangerName.slice(-4), strangerName.slice(-4),
destination?.slice(-4), destinationE164?.slice(-4),
'no profile, just phone number' 'no profile, just phone number'
); );
} }

View File

@@ -40,6 +40,7 @@ import {
SignedPreKeys, SignedPreKeys,
} from '../LibSignalStores'; } from '../LibSignalStores';
import { verifySignature } from '../Curve'; import { verifySignature } from '../Curve';
import { createName } from '../util/attachmentPath';
import { assertDev, strictAssert } from '../util/assert'; import { assertDev, strictAssert } from '../util/assert';
import type { BatcherType } from '../util/batcher'; import type { BatcherType } from '../util/batcher';
import { createBatcher } from '../util/batcher'; import { createBatcher } from '../util/batcher';
@@ -97,14 +98,17 @@ import type {
UnprocessedType, UnprocessedType,
} from './Types.d'; } from './Types.d';
import type { import type {
ConversationToDelete, ConversationIdentifier,
DeleteForMeSyncEventData, DeleteForMeSyncEventData,
AttachmentBackfillAttachmentType,
AttachmentBackfillResponseSyncEventData,
DeleteForMeSyncTarget, DeleteForMeSyncTarget,
MessageToDelete, AddressableMessage,
ReadSyncEventData, ReadSyncEventData,
ViewSyncEventData, ViewSyncEventData,
} from './messageReceiverEvents'; } from './messageReceiverEvents';
import { import {
AttachmentBackfillResponseSyncEvent,
CallEventSyncEvent, CallEventSyncEvent,
CallLinkUpdateSyncEvent, CallLinkUpdateSyncEvent,
CallLogEventSyncEvent, CallLogEventSyncEvent,
@@ -678,6 +682,11 @@ export default class MessageReceiver
handler: (ev: DeleteForMeSyncEvent) => void handler: (ev: DeleteForMeSyncEvent) => void
): void; ): void;
public override addEventListener(
name: 'attachmentBackfillResponseSync',
handler: (ev: AttachmentBackfillResponseSyncEvent) => void
): void;
public override addEventListener( public override addEventListener(
name: 'deviceNameChangeSync', name: 'deviceNameChangeSync',
handler: (ev: DeviceNameChangeSyncEvent) => void handler: (ev: DeviceNameChangeSyncEvent) => void
@@ -3160,6 +3169,12 @@ export default class MessageReceiver
syncMessage.deviceNameChange syncMessage.deviceNameChange
); );
} }
if (syncMessage.attachmentBackfillResponse) {
return this.#handleAttachmentBackfillResponse(
envelope,
syncMessage.attachmentBackfillResponse
);
}
this.#removeFromCache(envelope); this.#removeFromCache(envelope);
const envelopeId = getEnvelopeId(envelope); const envelopeId = getEnvelopeId(envelope);
@@ -3601,10 +3616,10 @@ export default class MessageReceiver
deleteSync.messageDeletes deleteSync.messageDeletes
.flatMap((item): Array<DeleteForMeSyncTarget> | undefined => { .flatMap((item): Array<DeleteForMeSyncTarget> | undefined => {
const messages = item.messages const messages = item.messages
?.map(message => processMessageToDelete(message, logId)) ?.map(message => processAddressableMessage(message, logId))
.filter(isNotNil); .filter(isNotNil);
const conversation = item.conversation const conversation = item.conversation
? processConversationToDelete(item.conversation, logId) ? processConversationIdentifier(item.conversation, logId)
: undefined; : undefined;
if (!conversation) { if (!conversation) {
@@ -3639,14 +3654,14 @@ export default class MessageReceiver
deleteSync.conversationDeletes deleteSync.conversationDeletes
.map(item => { .map(item => {
const mostRecentMessages = item.mostRecentMessages const mostRecentMessages = item.mostRecentMessages
?.map(message => processMessageToDelete(message, logId)) ?.map(message => processAddressableMessage(message, logId))
.filter(isNotNil); .filter(isNotNil);
const mostRecentNonExpiringMessages = const mostRecentNonExpiringMessages =
item.mostRecentNonExpiringMessages item.mostRecentNonExpiringMessages
?.map(message => processMessageToDelete(message, logId)) ?.map(message => processAddressableMessage(message, logId))
.filter(isNotNil); .filter(isNotNil);
const conversation = item.conversation const conversation = item.conversation
? processConversationToDelete(item.conversation, logId) ? processConversationIdentifier(item.conversation, logId)
: undefined; : undefined;
if (!conversation) { if (!conversation) {
@@ -3680,7 +3695,7 @@ export default class MessageReceiver
deleteSync.localOnlyConversationDeletes deleteSync.localOnlyConversationDeletes
.map(item => { .map(item => {
const conversation = item.conversation const conversation = item.conversation
? processConversationToDelete(item.conversation, logId) ? processConversationIdentifier(item.conversation, logId)
: undefined; : undefined;
if (!conversation) { if (!conversation) {
@@ -3712,10 +3727,10 @@ export default class MessageReceiver
targetMessage, targetMessage,
} = item; } = item;
const conversation = targetConversation const conversation = targetConversation
? processConversationToDelete(targetConversation, logId) ? processConversationIdentifier(targetConversation, logId)
: undefined; : undefined;
const message = targetMessage const message = targetMessage
? processMessageToDelete(targetMessage, logId) ? processAddressableMessage(targetMessage, logId)
: undefined; : undefined;
if (!conversation) { if (!conversation) {
@@ -3782,6 +3797,84 @@ export default class MessageReceiver
log.info('handleDeleteForMeSync: finished'); log.info('handleDeleteForMeSync: finished');
} }
async #handleAttachmentBackfillResponse(
envelope: ProcessedEnvelope,
response: Proto.SyncMessage.IAttachmentBackfillResponse
): Promise<void> {
const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleAttachmentBackfillResponse', logId);
logUnexpectedUrgentValue(envelope, 'attachmentBackfillResponseSync');
strictAssert(
response.targetMessage != null,
'MessageReceiver.handleAttachmentBackfillResponse: no target message'
);
strictAssert(
response.targetConversation != null,
'MessageReceiver.handleAttachmentBackfillResponse: no target conversation'
);
const targetMessage = processAddressableMessage(
response.targetMessage,
logId
);
const targetConversation = processConversationIdentifier(
response.targetConversation,
logId
);
let eventData: AttachmentBackfillResponseSyncEventData;
if (response.error != null) {
eventData = {
error: response.error,
targetMessage,
targetConversation,
};
} else {
strictAssert(
targetMessage != null,
'MessageReceiver.handleAttachmentBackfillResponse: invalid target message'
);
strictAssert(
targetConversation != null,
'MessageReceiver.handleAttachmentBackfillResponse: ' +
'invalid target conversation'
);
const { attachments } = response;
strictAssert(
attachments != null,
'MessageReceiver.handleAttachmentBackfillResponse: no attachments'
);
eventData = {
targetMessage,
targetConversation,
longText:
attachments.longText == null
? undefined
: processBackfilledAttachment(attachments.longText),
attachments: (attachments.attachments ?? []).map(
processBackfilledAttachment
),
};
}
const attachmentBackfillResponseSync =
new AttachmentBackfillResponseSyncEvent(
eventData,
envelope.timestamp,
envelope.id,
this.#removeFromCache.bind(this, envelope)
);
await this.#dispatchAndWait(logId, attachmentBackfillResponseSync);
log.info('handleAttachmentBackfillResponse: finished');
}
async #handleDeviceNameChangeSync( async #handleDeviceNameChangeSync(
envelope: ProcessedEnvelope, envelope: ProcessedEnvelope,
deviceNameChange: Proto.SyncMessage.IDeviceNameChange deviceNameChange: Proto.SyncMessage.IDeviceNameChange
@@ -4030,14 +4123,14 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`); throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
} }
function processMessageToDelete( function processAddressableMessage(
target: Proto.SyncMessage.DeleteForMe.IAddressableMessage, target: Proto.IAddressableMessage,
logId: string logId: string
): MessageToDelete | undefined { ): AddressableMessage | undefined {
const sentAt = target.sentTimestamp?.toNumber(); const sentAt = target.sentTimestamp?.toNumber();
if (!isNumber(sentAt)) { if (!isNumber(sentAt)) {
log.warn( log.warn(
`${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.` `${logId}/processAddressableMessage: No sentTimestamp found! Dropping AddressableMessage.`
); );
return undefined; return undefined;
} }
@@ -4049,7 +4142,7 @@ function processMessageToDelete(
type: 'aci' as const, type: 'aci' as const,
authorAci: normalizeAci( authorAci: normalizeAci(
authorServiceId, authorServiceId,
`${logId}/processMessageToDelete/aci` `${logId}/processAddressableMessage/aci`
), ),
sentAt, sentAt,
}; };
@@ -4059,13 +4152,13 @@ function processMessageToDelete(
type: 'pni' as const, type: 'pni' as const,
authorPni: normalizePni( authorPni: normalizePni(
authorServiceId, authorServiceId,
`${logId}/processMessageToDelete/pni` `${logId}/processAddressableMessage/pni`
), ),
sentAt, sentAt,
}; };
} }
log.error( log.error(
`${logId}/processMessageToDelete: invalid authorServiceId, Dropping AddressableMessage.` `${logId}/processAddressableMessage: invalid authorServiceId, Dropping AddressableMessage.`
); );
return undefined; return undefined;
} }
@@ -4078,15 +4171,15 @@ function processMessageToDelete(
} }
log.warn( log.warn(
`${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.` `${logId}/processAddressableMessage: No author field found! Dropping AddressableMessage.`
); );
return undefined; return undefined;
} }
function processConversationToDelete( function processConversationIdentifier(
target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier, target: Proto.IConversationIdentifier,
logId: string logId: string
): ConversationToDelete | undefined { ): ConversationIdentifier | undefined {
const { threadServiceId, threadGroupId, threadE164 } = target; const { threadServiceId, threadGroupId, threadE164 } = target;
if (threadServiceId) { if (threadServiceId) {
@@ -4103,7 +4196,7 @@ function processConversationToDelete(
}; };
} }
log.error( log.error(
`${logId}/processConversationToDelete: Invalid threadServiceId, dropping ConversationIdentifier.` `${logId}/processConversationIdentifier: Invalid threadServiceId, dropping ConversationIdentifier.`
); );
return undefined; return undefined;
} }
@@ -4121,7 +4214,30 @@ function processConversationToDelete(
} }
log.warn( log.warn(
`${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.` `${logId}/processConversationIdentifier: No identifier field found! Dropping ConversationIdentifier.`
); );
return undefined; return undefined;
} }
function processBackfilledAttachment(
data: Proto.SyncMessage.AttachmentBackfillResponse.IAttachmentData
): AttachmentBackfillAttachmentType {
if (data.status != null) {
return { status: data.status };
}
const attachment = processAttachment(data.attachment);
strictAssert(
attachment != null,
'MessageReceiver.handleAttachmentBackfillResponse: ' +
'invalid attachment pointer'
);
return {
attachment: {
...attachment,
// Resumable downloads
downloadPath: createName(),
},
};
}

View File

@@ -81,12 +81,12 @@ import {
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import type { import type {
ConversationToDelete, ConversationIdentifier,
DeleteForMeSyncEventData, DeleteForMeSyncEventData,
DeleteMessageSyncTarget, DeleteMessageSyncTarget,
MessageToDelete, AddressableMessage,
} from './messageReceiverEvents'; } from './messageReceiverEvents';
import { getConversationFromTarget } from '../util/deleteForMe'; import { getConversationFromTarget } from '../util/syncIdentifiers';
import type { CallDetails, CallHistoryDetails } from '../types/CallDisposition'; import type { CallDetails, CallHistoryDetails } from '../types/CallDisposition';
import { import {
AdhocCallStatus, AdhocCallStatus,
@@ -1609,6 +1609,35 @@ export default class MessageSender {
}; };
} }
static getAttachmentBackfillSyncMessage(
targetConversation: ConversationIdentifier,
targetMessage: AddressableMessage
): SingleProtoJobData {
const myAci = window.textsecure.storage.user.getCheckedAci();
const syncMessage = this.createSyncMessage();
syncMessage.attachmentBackfillRequest = {
targetMessage: toAddressableMessage(targetMessage),
targetConversation: toConversationIdentifier(targetConversation),
};
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return {
contentHint: ContentHint.RESENDABLE,
serviceId: myAci,
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'attachmentBackfillRequestSync',
urgent: false,
};
}
static getClearCallHistoryMessage( static getClearCallHistoryMessage(
latestCall: CallHistoryDetails latestCall: CallHistoryDetails
): SingleProtoJobData { ): SingleProtoJobData {
@@ -2465,8 +2494,8 @@ export default class MessageSender {
// Helpers // Helpers
function toAddressableMessage(message: MessageToDelete) { function toAddressableMessage(message: AddressableMessage) {
const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage(); const targetMessage = new Proto.AddressableMessage();
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt); targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
if (message.type === 'aci') { if (message.type === 'aci') {
@@ -2482,9 +2511,8 @@ function toAddressableMessage(message: MessageToDelete) {
return targetMessage; return targetMessage;
} }
function toConversationIdentifier(conversation: ConversationToDelete) { function toConversationIdentifier(conversation: ConversationIdentifier) {
const targetConversation = const targetConversation = new Proto.ConversationIdentifier();
new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
if (conversation.type === 'aci') { if (conversation.type === 'aci') {
targetConversation.threadServiceId = conversation.aci; targetConversation.threadServiceId = conversation.aci;

View File

@@ -782,11 +782,13 @@ export type WebAPIConnectType = {
export type CapabilitiesType = { export type CapabilitiesType = {
deleteSync: boolean; deleteSync: boolean;
ssre2: boolean; ssre2: boolean;
attachmentBackfill: boolean;
}; };
export type CapabilitiesUploadType = { export type CapabilitiesUploadType = {
deleteSync: true; deleteSync: true;
versionedExpirationTimer: true; versionedExpirationTimer: true;
ssre2: true; ssre2: true;
attachmentBackfill: true;
}; };
type StickerPackManifestType = Uint8Array; type StickerPackManifestType = Uint8Array;
@@ -2992,6 +2994,7 @@ export function initialize({
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true, versionedExpirationTimer: true,
ssre2: true, ssre2: true,
attachmentBackfill: true,
}; };
const jsonData = { const jsonData = {
@@ -3048,6 +3051,7 @@ export function initialize({
deleteSync: true, deleteSync: true,
versionedExpirationTimer: true, versionedExpirationTimer: true,
ssre2: true, ssre2: true,
attachmentBackfill: true,
}; };
const jsonData = { const jsonData = {

View File

@@ -491,7 +491,7 @@ export class DeviceNameChangeSyncEvent extends ConfirmableEvent {
} }
} }
const messageToDeleteSchema = z.union([ const addressableMessageSchema = z.union([
z.object({ z.object({
type: z.literal('aci').readonly(), type: z.literal('aci').readonly(),
authorAci: z.string().refine(isAciString), authorAci: z.string().refine(isAciString),
@@ -509,9 +509,9 @@ const messageToDeleteSchema = z.union([
}), }),
]); ]);
export type MessageToDelete = z.infer<typeof messageToDeleteSchema>; export type AddressableMessage = z.infer<typeof addressableMessageSchema>;
const conversationToDeleteSchema = z.union([ const conversationIdentifierSchema = z.union([
z.object({ z.object({
type: z.literal('aci').readonly(), type: z.literal('aci').readonly(),
aci: z.string().refine(isAciString), aci: z.string().refine(isAciString),
@@ -530,32 +530,34 @@ const conversationToDeleteSchema = z.union([
}), }),
]); ]);
export type ConversationToDelete = z.infer<typeof conversationToDeleteSchema>; export type ConversationIdentifier = z.infer<
typeof conversationIdentifierSchema
>;
export const deleteMessageSchema = z.object({ export const deleteMessageSchema = z.object({
type: z.literal('delete-message').readonly(), type: z.literal('delete-message').readonly(),
conversation: conversationToDeleteSchema, conversation: conversationIdentifierSchema,
message: messageToDeleteSchema, message: addressableMessageSchema,
timestamp: z.number(), timestamp: z.number(),
}); });
export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>; export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>;
export const deleteConversationSchema = z.object({ export const deleteConversationSchema = z.object({
type: z.literal('delete-conversation').readonly(), type: z.literal('delete-conversation').readonly(),
conversation: conversationToDeleteSchema, conversation: conversationIdentifierSchema,
mostRecentMessages: z.array(messageToDeleteSchema), mostRecentMessages: z.array(addressableMessageSchema),
mostRecentNonExpiringMessages: z.array(messageToDeleteSchema).optional(), mostRecentNonExpiringMessages: z.array(addressableMessageSchema).optional(),
isFullDelete: z.boolean(), isFullDelete: z.boolean(),
timestamp: z.number(), timestamp: z.number(),
}); });
export const deleteLocalConversationSchema = z.object({ export const deleteLocalConversationSchema = z.object({
type: z.literal('delete-local-conversation').readonly(), type: z.literal('delete-local-conversation').readonly(),
conversation: conversationToDeleteSchema, conversation: conversationIdentifierSchema,
timestamp: z.number(), timestamp: z.number(),
}); });
export const deleteAttachmentSchema = z.object({ export const deleteAttachmentSchema = z.object({
type: z.literal('delete-single-attachment').readonly(), type: z.literal('delete-single-attachment').readonly(),
conversation: conversationToDeleteSchema, conversation: conversationIdentifierSchema,
message: messageToDeleteSchema, message: addressableMessageSchema,
clientUuid: z.string().optional(), clientUuid: z.string().optional(),
fallbackDigest: z.string().optional(), fallbackDigest: z.string().optional(),
fallbackPlaintextHash: z.string().optional(), fallbackPlaintextHash: z.string().optional(),
@@ -583,6 +585,40 @@ export class DeleteForMeSyncEvent extends ConfirmableEvent {
} }
} }
export type AttachmentBackfillAttachmentType = Readonly<
| {
attachment: ProcessedAttachment;
}
| {
status: Proto.SyncMessage.AttachmentBackfillResponse.AttachmentData.Status;
}
>;
export type AttachmentBackfillResponseSyncEventData = Readonly<
| {
error: Proto.SyncMessage.AttachmentBackfillResponse.Error;
targetMessage?: AddressableMessage;
targetConversation?: ConversationIdentifier;
}
| {
attachments: ReadonlyArray<AttachmentBackfillAttachmentType>;
longText: AttachmentBackfillAttachmentType | undefined;
targetMessage: AddressableMessage;
targetConversation: ConversationIdentifier;
}
>;
export class AttachmentBackfillResponseSyncEvent extends ConfirmableEvent {
constructor(
public readonly response: AttachmentBackfillResponseSyncEventData,
public readonly timestamp: number,
public readonly envelopeId: string,
confirm: ConfirmCallback
) {
super('attachmentBackfillResponseSync', confirm);
}
}
export type CallLogEventSyncEventData = Readonly<{ export type CallLogEventSyncEventData = Readonly<{
callLogEventDetails: CallLogEventDetails; callLogEventDetails: CallLogEventDetails;
receivedAtCounter: number; receivedAtCounter: number;

View File

@@ -69,3 +69,8 @@ export const attachmentDownloadJobSchema = coreAttachmentDownloadJobSchema.and(
attachment: Record<string, unknown>; attachment: Record<string, unknown>;
} }
>; >;
export enum AttachmentDownloadUrgency {
IMMEDIATE = 'immediate',
STANDARD = 'standard',
}

View File

@@ -200,6 +200,7 @@ export type StorageAccessType = {
observedCapabilities: { observedCapabilities: {
deleteSync?: true; deleteSync?: true;
ssre2?: true; ssre2?: true;
attachmentBackfill?: true;
// Note: Upon capability deprecation - change the value type to `never` and // Note: Upon capability deprecation - change the value type to `never` and
// remove it in `ts/background.ts` // remove it in `ts/background.ts`

View File

@@ -0,0 +1,12 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { omit } from 'lodash';
import { type AttachmentType } from '../../types/Attachment';
export function markAttachmentAsPermanentlyErrored(
attachment: AttachmentType
): AttachmentType {
return { ...omit(attachment, ['key', 'id']), pending: false, error: true };
}

View File

@@ -4,90 +4,27 @@
import { last, sortBy } from 'lodash'; import { last, sortBy } from 'lodash';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { isAciString } from './isAciString';
import { isGroup, isGroupV2 } from './whatTypeOfConversation';
import {
getConversationIdForLogging,
getMessageIdForLogging,
} from './idForLogging';
import { missingCaseError } from './missingCaseError';
import { getMessageSentTimestampSet } from './getMessageSentTimestampSet';
import { getAuthor } from '../messages/helpers';
import { isPniString } from '../types/ServiceId';
import { DataReader, DataWriter, deleteAndCleanup } from '../sql/Client'; import { DataReader, DataWriter, deleteAndCleanup } from '../sql/Client';
import { deleteData } from '../types/Attachment'; import { deleteData } from '../types/Attachment';
import type { import type { MessageAttributesType } from '../model-types';
ConversationAttributesType,
MessageAttributesType,
} from '../model-types';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import type { import type { AddressableMessage } from '../textsecure/messageReceiverEvents';
ConversationToDelete,
MessageToDelete,
} from '../textsecure/messageReceiverEvents';
import type { AciString, PniString } from '../types/ServiceId';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { cleanupMessages, postSaveUpdates } from './cleanup'; import { cleanupMessages, postSaveUpdates } from './cleanup';
import {
findMatchingMessage,
getMessageQueryFromTarget,
} from './syncIdentifiers';
const { getMessagesBySentAt, getMostRecentAddressableMessages } = DataReader; const { getMostRecentAddressableMessages } = DataReader;
const { removeMessagesInConversation, saveMessage } = DataWriter; const { removeMessagesInConversation, saveMessage } = DataWriter;
export function doesMessageMatch({
conversationId,
message,
query,
sentTimestamps,
}: {
message: MessageAttributesType;
conversationId: string;
query: MessageQuery;
sentTimestamps: ReadonlySet<number>;
}): boolean {
const author = getAuthor(message);
const conversationMatches = message.conversationId === conversationId;
const aciMatches =
query.authorAci && author?.attributes.serviceId === query.authorAci;
const pniMatches =
query.authorPni && author?.attributes.serviceId === query.authorPni;
const e164Matches =
query.authorE164 && author?.attributes.e164 === query.authorE164;
const timestampMatches = sentTimestamps.has(query.sentAt);
return Boolean(
conversationMatches &&
timestampMatches &&
(aciMatches || e164Matches || pniMatches)
);
}
export async function findMatchingMessage(
conversationId: string,
query: MessageQuery
): Promise<MessageAttributesType | undefined> {
const sentAtMatches = await getMessagesBySentAt(query.sentAt);
if (!sentAtMatches.length) {
return undefined;
}
return sentAtMatches.find(message => {
const sentTimestamps = getMessageSentTimestampSet(message);
return doesMessageMatch({
conversationId,
message,
query,
sentTimestamps,
});
});
}
export async function deleteMessage( export async function deleteMessage(
conversationId: string, conversationId: string,
targetMessage: MessageToDelete, targetMessage: AddressableMessage,
logId: string logId: string
): Promise<boolean> { ): Promise<boolean> {
const query = getMessageQueryFromTarget(targetMessage); const query = getMessageQueryFromTarget(targetMessage);
@@ -115,7 +52,7 @@ export async function applyDeleteMessage(
export async function deleteAttachmentFromMessage( export async function deleteAttachmentFromMessage(
conversationId: string, conversationId: string,
targetMessage: MessageToDelete, targetMessage: AddressableMessage,
deleteAttachmentData: { deleteAttachmentData: {
clientUuid?: string; clientUuid?: string;
fallbackDigest?: string; fallbackDigest?: string;
@@ -256,7 +193,7 @@ export async function applyDeleteAttachmentFromMessage(
async function getMostRecentMatchingMessage( async function getMostRecentMatchingMessage(
conversationId: string, conversationId: string,
targetMessages: Array<MessageToDelete> targetMessages: Array<AddressableMessage>
): Promise<MessageAttributesType | undefined> { ): Promise<MessageAttributesType | undefined> {
const queries = targetMessages.map(getMessageQueryFromTarget); const queries = targetMessages.map(getMessageQueryFromTarget);
const found = await Promise.all( const found = await Promise.all(
@@ -269,8 +206,8 @@ async function getMostRecentMatchingMessage(
export async function deleteConversation( export async function deleteConversation(
conversation: ConversationModel, conversation: ConversationModel,
mostRecentMessages: Array<MessageToDelete>, mostRecentMessages: Array<AddressableMessage>,
mostRecentNonExpiringMessages: Array<MessageToDelete> | undefined, mostRecentNonExpiringMessages: Array<AddressableMessage> | undefined,
isFullDelete: boolean, isFullDelete: boolean,
providedLogId: string providedLogId: string
): Promise<boolean> { ): Promise<boolean> {
@@ -351,118 +288,3 @@ export async function deleteLocalOnlyConversation(
return true; return true;
} }
export function getConversationFromTarget(
targetConversation: ConversationToDelete
): ConversationModel | undefined {
const { type } = targetConversation;
if (type === 'aci') {
return window.ConversationController.get(targetConversation.aci);
}
if (type === 'group') {
return window.ConversationController.get(targetConversation.groupId);
}
if (type === 'e164') {
return window.ConversationController.get(targetConversation.e164);
}
if (type === 'pni') {
return window.ConversationController.get(targetConversation.pni);
}
throw missingCaseError(type);
}
type MessageQuery = {
sentAt: number;
authorAci?: AciString;
authorE164?: string;
authorPni?: PniString;
};
export function getMessageQueryFromTarget(
targetMessage: MessageToDelete
): MessageQuery {
const { type, sentAt } = targetMessage;
if (type === 'aci') {
if (!isAciString(targetMessage.authorAci)) {
throw new Error('Provided authorAci was not an ACI!');
}
return { sentAt, authorAci: targetMessage.authorAci };
}
if (type === 'pni') {
if (!isPniString(targetMessage.authorPni)) {
throw new Error('Provided authorPni was not a PNI!');
}
return { sentAt, authorPni: targetMessage.authorPni };
}
if (type === 'e164') {
return { sentAt, authorE164: targetMessage.authorE164 };
}
throw missingCaseError(type);
}
export function getConversationToDelete(
attributes: ConversationAttributesType
): ConversationToDelete {
const { groupId, serviceId: aci, e164 } = attributes;
const idForLogging = getConversationIdForLogging(attributes);
const logId = `getConversationToDelete(${idForLogging})`;
if (isGroupV2(attributes) && groupId) {
return {
type: 'group',
groupId,
};
}
if (isGroup(attributes)) {
throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`);
}
if (aci && isAciString(aci)) {
return {
type: 'aci',
aci,
};
}
if (e164) {
return {
type: 'e164',
e164,
};
}
throw new Error(`${logId}: No valid identifier found!`);
}
export function getMessageToDelete(
attributes: MessageAttributesType
): MessageToDelete | undefined {
const logId = `getMessageToDelete(${getMessageIdForLogging(attributes)})`;
const { sent_at: sentAt } = attributes;
const author = getAuthor(attributes);
const authorAci = author?.get('serviceId');
const authorE164 = author?.get('e164');
if (authorAci && isAciString(authorAci)) {
return {
type: 'aci' as const,
authorAci,
sentAt,
};
}
if (authorE164) {
return {
type: 'e164' as const,
authorE164,
sentAt,
};
}
log.warn(`${logId}: Message was missing source ACI/e164`);
return undefined;
}

View File

@@ -70,6 +70,8 @@ export const sendTypesEnum = z.enum([
'callLinkUpdateSync', 'callLinkUpdateSync',
'callLogEventSync', 'callLogEventSync',
'deviceNameChangeSync', 'deviceNameChangeSync',
'attachmentBackfillRequestSync',
'attachmentBackfillResponseSync',
// No longer used, all non-urgent // No longer used, all non-urgent
'legacyGroupChange', 'legacyGroupChange',

View File

@@ -26,13 +26,12 @@ import {
isVoiceMessage, isVoiceMessage,
partitionBodyAndNormalAttachments, partitionBodyAndNormalAttachments,
} from '../types/Attachment'; } from '../types/Attachment';
import { AttachmentDownloadUrgency } from '../types/AttachmentDownload';
import type { StickerType } from '../types/Stickers'; import type { StickerType } from '../types/Stickers';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { strictAssert } from './assert';
import { isNotNil } from './isNotNil'; import { isNotNil } from './isNotNil';
import { import { AttachmentDownloadManager } from '../jobs/AttachmentDownloadManager';
AttachmentDownloadManager,
AttachmentDownloadUrgency,
} from '../jobs/AttachmentDownloadManager';
import { AttachmentDownloadSource } from '../sql/Interface'; import { AttachmentDownloadSource } from '../sql/Interface';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
@@ -97,6 +96,7 @@ export async function queueAttachmentDownloadsForMessage(
message: MessageModel, message: MessageModel,
options: { options: {
urgency?: AttachmentDownloadUrgency; urgency?: AttachmentDownloadUrgency;
source?: AttachmentDownloadSource;
isManualDownload: boolean; isManualDownload: boolean;
} }
): Promise<boolean> { ): Promise<boolean> {
@@ -304,7 +304,6 @@ export async function queueAttachmentDownloads(
let sticker = message.get('sticker'); let sticker = message.get('sticker');
let copiedSticker = false; let copiedSticker = false;
let queuedStickerDownload = false;
if (sticker && sticker.data && sticker.data.path) { if (sticker && sticker.data && sticker.data.path) {
log.info(`${logId}: Sticker attachment already downloaded`); log.info(`${logId}: Sticker attachment already downloaded`);
} else if (sticker) { } else if (sticker) {
@@ -312,13 +311,23 @@ export async function queueAttachmentDownloads(
const { packId, stickerId, packKey } = sticker; const { packId, stickerId, packKey } = sticker;
const status = getStickerPackStatus(packId); const status = getStickerPackStatus(packId);
let data: AttachmentType | undefined;
if (status && (status === 'downloaded' || status === 'installed')) { if (status && (status === 'downloaded' || status === 'installed')) {
try { try {
log.info(`${logId}: Copying sticker from installed pack`); log.info(`${logId}: Copying sticker from installed pack`);
data = await copyStickerToAttachments(packId, stickerId);
copiedSticker = true; copiedSticker = true;
const data = await copyStickerToAttachments(packId, stickerId);
// Refresh sticker attachment since we had to await above
const freshSticker = message.get('sticker');
strictAssert(freshSticker != null, 'Sticker is gone while copying');
sticker = {
...freshSticker,
data,
};
message.set({
sticker,
});
} catch (error) { } catch (error) {
log.error( log.error(
`${logId}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`, `${logId}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
@@ -327,11 +336,10 @@ export async function queueAttachmentDownloads(
} }
} }
if (!data) { if (!copiedSticker) {
if (sticker.data) { if (sticker.data) {
log.info(`${logId}: Queueing sticker download`); log.info(`${logId}: Queueing sticker download`);
queuedStickerDownload = true; await AttachmentDownloadManager.addJob({
data = await AttachmentDownloadManager.addJob({
attachment: sticker.data, attachment: sticker.data,
attachmentType: 'sticker', attachmentType: 'sticker',
isManualDownload, isManualDownload,
@@ -357,18 +365,6 @@ export async function queueAttachmentDownloads(
} else { } else {
await DataWriter.addStickerPackReference(stickerRef); await DataWriter.addStickerPackReference(stickerRef);
} }
if (!data) {
throw new Error('queueAttachmentDownloads: Failed to fetch sticker data');
}
sticker = {
...sticker,
data,
};
}
if (queuedStickerDownload || copiedSticker) {
message.set({ sticker });
} }
let editHistory = message.get('editHistory'); let editHistory = message.get('editHistory');

View File

@@ -0,0 +1,29 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ToastType } from '../types/Toast';
import { SECOND } from './durations';
import { isOlderThan } from './timestamp';
const DOWNLOAD_FAILED_TIMESTAMP_REST = 10 * SECOND;
export const lastErrorsByMessageId = new Map<string, number>();
export function showDownloadFailedToast(messageId: string): void {
const now = Date.now();
for (const [id, timestamp] of lastErrorsByMessageId) {
if (isOlderThan(timestamp, DOWNLOAD_FAILED_TIMESTAMP_REST)) {
lastErrorsByMessageId.delete(id);
}
}
const existing = lastErrorsByMessageId.get(messageId);
if (!existing) {
window.reduxActions.toast.showToast({
toastType: ToastType.AttachmentDownloadFailed,
parameters: { messageId },
});
lastErrorsByMessageId.set(messageId, now);
}
}

193
ts/util/syncIdentifiers.ts Normal file
View File

@@ -0,0 +1,193 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
ConversationIdentifier,
AddressableMessage,
} from '../textsecure/messageReceiverEvents';
import type {
ConversationAttributesType,
ReadonlyMessageAttributesType,
MessageAttributesType,
} from '../model-types';
import type { AciString, PniString } from '../types/ServiceId';
import { isPniString } from '../types/ServiceId';
import { getAuthor } from '../messages/helpers';
import * as log from '../logging/log';
import type { ConversationModel } from '../models/conversations';
import { DataReader } from '../sql/Client';
import {
getConversationIdForLogging,
getMessageIdForLogging,
} from './idForLogging';
import { isGroup, isGroupV2 } from './whatTypeOfConversation';
import { getMessageSentTimestampSet } from './getMessageSentTimestampSet';
import { isAciString } from './isAciString';
import { missingCaseError } from './missingCaseError';
const { getMessagesBySentAt } = DataReader;
export function doesMessageMatch({
conversationId,
message,
query,
sentTimestamps,
}: {
message: ReadonlyMessageAttributesType;
conversationId: string;
query: MessageQuery;
sentTimestamps: ReadonlySet<number>;
}): boolean {
const author = getAuthor(message);
const conversationMatches = message.conversationId === conversationId;
const aciMatches =
query.authorAci && author?.attributes.serviceId === query.authorAci;
const pniMatches =
query.authorPni && author?.attributes.serviceId === query.authorPni;
const e164Matches =
query.authorE164 && author?.attributes.e164 === query.authorE164;
const timestampMatches = sentTimestamps.has(query.sentAt);
return Boolean(
conversationMatches &&
timestampMatches &&
(aciMatches || e164Matches || pniMatches)
);
}
export async function findMatchingMessage(
conversationId: string,
query: MessageQuery
): Promise<MessageAttributesType | undefined> {
const sentAtMatches = await getMessagesBySentAt(query.sentAt);
if (!sentAtMatches.length) {
return undefined;
}
return sentAtMatches.find(message => {
const sentTimestamps = getMessageSentTimestampSet(message);
return doesMessageMatch({
conversationId,
message,
query,
sentTimestamps,
});
});
}
export function getConversationFromTarget(
targetConversation: ConversationIdentifier
): ConversationModel | undefined {
const { type } = targetConversation;
if (type === 'aci') {
return window.ConversationController.get(targetConversation.aci);
}
if (type === 'group') {
return window.ConversationController.get(targetConversation.groupId);
}
if (type === 'e164') {
return window.ConversationController.get(targetConversation.e164);
}
if (type === 'pni') {
return window.ConversationController.get(targetConversation.pni);
}
throw missingCaseError(type);
}
export type MessageQuery = Readonly<{
sentAt: number;
authorAci?: AciString;
authorE164?: string;
authorPni?: PniString;
}>;
export function getMessageQueryFromTarget(
targetMessage: AddressableMessage
): MessageQuery {
const { type, sentAt } = targetMessage;
if (type === 'aci') {
if (!isAciString(targetMessage.authorAci)) {
throw new Error('Provided authorAci was not an ACI!');
}
return { sentAt, authorAci: targetMessage.authorAci };
}
if (type === 'pni') {
if (!isPniString(targetMessage.authorPni)) {
throw new Error('Provided authorPni was not a PNI!');
}
return { sentAt, authorPni: targetMessage.authorPni };
}
if (type === 'e164') {
return { sentAt, authorE164: targetMessage.authorE164 };
}
throw missingCaseError(type);
}
export function getConversationIdentifier(
attributes: ConversationAttributesType
): ConversationIdentifier {
const { groupId, serviceId: aci, e164 } = attributes;
const idForLogging = getConversationIdForLogging(attributes);
const logId = `getConversationIdentifier(${idForLogging})`;
if (isGroupV2(attributes) && groupId) {
return {
type: 'group',
groupId,
};
}
if (isGroup(attributes)) {
throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`);
}
if (aci && isAciString(aci)) {
return {
type: 'aci',
aci,
};
}
if (e164) {
return {
type: 'e164',
e164,
};
}
throw new Error(`${logId}: No valid identifier found!`);
}
export function getAddressableMessage(
attributes: ReadonlyMessageAttributesType
): AddressableMessage | undefined {
const logId = `getAddressableMessage(${getMessageIdForLogging(attributes)})`;
const { sent_at: sentAt } = attributes;
const author = getAuthor(attributes);
const authorAci = author?.get('serviceId');
const authorE164 = author?.get('e164');
if (authorAci && isAciString(authorAci)) {
return {
type: 'aci' as const,
authorAci,
sentAt,
};
}
if (authorE164) {
return {
type: 'e164' as const,
authorE164,
sentAt,
};
}
log.warn(`${logId}: Message was missing source ACI/e164`);
return undefined;
}

View File

@@ -17,11 +17,8 @@ import {
receiptSyncTaskSchema, receiptSyncTaskSchema,
onReceipt, onReceipt,
} from '../messageModifiers/MessageReceipts'; } from '../messageModifiers/MessageReceipts';
import { import { deleteConversation, deleteLocalOnlyConversation } from './deleteForMe';
deleteConversation, import { getConversationFromTarget } from './syncIdentifiers';
deleteLocalOnlyConversation,
getConversationFromTarget,
} from './deleteForMe';
import { import {
onSync as onReadSync, onSync as onReadSync,
readSyncTaskSchema, readSyncTaskSchema,