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.",
"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": {
"messageformat": "Save",
"description": "Used on save buttons"

View File

@@ -222,7 +222,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1",
"@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-actions": "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
version: 0.1.61
'@signalapp/mock-server':
specifier: 11.1.0
version: 11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
specifier: 11.2.0
version: 11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y':
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))
@@ -2543,8 +2543,8 @@ packages:
'@signalapp/libsignal-client@0.67.4':
resolution: {integrity: sha512-nenGxomG2zH0uCoFSwBzofqSAHnJRdbIbLr8libGy9y3rCL2z62nHL79Kh1o46ZnzxgAA7Ay3/qMhwPcXq7Iig==}
'@signalapp/mock-server@11.1.0':
resolution: {integrity: sha512-SYYek3QCh57vZZGNVQW+fSXMr+xHjnO8sMAFfj8DovjqW4HIBWbt3itGyyjxSoIXnaCMK346O6I/R79w8xY/aw==}
'@signalapp/mock-server@11.2.0':
resolution: {integrity: sha512-y8bueRcXVulyXRRVm2M/qT7YmxGpUbiwQsRSi7a+DDI4aUeZIDW9z7KgjElv1CN1/n9O6M1bYO+TLy4ys+7U6w==}
'@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@@ -12260,7 +12260,7 @@ snapshots:
type-fest: 4.26.1
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:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.60.2

View File

@@ -631,22 +631,6 @@ message SyncMessage {
}
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 {
optional ConversationIdentifier conversation = 1;
repeated AddressableMessage messages = 2;
@@ -685,6 +669,42 @@ message SyncMessage {
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 Contacts contacts = 2;
reserved /*groups*/ 3;
@@ -707,6 +727,8 @@ message SyncMessage {
optional CallLogEvent callLogEvent = 21;
optional DeleteForMe deleteForMe = 22;
optional DeviceNameChange deviceNameChange = 23;
optional AttachmentBackfillRequest attachmentBackfillRequest = 24;
optional AttachmentBackfillResponse attachmentBackfillResponse = 25;
}
message AttachmentPointer {
@@ -807,3 +829,19 @@ message BodyRange {
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/AvatarPreview.scss';
@use 'components/AvatarTextEditor.scss';
@use 'components/BackfillFailureModal.scss';
@use 'components/BackupMediaDownloadProgress.scss';
@use 'components/BadgeCarouselIndex.scss';
@use 'components/BadgeDialog.scss';

View File

@@ -76,6 +76,7 @@ import { LatestQueue } from './util/LatestQueue';
import { parseIntOrThrow } from './util/parseIntOrThrow';
import { getProfile } from './util/getProfile';
import type {
AttachmentBackfillResponseSyncEvent,
ConfigurationEvent,
DeliveryEvent,
EnvelopeQueuedEvent,
@@ -721,6 +722,10 @@ export async function startApp(): Promise<void> {
'deleteForMeSync',
queuedEventListener(onDeleteForMeSync, false)
);
messageReceiver.addEventListener(
'attachmentBackfillResponseSync',
queuedEventListener(onAttachmentBackfillResponseSync, false)
);
messageReceiver.addEventListener(
'deviceNameChangeSync',
queuedEventListener(onDeviceNameChangeSync, false)
@@ -1917,6 +1922,7 @@ export async function startApp(): Promise<void> {
deleteSync: true,
versionedExpirationTimer: true,
ssre2: true,
attachmentBackfill: true,
});
} catch (error) {
log.error(
@@ -3689,6 +3695,13 @@ export async function startApp(): Promise<void> {
log.info(`${logId}: Done`);
}
async function onAttachmentBackfillResponseSync(
ev: AttachmentBackfillResponseSyncEvent
) {
const { confirm } = ev;
await AttachmentDownloadManager.handleBackfillResponse(ev);
confirm();
}
}
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,
type DataPropsType as TapToViewNotAvailablePropsType,
} from './TapToViewNotAvailableModal';
import {
BackfillFailureModal,
type DataPropsType as BackfillFailureModalPropsType,
} from './BackfillFailureModal';
// NOTE: All types should be required for this component so that the smart
// component gives you type errors when adding/removing props.
@@ -124,6 +128,9 @@ export type PropsType = {
// TapToViewNotAvailableModal
tapToViewNotAvailableModalProps: TapToViewNotAvailablePropsType | undefined;
hideTapToViewNotAvailableModal: () => void;
// BackfillFailureModal
backfillFailureModalProps: BackfillFailureModalPropsType | undefined;
hideBackfillFailureModal: () => void;
// UserNotFoundModal
hideUserNotFoundModal: () => unknown;
userNotFoundModalState: UserNotFoundModalStateType | undefined;
@@ -214,6 +221,9 @@ export function GlobalModalContainer({
// TapToViewNotAvailableModal
tapToViewNotAvailableModalProps,
hideTapToViewNotAvailableModal,
// BackfillFailureModal
backfillFailureModalProps,
hideBackfillFailureModal,
// UserNotFoundModal
hideUserNotFoundModal,
userNotFoundModalState,
@@ -394,5 +404,15 @@ export function GlobalModalContainer({
);
}
if (backfillFailureModalProps != null) {
return (
<BackfillFailureModal
i18n={i18n}
onClose={hideBackfillFailureModal}
{...backfillFailureModalProps}
/>
);
}
return null;
}

View File

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

View File

@@ -4,10 +4,12 @@ import { noop, omit, throttle } from 'lodash';
import * as durations from '../util/durations';
import * as log from '../logging/log';
import type { AttachmentBackfillResponseSyncEvent } from '../textsecure/messageReceiverEvents';
import {
type AttachmentDownloadJobTypeType,
type AttachmentDownloadJobType,
type CoreAttachmentDownloadJobType,
AttachmentDownloadUrgency,
coreAttachmentDownloadJobSchema,
} from '../types/AttachmentDownload';
import {
@@ -24,6 +26,7 @@ import {
AttachmentVariant,
mightBeOnBackupTier,
} from '../types/Attachment';
import { type ReadonlyMessageAttributesType } from '../model-types.d';
import { getMessageById } from '../messages/getMessageById';
import {
KIBIBYTE,
@@ -53,13 +56,9 @@ import {
import { safeParsePartial } from '../util/schemas';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
import { createBatcher } from '../util/batcher';
import { isOlderThan } from '../util/timestamp';
import { ToastType } from '../types/Toast';
export enum AttachmentDownloadUrgency {
IMMEDIATE = 'immediate',
STANDARD = 'standard',
}
import { showDownloadFailedToast } from '../util/showDownloadFailedToast';
import { markAttachmentAsPermanentlyErrored } from '../util/attachments/markAttachmentAsPermanentlyErrored';
import { AttachmentBackfill } from './helpers/attachmentBackfill';
// Type for adding a new job
export type NewAttachmentDownloadJobType = {
@@ -74,7 +73,6 @@ export type NewAttachmentDownloadJobType = {
};
const MAX_CONCURRENT_JOBS = 3;
const DOWNLOAD_FAILED_TIMESTAMP_REST = 10 * durations.SECOND;
const DEFAULT_RETRY_CONFIG = {
maxAttempts: 5,
@@ -134,6 +132,8 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
},
});
#attachmentBackfill = new AttachmentBackfill();
private static _instance: AttachmentDownloadManager | undefined;
override logPrefix = 'AttachmentDownloadManager';
@@ -310,6 +310,18 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
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 = {
@@ -412,9 +424,21 @@ async function runDownloadAttachmentJob({
}
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(
message.id,
_markAttachmentAsPermanentlyErrored(job.attachment),
markAttachmentAsPermanentlyErrored(job.attachment),
logId,
{ 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 (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);
}
@@ -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({
attachment,
abortSignal,
@@ -711,17 +733,11 @@ async function downloadBackupThumbnail({
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
return {
..._markAttachmentAsPermanentlyErrored(attachment),
...markAttachmentAsPermanentlyErrored(attachment),
wasTooBig: true,
};
}
function _markAttachmentAsPermanentlyErrored(
attachment: AttachmentType
): AttachmentType {
return { ...omit(attachment, ['key', 'id']), pending: false, error: true };
}
function _markAttachmentAsTransientlyErrored(
attachment: 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 {
ConversationToDelete,
MessageToDelete,
ConversationIdentifier,
AddressableMessage,
} from '../textsecure/messageReceiverEvents';
import {
deleteAttachmentFromMessage,
deleteMessage,
} from '../util/deleteForMe';
import {
doesMessageMatch,
getConversationFromTarget,
getMessageQueryFromTarget,
} from '../util/deleteForMe';
} from '../util/syncIdentifiers';
import { DataWriter } from '../sql/Client';
const { removeSyncTaskById } = DataWriter;
export type DeleteForMeAttributesType = {
conversation: ConversationToDelete;
conversation: ConversationIdentifier;
deleteAttachmentData?: {
clientUuid?: string;
fallbackDigest?: string;
fallbackPlaintextHash?: string;
};
envelopeId: string;
message: MessageToDelete;
message: AddressableMessage;
syncTaskId: string;
timestamp: number;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -146,13 +146,19 @@ export function sendTextMessage({
to,
text,
attachments,
sticker,
preview,
quote,
desktop,
timestamp = Date.now(),
}: {
from: PrimaryDevice;
to: PrimaryDevice | Device | GroupInfo;
text: string;
text: string | undefined;
attachments?: Array<Proto.IAttachmentPointer>;
sticker?: Proto.DataMessage.ISticker;
preview?: Proto.IPreview;
quote?: Proto.DataMessage.IQuote;
desktop: Device;
timestamp?: number;
}): Promise<void> {
@@ -167,6 +173,9 @@ export function sendTextMessage({
dataMessage: {
body: text,
attachments,
sticker,
preview: preview == null ? null : [preview],
quote,
timestamp: Long.fromNumber(timestamp),
groupV2: groupInfo
? {
@@ -209,7 +218,7 @@ export function sendReaction({
reaction: {
emoji,
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');
await unknownContact.sendRaw(desktop, {
callingMessage: {
callMessage: {
offer: {
callId: new Long(Math.floor(Math.random() * 1e10)),
type: Proto.CallingMessage.Offer.Type.OFFER_AUDIO_CALL,
id: new Long(Math.floor(Math.random() * 1e10)),
type: Proto.CallMessage.Offer.Type.OFFER_AUDIO_CALL,
opaque: new Uint8Array(0),
},
},

View File

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

View File

@@ -40,6 +40,7 @@ import {
SignedPreKeys,
} from '../LibSignalStores';
import { verifySignature } from '../Curve';
import { createName } from '../util/attachmentPath';
import { assertDev, strictAssert } from '../util/assert';
import type { BatcherType } from '../util/batcher';
import { createBatcher } from '../util/batcher';
@@ -97,14 +98,17 @@ import type {
UnprocessedType,
} from './Types.d';
import type {
ConversationToDelete,
ConversationIdentifier,
DeleteForMeSyncEventData,
AttachmentBackfillAttachmentType,
AttachmentBackfillResponseSyncEventData,
DeleteForMeSyncTarget,
MessageToDelete,
AddressableMessage,
ReadSyncEventData,
ViewSyncEventData,
} from './messageReceiverEvents';
import {
AttachmentBackfillResponseSyncEvent,
CallEventSyncEvent,
CallLinkUpdateSyncEvent,
CallLogEventSyncEvent,
@@ -678,6 +682,11 @@ export default class MessageReceiver
handler: (ev: DeleteForMeSyncEvent) => void
): void;
public override addEventListener(
name: 'attachmentBackfillResponseSync',
handler: (ev: AttachmentBackfillResponseSyncEvent) => void
): void;
public override addEventListener(
name: 'deviceNameChangeSync',
handler: (ev: DeviceNameChangeSyncEvent) => void
@@ -3160,6 +3169,12 @@ export default class MessageReceiver
syncMessage.deviceNameChange
);
}
if (syncMessage.attachmentBackfillResponse) {
return this.#handleAttachmentBackfillResponse(
envelope,
syncMessage.attachmentBackfillResponse
);
}
this.#removeFromCache(envelope);
const envelopeId = getEnvelopeId(envelope);
@@ -3601,10 +3616,10 @@ export default class MessageReceiver
deleteSync.messageDeletes
.flatMap((item): Array<DeleteForMeSyncTarget> | undefined => {
const messages = item.messages
?.map(message => processMessageToDelete(message, logId))
?.map(message => processAddressableMessage(message, logId))
.filter(isNotNil);
const conversation = item.conversation
? processConversationToDelete(item.conversation, logId)
? processConversationIdentifier(item.conversation, logId)
: undefined;
if (!conversation) {
@@ -3639,14 +3654,14 @@ export default class MessageReceiver
deleteSync.conversationDeletes
.map(item => {
const mostRecentMessages = item.mostRecentMessages
?.map(message => processMessageToDelete(message, logId))
?.map(message => processAddressableMessage(message, logId))
.filter(isNotNil);
const mostRecentNonExpiringMessages =
item.mostRecentNonExpiringMessages
?.map(message => processMessageToDelete(message, logId))
?.map(message => processAddressableMessage(message, logId))
.filter(isNotNil);
const conversation = item.conversation
? processConversationToDelete(item.conversation, logId)
? processConversationIdentifier(item.conversation, logId)
: undefined;
if (!conversation) {
@@ -3680,7 +3695,7 @@ export default class MessageReceiver
deleteSync.localOnlyConversationDeletes
.map(item => {
const conversation = item.conversation
? processConversationToDelete(item.conversation, logId)
? processConversationIdentifier(item.conversation, logId)
: undefined;
if (!conversation) {
@@ -3712,10 +3727,10 @@ export default class MessageReceiver
targetMessage,
} = item;
const conversation = targetConversation
? processConversationToDelete(targetConversation, logId)
? processConversationIdentifier(targetConversation, logId)
: undefined;
const message = targetMessage
? processMessageToDelete(targetMessage, logId)
? processAddressableMessage(targetMessage, logId)
: undefined;
if (!conversation) {
@@ -3782,6 +3797,84 @@ export default class MessageReceiver
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(
envelope: ProcessedEnvelope,
deviceNameChange: Proto.SyncMessage.IDeviceNameChange
@@ -4030,14 +4123,14 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
}
function processMessageToDelete(
target: Proto.SyncMessage.DeleteForMe.IAddressableMessage,
function processAddressableMessage(
target: Proto.IAddressableMessage,
logId: string
): MessageToDelete | undefined {
): AddressableMessage | undefined {
const sentAt = target.sentTimestamp?.toNumber();
if (!isNumber(sentAt)) {
log.warn(
`${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.`
`${logId}/processAddressableMessage: No sentTimestamp found! Dropping AddressableMessage.`
);
return undefined;
}
@@ -4049,7 +4142,7 @@ function processMessageToDelete(
type: 'aci' as const,
authorAci: normalizeAci(
authorServiceId,
`${logId}/processMessageToDelete/aci`
`${logId}/processAddressableMessage/aci`
),
sentAt,
};
@@ -4059,13 +4152,13 @@ function processMessageToDelete(
type: 'pni' as const,
authorPni: normalizePni(
authorServiceId,
`${logId}/processMessageToDelete/pni`
`${logId}/processAddressableMessage/pni`
),
sentAt,
};
}
log.error(
`${logId}/processMessageToDelete: invalid authorServiceId, Dropping AddressableMessage.`
`${logId}/processAddressableMessage: invalid authorServiceId, Dropping AddressableMessage.`
);
return undefined;
}
@@ -4078,15 +4171,15 @@ function processMessageToDelete(
}
log.warn(
`${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.`
`${logId}/processAddressableMessage: No author field found! Dropping AddressableMessage.`
);
return undefined;
}
function processConversationToDelete(
target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier,
function processConversationIdentifier(
target: Proto.IConversationIdentifier,
logId: string
): ConversationToDelete | undefined {
): ConversationIdentifier | undefined {
const { threadServiceId, threadGroupId, threadE164 } = target;
if (threadServiceId) {
@@ -4103,7 +4196,7 @@ function processConversationToDelete(
};
}
log.error(
`${logId}/processConversationToDelete: Invalid threadServiceId, dropping ConversationIdentifier.`
`${logId}/processConversationIdentifier: Invalid threadServiceId, dropping ConversationIdentifier.`
);
return undefined;
}
@@ -4121,7 +4214,30 @@ function processConversationToDelete(
}
log.warn(
`${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.`
`${logId}/processConversationIdentifier: No identifier field found! Dropping ConversationIdentifier.`
);
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 { drop } from '../util/drop';
import type {
ConversationToDelete,
ConversationIdentifier,
DeleteForMeSyncEventData,
DeleteMessageSyncTarget,
MessageToDelete,
AddressableMessage,
} from './messageReceiverEvents';
import { getConversationFromTarget } from '../util/deleteForMe';
import { getConversationFromTarget } from '../util/syncIdentifiers';
import type { CallDetails, CallHistoryDetails } from '../types/CallDisposition';
import {
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(
latestCall: CallHistoryDetails
): SingleProtoJobData {
@@ -2465,8 +2494,8 @@ export default class MessageSender {
// Helpers
function toAddressableMessage(message: MessageToDelete) {
const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage();
function toAddressableMessage(message: AddressableMessage) {
const targetMessage = new Proto.AddressableMessage();
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
if (message.type === 'aci') {
@@ -2482,9 +2511,8 @@ function toAddressableMessage(message: MessageToDelete) {
return targetMessage;
}
function toConversationIdentifier(conversation: ConversationToDelete) {
const targetConversation =
new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
function toConversationIdentifier(conversation: ConversationIdentifier) {
const targetConversation = new Proto.ConversationIdentifier();
if (conversation.type === 'aci') {
targetConversation.threadServiceId = conversation.aci;

View File

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

View File

@@ -491,7 +491,7 @@ export class DeviceNameChangeSyncEvent extends ConfirmableEvent {
}
}
const messageToDeleteSchema = z.union([
const addressableMessageSchema = z.union([
z.object({
type: z.literal('aci').readonly(),
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({
type: z.literal('aci').readonly(),
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({
type: z.literal('delete-message').readonly(),
conversation: conversationToDeleteSchema,
message: messageToDeleteSchema,
conversation: conversationIdentifierSchema,
message: addressableMessageSchema,
timestamp: z.number(),
});
export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>;
export const deleteConversationSchema = z.object({
type: z.literal('delete-conversation').readonly(),
conversation: conversationToDeleteSchema,
mostRecentMessages: z.array(messageToDeleteSchema),
mostRecentNonExpiringMessages: z.array(messageToDeleteSchema).optional(),
conversation: conversationIdentifierSchema,
mostRecentMessages: z.array(addressableMessageSchema),
mostRecentNonExpiringMessages: z.array(addressableMessageSchema).optional(),
isFullDelete: z.boolean(),
timestamp: z.number(),
});
export const deleteLocalConversationSchema = z.object({
type: z.literal('delete-local-conversation').readonly(),
conversation: conversationToDeleteSchema,
conversation: conversationIdentifierSchema,
timestamp: z.number(),
});
export const deleteAttachmentSchema = z.object({
type: z.literal('delete-single-attachment').readonly(),
conversation: conversationToDeleteSchema,
message: messageToDeleteSchema,
conversation: conversationIdentifierSchema,
message: addressableMessageSchema,
clientUuid: z.string().optional(),
fallbackDigest: 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<{
callLogEventDetails: CallLogEventDetails;
receivedAtCounter: number;

View File

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

View File

@@ -200,6 +200,7 @@ export type StorageAccessType = {
observedCapabilities: {
deleteSync?: true;
ssre2?: true;
attachmentBackfill?: true;
// Note: Upon capability deprecation - change the value type to `never` and
// 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 * 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 { deleteData } from '../types/Attachment';
import type {
ConversationAttributesType,
MessageAttributesType,
} from '../model-types';
import type { MessageAttributesType } from '../model-types';
import type { ConversationModel } from '../models/conversations';
import type {
ConversationToDelete,
MessageToDelete,
} from '../textsecure/messageReceiverEvents';
import type { AciString, PniString } from '../types/ServiceId';
import type { AddressableMessage } from '../textsecure/messageReceiverEvents';
import type { AttachmentType } from '../types/Attachment';
import { MessageModel } from '../models/messages';
import { cleanupMessages, postSaveUpdates } from './cleanup';
import {
findMatchingMessage,
getMessageQueryFromTarget,
} from './syncIdentifiers';
const { getMessagesBySentAt, getMostRecentAddressableMessages } = DataReader;
const { getMostRecentAddressableMessages } = DataReader;
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(
conversationId: string,
targetMessage: MessageToDelete,
targetMessage: AddressableMessage,
logId: string
): Promise<boolean> {
const query = getMessageQueryFromTarget(targetMessage);
@@ -115,7 +52,7 @@ export async function applyDeleteMessage(
export async function deleteAttachmentFromMessage(
conversationId: string,
targetMessage: MessageToDelete,
targetMessage: AddressableMessage,
deleteAttachmentData: {
clientUuid?: string;
fallbackDigest?: string;
@@ -256,7 +193,7 @@ export async function applyDeleteAttachmentFromMessage(
async function getMostRecentMatchingMessage(
conversationId: string,
targetMessages: Array<MessageToDelete>
targetMessages: Array<AddressableMessage>
): Promise<MessageAttributesType | undefined> {
const queries = targetMessages.map(getMessageQueryFromTarget);
const found = await Promise.all(
@@ -269,8 +206,8 @@ async function getMostRecentMatchingMessage(
export async function deleteConversation(
conversation: ConversationModel,
mostRecentMessages: Array<MessageToDelete>,
mostRecentNonExpiringMessages: Array<MessageToDelete> | undefined,
mostRecentMessages: Array<AddressableMessage>,
mostRecentNonExpiringMessages: Array<AddressableMessage> | undefined,
isFullDelete: boolean,
providedLogId: string
): Promise<boolean> {
@@ -351,118 +288,3 @@ export async function deleteLocalOnlyConversation(
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',
'callLogEventSync',
'deviceNameChangeSync',
'attachmentBackfillRequestSync',
'attachmentBackfillResponseSync',
// No longer used, all non-urgent
'legacyGroupChange',

View File

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

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,
onReceipt,
} from '../messageModifiers/MessageReceipts';
import {
deleteConversation,
deleteLocalOnlyConversation,
getConversationFromTarget,
} from './deleteForMe';
import { deleteConversation, deleteLocalOnlyConversation } from './deleteForMe';
import { getConversationFromTarget } from './syncIdentifiers';
import {
onSync as onReadSync,
readSyncTaskSchema,