Enable more specific AttachmentDownload prioritization
This commit is contained in:
		
					parent
					
						
							
								87ea909ae9
							
						
					
				
			
			
				commit
				
					
						fc02762588
					
				
			
		
					 26 changed files with 2245 additions and 817 deletions
				
			
		| 
						 | 
					@ -128,7 +128,6 @@ import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnce
 | 
				
			||||||
import { ReadStatus } from './messages/MessageReadStatus';
 | 
					import { ReadStatus } from './messages/MessageReadStatus';
 | 
				
			||||||
import type { SendStateByConversationId } from './messages/MessageSendState';
 | 
					import type { SendStateByConversationId } from './messages/MessageSendState';
 | 
				
			||||||
import { SendStatus } from './messages/MessageSendState';
 | 
					import { SendStatus } from './messages/MessageSendState';
 | 
				
			||||||
import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads';
 | 
					 | 
				
			||||||
import * as Stickers from './types/Stickers';
 | 
					import * as Stickers from './types/Stickers';
 | 
				
			||||||
import * as Errors from './types/errors';
 | 
					import * as Errors from './types/errors';
 | 
				
			||||||
import { SignalService as Proto } from './protobuf';
 | 
					import { SignalService as Proto } from './protobuf';
 | 
				
			||||||
| 
						 | 
					@ -197,6 +196,7 @@ import {
 | 
				
			||||||
} from './util/callDisposition';
 | 
					} from './util/callDisposition';
 | 
				
			||||||
import { deriveStorageServiceKey } from './Crypto';
 | 
					import { deriveStorageServiceKey } from './Crypto';
 | 
				
			||||||
import { getThemeType } from './util/getThemeType';
 | 
					import { getThemeType } from './util/getThemeType';
 | 
				
			||||||
 | 
					import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function isOverHourIntoPast(timestamp: number): boolean {
 | 
					export function isOverHourIntoPast(timestamp: number): boolean {
 | 
				
			||||||
  return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
 | 
					  return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
 | 
				
			||||||
| 
						 | 
					@ -715,8 +715,9 @@ export async function startApp(): Promise<void> {
 | 
				
			||||||
          'background/shutdown: shutdown requested'
 | 
					          'background/shutdown: shutdown requested'
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        server?.cancelInflightRequests('shutdown');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Stop background processing
 | 
					        // Stop background processing
 | 
				
			||||||
        void AttachmentDownloads.stop();
 | 
					 | 
				
			||||||
        idleDetector.stop();
 | 
					        idleDetector.stop();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Stop processing incoming messages
 | 
					        // Stop processing incoming messages
 | 
				
			||||||
| 
						 | 
					@ -793,6 +794,14 @@ export async function startApp(): Promise<void> {
 | 
				
			||||||
          window.waitForAllWaitBatchers(),
 | 
					          window.waitForAllWaitBatchers(),
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        log.info(
 | 
				
			||||||
 | 
					          'background/shutdown: waiting for all attachment downloads to finish'
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Since we canceled the inflight requests earlier in shutdown, this should
 | 
				
			||||||
 | 
					        // resolve quickly
 | 
				
			||||||
 | 
					        await AttachmentDownloadManager.stop();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        log.info('background/shutdown: closing the database');
 | 
					        log.info('background/shutdown: closing the database');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Shut down the data interface cleanly
 | 
					        // Shut down the data interface cleanly
 | 
				
			||||||
| 
						 | 
					@ -1541,7 +1550,7 @@ export async function startApp(): Promise<void> {
 | 
				
			||||||
      log.info('background: offline');
 | 
					      log.info('background: offline');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      drop(challengeHandler?.onOffline());
 | 
					      drop(challengeHandler?.onOffline());
 | 
				
			||||||
      drop(AttachmentDownloads.stop());
 | 
					      drop(AttachmentDownloadManager.stop());
 | 
				
			||||||
      drop(messageReceiver?.drain());
 | 
					      drop(messageReceiver?.drain());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (connectCount === 0) {
 | 
					      if (connectCount === 0) {
 | 
				
			||||||
| 
						 | 
					@ -1686,11 +1695,7 @@ export async function startApp(): Promise<void> {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      void window.Signal.Services.initializeGroupCredentialFetcher();
 | 
					      void window.Signal.Services.initializeGroupCredentialFetcher();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      drop(
 | 
					      drop(AttachmentDownloadManager.start());
 | 
				
			||||||
        AttachmentDownloads.start({
 | 
					 | 
				
			||||||
          logger: log,
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (connectCount === 1) {
 | 
					      if (connectCount === 1) {
 | 
				
			||||||
        Stickers.downloadQueuedPacks();
 | 
					        Stickers.downloadQueuedPacks();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -112,6 +112,7 @@ type PropsHousekeepingType = {
 | 
				
			||||||
  i18n: LocalizerType;
 | 
					  i18n: LocalizerType;
 | 
				
			||||||
  theme: ThemeType;
 | 
					  theme: ThemeType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  updateVisibleMessages?: (messageIds: Array<string>) => void;
 | 
				
			||||||
  renderCollidingAvatars: (_: {
 | 
					  renderCollidingAvatars: (_: {
 | 
				
			||||||
    conversationIds: ReadonlyArray<string>;
 | 
					    conversationIds: ReadonlyArray<string>;
 | 
				
			||||||
  }) => JSX.Element;
 | 
					  }) => JSX.Element;
 | 
				
			||||||
| 
						 | 
					@ -371,6 +372,7 @@ export class Timeline extends React.Component<
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const intersectionRatios = new Map<Element, number>();
 | 
					    const intersectionRatios = new Map<Element, number>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.props.updateVisibleMessages?.([]);
 | 
				
			||||||
    const intersectionObserverCallback: IntersectionObserverCallback =
 | 
					    const intersectionObserverCallback: IntersectionObserverCallback =
 | 
				
			||||||
      entries => {
 | 
					      entries => {
 | 
				
			||||||
        // The first time this callback is called, we'll get entries in observation order
 | 
					        // The first time this callback is called, we'll get entries in observation order
 | 
				
			||||||
| 
						 | 
					@ -384,12 +386,16 @@ export class Timeline extends React.Component<
 | 
				
			||||||
        let oldestPartiallyVisible: undefined | Element;
 | 
					        let oldestPartiallyVisible: undefined | Element;
 | 
				
			||||||
        let newestPartiallyVisible: undefined | Element;
 | 
					        let newestPartiallyVisible: undefined | Element;
 | 
				
			||||||
        let newestFullyVisible: undefined | Element;
 | 
					        let newestFullyVisible: undefined | Element;
 | 
				
			||||||
 | 
					        const visibleMessageIds: Array<string> = [];
 | 
				
			||||||
        for (const [element, intersectionRatio] of intersectionRatios) {
 | 
					        for (const [element, intersectionRatio] of intersectionRatios) {
 | 
				
			||||||
          if (intersectionRatio === 0) {
 | 
					          if (intersectionRatio === 0) {
 | 
				
			||||||
            continue;
 | 
					            continue;
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          const messageId = getMessageIdFromElement(element);
 | 
				
			||||||
 | 
					          if (messageId) {
 | 
				
			||||||
 | 
					            visibleMessageIds.push(messageId);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
          // We use this "at bottom detector" for two reasons, both for performance. It's
 | 
					          // We use this "at bottom detector" for two reasons, both for performance. It's
 | 
				
			||||||
          //   usually faster to use an `IntersectionObserver` instead of a scroll event,
 | 
					          //   usually faster to use an `IntersectionObserver` instead of a scroll event,
 | 
				
			||||||
          //   and we want to do that here.
 | 
					          //   and we want to do that here.
 | 
				
			||||||
| 
						 | 
					@ -409,6 +415,8 @@ export class Timeline extends React.Component<
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.props.updateVisibleMessages?.(visibleMessageIds);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // If a message is fully visible, then you can see its bottom. If not, there's a
 | 
					        // If a message is fully visible, then you can see its bottom. If not, there's a
 | 
				
			||||||
        //   very tall message around. We assume you can see the bottom of a message if
 | 
					        //   very tall message around. We assume you can see the bottom of a message if
 | 
				
			||||||
        //   (1) another message is partly visible right below it, or (2) you're near the
 | 
					        //   (1) another message is partly visible right below it, or (2) you're near the
 | 
				
			||||||
| 
						 | 
					@ -554,6 +562,7 @@ export class Timeline extends React.Component<
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.intersectionObserver?.disconnect();
 | 
					    this.intersectionObserver?.disconnect();
 | 
				
			||||||
    this.cleanupGroupCallPeekTimeouts();
 | 
					    this.cleanupGroupCallPeekTimeouts();
 | 
				
			||||||
 | 
					    this.props.updateVisibleMessages?.([]);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public override getSnapshotBeforeUpdate(
 | 
					  public override getSnapshotBeforeUpdate(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										629
									
								
								ts/jobs/AttachmentDownloadManager.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										629
									
								
								ts/jobs/AttachmentDownloadManager.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,629 @@
 | 
				
			||||||
 | 
					// Copyright 2024 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					import { omit } from 'lodash';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { drop } from '../util/drop';
 | 
				
			||||||
 | 
					import * as durations from '../util/durations';
 | 
				
			||||||
 | 
					import { missingCaseError } from '../util/missingCaseError';
 | 
				
			||||||
 | 
					import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
 | 
				
			||||||
 | 
					import * as log from '../logging/log';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  type AttachmentDownloadJobTypeType,
 | 
				
			||||||
 | 
					  type AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					  attachmentDownloadJobSchema,
 | 
				
			||||||
 | 
					} from '../types/AttachmentDownload';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  AttachmentNotFoundOnCdnError,
 | 
				
			||||||
 | 
					  downloadAttachment,
 | 
				
			||||||
 | 
					} from '../util/downloadAttachment';
 | 
				
			||||||
 | 
					import dataInterface from '../sql/Client';
 | 
				
			||||||
 | 
					import { getValue } from '../RemoteConfig';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  explodePromise,
 | 
				
			||||||
 | 
					  type ExplodePromiseResultType,
 | 
				
			||||||
 | 
					} from '../util/explodePromise';
 | 
				
			||||||
 | 
					import { isInCall as isInCallSelector } from '../state/selectors/calling';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  type ExponentialBackoffOptionsType,
 | 
				
			||||||
 | 
					  exponentialBackoffSleepTime,
 | 
				
			||||||
 | 
					} from '../util/exponentialBackoff';
 | 
				
			||||||
 | 
					import { AttachmentSizeError, type AttachmentType } from '../types/Attachment';
 | 
				
			||||||
 | 
					import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
 | 
				
			||||||
 | 
					import type { MessageModel } from '../models/messages';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  KIBIBYTE,
 | 
				
			||||||
 | 
					  getMaximumIncomingAttachmentSizeInKb,
 | 
				
			||||||
 | 
					  getMaximumIncomingTextAttachmentSizeInKb,
 | 
				
			||||||
 | 
					} from '../types/AttachmentSize';
 | 
				
			||||||
 | 
					import { addAttachmentToMessage } from '../messageModifiers/AttachmentDownloads';
 | 
				
			||||||
 | 
					import * as Errors from '../types/errors';
 | 
				
			||||||
 | 
					import { redactGenericText } from '../util/privacy';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export enum AttachmentDownloadUrgency {
 | 
				
			||||||
 | 
					  IMMEDIATE = 'immediate',
 | 
				
			||||||
 | 
					  STANDARD = 'standard',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TICK_INTERVAL = durations.MINUTE;
 | 
				
			||||||
 | 
					const MAX_CONCURRENT_JOBS = 3;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AttachmentDownloadJobIdentifiersType = Pick<
 | 
				
			||||||
 | 
					  AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					  'messageId' | 'attachmentType' | 'digest'
 | 
				
			||||||
 | 
					>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Type for adding a new job
 | 
				
			||||||
 | 
					export type NewAttachmentDownloadJobType = {
 | 
				
			||||||
 | 
					  attachment: AttachmentType;
 | 
				
			||||||
 | 
					  messageId: string;
 | 
				
			||||||
 | 
					  receivedAt: number;
 | 
				
			||||||
 | 
					  sentAt: number;
 | 
				
			||||||
 | 
					  attachmentType: AttachmentDownloadJobTypeType;
 | 
				
			||||||
 | 
					  urgency?: AttachmentDownloadUrgency;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const RETRY_CONFIG: Record<
 | 
				
			||||||
 | 
					  'default',
 | 
				
			||||||
 | 
					  { maxRetries: number; backoffConfig: ExponentialBackoffOptionsType }
 | 
				
			||||||
 | 
					> = {
 | 
				
			||||||
 | 
					  default: {
 | 
				
			||||||
 | 
					    maxRetries: 4,
 | 
				
			||||||
 | 
					    backoffConfig: {
 | 
				
			||||||
 | 
					      // 30 seconds, 5 minutes, 50 minutes, (max) 6 hrs
 | 
				
			||||||
 | 
					      multiplier: 10,
 | 
				
			||||||
 | 
					      firstBackoffTime: 30 * durations.SECOND,
 | 
				
			||||||
 | 
					      maxBackoffTime: 6 * durations.HOUR,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type AttachmentDownloadManagerParamsType = {
 | 
				
			||||||
 | 
					  getNextJobs: (options: {
 | 
				
			||||||
 | 
					    limit: number;
 | 
				
			||||||
 | 
					    prioritizeMessageIds?: Array<string>;
 | 
				
			||||||
 | 
					    timestamp?: number;
 | 
				
			||||||
 | 
					  }) => Promise<Array<AttachmentDownloadJobType>>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  saveJob: (job: AttachmentDownloadJobType) => Promise<void>;
 | 
				
			||||||
 | 
					  removeJob: (job: AttachmentDownloadJobType) => Promise<unknown>;
 | 
				
			||||||
 | 
					  runJob: (
 | 
				
			||||||
 | 
					    job: AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					    isLastAttempt: boolean
 | 
				
			||||||
 | 
					  ) => Promise<JobResultType>;
 | 
				
			||||||
 | 
					  isInCall: () => boolean;
 | 
				
			||||||
 | 
					  beforeStart?: () => Promise<void>;
 | 
				
			||||||
 | 
					  maxAttempts: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export type JobResultType = { status: 'retry' | 'finished' };
 | 
				
			||||||
 | 
					export class AttachmentDownloadManager {
 | 
				
			||||||
 | 
					  private static _instance: AttachmentDownloadManager | undefined;
 | 
				
			||||||
 | 
					  private visibleTimelineMessages: Array<string> = [];
 | 
				
			||||||
 | 
					  private enabled: boolean = false;
 | 
				
			||||||
 | 
					  private activeJobs: Map<
 | 
				
			||||||
 | 
					    string,
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      completionPromise: ExplodePromiseResultType<void>;
 | 
				
			||||||
 | 
					      job: AttachmentDownloadJobType;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  > = new Map();
 | 
				
			||||||
 | 
					  private timeout: NodeJS.Timeout | null = null;
 | 
				
			||||||
 | 
					  private jobStartPromises: Map<string, ExplodePromiseResultType<void>> =
 | 
				
			||||||
 | 
					    new Map();
 | 
				
			||||||
 | 
					  private jobCompletePromises: Map<string, ExplodePromiseResultType<void>> =
 | 
				
			||||||
 | 
					    new Map();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static defaultParams: AttachmentDownloadManagerParamsType = {
 | 
				
			||||||
 | 
					    beforeStart: dataInterface.resetAttachmentDownloadActive,
 | 
				
			||||||
 | 
					    getNextJobs: dataInterface.getNextAttachmentDownloadJobs,
 | 
				
			||||||
 | 
					    saveJob: dataInterface.saveAttachmentDownloadJob,
 | 
				
			||||||
 | 
					    removeJob: dataInterface.removeAttachmentDownloadJob,
 | 
				
			||||||
 | 
					    runJob: runDownloadAttachmentJob,
 | 
				
			||||||
 | 
					    isInCall: () => {
 | 
				
			||||||
 | 
					      const reduxState = window.reduxStore?.getState();
 | 
				
			||||||
 | 
					      if (reduxState) {
 | 
				
			||||||
 | 
					        return isInCallSelector(reduxState);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    maxAttempts: RETRY_CONFIG.default.maxRetries + 1,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  readonly getNextJobs: AttachmentDownloadManagerParamsType['getNextJobs'];
 | 
				
			||||||
 | 
					  readonly saveJob: AttachmentDownloadManagerParamsType['saveJob'];
 | 
				
			||||||
 | 
					  readonly removeJob: AttachmentDownloadManagerParamsType['removeJob'];
 | 
				
			||||||
 | 
					  readonly runJob: AttachmentDownloadManagerParamsType['runJob'];
 | 
				
			||||||
 | 
					  readonly beforeStart: AttachmentDownloadManagerParamsType['beforeStart'];
 | 
				
			||||||
 | 
					  readonly isInCall: AttachmentDownloadManagerParamsType['isInCall'];
 | 
				
			||||||
 | 
					  readonly maxAttempts: number;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  constructor(
 | 
				
			||||||
 | 
					    params: AttachmentDownloadManagerParamsType = AttachmentDownloadManager.defaultParams
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    this.getNextJobs = params.getNextJobs;
 | 
				
			||||||
 | 
					    this.saveJob = params.saveJob;
 | 
				
			||||||
 | 
					    this.removeJob = params.removeJob;
 | 
				
			||||||
 | 
					    this.runJob = params.runJob;
 | 
				
			||||||
 | 
					    this.beforeStart = params.beforeStart;
 | 
				
			||||||
 | 
					    this.isInCall = params.isInCall;
 | 
				
			||||||
 | 
					    this.maxAttempts = params.maxAttempts;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async start(): Promise<void> {
 | 
				
			||||||
 | 
					    this.enabled = true;
 | 
				
			||||||
 | 
					    await this.beforeStart?.();
 | 
				
			||||||
 | 
					    this.tick();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async stop(): Promise<void> {
 | 
				
			||||||
 | 
					    this.enabled = false;
 | 
				
			||||||
 | 
					    clearTimeoutIfNecessary(this.timeout);
 | 
				
			||||||
 | 
					    this.timeout = null;
 | 
				
			||||||
 | 
					    await Promise.all(
 | 
				
			||||||
 | 
					      [...this.activeJobs.values()].map(
 | 
				
			||||||
 | 
					        ({ completionPromise }) => completionPromise.promise
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  tick(): void {
 | 
				
			||||||
 | 
					    clearTimeoutIfNecessary(this.timeout);
 | 
				
			||||||
 | 
					    this.timeout = null;
 | 
				
			||||||
 | 
					    drop(this.maybeStartJobs());
 | 
				
			||||||
 | 
					    this.timeout = setTimeout(() => this.tick(), TICK_INTERVAL);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async addJob(
 | 
				
			||||||
 | 
					    newJobData: NewAttachmentDownloadJobType
 | 
				
			||||||
 | 
					  ): Promise<AttachmentType> {
 | 
				
			||||||
 | 
					    const {
 | 
				
			||||||
 | 
					      attachment,
 | 
				
			||||||
 | 
					      messageId,
 | 
				
			||||||
 | 
					      attachmentType,
 | 
				
			||||||
 | 
					      receivedAt,
 | 
				
			||||||
 | 
					      sentAt,
 | 
				
			||||||
 | 
					      urgency = AttachmentDownloadUrgency.STANDARD,
 | 
				
			||||||
 | 
					    } = newJobData;
 | 
				
			||||||
 | 
					    const parseResult = attachmentDownloadJobSchema.safeParse({
 | 
				
			||||||
 | 
					      messageId,
 | 
				
			||||||
 | 
					      receivedAt,
 | 
				
			||||||
 | 
					      sentAt,
 | 
				
			||||||
 | 
					      attachmentType,
 | 
				
			||||||
 | 
					      digest: attachment.digest,
 | 
				
			||||||
 | 
					      contentType: attachment.contentType,
 | 
				
			||||||
 | 
					      size: attachment.size,
 | 
				
			||||||
 | 
					      attachment,
 | 
				
			||||||
 | 
					      active: false,
 | 
				
			||||||
 | 
					      attempts: 0,
 | 
				
			||||||
 | 
					      retryAfter: null,
 | 
				
			||||||
 | 
					      lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!parseResult.success) {
 | 
				
			||||||
 | 
					      log.error(
 | 
				
			||||||
 | 
					        `AttachmentDownloadManager/addJob(${sentAt}.${attachmentType}): invalid data`,
 | 
				
			||||||
 | 
					        parseResult.error
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return attachment;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const newJob = parseResult.data;
 | 
				
			||||||
 | 
					    const jobIdForLogging = getJobIdForLogging(newJob);
 | 
				
			||||||
 | 
					    const logId = `AttachmentDownloadManager/addJob(${jobIdForLogging})`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const runningJob = this.getRunningJob(newJob);
 | 
				
			||||||
 | 
					      if (runningJob) {
 | 
				
			||||||
 | 
					        log.info(`${logId}: already running; resetting attempts`);
 | 
				
			||||||
 | 
					        runningJob.attempts = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        await this.saveJob({
 | 
				
			||||||
 | 
					          ...runningJob,
 | 
				
			||||||
 | 
					          attempts: 0,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        return attachment;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await this.saveJob(newJob);
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      log.error(`${logId}: error saving job`, Errors.toLogFormat(e));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (urgency) {
 | 
				
			||||||
 | 
					      case AttachmentDownloadUrgency.IMMEDIATE:
 | 
				
			||||||
 | 
					        log.info(`${logId}: starting job immediately`);
 | 
				
			||||||
 | 
					        drop(this.startJob(newJob));
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      case AttachmentDownloadUrgency.STANDARD:
 | 
				
			||||||
 | 
					        drop(this.maybeStartJobs());
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        throw missingCaseError(urgency);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      ...attachment,
 | 
				
			||||||
 | 
					      pending: true,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  updateVisibleTimelineMessages(messageIds: Array<string>): void {
 | 
				
			||||||
 | 
					    this.visibleTimelineMessages = messageIds;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // used in testing
 | 
				
			||||||
 | 
					  public waitForJobToBeStarted(job: AttachmentDownloadJobType): Promise<void> {
 | 
				
			||||||
 | 
					    const id = this.getJobIdIncludingAttempts(job);
 | 
				
			||||||
 | 
					    const existingPromise = this.jobStartPromises.get(id)?.promise;
 | 
				
			||||||
 | 
					    if (existingPromise) {
 | 
				
			||||||
 | 
					      return existingPromise;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const { promise, resolve, reject } = explodePromise<void>();
 | 
				
			||||||
 | 
					    this.jobStartPromises.set(id, { promise, resolve, reject });
 | 
				
			||||||
 | 
					    return promise;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  public waitForJobToBeCompleted(
 | 
				
			||||||
 | 
					    job: AttachmentDownloadJobType
 | 
				
			||||||
 | 
					  ): Promise<void> {
 | 
				
			||||||
 | 
					    const id = this.getJobIdIncludingAttempts(job);
 | 
				
			||||||
 | 
					    const existingPromise = this.jobCompletePromises.get(id)?.promise;
 | 
				
			||||||
 | 
					    if (existingPromise) {
 | 
				
			||||||
 | 
					      return existingPromise;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const { promise, resolve, reject } = explodePromise<void>();
 | 
				
			||||||
 | 
					    this.jobCompletePromises.set(id, { promise, resolve, reject });
 | 
				
			||||||
 | 
					    return promise;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Private methods
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // maybeStartJobs is called:
 | 
				
			||||||
 | 
					  // 1. every minute (via tick)
 | 
				
			||||||
 | 
					  // 2. after a job is added (via addJob)
 | 
				
			||||||
 | 
					  // 3. after a job finishes (via startJob)
 | 
				
			||||||
 | 
					  // preventing re-entrancy allow us to simplify some logic and ensure we don't try to
 | 
				
			||||||
 | 
					  // start too many jobs
 | 
				
			||||||
 | 
					  private _inMaybeStartJobs = false;
 | 
				
			||||||
 | 
					  private async maybeStartJobs(): Promise<void> {
 | 
				
			||||||
 | 
					    if (this._inMaybeStartJobs) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      this._inMaybeStartJobs = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!this.enabled) {
 | 
				
			||||||
 | 
					        log.info(
 | 
				
			||||||
 | 
					          'AttachmentDownloadManager/_maybeStartJobs: not enabled, returning'
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.isInCall()) {
 | 
				
			||||||
 | 
					        log.info(
 | 
				
			||||||
 | 
					          'AttachmentDownloadManager/_maybeStartJobs: holding off on starting new jobs; in call'
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const numJobsToStart = this.getMaximumNumberOfJobsToStart();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (numJobsToStart <= 0) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const nextJobs = await this.getNextJobs({
 | 
				
			||||||
 | 
					        limit: numJobsToStart,
 | 
				
			||||||
 | 
					        // TODO (DESKTOP-6912): we'll want to prioritize more than just visible timeline
 | 
				
			||||||
 | 
					        // messages, including:
 | 
				
			||||||
 | 
					        // - media opened in lightbox
 | 
				
			||||||
 | 
					        // - media for stories
 | 
				
			||||||
 | 
					        prioritizeMessageIds: [...this.visibleTimelineMessages],
 | 
				
			||||||
 | 
					        timestamp: Date.now(),
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // TODO (DESKTOP-6913): if a prioritized job is selected, we will to update the
 | 
				
			||||||
 | 
					      // in-memory job with that information so we can handle it differently, including
 | 
				
			||||||
 | 
					      // e.g. downloading a thumbnail before the full-size version
 | 
				
			||||||
 | 
					      for (const job of nextJobs) {
 | 
				
			||||||
 | 
					        drop(this.startJob(job));
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      this._inMaybeStartJobs = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async startJob(job: AttachmentDownloadJobType): Promise<void> {
 | 
				
			||||||
 | 
					    const logId = `AttachmentDownloadManager/startJob(${getJobIdForLogging(
 | 
				
			||||||
 | 
					      job
 | 
				
			||||||
 | 
					    )})`;
 | 
				
			||||||
 | 
					    if (this.isJobRunning(job)) {
 | 
				
			||||||
 | 
					      log.info(`${logId}: job is already running`);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const isLastAttempt = job.attempts + 1 >= this.maxAttempts;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      log.info(`${logId}: starting job`);
 | 
				
			||||||
 | 
					      this.addRunningJob(job);
 | 
				
			||||||
 | 
					      await this.saveJob({ ...job, active: true });
 | 
				
			||||||
 | 
					      this.handleJobStartPromises(job);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const { status } = await this.runJob(job, isLastAttempt);
 | 
				
			||||||
 | 
					      log.info(`${logId}: job completed with status: ${status}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      switch (status) {
 | 
				
			||||||
 | 
					        case 'finished':
 | 
				
			||||||
 | 
					          await this.removeJob(job);
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        case 'retry':
 | 
				
			||||||
 | 
					          if (isLastAttempt) {
 | 
				
			||||||
 | 
					            throw new Error('Cannot retry on last attempt');
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          await this.retryJobLater(job);
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        default:
 | 
				
			||||||
 | 
					          throw missingCaseError(status);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      log.error(`${logId}: error when running job`, e);
 | 
				
			||||||
 | 
					      if (isLastAttempt) {
 | 
				
			||||||
 | 
					        await this.removeJob(job);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        await this.retryJobLater(job);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      this.removeRunningJob(job);
 | 
				
			||||||
 | 
					      drop(this.maybeStartJobs());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async retryJobLater(job: AttachmentDownloadJobType) {
 | 
				
			||||||
 | 
					    const now = Date.now();
 | 
				
			||||||
 | 
					    await this.saveJob({
 | 
				
			||||||
 | 
					      ...job,
 | 
				
			||||||
 | 
					      active: false,
 | 
				
			||||||
 | 
					      attempts: job.attempts + 1,
 | 
				
			||||||
 | 
					      // TODO (DESKTOP-6845): adjust retry based on job type (e.g. backup)
 | 
				
			||||||
 | 
					      retryAfter:
 | 
				
			||||||
 | 
					        now +
 | 
				
			||||||
 | 
					        exponentialBackoffSleepTime(
 | 
				
			||||||
 | 
					          job.attempts + 1,
 | 
				
			||||||
 | 
					          RETRY_CONFIG.default.backoffConfig
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      lastAttemptTimestamp: now,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  private getActiveJobCount(): number {
 | 
				
			||||||
 | 
					    return this.activeJobs.size;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getMaximumNumberOfJobsToStart(): number {
 | 
				
			||||||
 | 
					    return MAX_CONCURRENT_JOBS - this.getActiveJobCount();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getRunningJob(
 | 
				
			||||||
 | 
					    job: AttachmentDownloadJobIdentifiersType
 | 
				
			||||||
 | 
					  ): AttachmentDownloadJobType | undefined {
 | 
				
			||||||
 | 
					    const id = this.getJobId(job);
 | 
				
			||||||
 | 
					    return this.activeJobs.get(id)?.job;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private isJobRunning(job: AttachmentDownloadJobType): boolean {
 | 
				
			||||||
 | 
					    return Boolean(this.getRunningJob(job));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private removeRunningJob(job: AttachmentDownloadJobType) {
 | 
				
			||||||
 | 
					    const idWithAttempts = this.getJobIdIncludingAttempts(job);
 | 
				
			||||||
 | 
					    this.jobCompletePromises.get(idWithAttempts)?.resolve();
 | 
				
			||||||
 | 
					    this.jobCompletePromises.delete(idWithAttempts);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const id = this.getJobId(job);
 | 
				
			||||||
 | 
					    this.activeJobs.get(id)?.completionPromise.resolve();
 | 
				
			||||||
 | 
					    this.activeJobs.delete(id);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private addRunningJob(job: AttachmentDownloadJobType) {
 | 
				
			||||||
 | 
					    if (this.isJobRunning(job)) {
 | 
				
			||||||
 | 
					      const jobIdForLogging = getJobIdForLogging(job);
 | 
				
			||||||
 | 
					      log.warn(
 | 
				
			||||||
 | 
					        `attachmentDownloads/_addRunningJob: job ${jobIdForLogging} is already running`
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    this.activeJobs.set(this.getJobId(job), {
 | 
				
			||||||
 | 
					      completionPromise: explodePromise<void>(),
 | 
				
			||||||
 | 
					      job,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private handleJobStartPromises(job: AttachmentDownloadJobType) {
 | 
				
			||||||
 | 
					    const id = this.getJobIdIncludingAttempts(job);
 | 
				
			||||||
 | 
					    this.jobStartPromises.get(id)?.resolve();
 | 
				
			||||||
 | 
					    this.jobStartPromises.delete(id);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getJobIdIncludingAttempts(job: AttachmentDownloadJobType) {
 | 
				
			||||||
 | 
					    return `${this.getJobId(job)}.${job.attempts}`;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private getJobId(job: AttachmentDownloadJobIdentifiersType): string {
 | 
				
			||||||
 | 
					    const { messageId, attachmentType, digest } = job;
 | 
				
			||||||
 | 
					    return `${messageId}.${attachmentType}.${digest}`;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Static methods
 | 
				
			||||||
 | 
					  static get instance(): AttachmentDownloadManager {
 | 
				
			||||||
 | 
					    if (!AttachmentDownloadManager._instance) {
 | 
				
			||||||
 | 
					      AttachmentDownloadManager._instance = new AttachmentDownloadManager();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return AttachmentDownloadManager._instance;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static async start(): Promise<void> {
 | 
				
			||||||
 | 
					    log.info('AttachmentDownloadManager/starting');
 | 
				
			||||||
 | 
					    await AttachmentDownloadManager.instance.start();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static async stop(): Promise<void> {
 | 
				
			||||||
 | 
					    log.info('AttachmentDownloadManager/stopping');
 | 
				
			||||||
 | 
					    return AttachmentDownloadManager._instance?.stop();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static async addJob(
 | 
				
			||||||
 | 
					    newJob: NewAttachmentDownloadJobType
 | 
				
			||||||
 | 
					  ): Promise<AttachmentType> {
 | 
				
			||||||
 | 
					    return AttachmentDownloadManager.instance.addJob(newJob);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static updateVisibleTimelineMessages(messageIds: Array<string>): void {
 | 
				
			||||||
 | 
					    AttachmentDownloadManager.instance.updateVisibleTimelineMessages(
 | 
				
			||||||
 | 
					      messageIds
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function runDownloadAttachmentJob(
 | 
				
			||||||
 | 
					  job: AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					  isLastAttempt: boolean
 | 
				
			||||||
 | 
					): Promise<JobResultType> {
 | 
				
			||||||
 | 
					  const jobIdForLogging = getJobIdForLogging(job);
 | 
				
			||||||
 | 
					  const logId = `attachment_downloads/runDownloadAttachmentJob/${jobIdForLogging}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const message = await __DEPRECATED$getMessageById(job.messageId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!message) {
 | 
				
			||||||
 | 
					    log.error(`${logId} message not found`);
 | 
				
			||||||
 | 
					    return { status: 'finished' };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    log.info(`${logId}: Starting job`);
 | 
				
			||||||
 | 
					    await runDownloadAttachmentJobInner(job, message);
 | 
				
			||||||
 | 
					    return { status: 'finished' };
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    log.error(
 | 
				
			||||||
 | 
					      `${logId}: Failed to download attachment, attempt ${job.attempts}:`,
 | 
				
			||||||
 | 
					      Errors.toLogFormat(error)
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (error instanceof AttachmentSizeError) {
 | 
				
			||||||
 | 
					      await addAttachmentToMessage(
 | 
				
			||||||
 | 
					        message,
 | 
				
			||||||
 | 
					        _markAttachmentAsTooBig(job.attachment),
 | 
				
			||||||
 | 
					        { type: job.attachmentType }
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return { status: 'finished' };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (error instanceof AttachmentNotFoundOnCdnError) {
 | 
				
			||||||
 | 
					      await addAttachmentToMessage(
 | 
				
			||||||
 | 
					        message,
 | 
				
			||||||
 | 
					        _markAttachmentAsPermanentlyErrored(job.attachment),
 | 
				
			||||||
 | 
					        { type: job.attachmentType }
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return { status: 'finished' };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isLastAttempt) {
 | 
				
			||||||
 | 
					      await addAttachmentToMessage(
 | 
				
			||||||
 | 
					        message,
 | 
				
			||||||
 | 
					        _markAttachmentAsTransientlyErrored(job.attachment),
 | 
				
			||||||
 | 
					        { type: job.attachmentType }
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return { status: 'finished' };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Remove `pending` flag from the attachment and retry later
 | 
				
			||||||
 | 
					    await addAttachmentToMessage(
 | 
				
			||||||
 | 
					      message,
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        ...job.attachment,
 | 
				
			||||||
 | 
					        pending: false,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      { type: job.attachmentType }
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    return { status: 'retry' };
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    // This will fail if the message has been deleted before the download finished, which
 | 
				
			||||||
 | 
					    // is good
 | 
				
			||||||
 | 
					    await dataInterface.saveMessage(message.attributes, {
 | 
				
			||||||
 | 
					      ourAci: window.textsecure.storage.user.getCheckedAci(),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function runDownloadAttachmentJobInner(
 | 
				
			||||||
 | 
					  job: AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					  message: MessageModel
 | 
				
			||||||
 | 
					): Promise<void> {
 | 
				
			||||||
 | 
					  const { messageId, attachment, attachmentType: type } = job;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const jobIdForLogging = getJobIdForLogging(job);
 | 
				
			||||||
 | 
					  const logId = `attachment_downloads/_runDownloadJobInner(${jobIdForLogging})`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!job || !attachment || !messageId) {
 | 
				
			||||||
 | 
					    throw new Error(`${logId}: Key information required for job was missing.`);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  log.info(`${logId}: starting`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const maxInKib = getMaximumIncomingAttachmentSizeInKb(getValue);
 | 
				
			||||||
 | 
					  const maxTextAttachmentSizeInKib =
 | 
				
			||||||
 | 
					    getMaximumIncomingTextAttachmentSizeInKb(getValue);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { size } = attachment;
 | 
				
			||||||
 | 
					  const sizeInKib = size / KIBIBYTE;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!Number.isFinite(size) || size < 0 || sizeInKib > maxInKib) {
 | 
				
			||||||
 | 
					    throw new AttachmentSizeError(
 | 
				
			||||||
 | 
					      `${logId}: Attachment was ${sizeInKib}kib, max is ${maxInKib}kib`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  if (type === 'long-message' && sizeInKib > maxTextAttachmentSizeInKib) {
 | 
				
			||||||
 | 
					    throw new AttachmentSizeError(
 | 
				
			||||||
 | 
					      `${logId}: Text attachment was ${sizeInKib}kib, max is ${maxTextAttachmentSizeInKib}kib`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await addAttachmentToMessage(
 | 
				
			||||||
 | 
					    message,
 | 
				
			||||||
 | 
					    { ...attachment, pending: true },
 | 
				
			||||||
 | 
					    { type }
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const downloaded = await downloadAttachment(attachment);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const upgradedAttachment =
 | 
				
			||||||
 | 
					    await window.Signal.Migrations.processNewAttachment(downloaded);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await addAttachmentToMessage(message, omit(upgradedAttachment, 'error'), {
 | 
				
			||||||
 | 
					    type,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    ..._markAttachmentAsPermanentlyErrored(attachment),
 | 
				
			||||||
 | 
					    wasTooBig: true,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function _markAttachmentAsPermanentlyErrored(
 | 
				
			||||||
 | 
					  attachment: AttachmentType
 | 
				
			||||||
 | 
					): AttachmentType {
 | 
				
			||||||
 | 
					  return { ...omit(attachment, ['key', 'id']), pending: false, error: true };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function _markAttachmentAsTransientlyErrored(
 | 
				
			||||||
 | 
					  attachment: AttachmentType
 | 
				
			||||||
 | 
					): AttachmentType {
 | 
				
			||||||
 | 
					  return { ...attachment, pending: false, error: true };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function getJobIdForLogging(job: AttachmentDownloadJobType): string {
 | 
				
			||||||
 | 
					  const { sentAt, attachmentType, digest } = job;
 | 
				
			||||||
 | 
					  const redactedDigest = redactGenericText(digest);
 | 
				
			||||||
 | 
					  return `${sentAt}.${attachmentType}.${redactedDigest}`;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,530 +1,23 @@
 | 
				
			||||||
// Copyright 2019 Signal Messenger, LLC
 | 
					// Copyright 2019 Signal Messenger, LLC
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					import * as log from '../logging/log';
 | 
				
			||||||
import { isNumber, omit } from 'lodash';
 | 
					 | 
				
			||||||
import { v4 as getGuid } from 'uuid';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import dataInterface from '../sql/Client';
 | 
					 | 
				
			||||||
import * as durations from '../util/durations';
 | 
					 | 
				
			||||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
 | 
					 | 
				
			||||||
import { strictAssert } from '../util/assert';
 | 
					 | 
				
			||||||
import { downloadAttachment } from '../util/downloadAttachment';
 | 
					 | 
				
			||||||
import * as Bytes from '../Bytes';
 | 
					import * as Bytes from '../Bytes';
 | 
				
			||||||
import type {
 | 
					import type { AttachmentDownloadJobTypeType } from '../types/AttachmentDownload';
 | 
				
			||||||
  AttachmentDownloadJobType,
 | 
					 | 
				
			||||||
  AttachmentDownloadJobTypeType,
 | 
					 | 
				
			||||||
} from '../sql/Interface';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { getValue } from '../RemoteConfig';
 | 
					 | 
				
			||||||
import type { MessageModel } from '../models/messages';
 | 
					import type { MessageModel } from '../models/messages';
 | 
				
			||||||
import type { AttachmentType } from '../types/Attachment';
 | 
					import type { AttachmentType } from '../types/Attachment';
 | 
				
			||||||
import {
 | 
					import { getAttachmentSignature, isDownloaded } from '../types/Attachment';
 | 
				
			||||||
  AttachmentSizeError,
 | 
					 | 
				
			||||||
  getAttachmentSignature,
 | 
					 | 
				
			||||||
  isDownloaded,
 | 
					 | 
				
			||||||
} from '../types/Attachment';
 | 
					 | 
				
			||||||
import * as Errors from '../types/errors';
 | 
					 | 
				
			||||||
import type { LoggerType } from '../types/Logging';
 | 
					 | 
				
			||||||
import * as log from '../logging/log';
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
  KIBIBYTE,
 | 
					 | 
				
			||||||
  getMaximumIncomingAttachmentSizeInKb,
 | 
					 | 
				
			||||||
  getMaximumIncomingTextAttachmentSizeInKb,
 | 
					 | 
				
			||||||
} from '../types/AttachmentSize';
 | 
					 | 
				
			||||||
import { redactCdnKey } from '../util/privacy';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const {
 | 
					export async function addAttachmentToMessage(
 | 
				
			||||||
  getMessageById,
 | 
					 | 
				
			||||||
  getAttachmentDownloadJobById,
 | 
					 | 
				
			||||||
  getNextAttachmentDownloadJobs,
 | 
					 | 
				
			||||||
  removeAttachmentDownloadJob,
 | 
					 | 
				
			||||||
  resetAttachmentDownloadPending,
 | 
					 | 
				
			||||||
  saveAttachmentDownloadJob,
 | 
					 | 
				
			||||||
  saveMessage,
 | 
					 | 
				
			||||||
  setAttachmentDownloadJobPending,
 | 
					 | 
				
			||||||
} = dataInterface;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const MAX_ATTACHMENT_JOB_PARALLELISM = 3;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const TICK_INTERVAL = durations.MINUTE;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const RETRY_BACKOFF: Record<number, number> = {
 | 
					 | 
				
			||||||
  1: 30 * durations.SECOND,
 | 
					 | 
				
			||||||
  2: 30 * durations.MINUTE,
 | 
					 | 
				
			||||||
  3: 6 * durations.HOUR,
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
let enabled = false;
 | 
					 | 
				
			||||||
let timeout: NodeJS.Timeout | null;
 | 
					 | 
				
			||||||
let logger: LoggerType;
 | 
					 | 
				
			||||||
const _activeAttachmentDownloadJobs: Record<string, Promise<void> | undefined> =
 | 
					 | 
				
			||||||
  {};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type StartOptionsType = {
 | 
					 | 
				
			||||||
  logger: LoggerType;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export async function start(options: StartOptionsType): Promise<void> {
 | 
					 | 
				
			||||||
  ({ logger } = options);
 | 
					 | 
				
			||||||
  if (!logger) {
 | 
					 | 
				
			||||||
    throw new Error('attachment_downloads/start: logger must be provided!');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  logger.info('attachment_downloads/start: enabling');
 | 
					 | 
				
			||||||
  enabled = true;
 | 
					 | 
				
			||||||
  await resetAttachmentDownloadPending();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  void _tick();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export async function stop(): Promise<void> {
 | 
					 | 
				
			||||||
  // If `.start()` wasn't called - the `logger` is `undefined`
 | 
					 | 
				
			||||||
  if (logger) {
 | 
					 | 
				
			||||||
    logger.info('attachment_downloads/stop: disabling');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  enabled = false;
 | 
					 | 
				
			||||||
  clearTimeoutIfNecessary(timeout);
 | 
					 | 
				
			||||||
  timeout = null;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export async function addJob(
 | 
					 | 
				
			||||||
  attachment: AttachmentType,
 | 
					 | 
				
			||||||
  // TODO: DESKTOP-5279
 | 
					 | 
				
			||||||
  job: { messageId: string; type: AttachmentDownloadJobTypeType; index: number }
 | 
					 | 
				
			||||||
): Promise<AttachmentType> {
 | 
					 | 
				
			||||||
  if (!attachment) {
 | 
					 | 
				
			||||||
    throw new Error('attachments_download/addJob: attachment is required');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const { messageId, type, index } = job;
 | 
					 | 
				
			||||||
  if (!messageId) {
 | 
					 | 
				
			||||||
    throw new Error('attachments_download/addJob: job.messageId is required');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  if (!type) {
 | 
					 | 
				
			||||||
    throw new Error('attachments_download/addJob: job.type is required');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  if (!isNumber(index)) {
 | 
					 | 
				
			||||||
    throw new Error('attachments_download/addJob: index must be a number');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (attachment.downloadJobId) {
 | 
					 | 
				
			||||||
    let existingJob = await getAttachmentDownloadJobById(
 | 
					 | 
				
			||||||
      attachment.downloadJobId
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    if (existingJob) {
 | 
					 | 
				
			||||||
      // Reset job attempts through user's explicit action
 | 
					 | 
				
			||||||
      existingJob = { ...existingJob, attempts: 0 };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (_activeAttachmentDownloadJobs[existingJob.id]) {
 | 
					 | 
				
			||||||
        logger.info(
 | 
					 | 
				
			||||||
          `attachment_downloads/addJob: ${existingJob.id} already running`
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        logger.info(
 | 
					 | 
				
			||||||
          `attachment_downloads/addJob: restarting existing job ${existingJob.id}`
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        _activeAttachmentDownloadJobs[existingJob.id] = _runJob(existingJob);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        ...attachment,
 | 
					 | 
				
			||||||
        pending: true,
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const id = getGuid();
 | 
					 | 
				
			||||||
  const timestamp = Date.now();
 | 
					 | 
				
			||||||
  const toSave: AttachmentDownloadJobType = {
 | 
					 | 
				
			||||||
    ...job,
 | 
					 | 
				
			||||||
    id,
 | 
					 | 
				
			||||||
    attachment,
 | 
					 | 
				
			||||||
    timestamp,
 | 
					 | 
				
			||||||
    pending: 0,
 | 
					 | 
				
			||||||
    attempts: 0,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await saveAttachmentDownloadJob(toSave);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  void _maybeStartJob();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    ...attachment,
 | 
					 | 
				
			||||||
    pending: true,
 | 
					 | 
				
			||||||
    downloadJobId: id,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function _tick(): Promise<void> {
 | 
					 | 
				
			||||||
  clearTimeoutIfNecessary(timeout);
 | 
					 | 
				
			||||||
  timeout = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  void _maybeStartJob();
 | 
					 | 
				
			||||||
  timeout = setTimeout(_tick, TICK_INTERVAL);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function _maybeStartJob(): Promise<void> {
 | 
					 | 
				
			||||||
  if (!enabled) {
 | 
					 | 
				
			||||||
    logger.info('attachment_downloads/_maybeStartJob: not enabled, returning');
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const jobCount = getActiveJobCount();
 | 
					 | 
				
			||||||
  const limit = MAX_ATTACHMENT_JOB_PARALLELISM - jobCount;
 | 
					 | 
				
			||||||
  if (limit <= 0) {
 | 
					 | 
				
			||||||
    logger.info(
 | 
					 | 
				
			||||||
      'attachment_downloads/_maybeStartJob: reached active job limit, waiting'
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const nextJobs = await getNextAttachmentDownloadJobs(limit);
 | 
					 | 
				
			||||||
  if (nextJobs.length <= 0) {
 | 
					 | 
				
			||||||
    logger.info(
 | 
					 | 
				
			||||||
      'attachment_downloads/_maybeStartJob: no attachment jobs to run'
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // To prevent the race condition caused by two parallel database calls, eached kicked
 | 
					 | 
				
			||||||
  //   off because the jobCount wasn't at the max.
 | 
					 | 
				
			||||||
  const secondJobCount = getActiveJobCount();
 | 
					 | 
				
			||||||
  const needed = MAX_ATTACHMENT_JOB_PARALLELISM - secondJobCount;
 | 
					 | 
				
			||||||
  if (needed <= 0) {
 | 
					 | 
				
			||||||
    logger.info(
 | 
					 | 
				
			||||||
      'attachment_downloads/_maybeStartJob: reached active job limit after ' +
 | 
					 | 
				
			||||||
        'db query, waiting'
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const jobs = nextJobs.slice(0, Math.min(needed, nextJobs.length));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  logger.info(
 | 
					 | 
				
			||||||
    `attachment_downloads/_maybeStartJob: starting ${jobs.length} jobs`
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  for (let i = 0, max = jobs.length; i < max; i += 1) {
 | 
					 | 
				
			||||||
    const job = jobs[i];
 | 
					 | 
				
			||||||
    const existing = _activeAttachmentDownloadJobs[job.id];
 | 
					 | 
				
			||||||
    if (existing) {
 | 
					 | 
				
			||||||
      logger.warn(
 | 
					 | 
				
			||||||
        `attachment_downloads/_maybeStartJob: Job ${job.id} is already running`
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      logger.info(
 | 
					 | 
				
			||||||
        `attachment_downloads/_maybeStartJob: Starting job ${job.id}`
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      const promise = _runJob(job);
 | 
					 | 
				
			||||||
      _activeAttachmentDownloadJobs[job.id] = promise;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const postProcess = async () => {
 | 
					 | 
				
			||||||
        const logId = `attachment_downloads/_maybeStartJob/postProcess/${job.id}`;
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          await promise;
 | 
					 | 
				
			||||||
          if (_activeAttachmentDownloadJobs[job.id]) {
 | 
					 | 
				
			||||||
            throw new Error(
 | 
					 | 
				
			||||||
              `${logId}: Active attachments jobs list still has this job!`
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        } catch (error: unknown) {
 | 
					 | 
				
			||||||
          log.error(
 | 
					 | 
				
			||||||
            `${logId}: Download job threw an error, deleting.`,
 | 
					 | 
				
			||||||
            Errors.toLogFormat(error)
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          delete _activeAttachmentDownloadJobs[job.id];
 | 
					 | 
				
			||||||
          try {
 | 
					 | 
				
			||||||
            await _markAttachmentAsFailed(job);
 | 
					 | 
				
			||||||
          } catch (deleteError) {
 | 
					 | 
				
			||||||
            log.error(
 | 
					 | 
				
			||||||
              `${logId}: Failed to delete attachment job`,
 | 
					 | 
				
			||||||
              Errors.toLogFormat(deleteError)
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
          } finally {
 | 
					 | 
				
			||||||
            void _maybeStartJob();
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Note: intentionally not awaiting
 | 
					 | 
				
			||||||
      void postProcess();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function _runJob(job?: AttachmentDownloadJobType): Promise<void> {
 | 
					 | 
				
			||||||
  if (!job) {
 | 
					 | 
				
			||||||
    log.warn('attachment_downloads/_runJob: Job was missing!');
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const { id, messageId, attachment, type, index, attempts } = job;
 | 
					 | 
				
			||||||
  let message;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    if (!job || !attachment || !messageId) {
 | 
					 | 
				
			||||||
      throw new Error(
 | 
					 | 
				
			||||||
        `_runJob: Key information required for job was missing. Job id: ${id}`
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const pending = true;
 | 
					 | 
				
			||||||
    await setAttachmentDownloadJobPending(id, pending);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    message = await _getMessageById(id, messageId);
 | 
					 | 
				
			||||||
    logger.info(
 | 
					 | 
				
			||||||
      'attachment_downloads/_runJob' +
 | 
					 | 
				
			||||||
        `(jobId: ${id}, type: ${type}, index: ${index},` +
 | 
					 | 
				
			||||||
        ` cdnKey: ${
 | 
					 | 
				
			||||||
          attachment.cdnKey ? redactCdnKey(attachment.cdnKey) : null
 | 
					 | 
				
			||||||
        },` +
 | 
					 | 
				
			||||||
        ` messageTimestamp: ${message?.attributes.timestamp}): starting`
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!message) {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let downloaded: AttachmentType | null = null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const maxInKib = getMaximumIncomingAttachmentSizeInKb(getValue);
 | 
					 | 
				
			||||||
      const maxTextAttachmentSizeInKib =
 | 
					 | 
				
			||||||
        getMaximumIncomingTextAttachmentSizeInKb(getValue);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const { size } = attachment;
 | 
					 | 
				
			||||||
      const sizeInKib = size / KIBIBYTE;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!Number.isFinite(size) || size < 0 || sizeInKib > maxInKib) {
 | 
					 | 
				
			||||||
        throw new AttachmentSizeError(
 | 
					 | 
				
			||||||
          `Attachment Job ${id}: Attachment was ${sizeInKib}kib, max is ${maxInKib}kib`
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      if (type === 'long-message' && sizeInKib > maxTextAttachmentSizeInKib) {
 | 
					 | 
				
			||||||
        throw new AttachmentSizeError(
 | 
					 | 
				
			||||||
          `Attachment Job ${id}: Text attachment was ${sizeInKib}kib, max is ${maxTextAttachmentSizeInKib}kib`
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await _addAttachmentToMessage(
 | 
					 | 
				
			||||||
        message,
 | 
					 | 
				
			||||||
        { ...attachment, pending: true },
 | 
					 | 
				
			||||||
        { type, index }
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // If the download is bigger than expected, we'll stop in the middle
 | 
					 | 
				
			||||||
      downloaded = await downloadAttachment(attachment);
 | 
					 | 
				
			||||||
    } catch (error) {
 | 
					 | 
				
			||||||
      if (error instanceof AttachmentSizeError) {
 | 
					 | 
				
			||||||
        log.error(Errors.toLogFormat(error));
 | 
					 | 
				
			||||||
        await _addAttachmentToMessage(
 | 
					 | 
				
			||||||
          message,
 | 
					 | 
				
			||||||
          _markAttachmentAsTooBig(attachment),
 | 
					 | 
				
			||||||
          { type, index }
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        await _finishJob(message, id);
 | 
					 | 
				
			||||||
        return;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      throw error;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!downloaded) {
 | 
					 | 
				
			||||||
      logger.warn(
 | 
					 | 
				
			||||||
        `attachment_downloads/_runJob(${id}): Got 404 from server for CDN ${
 | 
					 | 
				
			||||||
          attachment.cdnNumber
 | 
					 | 
				
			||||||
        }, marking attachment ${
 | 
					 | 
				
			||||||
          attachment.cdnId || attachment.cdnKey
 | 
					 | 
				
			||||||
        } from message ${message.idForLogging()} as permanent error`
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await _addAttachmentToMessage(
 | 
					 | 
				
			||||||
        message,
 | 
					 | 
				
			||||||
        _markAttachmentAsPermanentError(attachment),
 | 
					 | 
				
			||||||
        { type, index }
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      await _finishJob(message, id);
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    logger.info(
 | 
					 | 
				
			||||||
      `attachment_downloads/_runJob(${id}): processing new attachment` +
 | 
					 | 
				
			||||||
        ` of type: ${type}`
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    const upgradedAttachment =
 | 
					 | 
				
			||||||
      await window.Signal.Migrations.processNewAttachment(downloaded);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await _addAttachmentToMessage(message, omit(upgradedAttachment, 'error'), {
 | 
					 | 
				
			||||||
      type,
 | 
					 | 
				
			||||||
      index,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await _finishJob(message, id);
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    const logId = message ? message.idForLogging() : id || '<no id>';
 | 
					 | 
				
			||||||
    const currentAttempt = (attempts || 0) + 1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (currentAttempt >= 3) {
 | 
					 | 
				
			||||||
      logger.error(
 | 
					 | 
				
			||||||
        `attachment_downloads/runJob(${id}): ${currentAttempt} failed ` +
 | 
					 | 
				
			||||||
          `attempts, marking attachment from message ${logId} as ` +
 | 
					 | 
				
			||||||
          'error:',
 | 
					 | 
				
			||||||
        Errors.toLogFormat(error)
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      try {
 | 
					 | 
				
			||||||
        await _addAttachmentToMessage(
 | 
					 | 
				
			||||||
          message,
 | 
					 | 
				
			||||||
          _markAttachmentAsTransientError(attachment),
 | 
					 | 
				
			||||||
          { type, index }
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      } finally {
 | 
					 | 
				
			||||||
        await _finishJob(message, id);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    logger.error(
 | 
					 | 
				
			||||||
      `attachment_downloads/_runJob(${id}): Failed to download attachment ` +
 | 
					 | 
				
			||||||
        `type ${type} for message ${logId}, attempt ${currentAttempt}:`,
 | 
					 | 
				
			||||||
      Errors.toLogFormat(error)
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      // Remove `pending` flag from the attachment.
 | 
					 | 
				
			||||||
      await _addAttachmentToMessage(
 | 
					 | 
				
			||||||
        message,
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
          ...attachment,
 | 
					 | 
				
			||||||
          downloadJobId: id,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        { type, index }
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      if (message) {
 | 
					 | 
				
			||||||
        await saveMessage(message.attributes, {
 | 
					 | 
				
			||||||
          ourAci: window.textsecure.storage.user.getCheckedAci(),
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const failedJob = {
 | 
					 | 
				
			||||||
        ...job,
 | 
					 | 
				
			||||||
        pending: 0,
 | 
					 | 
				
			||||||
        attempts: currentAttempt,
 | 
					 | 
				
			||||||
        timestamp:
 | 
					 | 
				
			||||||
          Date.now() + (RETRY_BACKOFF[currentAttempt] || RETRY_BACKOFF[3]),
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      await saveAttachmentDownloadJob(failedJob);
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      delete _activeAttachmentDownloadJobs[id];
 | 
					 | 
				
			||||||
      void _maybeStartJob();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function _markAttachmentAsFailed(
 | 
					 | 
				
			||||||
  job: AttachmentDownloadJobType
 | 
					 | 
				
			||||||
): Promise<void> {
 | 
					 | 
				
			||||||
  const { id, messageId, attachment, type, index } = job;
 | 
					 | 
				
			||||||
  const message = await _getMessageById(id, messageId);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    if (!message) {
 | 
					 | 
				
			||||||
      return;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    await _addAttachmentToMessage(
 | 
					 | 
				
			||||||
      message,
 | 
					 | 
				
			||||||
      _markAttachmentAsPermanentError(attachment),
 | 
					 | 
				
			||||||
      { type, index }
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  } finally {
 | 
					 | 
				
			||||||
    await _finishJob(message, id);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function _getMessageById(
 | 
					 | 
				
			||||||
  id: string,
 | 
					 | 
				
			||||||
  messageId: string
 | 
					 | 
				
			||||||
): Promise<MessageModel | undefined> {
 | 
					 | 
				
			||||||
  const message = window.MessageCache.__DEPRECATED$getById(messageId);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (message) {
 | 
					 | 
				
			||||||
    return message;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const messageAttributes = await getMessageById(messageId);
 | 
					 | 
				
			||||||
  if (!messageAttributes) {
 | 
					 | 
				
			||||||
    logger.error(
 | 
					 | 
				
			||||||
      `attachment_downloads/_runJob(${id}): ` +
 | 
					 | 
				
			||||||
        'Source message not found, deleting job'
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    await _finishJob(null, id);
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  strictAssert(messageId === messageAttributes.id, 'message id mismatch');
 | 
					 | 
				
			||||||
  return window.MessageCache.__DEPRECATED$register(
 | 
					 | 
				
			||||||
    messageId,
 | 
					 | 
				
			||||||
    messageAttributes,
 | 
					 | 
				
			||||||
    'AttachmentDownloads._getMessageById'
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function _finishJob(
 | 
					 | 
				
			||||||
  message: MessageModel | null | undefined,
 | 
					 | 
				
			||||||
  id: string
 | 
					 | 
				
			||||||
): Promise<void> {
 | 
					 | 
				
			||||||
  if (message) {
 | 
					 | 
				
			||||||
    logger.info(`attachment_downloads/_finishJob for job id: ${id}`);
 | 
					 | 
				
			||||||
    await saveMessage(message.attributes, {
 | 
					 | 
				
			||||||
      ourAci: window.textsecure.storage.user.getCheckedAci(),
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  await removeAttachmentDownloadJob(id);
 | 
					 | 
				
			||||||
  delete _activeAttachmentDownloadJobs[id];
 | 
					 | 
				
			||||||
  void _maybeStartJob();
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function getActiveJobCount(): number {
 | 
					 | 
				
			||||||
  return Object.keys(_activeAttachmentDownloadJobs).length;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function _markAttachmentAsPermanentError(
 | 
					 | 
				
			||||||
  attachment: AttachmentType
 | 
					 | 
				
			||||||
): AttachmentType {
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    ...omit(attachment, ['key', 'id']),
 | 
					 | 
				
			||||||
    error: true,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
 | 
					 | 
				
			||||||
  return {
 | 
					 | 
				
			||||||
    ...omit(attachment, ['key', 'id']),
 | 
					 | 
				
			||||||
    error: true,
 | 
					 | 
				
			||||||
    wasTooBig: true,
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function _markAttachmentAsTransientError(
 | 
					 | 
				
			||||||
  attachment: AttachmentType
 | 
					 | 
				
			||||||
): AttachmentType {
 | 
					 | 
				
			||||||
  return { ...attachment, error: true };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
async function _addAttachmentToMessage(
 | 
					 | 
				
			||||||
  message: MessageModel | null | undefined,
 | 
					  message: MessageModel | null | undefined,
 | 
				
			||||||
  attachment: AttachmentType,
 | 
					  attachment: AttachmentType,
 | 
				
			||||||
  { type, index }: { type: AttachmentDownloadJobTypeType; index: number }
 | 
					  { type }: { type: AttachmentDownloadJobTypeType }
 | 
				
			||||||
): Promise<void> {
 | 
					): Promise<void> {
 | 
				
			||||||
  if (!message) {
 | 
					  if (!message) {
 | 
				
			||||||
    return;
 | 
					    return;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const logPrefix = `${message.idForLogging()} (type: ${type}, index: ${index})`;
 | 
					  const logPrefix = `${message.idForLogging()} (type: ${type})`;
 | 
				
			||||||
  const attachmentSignature = getAttachmentSignature(attachment);
 | 
					  const attachmentSignature = getAttachmentSignature(attachment);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (type === 'long-message') {
 | 
					  if (type === 'long-message') {
 | 
				
			||||||
| 
						 | 
					@ -608,7 +101,7 @@ async function _addAttachmentToMessage(
 | 
				
			||||||
        await window.Signal.Migrations.deleteAttachmentData(attachment.path);
 | 
					        await window.Signal.Migrations.deleteAttachmentData(attachment.path);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      if (!handledAnywhere) {
 | 
					      if (!handledAnywhere) {
 | 
				
			||||||
        logger.warn(
 | 
					        log.warn(
 | 
				
			||||||
          `${logPrefix}: Long message attachment found no matching place to apply`
 | 
					          `${logPrefix}: Long message attachment found no matching place to apply`
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
| 
						 | 
					@ -670,7 +163,7 @@ async function _addAttachmentToMessage(
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!handledAnywhere) {
 | 
					    if (!handledAnywhere) {
 | 
				
			||||||
      logger.warn(
 | 
					      log.warn(
 | 
				
			||||||
        `${logPrefix}: 'attachment' type found no matching place to apply`
 | 
					        `${logPrefix}: 'attachment' type found no matching place to apply`
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -727,33 +220,37 @@ async function _addAttachmentToMessage(
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (type === 'contact') {
 | 
					  if (type === 'contact') {
 | 
				
			||||||
    const contact = message.get('contact');
 | 
					    const contacts = message.get('contact');
 | 
				
			||||||
    if (!contact || contact.length <= index) {
 | 
					    if (!contacts?.length) {
 | 
				
			||||||
 | 
					      throw new Error(`${logPrefix}: no contacts, cannot add attachment!`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    let handled = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const newContacts = contacts.map(contact => {
 | 
				
			||||||
 | 
					      if (!contact.avatar?.avatar) {
 | 
				
			||||||
 | 
					        return contact;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const existingAttachment = contact.avatar.avatar;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const newAttachment = maybeReplaceAttachment(existingAttachment);
 | 
				
			||||||
 | 
					      if (existingAttachment !== newAttachment) {
 | 
				
			||||||
 | 
					        handled = true;
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          ...contact,
 | 
				
			||||||
 | 
					          avatar: { ...contact.avatar, avatar: newAttachment },
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return contact;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!handled) {
 | 
				
			||||||
      throw new Error(
 | 
					      throw new Error(
 | 
				
			||||||
        `${logPrefix}: contact didn't exist or ${index} was too large`
 | 
					        `${logPrefix}: Couldn't find matching contact with avatar attachment for message`
 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const item = contact[index];
 | 
					 | 
				
			||||||
    if (item && item.avatar && item.avatar.avatar) {
 | 
					 | 
				
			||||||
      _checkOldAttachment(item.avatar, 'avatar', logPrefix);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const newContact = [...contact];
 | 
					 | 
				
			||||||
      newContact[index] = {
 | 
					 | 
				
			||||||
        ...item,
 | 
					 | 
				
			||||||
        avatar: {
 | 
					 | 
				
			||||||
          ...item.avatar,
 | 
					 | 
				
			||||||
          avatar: attachment,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      message.set({ contact: newContact });
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      logger.warn(
 | 
					 | 
				
			||||||
        `${logPrefix}: Couldn't update contact with avatar attachment for message`
 | 
					 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    message.set({ contact: newContacts });
 | 
				
			||||||
    return;
 | 
					    return;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -831,20 +328,3 @@ async function _addAttachmentToMessage(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  throw new Error(`${logPrefix}: Unknown job type ${type}`);
 | 
					  throw new Error(`${logPrefix}: Unknown job type ${type}`);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
function _checkOldAttachment(
 | 
					 | 
				
			||||||
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
					 | 
				
			||||||
  object: any,
 | 
					 | 
				
			||||||
  key: string,
 | 
					 | 
				
			||||||
  logPrefix: string
 | 
					 | 
				
			||||||
): void {
 | 
					 | 
				
			||||||
  const oldAttachment = object[key];
 | 
					 | 
				
			||||||
  if (oldAttachment && oldAttachment.path) {
 | 
					 | 
				
			||||||
    logger.error(
 | 
					 | 
				
			||||||
      `_checkOldAttachment: ${logPrefix} - old attachment already had path, not replacing`
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
    throw new Error(
 | 
					 | 
				
			||||||
      '_checkOldAttachment: old attachment already had path, not replacing'
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16,6 +16,7 @@ import { notificationService } from '../services/notifications';
 | 
				
			||||||
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
 | 
					import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
 | 
				
			||||||
import { queueUpdateMessage } from '../util/messageBatcher';
 | 
					import { queueUpdateMessage } from '../util/messageBatcher';
 | 
				
			||||||
import { generateCacheKey } from './generateCacheKey';
 | 
					import { generateCacheKey } from './generateCacheKey';
 | 
				
			||||||
 | 
					import { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type ViewSyncAttributesType = {
 | 
					export type ViewSyncAttributesType = {
 | 
				
			||||||
  envelopeId: string;
 | 
					  envelopeId: string;
 | 
				
			||||||
| 
						 | 
					@ -127,7 +128,8 @@ export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
 | 
				
			||||||
      const attachments = message.get('attachments');
 | 
					      const attachments = message.get('attachments');
 | 
				
			||||||
      if (!attachments?.every(isDownloaded)) {
 | 
					      if (!attachments?.every(isDownloaded)) {
 | 
				
			||||||
        const updatedFields = await queueAttachmentDownloads(
 | 
					        const updatedFields = await queueAttachmentDownloads(
 | 
				
			||||||
          message.attributes
 | 
					          message.attributes,
 | 
				
			||||||
 | 
					          AttachmentDownloadUrgency.STANDARD
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        if (updatedFields) {
 | 
					        if (updatedFields) {
 | 
				
			||||||
          message.set(updatedFields);
 | 
					          message.set(updatedFields);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -157,6 +157,7 @@ import {
 | 
				
			||||||
  getChangesForPropAtTimestamp,
 | 
					  getChangesForPropAtTimestamp,
 | 
				
			||||||
} from '../util/editHelpers';
 | 
					} from '../util/editHelpers';
 | 
				
			||||||
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
 | 
					import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
 | 
				
			||||||
 | 
					import type { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* eslint-disable more/no-then */
 | 
					/* eslint-disable more/no-then */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1368,8 +1369,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
 | 
				
			||||||
    return hasAttachmentDownloads(this.attributes);
 | 
					    return hasAttachmentDownloads(this.attributes);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async queueAttachmentDownloads(): Promise<boolean> {
 | 
					  async queueAttachmentDownloads(
 | 
				
			||||||
    const value = await queueAttachmentDownloads(this.attributes);
 | 
					    urgency?: AttachmentDownloadUrgency
 | 
				
			||||||
 | 
					  ): Promise<boolean> {
 | 
				
			||||||
 | 
					    const value = await queueAttachmentDownloads(this.attributes, urgency);
 | 
				
			||||||
    if (!value) {
 | 
					    if (!value) {
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -2279,8 +2282,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        window.Signal.Data.updateConversation(conversation.attributes);
 | 
					        window.Signal.Data.updateConversation(conversation.attributes);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const reduxState = window.reduxStore.getState();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const giftBadge = message.get('giftBadge');
 | 
					        const giftBadge = message.get('giftBadge');
 | 
				
			||||||
        if (giftBadge) {
 | 
					        if (giftBadge) {
 | 
				
			||||||
          const { level } = giftBadge;
 | 
					          const { level } = giftBadge;
 | 
				
			||||||
| 
						 | 
					@ -2315,35 +2316,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Only queue attachments for downloads if this is a story or
 | 
					 | 
				
			||||||
        // outgoing message or we've accepted the conversation
 | 
					 | 
				
			||||||
        const attachments = this.get('attachments') || [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        let queueStoryForDownload = false;
 | 
					 | 
				
			||||||
        if (isStory(message.attributes)) {
 | 
					 | 
				
			||||||
          queueStoryForDownload = await shouldDownloadStory(
 | 
					 | 
				
			||||||
            conversation.attributes
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const shouldHoldOffDownload =
 | 
					 | 
				
			||||||
          (isStory(message.attributes) && !queueStoryForDownload) ||
 | 
					 | 
				
			||||||
          (!isStory(message.attributes) &&
 | 
					 | 
				
			||||||
            (isImage(attachments) || isVideo(attachments)) &&
 | 
					 | 
				
			||||||
            isInCall(reduxState));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (
 | 
					 | 
				
			||||||
          this.hasAttachmentDownloads() &&
 | 
					 | 
				
			||||||
          (conversation.getAccepted() || isOutgoing(message.attributes)) &&
 | 
					 | 
				
			||||||
          !shouldHoldOffDownload
 | 
					 | 
				
			||||||
        ) {
 | 
					 | 
				
			||||||
          if (shouldUseAttachmentDownloadQueue()) {
 | 
					 | 
				
			||||||
            addToAttachmentDownloadQueue(idLog, message);
 | 
					 | 
				
			||||||
          } else {
 | 
					 | 
				
			||||||
            await message.queueAttachmentDownloads();
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const isFirstRun = true;
 | 
					        const isFirstRun = true;
 | 
				
			||||||
        await this.modifyTargetMessage(conversation, isFirstRun);
 | 
					        await this.modifyTargetMessage(conversation, isFirstRun);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2365,6 +2337,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    log.info('Message saved', this.get('sent_at'));
 | 
					    log.info('Message saved', this.get('sent_at'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Once the message is saved to DB, we queue attachment downloads
 | 
				
			||||||
 | 
					    await this.handleAttachmentDownloadsForNewMessage(conversation);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    conversation.trigger('newmessage', this);
 | 
					    conversation.trigger('newmessage', this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const isFirstRun = false;
 | 
					    const isFirstRun = false;
 | 
				
			||||||
| 
						 | 
					@ -2389,6 +2364,38 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private async handleAttachmentDownloadsForNewMessage(
 | 
				
			||||||
 | 
					    conversation: ConversationModel
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    const idLog = `handleAttachmentDownloadsForNewMessage/${conversation.idForLogging()} ${this.idForLogging()}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Only queue attachments for downloads if this is a story (with additional logic), or
 | 
				
			||||||
 | 
					    // if it's either an outgoing message or we've accepted the conversation
 | 
				
			||||||
 | 
					    let shouldDownloadNow = false;
 | 
				
			||||||
 | 
					    const attachments = this.get('attachments') || [];
 | 
				
			||||||
 | 
					    const reduxState = window.reduxStore.getState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isStory(this.attributes)) {
 | 
				
			||||||
 | 
					      shouldDownloadNow = await shouldDownloadStory(conversation.attributes);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const isVisualMediaAndUserInCall =
 | 
				
			||||||
 | 
					        isInCall(reduxState) && (isImage(attachments) || isVideo(attachments));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      shouldDownloadNow =
 | 
				
			||||||
 | 
					        this.hasAttachmentDownloads() &&
 | 
				
			||||||
 | 
					        (conversation.getAccepted() || isOutgoing(this.attributes)) &&
 | 
				
			||||||
 | 
					        !isVisualMediaAndUserInCall;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (shouldDownloadNow) {
 | 
				
			||||||
 | 
					      if (shouldUseAttachmentDownloadQueue()) {
 | 
				
			||||||
 | 
					        addToAttachmentDownloadQueue(idLog, this);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        await this.queueAttachmentDownloads();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // This function is called twice - once from handleDataMessage, and then again from
 | 
					  // This function is called twice - once from handleDataMessage, and then again from
 | 
				
			||||||
  //    saveAndNotify, a function called at the end of handleDataMessage as a cleanup for
 | 
					  //    saveAndNotify, a function called at the end of handleDataMessage as a cleanup for
 | 
				
			||||||
  //    any missed out-of-order events.
 | 
					  //    any missed out-of-order events.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -96,6 +96,7 @@ export class BackupImportStream extends Writable {
 | 
				
			||||||
        forceSave: true,
 | 
					        forceSave: true,
 | 
				
			||||||
        ourAci,
 | 
					        ourAci,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					      // TODO (DESKTOP-6845): after we save messages, queue their attachment downloads
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
  private ourConversation?: ConversationAttributesType;
 | 
					  private ourConversation?: ConversationAttributesType;
 | 
				
			||||||
| 
						 | 
					@ -626,6 +627,7 @@ export class BackupImportStream extends Writable {
 | 
				
			||||||
  ): Partial<MessageAttributesType> {
 | 
					  ): Partial<MessageAttributesType> {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      body: data.text?.body ?? '',
 | 
					      body: data.text?.body ?? '',
 | 
				
			||||||
 | 
					      // TODO (DESKTOP-6845): add attachments
 | 
				
			||||||
      reactions: data.reactions?.map(
 | 
					      reactions: data.reactions?.map(
 | 
				
			||||||
        ({ emoji, authorId, sentTimestamp, receivedTimestamp }) => {
 | 
					        ({ emoji, authorId, sentTimestamp, receivedTimestamp }) => {
 | 
				
			||||||
          strictAssert(emoji != null, 'reaction must have an emoji');
 | 
					          strictAssert(emoji != null, 'reaction must have an emoji');
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -35,7 +35,6 @@ import { ipcInvoke, doShutdown } from './channels';
 | 
				
			||||||
import type {
 | 
					import type {
 | 
				
			||||||
  AdjacentMessagesByConversationOptionsType,
 | 
					  AdjacentMessagesByConversationOptionsType,
 | 
				
			||||||
  AllItemsType,
 | 
					  AllItemsType,
 | 
				
			||||||
  AttachmentDownloadJobType,
 | 
					 | 
				
			||||||
  ClientInterface,
 | 
					  ClientInterface,
 | 
				
			||||||
  ClientExclusiveInterface,
 | 
					  ClientExclusiveInterface,
 | 
				
			||||||
  ClientSearchResultMessageType,
 | 
					  ClientSearchResultMessageType,
 | 
				
			||||||
| 
						 | 
					@ -66,6 +65,7 @@ import { getMessageIdForLogging } from '../util/idForLogging';
 | 
				
			||||||
import type { MessageAttributesType } from '../model-types';
 | 
					import type { MessageAttributesType } from '../model-types';
 | 
				
			||||||
import { incrementMessageCounter } from '../util/incrementMessageCounter';
 | 
					import { incrementMessageCounter } from '../util/incrementMessageCounter';
 | 
				
			||||||
import { generateSnippetAroundMention } from '../util/search';
 | 
					import { generateSnippetAroundMention } from '../util/search';
 | 
				
			||||||
 | 
					import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ERASE_SQL_KEY = 'erase-sql-key';
 | 
					const ERASE_SQL_KEY = 'erase-sql-key';
 | 
				
			||||||
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
 | 
					const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,7 +10,6 @@ import type { StoredJob } from '../jobs/types';
 | 
				
			||||||
import type { ReactionType, ReactionReadStatus } from '../types/Reactions';
 | 
					import type { ReactionType, ReactionReadStatus } from '../types/Reactions';
 | 
				
			||||||
import type { ConversationColorType, CustomColorType } from '../types/Colors';
 | 
					import type { ConversationColorType, CustomColorType } from '../types/Colors';
 | 
				
			||||||
import type { StorageAccessType } from '../types/Storage.d';
 | 
					import type { StorageAccessType } from '../types/Storage.d';
 | 
				
			||||||
import type { AttachmentType } from '../types/Attachment';
 | 
					 | 
				
			||||||
import type { BytesToStrings } from '../types/Util';
 | 
					import type { BytesToStrings } from '../types/Util';
 | 
				
			||||||
import type { QualifiedAddressStringType } from '../types/QualifiedAddress';
 | 
					import type { QualifiedAddressStringType } from '../types/QualifiedAddress';
 | 
				
			||||||
import type { StoryDistributionIdString } from '../types/StoryDistributionId';
 | 
					import type { StoryDistributionIdString } from '../types/StoryDistributionId';
 | 
				
			||||||
| 
						 | 
					@ -31,6 +30,7 @@ import type {
 | 
				
			||||||
  CallHistoryPagination,
 | 
					  CallHistoryPagination,
 | 
				
			||||||
} from '../types/CallDisposition';
 | 
					} from '../types/CallDisposition';
 | 
				
			||||||
import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink';
 | 
					import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink';
 | 
				
			||||||
 | 
					import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
 | 
					export type AdjacentMessagesByConversationOptionsType = Readonly<{
 | 
				
			||||||
  conversationId: string;
 | 
					  conversationId: string;
 | 
				
			||||||
| 
						 | 
					@ -51,24 +51,6 @@ export type GetNearbyMessageFromDeletedSetOptionsType = Readonly<{
 | 
				
			||||||
  includeStoryReplies: boolean;
 | 
					  includeStoryReplies: boolean;
 | 
				
			||||||
}>;
 | 
					}>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type AttachmentDownloadJobTypeType =
 | 
					 | 
				
			||||||
  | 'long-message'
 | 
					 | 
				
			||||||
  | 'attachment'
 | 
					 | 
				
			||||||
  | 'preview'
 | 
					 | 
				
			||||||
  | 'contact'
 | 
					 | 
				
			||||||
  | 'quote'
 | 
					 | 
				
			||||||
  | 'sticker';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export type AttachmentDownloadJobType = {
 | 
					 | 
				
			||||||
  attachment: AttachmentType;
 | 
					 | 
				
			||||||
  attempts: number;
 | 
					 | 
				
			||||||
  id: string;
 | 
					 | 
				
			||||||
  index: number;
 | 
					 | 
				
			||||||
  messageId: string;
 | 
					 | 
				
			||||||
  pending: number;
 | 
					 | 
				
			||||||
  timestamp: number;
 | 
					 | 
				
			||||||
  type: AttachmentDownloadJobTypeType;
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
export type MessageMetricsType = {
 | 
					export type MessageMetricsType = {
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
  received_at: number;
 | 
					  received_at: number;
 | 
				
			||||||
| 
						 | 
					@ -741,21 +723,22 @@ export type DataInterface = {
 | 
				
			||||||
  /** only for testing */
 | 
					  /** only for testing */
 | 
				
			||||||
  removeAllUnprocessed: () => Promise<void>;
 | 
					  removeAllUnprocessed: () => Promise<void>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getAttachmentDownloadJobById: (
 | 
					  getAttachmentDownloadJob(
 | 
				
			||||||
    id: string
 | 
					    job: Pick<
 | 
				
			||||||
  ) => Promise<AttachmentDownloadJobType | undefined>;
 | 
					      AttachmentDownloadJobType,
 | 
				
			||||||
  getNextAttachmentDownloadJobs: (
 | 
					      'messageId' | 'attachmentType' | 'digest'
 | 
				
			||||||
    limit?: number,
 | 
					    >
 | 
				
			||||||
    options?: { timestamp?: number }
 | 
					  ): AttachmentDownloadJobType;
 | 
				
			||||||
  ) => Promise<Array<AttachmentDownloadJobType>>;
 | 
					  getNextAttachmentDownloadJobs: (options: {
 | 
				
			||||||
 | 
					    limit: number;
 | 
				
			||||||
 | 
					    prioritizeMessageIds?: Array<string>;
 | 
				
			||||||
 | 
					    timestamp?: number;
 | 
				
			||||||
 | 
					  }) => Promise<Array<AttachmentDownloadJobType>>;
 | 
				
			||||||
  saveAttachmentDownloadJob: (job: AttachmentDownloadJobType) => Promise<void>;
 | 
					  saveAttachmentDownloadJob: (job: AttachmentDownloadJobType) => Promise<void>;
 | 
				
			||||||
  resetAttachmentDownloadPending: () => Promise<void>;
 | 
					  resetAttachmentDownloadActive: () => Promise<void>;
 | 
				
			||||||
  setAttachmentDownloadJobPending: (
 | 
					  removeAttachmentDownloadJob: (
 | 
				
			||||||
    id: string,
 | 
					    job: AttachmentDownloadJobType
 | 
				
			||||||
    pending: boolean
 | 
					 | 
				
			||||||
  ) => Promise<void>;
 | 
					  ) => Promise<void>;
 | 
				
			||||||
  removeAttachmentDownloadJob: (id: string) => Promise<number>;
 | 
					 | 
				
			||||||
  removeAllAttachmentDownloadJobs: () => Promise<number>;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  createOrUpdateStickerPack: (pack: StickerPackType) => Promise<void>;
 | 
					  createOrUpdateStickerPack: (pack: StickerPackType) => Promise<void>;
 | 
				
			||||||
  updateStickerPackStatus: (
 | 
					  updateStickerPackStatus: (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										244
									
								
								ts/sql/Server.ts
									
										
									
									
									
								
							
							
						
						
									
										244
									
								
								ts/sql/Server.ts
									
										
									
									
									
								
							| 
						 | 
					@ -88,7 +88,6 @@ import { updateSchema } from './migrations';
 | 
				
			||||||
import type {
 | 
					import type {
 | 
				
			||||||
  AdjacentMessagesByConversationOptionsType,
 | 
					  AdjacentMessagesByConversationOptionsType,
 | 
				
			||||||
  StoredAllItemsType,
 | 
					  StoredAllItemsType,
 | 
				
			||||||
  AttachmentDownloadJobType,
 | 
					 | 
				
			||||||
  ConversationMetricsType,
 | 
					  ConversationMetricsType,
 | 
				
			||||||
  ConversationType,
 | 
					  ConversationType,
 | 
				
			||||||
  DeleteSentProtoRecipientOptionsType,
 | 
					  DeleteSentProtoRecipientOptionsType,
 | 
				
			||||||
| 
						 | 
					@ -173,6 +172,10 @@ import {
 | 
				
			||||||
  updateCallLinkState,
 | 
					  updateCallLinkState,
 | 
				
			||||||
} from './server/callLinks';
 | 
					} from './server/callLinks';
 | 
				
			||||||
import { CallMode } from '../types/Calling';
 | 
					import { CallMode } from '../types/Calling';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  attachmentDownloadJobSchema,
 | 
				
			||||||
 | 
					  type AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					} from '../types/AttachmentDownload';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ConversationRow = Readonly<{
 | 
					type ConversationRow = Readonly<{
 | 
				
			||||||
  json: string;
 | 
					  json: string;
 | 
				
			||||||
| 
						 | 
					@ -353,13 +356,11 @@ const dataInterface: ServerInterface = {
 | 
				
			||||||
  removeUnprocessed,
 | 
					  removeUnprocessed,
 | 
				
			||||||
  removeAllUnprocessed,
 | 
					  removeAllUnprocessed,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getAttachmentDownloadJobById,
 | 
					  getAttachmentDownloadJob,
 | 
				
			||||||
  getNextAttachmentDownloadJobs,
 | 
					  getNextAttachmentDownloadJobs,
 | 
				
			||||||
  saveAttachmentDownloadJob,
 | 
					  saveAttachmentDownloadJob,
 | 
				
			||||||
  resetAttachmentDownloadPending,
 | 
					  resetAttachmentDownloadActive,
 | 
				
			||||||
  setAttachmentDownloadJobPending,
 | 
					 | 
				
			||||||
  removeAttachmentDownloadJob,
 | 
					  removeAttachmentDownloadJob,
 | 
				
			||||||
  removeAllAttachmentDownloadJobs,
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  createOrUpdateStickerPack,
 | 
					  createOrUpdateStickerPack,
 | 
				
			||||||
  updateStickerPackStatus,
 | 
					  updateStickerPackStatus,
 | 
				
			||||||
| 
						 | 
					@ -4403,127 +4404,184 @@ async function removeAllUnprocessed(): Promise<void> {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Attachment Downloads
 | 
					// Attachment Downloads
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ATTACHMENT_DOWNLOADS_TABLE = 'attachment_downloads';
 | 
					function getAttachmentDownloadJob(
 | 
				
			||||||
async function getAttachmentDownloadJobById(
 | 
					  job: Pick<
 | 
				
			||||||
  id: string
 | 
					    AttachmentDownloadJobType,
 | 
				
			||||||
): Promise<AttachmentDownloadJobType | undefined> {
 | 
					    'messageId' | 'attachmentType' | 'digest'
 | 
				
			||||||
  return getById(getReadonlyInstance(), ATTACHMENT_DOWNLOADS_TABLE, id);
 | 
					  >
 | 
				
			||||||
 | 
					): AttachmentDownloadJobType {
 | 
				
			||||||
 | 
					  const db = getReadonlyInstance();
 | 
				
			||||||
 | 
					  const [query, params] = sql`
 | 
				
			||||||
 | 
					    SELECT * FROM attachment_downloads
 | 
				
			||||||
 | 
					    WHERE
 | 
				
			||||||
 | 
					      messageId = ${job.messageId}
 | 
				
			||||||
 | 
					    AND 
 | 
				
			||||||
 | 
					      attachmentType = ${job.attachmentType}
 | 
				
			||||||
 | 
					    AND 
 | 
				
			||||||
 | 
					      digest = ${job.digest};
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return db.prepare(query).get(params);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
async function getNextAttachmentDownloadJobs(
 | 
					
 | 
				
			||||||
  limit?: number,
 | 
					async function getNextAttachmentDownloadJobs({
 | 
				
			||||||
  options: { timestamp?: number } = {}
 | 
					  limit = 3,
 | 
				
			||||||
): Promise<Array<AttachmentDownloadJobType>> {
 | 
					  prioritizeMessageIds,
 | 
				
			||||||
 | 
					  timestamp = Date.now(),
 | 
				
			||||||
 | 
					  maxLastAttemptForPrioritizedMessages,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  limit: number;
 | 
				
			||||||
 | 
					  prioritizeMessageIds?: Array<string>;
 | 
				
			||||||
 | 
					  timestamp?: number;
 | 
				
			||||||
 | 
					  maxLastAttemptForPrioritizedMessages?: number;
 | 
				
			||||||
 | 
					}): Promise<Array<AttachmentDownloadJobType>> {
 | 
				
			||||||
  const db = await getWritableInstance();
 | 
					  const db = await getWritableInstance();
 | 
				
			||||||
  const timestamp =
 | 
					 | 
				
			||||||
    options && options.timestamp ? options.timestamp : Date.now();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const rows: Array<{ json: string; id: string }> = db
 | 
					  let priorityJobs = [];
 | 
				
			||||||
    .prepare<Query>(
 | 
					 | 
				
			||||||
      `
 | 
					 | 
				
			||||||
      SELECT id, json
 | 
					 | 
				
			||||||
      FROM attachment_downloads
 | 
					 | 
				
			||||||
      WHERE pending = 0 AND timestamp <= $timestamp
 | 
					 | 
				
			||||||
      ORDER BY timestamp DESC
 | 
					 | 
				
			||||||
      LIMIT $limit;
 | 
					 | 
				
			||||||
      `
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    .all({
 | 
					 | 
				
			||||||
      limit: limit || 3,
 | 
					 | 
				
			||||||
      timestamp,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const INNER_ERROR = 'jsonToObject error';
 | 
					  // First, try to get jobs for prioritized messages (e.g. those currently user-visible)
 | 
				
			||||||
 | 
					  if (prioritizeMessageIds?.length) {
 | 
				
			||||||
 | 
					    const [priorityQuery, priorityParams] = sql`
 | 
				
			||||||
 | 
					      SELECT * FROM attachment_downloads
 | 
				
			||||||
 | 
					      -- very few rows will match messageIds, so in this case we want to optimize
 | 
				
			||||||
 | 
					      -- the WHERE clause rather than the ORDER BY
 | 
				
			||||||
 | 
					      INDEXED BY attachment_downloads_active_messageId
 | 
				
			||||||
 | 
					      WHERE
 | 
				
			||||||
 | 
					        active = 0
 | 
				
			||||||
 | 
					      AND
 | 
				
			||||||
 | 
					        -- for priority messages, we want to retry based on the last attempt, rather than retryAfter
 | 
				
			||||||
 | 
					        (lastAttemptTimestamp is NULL OR lastAttemptTimestamp <= ${
 | 
				
			||||||
 | 
					          maxLastAttemptForPrioritizedMessages ?? timestamp - durations.HOUR
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					      AND
 | 
				
			||||||
 | 
					        messageId IN (${sqlJoin(prioritizeMessageIds)})
 | 
				
			||||||
 | 
					      -- for priority messages, let's load them oldest first; this helps, e.g. for stories where we 
 | 
				
			||||||
 | 
					      -- want the oldest one first
 | 
				
			||||||
 | 
					      ORDER BY receivedAt ASC
 | 
				
			||||||
 | 
					      LIMIT ${limit}
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					    priorityJobs = db.prepare(priorityQuery).all(priorityParams);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Next, get any other jobs, sorted by receivedAt
 | 
				
			||||||
 | 
					  const numJobsRemaining = limit - priorityJobs.length;
 | 
				
			||||||
 | 
					  let standardJobs = [];
 | 
				
			||||||
 | 
					  if (numJobsRemaining > 0) {
 | 
				
			||||||
 | 
					    const [query, params] = sql`
 | 
				
			||||||
 | 
					      SELECT * FROM attachment_downloads
 | 
				
			||||||
 | 
					      WHERE
 | 
				
			||||||
 | 
					        active = 0
 | 
				
			||||||
 | 
					      AND
 | 
				
			||||||
 | 
					        (retryAfter is NULL OR retryAfter <= ${timestamp})
 | 
				
			||||||
 | 
					      ORDER BY receivedAt DESC
 | 
				
			||||||
 | 
					      LIMIT ${numJobsRemaining}
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    standardJobs = db.prepare(query).all(params);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const allJobs = priorityJobs.concat(standardJobs);
 | 
				
			||||||
 | 
					  const INNER_ERROR = 'jsonToObject or SchemaParse error';
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    return rows.map(row => {
 | 
					    return allJobs.map(row => {
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        return jsonToObject(row.json);
 | 
					        return attachmentDownloadJobSchema.parse({
 | 
				
			||||||
 | 
					          ...row,
 | 
				
			||||||
 | 
					          active: Boolean(row.active),
 | 
				
			||||||
 | 
					          attachment: jsonToObject(row.attachmentJson),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
        logger.error(
 | 
					        logger.error(
 | 
				
			||||||
          `getNextAttachmentDownloadJobs: Error with job '${row.id}', deleting. ` +
 | 
					          `getNextAttachmentDownloadJobs: Error with job for message ${row.messageId}, deleting.`
 | 
				
			||||||
            `JSON: '${row.json}' ` +
 | 
					 | 
				
			||||||
            `Error: ${Errors.toLogFormat(error)}`
 | 
					 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
        removeAttachmentDownloadJobSync(db, row.id);
 | 
					
 | 
				
			||||||
        throw new Error(INNER_ERROR);
 | 
					        removeAttachmentDownloadJobSync(db, row);
 | 
				
			||||||
 | 
					        throw new Error(error);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    if ('message' in error && error.message === INNER_ERROR) {
 | 
					    if ('message' in error && error.message === INNER_ERROR) {
 | 
				
			||||||
      return getNextAttachmentDownloadJobs(limit, { timestamp });
 | 
					      return getNextAttachmentDownloadJobs({
 | 
				
			||||||
 | 
					        limit,
 | 
				
			||||||
 | 
					        prioritizeMessageIds,
 | 
				
			||||||
 | 
					        timestamp,
 | 
				
			||||||
 | 
					        maxLastAttemptForPrioritizedMessages,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    throw error;
 | 
					    throw error;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function saveAttachmentDownloadJob(
 | 
					async function saveAttachmentDownloadJob(
 | 
				
			||||||
  job: AttachmentDownloadJobType
 | 
					  job: AttachmentDownloadJobType
 | 
				
			||||||
): Promise<void> {
 | 
					): Promise<void> {
 | 
				
			||||||
  const db = await getWritableInstance();
 | 
					  const db = await getWritableInstance();
 | 
				
			||||||
  const { id, pending, timestamp } = job;
 | 
					 | 
				
			||||||
  if (!id) {
 | 
					 | 
				
			||||||
    throw new Error(
 | 
					 | 
				
			||||||
      'saveAttachmentDownloadJob: Provided job did not have a truthy id'
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  db.prepare<Query>(
 | 
					  const [query, params] = sql`
 | 
				
			||||||
    `
 | 
					 | 
				
			||||||
    INSERT OR REPLACE INTO attachment_downloads (
 | 
					    INSERT OR REPLACE INTO attachment_downloads (
 | 
				
			||||||
      id,
 | 
					      messageId,
 | 
				
			||||||
      pending,
 | 
					      attachmentType,
 | 
				
			||||||
      timestamp,
 | 
					      digest,
 | 
				
			||||||
      json
 | 
					      receivedAt,
 | 
				
			||||||
    ) values (
 | 
					      sentAt,
 | 
				
			||||||
      $id,
 | 
					      contentType,
 | 
				
			||||||
      $pending,
 | 
					      size,
 | 
				
			||||||
      $timestamp,
 | 
					      active,
 | 
				
			||||||
      $json
 | 
					      attempts,
 | 
				
			||||||
    )
 | 
					      retryAfter,
 | 
				
			||||||
    `
 | 
					      lastAttemptTimestamp,
 | 
				
			||||||
  ).run({
 | 
					      attachmentJson
 | 
				
			||||||
    id,
 | 
					    ) VALUES (
 | 
				
			||||||
    pending,
 | 
					      ${job.messageId},
 | 
				
			||||||
    timestamp,
 | 
					      ${job.attachmentType},
 | 
				
			||||||
    json: objectToJSON(job),
 | 
					      ${job.digest},
 | 
				
			||||||
  });
 | 
					      ${job.receivedAt},
 | 
				
			||||||
 | 
					      ${job.sentAt},
 | 
				
			||||||
 | 
					      ${job.contentType},
 | 
				
			||||||
 | 
					      ${job.size},
 | 
				
			||||||
 | 
					      ${job.active ? 1 : 0},
 | 
				
			||||||
 | 
					      ${job.attempts},
 | 
				
			||||||
 | 
					      ${job.retryAfter},
 | 
				
			||||||
 | 
					      ${job.lastAttemptTimestamp},
 | 
				
			||||||
 | 
					      ${objectToJSON(job.attachment)}
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
 | 
					  db.prepare(query).run(params);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
async function setAttachmentDownloadJobPending(
 | 
					
 | 
				
			||||||
  id: string,
 | 
					async function resetAttachmentDownloadActive(): Promise<void> {
 | 
				
			||||||
  pending: boolean
 | 
					 | 
				
			||||||
): Promise<void> {
 | 
					 | 
				
			||||||
  const db = await getWritableInstance();
 | 
					 | 
				
			||||||
  db.prepare<Query>(
 | 
					 | 
				
			||||||
    `
 | 
					 | 
				
			||||||
    UPDATE attachment_downloads
 | 
					 | 
				
			||||||
    SET pending = $pending
 | 
					 | 
				
			||||||
    WHERE id = $id;
 | 
					 | 
				
			||||||
    `
 | 
					 | 
				
			||||||
  ).run({
 | 
					 | 
				
			||||||
    id,
 | 
					 | 
				
			||||||
    pending: pending ? 1 : 0,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
async function resetAttachmentDownloadPending(): Promise<void> {
 | 
					 | 
				
			||||||
  const db = await getWritableInstance();
 | 
					  const db = await getWritableInstance();
 | 
				
			||||||
  db.prepare<EmptyQuery>(
 | 
					  db.prepare<EmptyQuery>(
 | 
				
			||||||
    `
 | 
					    `
 | 
				
			||||||
    UPDATE attachment_downloads
 | 
					    UPDATE attachment_downloads
 | 
				
			||||||
    SET pending = 0
 | 
					    SET active = 0
 | 
				
			||||||
    WHERE pending != 0;
 | 
					    WHERE active != 0;
 | 
				
			||||||
    `
 | 
					    `
 | 
				
			||||||
  ).run();
 | 
					  ).run();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
function removeAttachmentDownloadJobSync(db: Database, id: string): number {
 | 
					
 | 
				
			||||||
  return removeById(db, ATTACHMENT_DOWNLOADS_TABLE, id);
 | 
					function removeAttachmentDownloadJobSync(
 | 
				
			||||||
 | 
					  db: Database,
 | 
				
			||||||
 | 
					  job: AttachmentDownloadJobType
 | 
				
			||||||
 | 
					): void {
 | 
				
			||||||
 | 
					  const [query, params] = sql`
 | 
				
			||||||
 | 
					    DELETE FROM attachment_downloads
 | 
				
			||||||
 | 
					    WHERE 
 | 
				
			||||||
 | 
					      messageId = ${job.messageId}
 | 
				
			||||||
 | 
					    AND
 | 
				
			||||||
 | 
					      attachmentType = ${job.attachmentType} 
 | 
				
			||||||
 | 
					    AND 
 | 
				
			||||||
 | 
					      digest = ${job.digest};
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  db.prepare(query).run(params);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
async function removeAttachmentDownloadJob(id: string): Promise<number> {
 | 
					
 | 
				
			||||||
 | 
					async function removeAttachmentDownloadJob(
 | 
				
			||||||
 | 
					  job: AttachmentDownloadJobType
 | 
				
			||||||
 | 
					): Promise<void> {
 | 
				
			||||||
  const db = await getWritableInstance();
 | 
					  const db = await getWritableInstance();
 | 
				
			||||||
  return removeAttachmentDownloadJobSync(db, id);
 | 
					  return removeAttachmentDownloadJobSync(db, job);
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
async function removeAllAttachmentDownloadJobs(): Promise<number> {
 | 
					 | 
				
			||||||
  return removeAllFromTable(
 | 
					 | 
				
			||||||
    await getWritableInstance(),
 | 
					 | 
				
			||||||
    ATTACHMENT_DOWNLOADS_TABLE
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Stickers
 | 
					// Stickers
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										208
									
								
								ts/sql/migrations/1040-undownloaded-backed-up-media.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								ts/sql/migrations/1040-undownloaded-backed-up-media.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,208 @@
 | 
				
			||||||
 | 
					// Copyright 2024 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { Database } from '@signalapp/better-sqlite3';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { LoggerType } from '../../types/Logging';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  attachmentDownloadJobSchema,
 | 
				
			||||||
 | 
					  type AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					  type AttachmentDownloadJobTypeType,
 | 
				
			||||||
 | 
					} from '../../types/AttachmentDownload';
 | 
				
			||||||
 | 
					import type { AttachmentType } from '../../types/Attachment';
 | 
				
			||||||
 | 
					import { jsonToObject, objectToJSON, sql } from '../util';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const version = 1040;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type LegacyAttachmentDownloadJobType = {
 | 
				
			||||||
 | 
					  attachment: AttachmentType;
 | 
				
			||||||
 | 
					  attempts: number;
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  index: number;
 | 
				
			||||||
 | 
					  messageId: string;
 | 
				
			||||||
 | 
					  pending: number;
 | 
				
			||||||
 | 
					  timestamp: number;
 | 
				
			||||||
 | 
					  type: AttachmentDownloadJobTypeType;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function updateToSchemaVersion1040(
 | 
				
			||||||
 | 
					  currentVersion: number,
 | 
				
			||||||
 | 
					  db: Database,
 | 
				
			||||||
 | 
					  logger: LoggerType
 | 
				
			||||||
 | 
					): void {
 | 
				
			||||||
 | 
					  if (currentVersion >= 1040) {
 | 
				
			||||||
 | 
					    return;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  db.transaction(() => {
 | 
				
			||||||
 | 
					    // 1. Load all existing rows into memory (shouldn't be many)
 | 
				
			||||||
 | 
					    const existingJobs: Array<{
 | 
				
			||||||
 | 
					      id: string | null;
 | 
				
			||||||
 | 
					      timestamp: number | null;
 | 
				
			||||||
 | 
					      pending: number | null;
 | 
				
			||||||
 | 
					      json: string | null;
 | 
				
			||||||
 | 
					    }> = db
 | 
				
			||||||
 | 
					      .prepare(
 | 
				
			||||||
 | 
					        `
 | 
				
			||||||
 | 
					          SELECT id, timestamp, pending, json from attachment_downloads
 | 
				
			||||||
 | 
					        `
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					      .all();
 | 
				
			||||||
 | 
					    logger.info(
 | 
				
			||||||
 | 
					      `updateToSchemaVersion1040: loaded ${existingJobs.length} existing jobs`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 2. Create new temp table, with a couple new columns and stricter typing
 | 
				
			||||||
 | 
					    db.exec(`
 | 
				
			||||||
 | 
					        CREATE TABLE tmp_attachment_downloads (
 | 
				
			||||||
 | 
					          messageId TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
 | 
				
			||||||
 | 
					          attachmentType TEXT NOT NULL,
 | 
				
			||||||
 | 
					          digest TEXT NOT NULL,
 | 
				
			||||||
 | 
					          receivedAt INTEGER NOT NULL,
 | 
				
			||||||
 | 
					          sentAt INTEGER NOT NULL,
 | 
				
			||||||
 | 
					          contentType TEXT NOT NULL,
 | 
				
			||||||
 | 
					          size INTEGER NOT NULL,
 | 
				
			||||||
 | 
					          attachmentJson TEXT NOT NULL,
 | 
				
			||||||
 | 
					          active INTEGER NOT NULL,
 | 
				
			||||||
 | 
					          attempts INTEGER NOT NULL,
 | 
				
			||||||
 | 
					          retryAfter INTEGER,
 | 
				
			||||||
 | 
					          lastAttemptTimestamp INTEGER,
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          PRIMARY KEY (messageId, attachmentType, digest)
 | 
				
			||||||
 | 
					        ) STRICT;
 | 
				
			||||||
 | 
					    `);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 3. Drop existing table
 | 
				
			||||||
 | 
					    db.exec('DROP TABLE attachment_downloads;');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 4. Rename temp table
 | 
				
			||||||
 | 
					    db.exec(
 | 
				
			||||||
 | 
					      'ALTER TABLE tmp_attachment_downloads RENAME TO attachment_downloads;'
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 5. Add new index on active & receivedAt. For most queries when there are lots of
 | 
				
			||||||
 | 
					    //    jobs (like during backup restore), many jobs will match the the WHERE clause, so
 | 
				
			||||||
 | 
					    //    the ORDER BY on receivedAt is probably the most expensive part.
 | 
				
			||||||
 | 
					    db.exec(`      
 | 
				
			||||||
 | 
					      CREATE INDEX attachment_downloads_active_receivedAt
 | 
				
			||||||
 | 
					        ON attachment_downloads (
 | 
				
			||||||
 | 
					          active, receivedAt
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    `);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 6. Add new index on active & messageId. In order to prioritize visible messages,
 | 
				
			||||||
 | 
					    //    we'll also query for rows with a matching messageId. For these, the messageId
 | 
				
			||||||
 | 
					    //    matching is likely going to be the most expensive part.
 | 
				
			||||||
 | 
					    db.exec(` 
 | 
				
			||||||
 | 
					      CREATE INDEX attachment_downloads_active_messageId
 | 
				
			||||||
 | 
					        ON attachment_downloads (
 | 
				
			||||||
 | 
					          active, messageId
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    `);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 7. Add new index just on messageId, for the ON DELETE CASCADE foreign key
 | 
				
			||||||
 | 
					    //    constraint
 | 
				
			||||||
 | 
					    db.exec(` 
 | 
				
			||||||
 | 
					      CREATE INDEX attachment_downloads_messageId
 | 
				
			||||||
 | 
					        ON attachment_downloads (
 | 
				
			||||||
 | 
					          messageId
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    `);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 8. Rewrite old rows to match new schema
 | 
				
			||||||
 | 
					    const rowsToTransfer: Array<AttachmentDownloadJobType> = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const existingJob of existingJobs) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        // Type this as partial in case there is missing data
 | 
				
			||||||
 | 
					        const existingJobData: Partial<LegacyAttachmentDownloadJobType> =
 | 
				
			||||||
 | 
					          jsonToObject(existingJob.json ?? '');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const updatedJob: Partial<AttachmentDownloadJobType> = {
 | 
				
			||||||
 | 
					          messageId: existingJobData.messageId,
 | 
				
			||||||
 | 
					          attachmentType: existingJobData.type,
 | 
				
			||||||
 | 
					          attachment: existingJobData.attachment,
 | 
				
			||||||
 | 
					          // The existing timestamp column works reasonably well in place of
 | 
				
			||||||
 | 
					          // actually retrieving the message's receivedAt
 | 
				
			||||||
 | 
					          receivedAt: existingJobData.timestamp ?? Date.now(),
 | 
				
			||||||
 | 
					          sentAt: existingJobData.timestamp ?? Date.now(),
 | 
				
			||||||
 | 
					          digest: existingJobData.attachment?.digest,
 | 
				
			||||||
 | 
					          contentType: existingJobData.attachment?.contentType,
 | 
				
			||||||
 | 
					          size: existingJobData.attachment?.size,
 | 
				
			||||||
 | 
					          active: false, // all jobs are inactive on app start
 | 
				
			||||||
 | 
					          attempts: existingJobData.attempts ?? 0,
 | 
				
			||||||
 | 
					          retryAfter: null,
 | 
				
			||||||
 | 
					          lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const parsed = attachmentDownloadJobSchema.parse(updatedJob);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        rowsToTransfer.push(parsed as AttachmentDownloadJobType);
 | 
				
			||||||
 | 
					      } catch {
 | 
				
			||||||
 | 
					        logger.warn(
 | 
				
			||||||
 | 
					          `updateToSchemaVersion1040: unable to transfer job ${existingJob.id} to new table; invalid data`
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let numTransferred = 0;
 | 
				
			||||||
 | 
					    if (rowsToTransfer.length) {
 | 
				
			||||||
 | 
					      logger.info(
 | 
				
			||||||
 | 
					        `updateToSchemaVersion1040: transferring ${rowsToTransfer.length} rows`
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      for (const row of rowsToTransfer) {
 | 
				
			||||||
 | 
					        const [insertQuery, insertParams] = sql`
 | 
				
			||||||
 | 
					          INSERT INTO attachment_downloads
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
 | 
					              messageId,
 | 
				
			||||||
 | 
					              attachmentType,
 | 
				
			||||||
 | 
					              receivedAt, 
 | 
				
			||||||
 | 
					              sentAt,
 | 
				
			||||||
 | 
					              digest,
 | 
				
			||||||
 | 
					              contentType,
 | 
				
			||||||
 | 
					              size,
 | 
				
			||||||
 | 
					              attachmentJson,
 | 
				
			||||||
 | 
					              active, 
 | 
				
			||||||
 | 
					              attempts,
 | 
				
			||||||
 | 
					              retryAfter,
 | 
				
			||||||
 | 
					              lastAttemptTimestamp
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					          VALUES
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
 | 
					              ${row.messageId},
 | 
				
			||||||
 | 
					              ${row.attachmentType},
 | 
				
			||||||
 | 
					              ${row.receivedAt},
 | 
				
			||||||
 | 
					              ${row.sentAt},
 | 
				
			||||||
 | 
					              ${row.digest},
 | 
				
			||||||
 | 
					              ${row.contentType},
 | 
				
			||||||
 | 
					              ${row.size},
 | 
				
			||||||
 | 
					              ${objectToJSON(row.attachment)},
 | 
				
			||||||
 | 
					              ${row.active ? 1 : 0}, 
 | 
				
			||||||
 | 
					              ${row.attempts},
 | 
				
			||||||
 | 
					              ${row.retryAfter},
 | 
				
			||||||
 | 
					              ${row.lastAttemptTimestamp}
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        `;
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          db.prepare(insertQuery).run(insertParams);
 | 
				
			||||||
 | 
					          numTransferred += 1;
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					          logger.error(
 | 
				
			||||||
 | 
					            'updateToSchemaVersion1040: error when transferring row',
 | 
				
			||||||
 | 
					            error
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logger.info(
 | 
				
			||||||
 | 
					      `updateToSchemaVersion1040: transferred ${numTransferred} rows, removed ${
 | 
				
			||||||
 | 
					        existingJobs.length - numTransferred
 | 
				
			||||||
 | 
					      }`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  })();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  db.pragma('user_version = 1040');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  logger.info('updateToSchemaVersion1040: success!');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -78,10 +78,11 @@ import { updateToSchemaVersion990 } from './990-phone-number-sharing';
 | 
				
			||||||
import { updateToSchemaVersion1000 } from './1000-mark-unread-call-history-messages-as-unseen';
 | 
					import { updateToSchemaVersion1000 } from './1000-mark-unread-call-history-messages-as-unseen';
 | 
				
			||||||
import { updateToSchemaVersion1010 } from './1010-call-links-table';
 | 
					import { updateToSchemaVersion1010 } from './1010-call-links-table';
 | 
				
			||||||
import { updateToSchemaVersion1020 } from './1020-self-merges';
 | 
					import { updateToSchemaVersion1020 } from './1020-self-merges';
 | 
				
			||||||
 | 
					import { updateToSchemaVersion1030 } from './1030-unblock-event';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
 | 
					  updateToSchemaVersion1040,
 | 
				
			||||||
  version as MAX_VERSION,
 | 
					  version as MAX_VERSION,
 | 
				
			||||||
  updateToSchemaVersion1030,
 | 
					} from './1040-undownloaded-backed-up-media';
 | 
				
			||||||
} from './1030-unblock-event';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
function updateToSchemaVersion1(
 | 
					function updateToSchemaVersion1(
 | 
				
			||||||
  currentVersion: number,
 | 
					  currentVersion: number,
 | 
				
			||||||
| 
						 | 
					@ -2027,6 +2028,7 @@ export const SCHEMA_VERSIONS = [
 | 
				
			||||||
  updateToSchemaVersion1010,
 | 
					  updateToSchemaVersion1010,
 | 
				
			||||||
  updateToSchemaVersion1020,
 | 
					  updateToSchemaVersion1020,
 | 
				
			||||||
  updateToSchemaVersion1030,
 | 
					  updateToSchemaVersion1030,
 | 
				
			||||||
 | 
					  updateToSchemaVersion1040,
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class DBVersionFromFutureError extends Error {
 | 
					export class DBVersionFromFutureError extends Error {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -183,6 +183,7 @@ import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPe
 | 
				
			||||||
import { getConversationIdForLogging } from '../../util/idForLogging';
 | 
					import { getConversationIdForLogging } from '../../util/idForLogging';
 | 
				
			||||||
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
 | 
					import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
 | 
				
			||||||
import MessageSender from '../../textsecure/SendMessage';
 | 
					import MessageSender from '../../textsecure/SendMessage';
 | 
				
			||||||
 | 
					import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// State
 | 
					// State
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2174,7 +2175,9 @@ function kickOffAttachmentDownload(
 | 
				
			||||||
        `kickOffAttachmentDownload: Message ${options.messageId} missing!`
 | 
					        `kickOffAttachmentDownload: Message ${options.messageId} missing!`
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    const didUpdateValues = await message.queueAttachmentDownloads();
 | 
					    const didUpdateValues = await message.queueAttachmentDownloads(
 | 
				
			||||||
 | 
					      AttachmentDownloadUrgency.IMMEDIATE
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (didUpdateValues) {
 | 
					    if (didUpdateValues) {
 | 
				
			||||||
      drop(
 | 
					      drop(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -512,10 +512,9 @@ function queueStoryDownload(
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // isDownloading checks for the downloadJobId which is set by
 | 
					    // isDownloading checks if the download is pending but we optimistically set
 | 
				
			||||||
    // queueAttachmentDownloads but we optimistically set story.startedDownload
 | 
					    // story.startedDownload in redux to prevent race conditions from queuing up multiple
 | 
				
			||||||
    // in redux to prevent race conditions from queuing up multiple attachment
 | 
					    // attachment downloads before the attachment save takes place.
 | 
				
			||||||
    // downloads before the attachment save takes place.
 | 
					 | 
				
			||||||
    if (isDownloading(attachment) || story.startedDownload) {
 | 
					    if (isDownloading(attachment) || story.startedDownload) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -42,6 +42,7 @@ import { SmartHeroRow } from './HeroRow';
 | 
				
			||||||
import { SmartMiniPlayer } from './MiniPlayer';
 | 
					import { SmartMiniPlayer } from './MiniPlayer';
 | 
				
			||||||
import { SmartTimelineItem, type SmartTimelineItemProps } from './TimelineItem';
 | 
					import { SmartTimelineItem, type SmartTimelineItemProps } from './TimelineItem';
 | 
				
			||||||
import { SmartTypingBubble } from './TypingBubble';
 | 
					import { SmartTypingBubble } from './TypingBubble';
 | 
				
			||||||
 | 
					import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ExternalProps = {
 | 
					type ExternalProps = {
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
| 
						 | 
					@ -266,6 +267,9 @@ export const SmartTimeline = memo(function SmartTimeline({
 | 
				
			||||||
      markMessageRead={markMessageRead}
 | 
					      markMessageRead={markMessageRead}
 | 
				
			||||||
      messageChangeCounter={messageChangeCounter}
 | 
					      messageChangeCounter={messageChangeCounter}
 | 
				
			||||||
      messageLoadingState={messageLoadingState}
 | 
					      messageLoadingState={messageLoadingState}
 | 
				
			||||||
 | 
					      updateVisibleMessages={
 | 
				
			||||||
 | 
					        AttachmentDownloadManager.updateVisibleTimelineMessages
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      oldestUnseenIndex={oldestUnseenIndex}
 | 
					      oldestUnseenIndex={oldestUnseenIndex}
 | 
				
			||||||
      peekGroupCallForTheFirstTime={peekGroupCallForTheFirstTime}
 | 
					      peekGroupCallForTheFirstTime={peekGroupCallForTheFirstTime}
 | 
				
			||||||
      peekGroupCallIfItHasMembers={peekGroupCallIfItHasMembers}
 | 
					      peekGroupCallIfItHasMembers={peekGroupCallIfItHasMembers}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -25,6 +25,21 @@ describe('exponential backoff utilities', () => {
 | 
				
			||||||
        assert.strictEqual(exponentialBackoffSleepTime(attempt), maximum);
 | 
					        assert.strictEqual(exponentialBackoffSleepTime(attempt), maximum);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('respects custom variables', () => {
 | 
				
			||||||
 | 
					      const options = {
 | 
				
			||||||
 | 
					        maxBackoffTime: 10000,
 | 
				
			||||||
 | 
					        multiplier: 2,
 | 
				
			||||||
 | 
					        firstBackoffTime: 1000,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      assert.strictEqual(exponentialBackoffSleepTime(1, options), 0);
 | 
				
			||||||
 | 
					      assert.strictEqual(exponentialBackoffSleepTime(2, options), 1000);
 | 
				
			||||||
 | 
					      assert.strictEqual(exponentialBackoffSleepTime(3, options), 2000);
 | 
				
			||||||
 | 
					      assert.strictEqual(exponentialBackoffSleepTime(4, options), 4000);
 | 
				
			||||||
 | 
					      assert.strictEqual(exponentialBackoffSleepTime(5, options), 8000);
 | 
				
			||||||
 | 
					      assert.strictEqual(exponentialBackoffSleepTime(6, options), 10000);
 | 
				
			||||||
 | 
					      assert.strictEqual(exponentialBackoffSleepTime(7, options), 10000);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('exponentialBackoffMaxAttempts', () => {
 | 
					  describe('exponentialBackoffMaxAttempts', () => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										367
									
								
								ts/test-electron/services/AttachmentDownloadManager_test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										367
									
								
								ts/test-electron/services/AttachmentDownloadManager_test.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,367 @@
 | 
				
			||||||
 | 
					// Copyright 2024 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* eslint-disable more/no-then */
 | 
				
			||||||
 | 
					/* eslint-disable @typescript-eslint/no-floating-promises */
 | 
				
			||||||
 | 
					import * as sinon from 'sinon';
 | 
				
			||||||
 | 
					import { assert } from 'chai';
 | 
				
			||||||
 | 
					import * as MIME from '../../types/MIME';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  AttachmentDownloadManager,
 | 
				
			||||||
 | 
					  AttachmentDownloadUrgency,
 | 
				
			||||||
 | 
					  type NewAttachmentDownloadJobType,
 | 
				
			||||||
 | 
					} from '../../jobs/AttachmentDownloadManager';
 | 
				
			||||||
 | 
					import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
 | 
				
			||||||
 | 
					import dataInterface from '../../sql/Client';
 | 
				
			||||||
 | 
					import { HOUR, MINUTE, SECOND } from '../../util/durations';
 | 
				
			||||||
 | 
					import { type AciString } from '../../types/ServiceId';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('AttachmentDownloadManager', () => {
 | 
				
			||||||
 | 
					  let downloadManager: AttachmentDownloadManager | undefined;
 | 
				
			||||||
 | 
					  let runJob: sinon.SinonStub;
 | 
				
			||||||
 | 
					  let sandbox: sinon.SinonSandbox;
 | 
				
			||||||
 | 
					  let clock: sinon.SinonFakeTimers;
 | 
				
			||||||
 | 
					  let isInCall: sinon.SinonStub;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function composeJob({
 | 
				
			||||||
 | 
					    messageId,
 | 
				
			||||||
 | 
					    receivedAt,
 | 
				
			||||||
 | 
					  }: Pick<
 | 
				
			||||||
 | 
					    NewAttachmentDownloadJobType,
 | 
				
			||||||
 | 
					    'messageId' | 'receivedAt'
 | 
				
			||||||
 | 
					  >): AttachmentDownloadJobType {
 | 
				
			||||||
 | 
					    const digest = `digestFor${messageId}`;
 | 
				
			||||||
 | 
					    const size = 128;
 | 
				
			||||||
 | 
					    const contentType = MIME.IMAGE_PNG;
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      messageId,
 | 
				
			||||||
 | 
					      receivedAt,
 | 
				
			||||||
 | 
					      sentAt: receivedAt,
 | 
				
			||||||
 | 
					      attachmentType: 'attachment',
 | 
				
			||||||
 | 
					      digest,
 | 
				
			||||||
 | 
					      size,
 | 
				
			||||||
 | 
					      contentType,
 | 
				
			||||||
 | 
					      active: false,
 | 
				
			||||||
 | 
					      attempts: 0,
 | 
				
			||||||
 | 
					      retryAfter: null,
 | 
				
			||||||
 | 
					      lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					      attachment: {
 | 
				
			||||||
 | 
					        contentType,
 | 
				
			||||||
 | 
					        size,
 | 
				
			||||||
 | 
					        digest: `digestFor${messageId}`,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  beforeEach(async () => {
 | 
				
			||||||
 | 
					    await dataInterface.removeAll();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    sandbox = sinon.createSandbox();
 | 
				
			||||||
 | 
					    clock = sinon.useFakeTimers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isInCall = sinon.stub().returns(false);
 | 
				
			||||||
 | 
					    runJob = sinon.stub().callsFake(async () => {
 | 
				
			||||||
 | 
					      return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
 | 
				
			||||||
 | 
					        Promise.resolve().then(() => {
 | 
				
			||||||
 | 
					          resolve({ status: 'finished' });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    downloadManager = new AttachmentDownloadManager({
 | 
				
			||||||
 | 
					      ...AttachmentDownloadManager.defaultParams,
 | 
				
			||||||
 | 
					      isInCall,
 | 
				
			||||||
 | 
					      runJob,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  afterEach(async () => {
 | 
				
			||||||
 | 
					    sandbox.restore();
 | 
				
			||||||
 | 
					    clock.restore();
 | 
				
			||||||
 | 
					    await downloadManager?.stop();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async function addJob(
 | 
				
			||||||
 | 
					    job: AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					    urgency: AttachmentDownloadUrgency
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    // Save message first to satisfy foreign key constraint
 | 
				
			||||||
 | 
					    await dataInterface.saveMessage(
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        id: job.messageId,
 | 
				
			||||||
 | 
					        type: 'incoming',
 | 
				
			||||||
 | 
					        sent_at: job.sentAt,
 | 
				
			||||||
 | 
					        timestamp: job.sentAt,
 | 
				
			||||||
 | 
					        received_at: job.receivedAt + 1,
 | 
				
			||||||
 | 
					        conversationId: 'convoId',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        ourAci: 'ourAci' as AciString,
 | 
				
			||||||
 | 
					        forceSave: true,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    await downloadManager?.addJob({
 | 
				
			||||||
 | 
					      ...job,
 | 
				
			||||||
 | 
					      urgency,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  async function addJobs(
 | 
				
			||||||
 | 
					    num: number
 | 
				
			||||||
 | 
					  ): Promise<Array<AttachmentDownloadJobType>> {
 | 
				
			||||||
 | 
					    const jobs = new Array(num)
 | 
				
			||||||
 | 
					      .fill(null)
 | 
				
			||||||
 | 
					      .map((_, idx) =>
 | 
				
			||||||
 | 
					        composeJob({ messageId: `message-${idx}`, receivedAt: idx })
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    for (const job of jobs) {
 | 
				
			||||||
 | 
					      // eslint-disable-next-line no-await-in-loop
 | 
				
			||||||
 | 
					      await addJob(job, AttachmentDownloadUrgency.STANDARD);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return jobs;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function waitForJobToBeStarted(job: AttachmentDownloadJobType) {
 | 
				
			||||||
 | 
					    return downloadManager?.waitForJobToBeStarted(job);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function waitForJobToBeCompleted(job: AttachmentDownloadJobType) {
 | 
				
			||||||
 | 
					    return downloadManager?.waitForJobToBeCompleted(job);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function assertRunJobCalledWith(jobs: Array<AttachmentDownloadJobType>) {
 | 
				
			||||||
 | 
					    return assert.strictEqual(
 | 
				
			||||||
 | 
					      JSON.stringify(
 | 
				
			||||||
 | 
					        runJob
 | 
				
			||||||
 | 
					          .getCalls()
 | 
				
			||||||
 | 
					          .map(
 | 
				
			||||||
 | 
					            call =>
 | 
				
			||||||
 | 
					              `${call.args[0].messageId}${call.args[0].attachmentType}.${call.args[0].digest}`
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      JSON.stringify(
 | 
				
			||||||
 | 
					        jobs.map(job => `${job.messageId}${job.attachmentType}.${job.digest}`)
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async function advanceTime(ms: number) {
 | 
				
			||||||
 | 
					    // When advancing the timers, we want to make sure any DB operations are completed
 | 
				
			||||||
 | 
					    // first. In cases like maybeStartJobs where we prevent re-entrancy, without this,
 | 
				
			||||||
 | 
					    // prior (unfinished) invocations can prevent subsequent calls after the clock is
 | 
				
			||||||
 | 
					    // ticked forward and make tests unreliable
 | 
				
			||||||
 | 
					    await dataInterface.getAllItems();
 | 
				
			||||||
 | 
					    await clock.tickAsync(ms);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function getPromisesForAttempts(
 | 
				
			||||||
 | 
					    job: AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					    attempts: number
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    return new Array(attempts).fill(null).map((_, idx) => {
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        started: waitForJobToBeStarted({ ...job, attempts: idx }),
 | 
				
			||||||
 | 
					        completed: waitForJobToBeCompleted({ ...job, attempts: idx }),
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('runs 3 jobs at a time in descending receivedAt order', async () => {
 | 
				
			||||||
 | 
					    const jobs = await addJobs(5);
 | 
				
			||||||
 | 
					    // Confirm they are saved to DB
 | 
				
			||||||
 | 
					    const allJobs = await dataInterface.getNextAttachmentDownloadJobs({
 | 
				
			||||||
 | 
					      limit: 100,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert.strictEqual(allJobs.length, 5);
 | 
				
			||||||
 | 
					    assert.strictEqual(
 | 
				
			||||||
 | 
					      JSON.stringify(allJobs.map(job => job.messageId)),
 | 
				
			||||||
 | 
					      JSON.stringify([
 | 
				
			||||||
 | 
					        'message-4',
 | 
				
			||||||
 | 
					        'message-3',
 | 
				
			||||||
 | 
					        'message-2',
 | 
				
			||||||
 | 
					        'message-1',
 | 
				
			||||||
 | 
					        'message-0',
 | 
				
			||||||
 | 
					      ])
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await downloadManager?.start();
 | 
				
			||||||
 | 
					    await waitForJobToBeStarted(jobs[2]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 3);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([jobs[4], jobs[3], jobs[2]]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await waitForJobToBeStarted(jobs[0]);
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 5);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([jobs[4], jobs[3], jobs[2], jobs[1], jobs[0]]);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('runs a job immediately if urgency is IMMEDIATE', async () => {
 | 
				
			||||||
 | 
					    const jobs = await addJobs(6);
 | 
				
			||||||
 | 
					    await downloadManager?.start();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const urgentJobForOldMessage = composeJob({
 | 
				
			||||||
 | 
					      messageId: 'message-urgent',
 | 
				
			||||||
 | 
					      receivedAt: 0,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await addJob(urgentJobForOldMessage, AttachmentDownloadUrgency.IMMEDIATE);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await waitForJobToBeStarted(urgentJobForOldMessage);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 4);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([jobs[5], jobs[4], jobs[3], urgentJobForOldMessage]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await waitForJobToBeStarted(jobs[0]);
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 7);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([
 | 
				
			||||||
 | 
					      jobs[5],
 | 
				
			||||||
 | 
					      jobs[4],
 | 
				
			||||||
 | 
					      jobs[3],
 | 
				
			||||||
 | 
					      urgentJobForOldMessage,
 | 
				
			||||||
 | 
					      jobs[2],
 | 
				
			||||||
 | 
					      jobs[1],
 | 
				
			||||||
 | 
					      jobs[0],
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('prefers jobs for visible messages', async () => {
 | 
				
			||||||
 | 
					    const jobs = await addJobs(5);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    downloadManager?.updateVisibleTimelineMessages(['message-0', 'message-1']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await downloadManager?.start();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await waitForJobToBeStarted(jobs[4]);
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 3);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([jobs[0], jobs[1], jobs[4]]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await waitForJobToBeStarted(jobs[2]);
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 5);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([jobs[0], jobs[1], jobs[4], jobs[3], jobs[2]]);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it("does not start a job if we're in a call", async () => {
 | 
				
			||||||
 | 
					    const jobs = await addJobs(5);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isInCall.callsFake(() => true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await downloadManager?.start();
 | 
				
			||||||
 | 
					    await advanceTime(2 * MINUTE);
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    isInCall.callsFake(() => false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(2 * MINUTE);
 | 
				
			||||||
 | 
					    await waitForJobToBeStarted(jobs[0]);
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 5);
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('handles retries for failed', async () => {
 | 
				
			||||||
 | 
					    const jobs = await addJobs(2);
 | 
				
			||||||
 | 
					    const job0Attempts = getPromisesForAttempts(jobs[0], 1);
 | 
				
			||||||
 | 
					    const job1Attempts = getPromisesForAttempts(jobs[1], 5);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    runJob.callsFake(async (job: AttachmentDownloadJobType) => {
 | 
				
			||||||
 | 
					      return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
 | 
				
			||||||
 | 
					        Promise.resolve().then(() => {
 | 
				
			||||||
 | 
					          if (job.messageId === jobs[0].messageId) {
 | 
				
			||||||
 | 
					            resolve({ status: 'finished' });
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            resolve({ status: 'retry' });
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await downloadManager?.start();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await job0Attempts[0].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 2);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([jobs[1], jobs[0]]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const retriedJob = await dataInterface.getAttachmentDownloadJob(jobs[1]);
 | 
				
			||||||
 | 
					    const finishedJob = await dataInterface.getAttachmentDownloadJob(jobs[0]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert.isUndefined(finishedJob);
 | 
				
			||||||
 | 
					    assert.strictEqual(retriedJob?.attempts, 1);
 | 
				
			||||||
 | 
					    assert.isNumber(retriedJob?.retryAfter);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(30 * SECOND);
 | 
				
			||||||
 | 
					    await job1Attempts[1].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 3);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(5 * MINUTE);
 | 
				
			||||||
 | 
					    await job1Attempts[2].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 4);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(50 * MINUTE);
 | 
				
			||||||
 | 
					    await job1Attempts[3].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 5);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(6 * HOUR);
 | 
				
			||||||
 | 
					    await job1Attempts[4].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 6);
 | 
				
			||||||
 | 
					    assertRunJobCalledWith([
 | 
				
			||||||
 | 
					      jobs[1],
 | 
				
			||||||
 | 
					      jobs[0],
 | 
				
			||||||
 | 
					      jobs[1],
 | 
				
			||||||
 | 
					      jobs[1],
 | 
				
			||||||
 | 
					      jobs[1],
 | 
				
			||||||
 | 
					      jobs[1],
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Ensure it's been removed after completed
 | 
				
			||||||
 | 
					    assert.isUndefined(await dataInterface.getAttachmentDownloadJob(jobs[1]));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('will reset attempts if addJob is called again', async () => {
 | 
				
			||||||
 | 
					    const jobs = await addJobs(1);
 | 
				
			||||||
 | 
					    runJob.callsFake(async () => {
 | 
				
			||||||
 | 
					      return new Promise<{ status: 'finished' | 'retry' }>(resolve => {
 | 
				
			||||||
 | 
					        Promise.resolve().then(() => {
 | 
				
			||||||
 | 
					          resolve({ status: 'retry' });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let attempts = getPromisesForAttempts(jobs[0], 4);
 | 
				
			||||||
 | 
					    await downloadManager?.start();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await attempts[0].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(30 * SECOND);
 | 
				
			||||||
 | 
					    await attempts[1].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 2);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(5 * MINUTE);
 | 
				
			||||||
 | 
					    await attempts[2].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 3);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // add the same job again and it should retry ASAP and reset attempts
 | 
				
			||||||
 | 
					    attempts = getPromisesForAttempts(jobs[0], 4);
 | 
				
			||||||
 | 
					    await downloadManager?.addJob(jobs[0]);
 | 
				
			||||||
 | 
					    await attempts[0].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 4);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(30 * SECOND);
 | 
				
			||||||
 | 
					    await attempts[1].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 5);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(5 * MINUTE);
 | 
				
			||||||
 | 
					    await attempts[2].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 6);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(50 * MINUTE);
 | 
				
			||||||
 | 
					    await attempts[3].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 7);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await advanceTime(6 * HOUR);
 | 
				
			||||||
 | 
					    await attempts[3].completed;
 | 
				
			||||||
 | 
					    assert.strictEqual(runJob.callCount, 8);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Ensure it's been removed
 | 
				
			||||||
 | 
					    assert.isUndefined(await dataInterface.getAttachmentDownloadJob(jobs[0]));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										484
									
								
								ts/test-node/sql/migration_1040_test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										484
									
								
								ts/test-node/sql/migration_1040_test.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,484 @@
 | 
				
			||||||
 | 
					// Copyright 2024 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					import { omit } from 'lodash';
 | 
				
			||||||
 | 
					import { assert } from 'chai';
 | 
				
			||||||
 | 
					import type { Database } from '@signalapp/better-sqlite3';
 | 
				
			||||||
 | 
					import SQL from '@signalapp/better-sqlite3';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { jsonToObject, objectToJSON, sql, sqlJoin } from '../../sql/util';
 | 
				
			||||||
 | 
					import { updateToVersion } from './helpers';
 | 
				
			||||||
 | 
					import type { LegacyAttachmentDownloadJobType } from '../../sql/migrations/1040-undownloaded-backed-up-media';
 | 
				
			||||||
 | 
					import type { AttachmentType } from '../../types/Attachment';
 | 
				
			||||||
 | 
					import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
 | 
				
			||||||
 | 
					import { IMAGE_JPEG } from '../../types/MIME';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function getAttachmentDownloadJobs(db: Database) {
 | 
				
			||||||
 | 
					  const [query] = sql`
 | 
				
			||||||
 | 
					    SELECT * FROM attachment_downloads ORDER BY receivedAt DESC;
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return db
 | 
				
			||||||
 | 
					    .prepare(query)
 | 
				
			||||||
 | 
					    .all()
 | 
				
			||||||
 | 
					    .map(job => ({
 | 
				
			||||||
 | 
					      ...omit(job, 'attachmentJson'),
 | 
				
			||||||
 | 
					      attachment: jsonToObject(job.attachmentJson),
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type UnflattenedAttachmentDownloadJobType = Omit<
 | 
				
			||||||
 | 
					  AttachmentDownloadJobType,
 | 
				
			||||||
 | 
					  'digest' | 'contentType' | 'size'
 | 
				
			||||||
 | 
					>;
 | 
				
			||||||
 | 
					function insertNewJob(
 | 
				
			||||||
 | 
					  db: Database,
 | 
				
			||||||
 | 
					  job: UnflattenedAttachmentDownloadJobType,
 | 
				
			||||||
 | 
					  addMessageFirst: boolean = true
 | 
				
			||||||
 | 
					): void {
 | 
				
			||||||
 | 
					  if (addMessageFirst) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      db.prepare('INSERT INTO messages (id) VALUES ($id)').run({
 | 
				
			||||||
 | 
					        id: job.messageId,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      // pass; message has already been inserted
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  const [query, params] = sql`
 | 
				
			||||||
 | 
					  INSERT INTO attachment_downloads
 | 
				
			||||||
 | 
					    (
 | 
				
			||||||
 | 
					      messageId,
 | 
				
			||||||
 | 
					      attachmentType,
 | 
				
			||||||
 | 
					      attachmentJson,
 | 
				
			||||||
 | 
					      digest,
 | 
				
			||||||
 | 
					      contentType,
 | 
				
			||||||
 | 
					      size,
 | 
				
			||||||
 | 
					      receivedAt, 
 | 
				
			||||||
 | 
					      sentAt,
 | 
				
			||||||
 | 
					      active, 
 | 
				
			||||||
 | 
					      attempts,
 | 
				
			||||||
 | 
					      retryAfter,
 | 
				
			||||||
 | 
					      lastAttemptTimestamp
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  VALUES
 | 
				
			||||||
 | 
					    (
 | 
				
			||||||
 | 
					      ${job.messageId},
 | 
				
			||||||
 | 
					      ${job.attachmentType},
 | 
				
			||||||
 | 
					      ${objectToJSON(job.attachment)},
 | 
				
			||||||
 | 
					      ${job.attachment.digest},
 | 
				
			||||||
 | 
					      ${job.attachment.contentType},
 | 
				
			||||||
 | 
					      ${job.attachment.size},
 | 
				
			||||||
 | 
					      ${job.receivedAt},
 | 
				
			||||||
 | 
					      ${job.sentAt},
 | 
				
			||||||
 | 
					      ${job.active ? 1 : 0},
 | 
				
			||||||
 | 
					      ${job.attempts},
 | 
				
			||||||
 | 
					      ${job.retryAfter},
 | 
				
			||||||
 | 
					      ${job.lastAttemptTimestamp}
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  db.prepare(query).run(params);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('SQL/updateToSchemaVersion1040', () => {
 | 
				
			||||||
 | 
					  describe('Storing of new attachment jobs', () => {
 | 
				
			||||||
 | 
					    let db: Database;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    beforeEach(() => {
 | 
				
			||||||
 | 
					      db = new SQL(':memory:');
 | 
				
			||||||
 | 
					      updateToVersion(db, 1040);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    afterEach(() => {
 | 
				
			||||||
 | 
					      db.close();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('allows storing of new backup attachment jobs', () => {
 | 
				
			||||||
 | 
					      insertNewJob(db, {
 | 
				
			||||||
 | 
					        messageId: 'message1',
 | 
				
			||||||
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					          contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          size: 128,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        receivedAt: 1970,
 | 
				
			||||||
 | 
					        sentAt: 2070,
 | 
				
			||||||
 | 
					        active: false,
 | 
				
			||||||
 | 
					        retryAfter: null,
 | 
				
			||||||
 | 
					        attempts: 0,
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      insertNewJob(db, {
 | 
				
			||||||
 | 
					        messageId: 'message2',
 | 
				
			||||||
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          digest: 'digest2',
 | 
				
			||||||
 | 
					          contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          size: 128,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        receivedAt: 1971,
 | 
				
			||||||
 | 
					        sentAt: 2071,
 | 
				
			||||||
 | 
					        active: false,
 | 
				
			||||||
 | 
					        retryAfter: 1204,
 | 
				
			||||||
 | 
					        attempts: 0,
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: 1004,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const attachments = getAttachmentDownloadJobs(db);
 | 
				
			||||||
 | 
					      assert.strictEqual(attachments.length, 2);
 | 
				
			||||||
 | 
					      assert.deepEqual(attachments, [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          messageId: 'message2',
 | 
				
			||||||
 | 
					          attachmentType: 'attachment',
 | 
				
			||||||
 | 
					          digest: 'digest2',
 | 
				
			||||||
 | 
					          contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          size: 128,
 | 
				
			||||||
 | 
					          receivedAt: 1971,
 | 
				
			||||||
 | 
					          sentAt: 2071,
 | 
				
			||||||
 | 
					          active: 0,
 | 
				
			||||||
 | 
					          retryAfter: 1204,
 | 
				
			||||||
 | 
					          attempts: 0,
 | 
				
			||||||
 | 
					          lastAttemptTimestamp: 1004,
 | 
				
			||||||
 | 
					          attachment: {
 | 
				
			||||||
 | 
					            digest: 'digest2',
 | 
				
			||||||
 | 
					            contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					            size: 128,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          messageId: 'message1',
 | 
				
			||||||
 | 
					          attachmentType: 'attachment',
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					          contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          size: 128,
 | 
				
			||||||
 | 
					          receivedAt: 1970,
 | 
				
			||||||
 | 
					          sentAt: 2070,
 | 
				
			||||||
 | 
					          active: 0,
 | 
				
			||||||
 | 
					          retryAfter: null,
 | 
				
			||||||
 | 
					          attempts: 0,
 | 
				
			||||||
 | 
					          lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					          attachment: {
 | 
				
			||||||
 | 
					            digest: 'digest1',
 | 
				
			||||||
 | 
					            contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					            size: 128,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ]);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('Respects primary key constraint', () => {
 | 
				
			||||||
 | 
					      const job: UnflattenedAttachmentDownloadJobType = {
 | 
				
			||||||
 | 
					        messageId: 'message1',
 | 
				
			||||||
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					          contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          size: 128,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        receivedAt: 1970,
 | 
				
			||||||
 | 
					        sentAt: 2070,
 | 
				
			||||||
 | 
					        active: false,
 | 
				
			||||||
 | 
					        retryAfter: null,
 | 
				
			||||||
 | 
					        attempts: 0,
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      insertNewJob(db, job);
 | 
				
			||||||
 | 
					      assert.throws(() => {
 | 
				
			||||||
 | 
					        insertNewJob(db, { ...job, attempts: 1 });
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const attachments = getAttachmentDownloadJobs(db);
 | 
				
			||||||
 | 
					      assert.strictEqual(attachments.length, 1);
 | 
				
			||||||
 | 
					      assert.strictEqual(attachments[0].attempts, 0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('uses indices searching for next job', () => {
 | 
				
			||||||
 | 
					      const now = Date.now();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const job: UnflattenedAttachmentDownloadJobType = {
 | 
				
			||||||
 | 
					        messageId: 'message1',
 | 
				
			||||||
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					          contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          size: 128,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        receivedAt: 101,
 | 
				
			||||||
 | 
					        sentAt: 101,
 | 
				
			||||||
 | 
					        attempts: 0,
 | 
				
			||||||
 | 
					        active: false,
 | 
				
			||||||
 | 
					        retryAfter: null,
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      insertNewJob(db, job);
 | 
				
			||||||
 | 
					      insertNewJob(db, {
 | 
				
			||||||
 | 
					        ...job,
 | 
				
			||||||
 | 
					        messageId: 'message2',
 | 
				
			||||||
 | 
					        receivedAt: 102,
 | 
				
			||||||
 | 
					        sentAt: 102,
 | 
				
			||||||
 | 
					        retryAfter: now + 1,
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: now - 10,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      insertNewJob(db, {
 | 
				
			||||||
 | 
					        ...job,
 | 
				
			||||||
 | 
					        messageId: 'message3',
 | 
				
			||||||
 | 
					        active: true,
 | 
				
			||||||
 | 
					        receivedAt: 103,
 | 
				
			||||||
 | 
					        sentAt: 103,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      insertNewJob(db, {
 | 
				
			||||||
 | 
					        ...job,
 | 
				
			||||||
 | 
					        messageId: 'message4',
 | 
				
			||||||
 | 
					        attachmentType: 'contact',
 | 
				
			||||||
 | 
					        receivedAt: 104,
 | 
				
			||||||
 | 
					        sentAt: 104,
 | 
				
			||||||
 | 
					        retryAfter: now,
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: now - 1000,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        const [query, params] = sql`
 | 
				
			||||||
 | 
					          SELECT * FROM attachment_downloads
 | 
				
			||||||
 | 
					          WHERE
 | 
				
			||||||
 | 
					            active = 0
 | 
				
			||||||
 | 
					          AND
 | 
				
			||||||
 | 
					            (retryAfter is NULL OR retryAfter <= ${now})
 | 
				
			||||||
 | 
					          ORDER BY receivedAt DESC
 | 
				
			||||||
 | 
					          LIMIT 5
 | 
				
			||||||
 | 
					        `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const result = db.prepare(query).all(params);
 | 
				
			||||||
 | 
					        assert.strictEqual(result.length, 2);
 | 
				
			||||||
 | 
					        assert.deepStrictEqual(
 | 
				
			||||||
 | 
					          result.map(res => res.messageId),
 | 
				
			||||||
 | 
					          ['message4', 'message1']
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const details = db
 | 
				
			||||||
 | 
					          .prepare(`EXPLAIN QUERY PLAN ${query}`)
 | 
				
			||||||
 | 
					          .all(params)
 | 
				
			||||||
 | 
					          .map(step => step.detail)
 | 
				
			||||||
 | 
					          .join(', ');
 | 
				
			||||||
 | 
					        assert.include(
 | 
				
			||||||
 | 
					          details,
 | 
				
			||||||
 | 
					          'USING INDEX attachment_downloads_active_receivedAt'
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        assert.notInclude(details, 'TEMP B-TREE');
 | 
				
			||||||
 | 
					        assert.notInclude(details, 'SCAN');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        const messageIds = ['message1', 'message2', 'message4'];
 | 
				
			||||||
 | 
					        const [query, params] = sql`
 | 
				
			||||||
 | 
					        SELECT * FROM attachment_downloads
 | 
				
			||||||
 | 
					        INDEXED BY attachment_downloads_active_messageId
 | 
				
			||||||
 | 
					        WHERE
 | 
				
			||||||
 | 
					          active = 0
 | 
				
			||||||
 | 
					        AND
 | 
				
			||||||
 | 
					          (lastAttemptTimestamp is NULL OR lastAttemptTimestamp <= ${now - 100})
 | 
				
			||||||
 | 
					        AND
 | 
				
			||||||
 | 
					          messageId IN (${sqlJoin(messageIds)})
 | 
				
			||||||
 | 
					        ORDER BY receivedAt ASC
 | 
				
			||||||
 | 
					        LIMIT 5
 | 
				
			||||||
 | 
					        `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const result = db.prepare(query).all(params);
 | 
				
			||||||
 | 
					        assert.strictEqual(result.length, 2);
 | 
				
			||||||
 | 
					        assert.deepStrictEqual(
 | 
				
			||||||
 | 
					          result.map(res => res.messageId),
 | 
				
			||||||
 | 
					          ['message1', 'message4']
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        const details = db
 | 
				
			||||||
 | 
					          .prepare(`EXPLAIN QUERY PLAN ${query}`)
 | 
				
			||||||
 | 
					          .all(params)
 | 
				
			||||||
 | 
					          .map(step => step.detail)
 | 
				
			||||||
 | 
					          .join(', ');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // This query _will_ use a temp b-tree for ordering, but the number of rows
 | 
				
			||||||
 | 
					        // should be quite low.
 | 
				
			||||||
 | 
					        assert.include(
 | 
				
			||||||
 | 
					          details,
 | 
				
			||||||
 | 
					          'USING INDEX attachment_downloads_active_messageId'
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('respects foreign key constraint on messageId', () => {
 | 
				
			||||||
 | 
					      const job: AttachmentDownloadJobType = {
 | 
				
			||||||
 | 
					        messageId: 'message1',
 | 
				
			||||||
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					          contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					          size: 128,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        receivedAt: 1970,
 | 
				
			||||||
 | 
					        digest: 'digest1',
 | 
				
			||||||
 | 
					        contentType: IMAGE_JPEG,
 | 
				
			||||||
 | 
					        size: 128,
 | 
				
			||||||
 | 
					        sentAt: 2070,
 | 
				
			||||||
 | 
					        active: false,
 | 
				
			||||||
 | 
					        retryAfter: null,
 | 
				
			||||||
 | 
					        attempts: 0,
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      // throws if we don't add the message first
 | 
				
			||||||
 | 
					      assert.throws(() => insertNewJob(db, job, false));
 | 
				
			||||||
 | 
					      insertNewJob(db, job, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      assert.strictEqual(getAttachmentDownloadJobs(db).length, 1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Deletes the job when the message is deleted
 | 
				
			||||||
 | 
					      db.prepare('DELETE FROM messages WHERE id = $id').run({
 | 
				
			||||||
 | 
					        id: job.messageId,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      assert.strictEqual(getAttachmentDownloadJobs(db).length, 0);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe('existing jobs are transferred', () => {
 | 
				
			||||||
 | 
					    let db: Database;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    beforeEach(() => {
 | 
				
			||||||
 | 
					      db = new SQL(':memory:');
 | 
				
			||||||
 | 
					      updateToVersion(db, 1030);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    afterEach(() => {
 | 
				
			||||||
 | 
					      db.close();
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it('existing rows are retained; invalid existing rows are removed', () => {
 | 
				
			||||||
 | 
					      insertLegacyJob(db, {
 | 
				
			||||||
 | 
					        id: 'id-1',
 | 
				
			||||||
 | 
					        messageId: 'message-1',
 | 
				
			||||||
 | 
					        timestamp: 1000,
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          size: 100,
 | 
				
			||||||
 | 
					          contentType: 'image/png',
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					          cdnKey: 'key1',
 | 
				
			||||||
 | 
					        } as AttachmentType,
 | 
				
			||||||
 | 
					        pending: 0,
 | 
				
			||||||
 | 
					        index: 0,
 | 
				
			||||||
 | 
					        type: 'attachment',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      insertLegacyJob(db, {
 | 
				
			||||||
 | 
					        id: 'invalid-1',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      insertLegacyJob(db, {
 | 
				
			||||||
 | 
					        id: 'id-2',
 | 
				
			||||||
 | 
					        messageId: 'message-2',
 | 
				
			||||||
 | 
					        timestamp: 1001,
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          size: 100,
 | 
				
			||||||
 | 
					          contentType: 'image/jpeg',
 | 
				
			||||||
 | 
					          digest: 'digest2',
 | 
				
			||||||
 | 
					          cdnKey: 'key2',
 | 
				
			||||||
 | 
					        } as AttachmentType,
 | 
				
			||||||
 | 
					        pending: 1,
 | 
				
			||||||
 | 
					        index: 2,
 | 
				
			||||||
 | 
					        type: 'attachment',
 | 
				
			||||||
 | 
					        attempts: 1,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      insertLegacyJob(db, {
 | 
				
			||||||
 | 
					        id: 'invalid-2',
 | 
				
			||||||
 | 
					        timestamp: 1000,
 | 
				
			||||||
 | 
					        attachment: { size: 100, contentType: 'image/jpeg' } as AttachmentType,
 | 
				
			||||||
 | 
					        pending: 0,
 | 
				
			||||||
 | 
					        index: 0,
 | 
				
			||||||
 | 
					        type: 'attachment',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      insertLegacyJob(db, {
 | 
				
			||||||
 | 
					        id: 'invalid-3-no-content-type',
 | 
				
			||||||
 | 
					        timestamp: 1000,
 | 
				
			||||||
 | 
					        attachment: { size: 100 } as AttachmentType,
 | 
				
			||||||
 | 
					        pending: 0,
 | 
				
			||||||
 | 
					        index: 0,
 | 
				
			||||||
 | 
					        type: 'attachment',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      insertLegacyJob(db, {
 | 
				
			||||||
 | 
					        id: 'duplicate-1',
 | 
				
			||||||
 | 
					        messageId: 'message-1',
 | 
				
			||||||
 | 
					        timestamp: 1000,
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          size: 100,
 | 
				
			||||||
 | 
					          contentType: 'image/jpeg',
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					        } as AttachmentType,
 | 
				
			||||||
 | 
					        pending: 0,
 | 
				
			||||||
 | 
					        index: 0,
 | 
				
			||||||
 | 
					        type: 'attachment',
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const legacyJobs = db.prepare('SELECT * FROM attachment_downloads').all();
 | 
				
			||||||
 | 
					      assert.strictEqual(legacyJobs.length, 6);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      updateToVersion(db, 1040);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const newJobs = getAttachmentDownloadJobs(db);
 | 
				
			||||||
 | 
					      assert.strictEqual(newJobs.length, 2);
 | 
				
			||||||
 | 
					      assert.deepEqual(newJobs[1], {
 | 
				
			||||||
 | 
					        messageId: 'message-1',
 | 
				
			||||||
 | 
					        receivedAt: 1000,
 | 
				
			||||||
 | 
					        sentAt: 1000,
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          size: 100,
 | 
				
			||||||
 | 
					          contentType: 'image/png',
 | 
				
			||||||
 | 
					          digest: 'digest1',
 | 
				
			||||||
 | 
					          cdnKey: 'key1',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        size: 100,
 | 
				
			||||||
 | 
					        contentType: 'image/png',
 | 
				
			||||||
 | 
					        digest: 'digest1',
 | 
				
			||||||
 | 
					        active: 0,
 | 
				
			||||||
 | 
					        attempts: 0,
 | 
				
			||||||
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					        retryAfter: null,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      assert.deepEqual(newJobs[0], {
 | 
				
			||||||
 | 
					        messageId: 'message-2',
 | 
				
			||||||
 | 
					        receivedAt: 1001,
 | 
				
			||||||
 | 
					        sentAt: 1001,
 | 
				
			||||||
 | 
					        attachment: {
 | 
				
			||||||
 | 
					          size: 100,
 | 
				
			||||||
 | 
					          contentType: 'image/jpeg',
 | 
				
			||||||
 | 
					          digest: 'digest2',
 | 
				
			||||||
 | 
					          cdnKey: 'key2',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        size: 100,
 | 
				
			||||||
 | 
					        contentType: 'image/jpeg',
 | 
				
			||||||
 | 
					        digest: 'digest2',
 | 
				
			||||||
 | 
					        active: 0,
 | 
				
			||||||
 | 
					        attempts: 1,
 | 
				
			||||||
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
 | 
					        lastAttemptTimestamp: null,
 | 
				
			||||||
 | 
					        retryAfter: null,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function insertLegacyJob(
 | 
				
			||||||
 | 
					  db: Database,
 | 
				
			||||||
 | 
					  job: Partial<LegacyAttachmentDownloadJobType>
 | 
				
			||||||
 | 
					): void {
 | 
				
			||||||
 | 
					  db.prepare('INSERT OR REPLACE INTO messages (id) VALUES ($id)').run({
 | 
				
			||||||
 | 
					    id: job.messageId,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					  const [query, params] = sql`
 | 
				
			||||||
 | 
					    INSERT INTO attachment_downloads
 | 
				
			||||||
 | 
					      (id, timestamp, pending, json)
 | 
				
			||||||
 | 
					    VALUES
 | 
				
			||||||
 | 
					      (
 | 
				
			||||||
 | 
					        ${job.id},
 | 
				
			||||||
 | 
					        ${job.timestamp},
 | 
				
			||||||
 | 
					        ${job.pending},
 | 
				
			||||||
 | 
					        ${objectToJSON(job)}
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  db.prepare(query).run(params);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -96,6 +96,8 @@ export async function downloadAttachmentV2(
 | 
				
			||||||
  strictAssert(key, `${logId}: missing key`);
 | 
					  strictAssert(key, `${logId}: missing key`);
 | 
				
			||||||
  strictAssert(isNumber(size), `${logId}: missing size`);
 | 
					  strictAssert(isNumber(size), `${logId}: missing size`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // TODO (DESKTOP-6845): download attachments differentially based on their
 | 
				
			||||||
 | 
					  // media tier (i.e. transit tier or backup tier)
 | 
				
			||||||
  const downloadStream = await server.getAttachmentV2(
 | 
					  const downloadStream = await server.getAttachmentV2(
 | 
				
			||||||
    cdn,
 | 
					    cdn,
 | 
				
			||||||
    dropNull(cdnNumber),
 | 
					    dropNull(cdnNumber),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -70,7 +70,6 @@ export type AttachmentType = {
 | 
				
			||||||
  flags?: number;
 | 
					  flags?: number;
 | 
				
			||||||
  thumbnail?: ThumbnailType;
 | 
					  thumbnail?: ThumbnailType;
 | 
				
			||||||
  isCorrupted?: boolean;
 | 
					  isCorrupted?: boolean;
 | 
				
			||||||
  downloadJobId?: string;
 | 
					 | 
				
			||||||
  cdnNumber?: number;
 | 
					  cdnNumber?: number;
 | 
				
			||||||
  cdnId?: string;
 | 
					  cdnId?: string;
 | 
				
			||||||
  cdnKey?: string;
 | 
					  cdnKey?: string;
 | 
				
			||||||
| 
						 | 
					@ -696,7 +695,7 @@ export function hasNotResolved(attachment?: AttachmentType): boolean {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function isDownloading(attachment?: AttachmentType): boolean {
 | 
					export function isDownloading(attachment?: AttachmentType): boolean {
 | 
				
			||||||
  const resolved = resolveNestedAttachment(attachment);
 | 
					  const resolved = resolveNestedAttachment(attachment);
 | 
				
			||||||
  return Boolean(resolved && resolved.downloadJobId && resolved.pending);
 | 
					  return Boolean(resolved && resolved.pending);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function hasFailed(attachment?: AttachmentType): boolean {
 | 
					export function hasFailed(attachment?: AttachmentType): boolean {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										56
									
								
								ts/types/AttachmentDownload.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								ts/types/AttachmentDownload.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,56 @@
 | 
				
			||||||
 | 
					// Copyright 2024 Signal Messenger, LLC
 | 
				
			||||||
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					import { z } from 'zod';
 | 
				
			||||||
 | 
					import { MIMETypeSchema, type MIMEType } from './MIME';
 | 
				
			||||||
 | 
					import type { AttachmentType } from './Attachment';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const attachmentDownloadTypeSchema = z.enum([
 | 
				
			||||||
 | 
					  'long-message',
 | 
				
			||||||
 | 
					  'attachment',
 | 
				
			||||||
 | 
					  'preview',
 | 
				
			||||||
 | 
					  'contact',
 | 
				
			||||||
 | 
					  'quote',
 | 
				
			||||||
 | 
					  'sticker',
 | 
				
			||||||
 | 
					]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type AttachmentDownloadJobTypeType = z.infer<
 | 
				
			||||||
 | 
					  typeof attachmentDownloadTypeSchema
 | 
				
			||||||
 | 
					>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type AttachmentDownloadJobType = {
 | 
				
			||||||
 | 
					  messageId: string;
 | 
				
			||||||
 | 
					  receivedAt: number;
 | 
				
			||||||
 | 
					  sentAt: number;
 | 
				
			||||||
 | 
					  attachmentType: AttachmentDownloadJobTypeType;
 | 
				
			||||||
 | 
					  attachment: AttachmentType;
 | 
				
			||||||
 | 
					  attempts: number;
 | 
				
			||||||
 | 
					  active: boolean;
 | 
				
			||||||
 | 
					  retryAfter: number | null;
 | 
				
			||||||
 | 
					  lastAttemptTimestamp: number | null;
 | 
				
			||||||
 | 
					  digest: string;
 | 
				
			||||||
 | 
					  contentType: MIMEType;
 | 
				
			||||||
 | 
					  size: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const attachmentDownloadJobSchema = z.object({
 | 
				
			||||||
 | 
					  messageId: z.string(),
 | 
				
			||||||
 | 
					  receivedAt: z.number(),
 | 
				
			||||||
 | 
					  sentAt: z.number(),
 | 
				
			||||||
 | 
					  attachmentType: attachmentDownloadTypeSchema,
 | 
				
			||||||
 | 
					  attachment: z
 | 
				
			||||||
 | 
					    .object({ size: z.number(), contentType: MIMETypeSchema })
 | 
				
			||||||
 | 
					    .passthrough(),
 | 
				
			||||||
 | 
					  attempts: z.number(),
 | 
				
			||||||
 | 
					  active: z.boolean(),
 | 
				
			||||||
 | 
					  retryAfter: z.number().nullable(),
 | 
				
			||||||
 | 
					  lastAttemptTimestamp: z.number().nullable(),
 | 
				
			||||||
 | 
					  digest: z.string(),
 | 
				
			||||||
 | 
					  contentType: MIMETypeSchema,
 | 
				
			||||||
 | 
					  size: z.number(),
 | 
				
			||||||
 | 
					  messageIdForLogging: z.string().optional(),
 | 
				
			||||||
 | 
					}) satisfies z.ZodType<
 | 
				
			||||||
 | 
					  Omit<AttachmentDownloadJobType, 'attachment' | 'contentType'> & {
 | 
				
			||||||
 | 
					    contentType: string;
 | 
				
			||||||
 | 
					    attachment: Record<string, unknown>;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					>;
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,9 @@
 | 
				
			||||||
// Copyright 2018 Signal Messenger, LLC
 | 
					// Copyright 2018 Signal Messenger, LLC
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					import { z } from 'zod';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type MIMEType = string & { _mimeTypeBrand: never };
 | 
					export const MIMETypeSchema = z.string().brand('mimeType');
 | 
				
			||||||
 | 
					export type MIMEType = z.infer<typeof MIMETypeSchema>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const stringToMIMEType = (value: string): MIMEType => {
 | 
					export const stringToMIMEType = (value: string): MIMEType => {
 | 
				
			||||||
  return value as MIMEType;
 | 
					  return value as MIMEType;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,9 +4,10 @@
 | 
				
			||||||
import type { AttachmentType } from '../types/Attachment';
 | 
					import type { AttachmentType } from '../types/Attachment';
 | 
				
			||||||
import { downloadAttachmentV2 as doDownloadAttachment } from '../textsecure/downloadAttachment';
 | 
					import { downloadAttachmentV2 as doDownloadAttachment } from '../textsecure/downloadAttachment';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class AttachmentNotFoundOnCdnError extends Error {}
 | 
				
			||||||
export async function downloadAttachment(
 | 
					export async function downloadAttachment(
 | 
				
			||||||
  attachmentData: AttachmentType
 | 
					  attachmentData: AttachmentType
 | 
				
			||||||
): Promise<AttachmentType | null> {
 | 
					): Promise<AttachmentType> {
 | 
				
			||||||
  let migratedAttachment: AttachmentType;
 | 
					  let migratedAttachment: AttachmentType;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { server } = window.textsecure;
 | 
					  const { server } = window.textsecure;
 | 
				
			||||||
| 
						 | 
					@ -30,7 +31,7 @@ export async function downloadAttachment(
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    // Attachments on the server expire after 30 days, then start returning 404 or 403
 | 
					    // Attachments on the server expire after 30 days, then start returning 404 or 403
 | 
				
			||||||
    if (error && (error.code === 404 || error.code === 403)) {
 | 
					    if (error && (error.code === 404 || error.code === 403)) {
 | 
				
			||||||
      return null;
 | 
					      throw new AttachmentNotFoundOnCdnError(error.code);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    throw error;
 | 
					    throw error;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,6 +5,7 @@ import * as durations from './durations';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const BACKOFF_FACTOR = 1.9;
 | 
					const BACKOFF_FACTOR = 1.9;
 | 
				
			||||||
const MAX_BACKOFF = 15 * durations.MINUTE;
 | 
					const MAX_BACKOFF = 15 * durations.MINUTE;
 | 
				
			||||||
 | 
					const FIRST_BACKOFF = 100 * BACKOFF_FACTOR;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * For a given attempt, how long should we sleep (in milliseconds)?
 | 
					 * For a given attempt, how long should we sleep (in milliseconds)?
 | 
				
			||||||
| 
						 | 
					@ -16,12 +17,29 @@ const MAX_BACKOFF = 15 * durations.MINUTE;
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * [0]: https://github.com/signalapp/Signal-iOS/blob/6069741602421744edfb59923d2fb3a66b1b23c1/SignalServiceKit/src/Util/OWSOperation.swift
 | 
					 * [0]: https://github.com/signalapp/Signal-iOS/blob/6069741602421744edfb59923d2fb3a66b1b23c1/SignalServiceKit/src/Util/OWSOperation.swift
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export function exponentialBackoffSleepTime(attempt: number): number {
 | 
					
 | 
				
			||||||
  const failureCount = attempt - 1;
 | 
					export type ExponentialBackoffOptionsType = {
 | 
				
			||||||
  if (failureCount === 0) {
 | 
					  maxBackoffTime: number;
 | 
				
			||||||
 | 
					  multiplier: number;
 | 
				
			||||||
 | 
					  firstBackoffTime: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export function exponentialBackoffSleepTime(
 | 
				
			||||||
 | 
					  attempt: number,
 | 
				
			||||||
 | 
					  options: ExponentialBackoffOptionsType = {
 | 
				
			||||||
 | 
					    maxBackoffTime: MAX_BACKOFF,
 | 
				
			||||||
 | 
					    multiplier: BACKOFF_FACTOR,
 | 
				
			||||||
 | 
					    firstBackoffTime: FIRST_BACKOFF,
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					): number {
 | 
				
			||||||
 | 
					  if (attempt === 1) {
 | 
				
			||||||
    return 0;
 | 
					    return 0;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  return Math.min(MAX_BACKOFF, 100 * BACKOFF_FACTOR ** failureCount);
 | 
					
 | 
				
			||||||
 | 
					  return Math.min(
 | 
				
			||||||
 | 
					    options.maxBackoffTime,
 | 
				
			||||||
 | 
					    (options.firstBackoffTime / options.multiplier) *
 | 
				
			||||||
 | 
					      options.multiplier ** (attempt - 1)
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
| 
						 | 
					@ -31,7 +49,8 @@ export function exponentialBackoffSleepTime(attempt: number): number {
 | 
				
			||||||
 * `desiredDurationMs` should be at least 1.
 | 
					 * `desiredDurationMs` should be at least 1.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export function exponentialBackoffMaxAttempts(
 | 
					export function exponentialBackoffMaxAttempts(
 | 
				
			||||||
  desiredDurationMs: number
 | 
					  desiredDurationMs: number,
 | 
				
			||||||
 | 
					  options?: ExponentialBackoffOptionsType
 | 
				
			||||||
): number {
 | 
					): number {
 | 
				
			||||||
  let attempts = 0;
 | 
					  let attempts = 0;
 | 
				
			||||||
  let total = 0;
 | 
					  let total = 0;
 | 
				
			||||||
| 
						 | 
					@ -39,7 +58,7 @@ export function exponentialBackoffMaxAttempts(
 | 
				
			||||||
  //   fast even for giant numbers, and is typically called just once at startup.
 | 
					  //   fast even for giant numbers, and is typically called just once at startup.
 | 
				
			||||||
  do {
 | 
					  do {
 | 
				
			||||||
    attempts += 1;
 | 
					    attempts += 1;
 | 
				
			||||||
    total += exponentialBackoffSleepTime(attempts);
 | 
					    total += exponentialBackoffSleepTime(attempts, options);
 | 
				
			||||||
  } while (total < desiredDurationMs);
 | 
					  } while (total < desiredDurationMs);
 | 
				
			||||||
  return attempts;
 | 
					  return attempts;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -167,6 +167,10 @@ export const redactCdnKey = (cdnKey: string): string => {
 | 
				
			||||||
  return `${REDACTION_PLACEHOLDER}${cdnKey.slice(-3)}`;
 | 
					  return `${REDACTION_PLACEHOLDER}${cdnKey.slice(-3)}`;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const redactGenericText = (text: string): string => {
 | 
				
			||||||
 | 
					  return `${REDACTION_PLACEHOLDER}${text.slice(-3)}`;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const createRedactSensitivePaths = (
 | 
					const createRedactSensitivePaths = (
 | 
				
			||||||
  paths: ReadonlyArray<string>
 | 
					  paths: ReadonlyArray<string>
 | 
				
			||||||
): RedactFunction => {
 | 
					): RedactFunction => {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,6 @@
 | 
				
			||||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
					// SPDX-License-Identifier: AGPL-3.0-only
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { partition } from 'lodash';
 | 
					import { partition } from 'lodash';
 | 
				
			||||||
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
 | 
					 | 
				
			||||||
import * as log from '../logging/log';
 | 
					import * as log from '../logging/log';
 | 
				
			||||||
import { isLongMessage } from '../types/MIME';
 | 
					import { isLongMessage } from '../types/MIME';
 | 
				
			||||||
import { getMessageIdForLogging } from './idForLogging';
 | 
					import { getMessageIdForLogging } from './idForLogging';
 | 
				
			||||||
| 
						 | 
					@ -29,6 +28,10 @@ import {
 | 
				
			||||||
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 { isNotNil } from './isNotNil';
 | 
					import { isNotNil } from './isNotNil';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  AttachmentDownloadManager,
 | 
				
			||||||
 | 
					  AttachmentDownloadUrgency,
 | 
				
			||||||
 | 
					} from '../jobs/AttachmentDownloadManager';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type MessageAttachmentsDownloadedType = {
 | 
					export type MessageAttachmentsDownloadedType = {
 | 
				
			||||||
  bodyAttachment?: AttachmentType;
 | 
					  bodyAttachment?: AttachmentType;
 | 
				
			||||||
| 
						 | 
					@ -58,7 +61,8 @@ function getAttachmentSignatureSafe(
 | 
				
			||||||
// NOTE: If you're changing any logic in this function that deals with the
 | 
					// NOTE: If you're changing any logic in this function that deals with the
 | 
				
			||||||
// count then you'll also have to modify ./hasAttachmentsDownloads
 | 
					// count then you'll also have to modify ./hasAttachmentsDownloads
 | 
				
			||||||
export async function queueAttachmentDownloads(
 | 
					export async function queueAttachmentDownloads(
 | 
				
			||||||
  message: MessageAttributesType
 | 
					  message: MessageAttributesType,
 | 
				
			||||||
 | 
					  urgency: AttachmentDownloadUrgency = AttachmentDownloadUrgency.STANDARD
 | 
				
			||||||
): Promise<MessageAttachmentsDownloadedType | undefined> {
 | 
					): Promise<MessageAttachmentsDownloadedType | undefined> {
 | 
				
			||||||
  const attachmentsToQueue = message.attachments || [];
 | 
					  const attachmentsToQueue = message.attachments || [];
 | 
				
			||||||
  const messageId = message.id;
 | 
					  const messageId = message.id;
 | 
				
			||||||
| 
						 | 
					@ -82,9 +86,11 @@ export async function queueAttachmentDownloads(
 | 
				
			||||||
    log.error(`${idLog}: Received more than one long message attachment`);
 | 
					    log.error(`${idLog}: Received more than one long message attachment`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  log.info(
 | 
					  if (longMessageAttachments.length > 0) {
 | 
				
			||||||
    `${idLog}: Queueing ${longMessageAttachments.length} long message attachment downloads`
 | 
					    log.info(
 | 
				
			||||||
  );
 | 
					      `${idLog}: Queueing ${longMessageAttachments.length} long message attachment downloads`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (longMessageAttachments.length > 0) {
 | 
					  if (longMessageAttachments.length > 0) {
 | 
				
			||||||
    count += 1;
 | 
					    count += 1;
 | 
				
			||||||
| 
						 | 
					@ -96,54 +102,77 @@ export async function queueAttachmentDownloads(
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (bodyAttachment) {
 | 
					  if (bodyAttachment) {
 | 
				
			||||||
    await AttachmentDownloads.addJob(bodyAttachment, {
 | 
					    await AttachmentDownloadManager.addJob({
 | 
				
			||||||
 | 
					      attachment: bodyAttachment,
 | 
				
			||||||
      messageId,
 | 
					      messageId,
 | 
				
			||||||
      type: 'long-message',
 | 
					      attachmentType: 'long-message',
 | 
				
			||||||
      index: 0,
 | 
					      receivedAt: message.received_at,
 | 
				
			||||||
 | 
					      sentAt: message.sent_at,
 | 
				
			||||||
 | 
					      urgency,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  log.info(
 | 
					  if (normalAttachments.length > 0) {
 | 
				
			||||||
    `${idLog}: Queueing ${normalAttachments.length} normal attachment downloads`
 | 
					    log.info(
 | 
				
			||||||
  );
 | 
					      `${idLog}: Queueing ${normalAttachments.length} normal attachment downloads`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  const { attachments, count: attachmentsCount } = await queueNormalAttachments(
 | 
					  const { attachments, count: attachmentsCount } = await queueNormalAttachments(
 | 
				
			||||||
    idLog,
 | 
					    {
 | 
				
			||||||
    messageId,
 | 
					      idLog,
 | 
				
			||||||
    normalAttachments,
 | 
					      messageId,
 | 
				
			||||||
    message.editHistory?.flatMap(x => x.attachments ?? [])
 | 
					      attachments: normalAttachments,
 | 
				
			||||||
 | 
					      otherAttachments: message.editHistory?.flatMap(x => x.attachments ?? []),
 | 
				
			||||||
 | 
					      receivedAt: message.received_at,
 | 
				
			||||||
 | 
					      sentAt: message.sent_at,
 | 
				
			||||||
 | 
					      urgency,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
  count += attachmentsCount;
 | 
					  count += attachmentsCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const previewsToQueue = message.preview || [];
 | 
					  const previewsToQueue = message.preview || [];
 | 
				
			||||||
  log.info(
 | 
					  if (previewsToQueue.length > 0) {
 | 
				
			||||||
    `${idLog}: Queueing ${previewsToQueue.length} preview attachment downloads`
 | 
					    log.info(
 | 
				
			||||||
  );
 | 
					      `${idLog}: Queueing ${previewsToQueue.length} preview attachment downloads`
 | 
				
			||||||
  const { preview, count: previewCount } = await queuePreviews(
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  const { preview, count: previewCount } = await queuePreviews({
 | 
				
			||||||
    idLog,
 | 
					    idLog,
 | 
				
			||||||
    messageId,
 | 
					    messageId,
 | 
				
			||||||
    previewsToQueue,
 | 
					    previews: previewsToQueue,
 | 
				
			||||||
    message.editHistory?.flatMap(x => x.preview ?? [])
 | 
					    otherPreviews: message.editHistory?.flatMap(x => x.preview ?? []),
 | 
				
			||||||
  );
 | 
					    receivedAt: message.received_at,
 | 
				
			||||||
 | 
					    sentAt: message.sent_at,
 | 
				
			||||||
 | 
					    urgency,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
  count += previewCount;
 | 
					  count += previewCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  log.info(
 | 
					  const numQuoteAttachments = message.quote?.attachments?.length ?? 0;
 | 
				
			||||||
    `${idLog}: Queueing ${message.quote?.attachments?.length ?? 0} ` +
 | 
					  if (numQuoteAttachments > 0) {
 | 
				
			||||||
      'quote attachment downloads'
 | 
					    log.info(
 | 
				
			||||||
  );
 | 
					      `${idLog}: Queueing ${numQuoteAttachments} ` +
 | 
				
			||||||
  const { quote, count: thumbnailCount } = await queueQuoteAttachments(
 | 
					        'quote attachment downloads'
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  const { quote, count: thumbnailCount } = await queueQuoteAttachments({
 | 
				
			||||||
    idLog,
 | 
					    idLog,
 | 
				
			||||||
    messageId,
 | 
					    messageId,
 | 
				
			||||||
    message.quote,
 | 
					    quote: message.quote,
 | 
				
			||||||
    message.editHistory?.map(x => x.quote).filter(isNotNil) ?? []
 | 
					    otherQuotes: message.editHistory?.map(x => x.quote).filter(isNotNil) ?? [],
 | 
				
			||||||
  );
 | 
					    receivedAt: message.received_at,
 | 
				
			||||||
 | 
					    sentAt: message.sent_at,
 | 
				
			||||||
 | 
					    urgency,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
  count += thumbnailCount;
 | 
					  count += thumbnailCount;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const contactsToQueue = message.contact || [];
 | 
					  const contactsToQueue = message.contact || [];
 | 
				
			||||||
  log.info(
 | 
					  if (contactsToQueue.length > 0) {
 | 
				
			||||||
    `${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads`
 | 
					    log.info(
 | 
				
			||||||
  );
 | 
					      `${idLog}: Queueing ${contactsToQueue.length} contact attachment downloads`
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  const contact = await Promise.all(
 | 
					  const contact = await Promise.all(
 | 
				
			||||||
    contactsToQueue.map(async (item, index) => {
 | 
					    contactsToQueue.map(async item => {
 | 
				
			||||||
      if (!item.avatar || !item.avatar.avatar) {
 | 
					      if (!item.avatar || !item.avatar.avatar) {
 | 
				
			||||||
        return item;
 | 
					        return item;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
| 
						 | 
					@ -158,10 +187,13 @@ export async function queueAttachmentDownloads(
 | 
				
			||||||
        ...item,
 | 
					        ...item,
 | 
				
			||||||
        avatar: {
 | 
					        avatar: {
 | 
				
			||||||
          ...item.avatar,
 | 
					          ...item.avatar,
 | 
				
			||||||
          avatar: await AttachmentDownloads.addJob(item.avatar.avatar, {
 | 
					          avatar: await AttachmentDownloadManager.addJob({
 | 
				
			||||||
 | 
					            attachment: item.avatar.avatar,
 | 
				
			||||||
            messageId,
 | 
					            messageId,
 | 
				
			||||||
            type: 'contact',
 | 
					            attachmentType: 'contact',
 | 
				
			||||||
            index,
 | 
					            receivedAt: message.received_at,
 | 
				
			||||||
 | 
					            sentAt: message.sent_at,
 | 
				
			||||||
 | 
					            urgency,
 | 
				
			||||||
          }),
 | 
					          }),
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
| 
						 | 
					@ -191,10 +223,13 @@ export async function queueAttachmentDownloads(
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if (!data) {
 | 
					    if (!data) {
 | 
				
			||||||
      if (sticker.data) {
 | 
					      if (sticker.data) {
 | 
				
			||||||
        data = await AttachmentDownloads.addJob(sticker.data, {
 | 
					        data = await AttachmentDownloadManager.addJob({
 | 
				
			||||||
 | 
					          attachment: sticker.data,
 | 
				
			||||||
          messageId,
 | 
					          messageId,
 | 
				
			||||||
          type: 'sticker',
 | 
					          attachmentType: 'sticker',
 | 
				
			||||||
          index: 0,
 | 
					          receivedAt: message.received_at,
 | 
				
			||||||
 | 
					          sentAt: message.sent_at,
 | 
				
			||||||
 | 
					          urgency,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        log.error(`${idLog}: Sticker data was missing`);
 | 
					        log.error(`${idLog}: Sticker data was missing`);
 | 
				
			||||||
| 
						 | 
					@ -224,12 +259,15 @@ export async function queueAttachmentDownloads(
 | 
				
			||||||
    editHistory = await Promise.all(
 | 
					    editHistory = await Promise.all(
 | 
				
			||||||
      editHistory.map(async edit => {
 | 
					      editHistory.map(async edit => {
 | 
				
			||||||
        const { attachments: editAttachments, count: editAttachmentsCount } =
 | 
					        const { attachments: editAttachments, count: editAttachmentsCount } =
 | 
				
			||||||
          await queueNormalAttachments(
 | 
					          await queueNormalAttachments({
 | 
				
			||||||
            idLog,
 | 
					            idLog,
 | 
				
			||||||
            messageId,
 | 
					            messageId,
 | 
				
			||||||
            edit.attachments,
 | 
					            attachments: edit.attachments,
 | 
				
			||||||
            attachments
 | 
					            otherAttachments: attachments,
 | 
				
			||||||
          );
 | 
					            receivedAt: message.received_at,
 | 
				
			||||||
 | 
					            sentAt: message.sent_at,
 | 
				
			||||||
 | 
					            urgency,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
        count += editAttachmentsCount;
 | 
					        count += editAttachmentsCount;
 | 
				
			||||||
        if (editAttachmentsCount !== 0) {
 | 
					        if (editAttachmentsCount !== 0) {
 | 
				
			||||||
          log.info(
 | 
					          log.info(
 | 
				
			||||||
| 
						 | 
					@ -239,7 +277,15 @@ export async function queueAttachmentDownloads(
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const { preview: editPreview, count: editPreviewCount } =
 | 
					        const { preview: editPreview, count: editPreviewCount } =
 | 
				
			||||||
          await queuePreviews(idLog, messageId, edit.preview, preview);
 | 
					          await queuePreviews({
 | 
				
			||||||
 | 
					            idLog,
 | 
				
			||||||
 | 
					            messageId,
 | 
				
			||||||
 | 
					            previews: edit.preview,
 | 
				
			||||||
 | 
					            otherPreviews: preview,
 | 
				
			||||||
 | 
					            receivedAt: message.received_at,
 | 
				
			||||||
 | 
					            sentAt: message.sent_at,
 | 
				
			||||||
 | 
					            urgency,
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
        count += editPreviewCount;
 | 
					        count += editPreviewCount;
 | 
				
			||||||
        if (editPreviewCount !== 0) {
 | 
					        if (editPreviewCount !== 0) {
 | 
				
			||||||
          log.info(
 | 
					          log.info(
 | 
				
			||||||
| 
						 | 
					@ -274,12 +320,23 @@ export async function queueAttachmentDownloads(
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function queueNormalAttachments(
 | 
					async function queueNormalAttachments({
 | 
				
			||||||
  idLog: string,
 | 
					  idLog,
 | 
				
			||||||
  messageId: string,
 | 
					  messageId,
 | 
				
			||||||
  attachments: MessageAttributesType['attachments'] = [],
 | 
					  attachments = [],
 | 
				
			||||||
  otherAttachments: MessageAttributesType['attachments']
 | 
					  otherAttachments,
 | 
				
			||||||
): Promise<{
 | 
					  receivedAt,
 | 
				
			||||||
 | 
					  sentAt,
 | 
				
			||||||
 | 
					  urgency,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  idLog: string;
 | 
				
			||||||
 | 
					  messageId: string;
 | 
				
			||||||
 | 
					  attachments: MessageAttributesType['attachments'];
 | 
				
			||||||
 | 
					  otherAttachments: MessageAttributesType['attachments'];
 | 
				
			||||||
 | 
					  receivedAt: number;
 | 
				
			||||||
 | 
					  sentAt: number;
 | 
				
			||||||
 | 
					  urgency: AttachmentDownloadUrgency;
 | 
				
			||||||
 | 
					}): Promise<{
 | 
				
			||||||
  attachments: Array<AttachmentType>;
 | 
					  attachments: Array<AttachmentType>;
 | 
				
			||||||
  count: number;
 | 
					  count: number;
 | 
				
			||||||
}> {
 | 
					}> {
 | 
				
			||||||
| 
						 | 
					@ -299,7 +356,7 @@ async function queueNormalAttachments(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let count = 0;
 | 
					  let count = 0;
 | 
				
			||||||
  const nextAttachments = await Promise.all(
 | 
					  const nextAttachments = await Promise.all(
 | 
				
			||||||
    attachments.map((attachment, index) => {
 | 
					    attachments.map(attachment => {
 | 
				
			||||||
      if (!attachment) {
 | 
					      if (!attachment) {
 | 
				
			||||||
        return attachment;
 | 
					        return attachment;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
| 
						 | 
					@ -329,10 +386,13 @@ async function queueNormalAttachments(
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      count += 1;
 | 
					      count += 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return AttachmentDownloads.addJob(attachment, {
 | 
					      return AttachmentDownloadManager.addJob({
 | 
				
			||||||
 | 
					        attachment,
 | 
				
			||||||
        messageId,
 | 
					        messageId,
 | 
				
			||||||
        type: 'attachment',
 | 
					        attachmentType: 'attachment',
 | 
				
			||||||
        index,
 | 
					        receivedAt,
 | 
				
			||||||
 | 
					        sentAt,
 | 
				
			||||||
 | 
					        urgency,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
| 
						 | 
					@ -358,12 +418,23 @@ function getLinkPreviewSignature(preview: LinkPreviewType): string | undefined {
 | 
				
			||||||
  return `<${url}>${signature}`;
 | 
					  return `<${url}>${signature}`;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function queuePreviews(
 | 
					async function queuePreviews({
 | 
				
			||||||
  idLog: string,
 | 
					  idLog,
 | 
				
			||||||
  messageId: string,
 | 
					  messageId,
 | 
				
			||||||
  previews: MessageAttributesType['preview'] = [],
 | 
					  previews = [],
 | 
				
			||||||
  otherPreviews: MessageAttributesType['preview']
 | 
					  otherPreviews,
 | 
				
			||||||
): Promise<{ preview: Array<LinkPreviewType>; count: number }> {
 | 
					  receivedAt,
 | 
				
			||||||
 | 
					  sentAt,
 | 
				
			||||||
 | 
					  urgency,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  idLog: string;
 | 
				
			||||||
 | 
					  messageId: string;
 | 
				
			||||||
 | 
					  previews: MessageAttributesType['preview'];
 | 
				
			||||||
 | 
					  otherPreviews: MessageAttributesType['preview'];
 | 
				
			||||||
 | 
					  receivedAt: number;
 | 
				
			||||||
 | 
					  sentAt: number;
 | 
				
			||||||
 | 
					  urgency: AttachmentDownloadUrgency;
 | 
				
			||||||
 | 
					}): Promise<{ preview: Array<LinkPreviewType>; count: number }> {
 | 
				
			||||||
  // Similar to queueNormalAttachments' logic for detecting same attachments
 | 
					  // Similar to queueNormalAttachments' logic for detecting same attachments
 | 
				
			||||||
  // except here we also pick by link preview URL.
 | 
					  // except here we also pick by link preview URL.
 | 
				
			||||||
  const previewSignatures: Map<string, LinkPreviewType> = new Map();
 | 
					  const previewSignatures: Map<string, LinkPreviewType> = new Map();
 | 
				
			||||||
| 
						 | 
					@ -378,7 +449,7 @@ async function queuePreviews(
 | 
				
			||||||
  let count = 0;
 | 
					  let count = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const preview = await Promise.all(
 | 
					  const preview = await Promise.all(
 | 
				
			||||||
    previews.map(async (item, index) => {
 | 
					    previews.map(async item => {
 | 
				
			||||||
      if (!item.image) {
 | 
					      if (!item.image) {
 | 
				
			||||||
        return item;
 | 
					        return item;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
| 
						 | 
					@ -407,10 +478,13 @@ async function queuePreviews(
 | 
				
			||||||
      count += 1;
 | 
					      count += 1;
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        ...item,
 | 
					        ...item,
 | 
				
			||||||
        image: await AttachmentDownloads.addJob(item.image, {
 | 
					        image: await AttachmentDownloadManager.addJob({
 | 
				
			||||||
 | 
					          attachment: item.image,
 | 
				
			||||||
          messageId,
 | 
					          messageId,
 | 
				
			||||||
          type: 'preview',
 | 
					          attachmentType: 'preview',
 | 
				
			||||||
          index,
 | 
					          receivedAt,
 | 
				
			||||||
 | 
					          sentAt,
 | 
				
			||||||
 | 
					          urgency,
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
| 
						 | 
					@ -436,12 +510,23 @@ function getQuoteThumbnailSignature(
 | 
				
			||||||
  return `<${quote.id}>${signature}`;
 | 
					  return `<${quote.id}>${signature}`;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function queueQuoteAttachments(
 | 
					async function queueQuoteAttachments({
 | 
				
			||||||
  idLog: string,
 | 
					  idLog,
 | 
				
			||||||
  messageId: string,
 | 
					  messageId,
 | 
				
			||||||
  quote: QuotedMessageType | undefined,
 | 
					  quote,
 | 
				
			||||||
  otherQuotes: ReadonlyArray<QuotedMessageType>
 | 
					  otherQuotes,
 | 
				
			||||||
): Promise<{ quote?: QuotedMessageType; count: number }> {
 | 
					  receivedAt,
 | 
				
			||||||
 | 
					  sentAt,
 | 
				
			||||||
 | 
					  urgency,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  idLog: string;
 | 
				
			||||||
 | 
					  messageId: string;
 | 
				
			||||||
 | 
					  quote: QuotedMessageType | undefined;
 | 
				
			||||||
 | 
					  otherQuotes: ReadonlyArray<QuotedMessageType>;
 | 
				
			||||||
 | 
					  receivedAt: number;
 | 
				
			||||||
 | 
					  sentAt: number;
 | 
				
			||||||
 | 
					  urgency: AttachmentDownloadUrgency;
 | 
				
			||||||
 | 
					}): Promise<{ quote?: QuotedMessageType; count: number }> {
 | 
				
			||||||
  let count = 0;
 | 
					  let count = 0;
 | 
				
			||||||
  if (!quote) {
 | 
					  if (!quote) {
 | 
				
			||||||
    return { quote, count };
 | 
					    return { quote, count };
 | 
				
			||||||
| 
						 | 
					@ -473,7 +558,7 @@ async function queueQuoteAttachments(
 | 
				
			||||||
    quote: {
 | 
					    quote: {
 | 
				
			||||||
      ...quote,
 | 
					      ...quote,
 | 
				
			||||||
      attachments: await Promise.all(
 | 
					      attachments: await Promise.all(
 | 
				
			||||||
        quote.attachments.map(async (item, index) => {
 | 
					        quote.attachments.map(async item => {
 | 
				
			||||||
          if (!item.thumbnail) {
 | 
					          if (!item.thumbnail) {
 | 
				
			||||||
            return item;
 | 
					            return item;
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
| 
						 | 
					@ -508,10 +593,13 @@ async function queueQuoteAttachments(
 | 
				
			||||||
          count += 1;
 | 
					          count += 1;
 | 
				
			||||||
          return {
 | 
					          return {
 | 
				
			||||||
            ...item,
 | 
					            ...item,
 | 
				
			||||||
            thumbnail: await AttachmentDownloads.addJob(item.thumbnail, {
 | 
					            thumbnail: await AttachmentDownloadManager.addJob({
 | 
				
			||||||
 | 
					              attachment: item.thumbnail,
 | 
				
			||||||
              messageId,
 | 
					              messageId,
 | 
				
			||||||
              type: 'quote',
 | 
					              attachmentType: 'quote',
 | 
				
			||||||
              index,
 | 
					              receivedAt,
 | 
				
			||||||
 | 
					              sentAt,
 | 
				
			||||||
 | 
					              urgency,
 | 
				
			||||||
            }),
 | 
					            }),
 | 
				
			||||||
          };
 | 
					          };
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue