+ {i18n('icu:BackupMediaDownloadProgress__title-paused')}
+
+
+ {i18n('icu:BackupMediaDownloadProgress__title-in-progress')}
+
+
+
+ {i18n('icu:BackupMediaDownloadProgress__progressbar-hint', {
+ currentSize: formatFileSize(downloadedBytes),
+ totalSize: formatFileSize(totalBytes),
+ })}
+
+ >
+ );
+ }
+
+ actionButton = (
+
-
-
-
- {i18n('icu:BackupMediaDownloadProgress__title')}
-
-
-
- {i18n('icu:BackupMediaDownloadProgress__progressbar-hint', {
- currentSize: formatFileSize(downloadedBytes),
- totalSize: formatFileSize(totalBytes),
- fractionComplete,
- })}
-
-
+
+ {icon}
+
{content}
+ {actionButton}
+ {isShowingCancelConfirmation ? (
+
setIsShowingCancelConfirmation(false)}
+ handleConfirmCancel={handleConfirmedCancel}
+ />
+ ) : null}
);
}
diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx
index 8d7f6f8e0b..fda271a2ff 100644
--- a/ts/components/LeftPane.stories.tsx
+++ b/ts/components/LeftPane.stories.tsx
@@ -137,7 +137,12 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
unreadMentionsCount: 0,
markedUnread: false,
},
- backupMediaDownloadProgress: { totalBytes: 0, downloadedBytes: 0 },
+ backupMediaDownloadProgress: {
+ downloadBannerDismissed: false,
+ isPaused: false,
+ totalBytes: 0,
+ downloadedBytes: 0,
+ },
clearConversationSearch: action('clearConversationSearch'),
clearGroupCreationError: action('clearGroupCreationError'),
clearSearch: action('clearSearch'),
@@ -147,6 +152,12 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
composeReplaceAvatar: action('composeReplaceAvatar'),
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
createGroup: action('createGroup'),
+ dismissBackupMediaDownloadBanner: action(
+ 'dismissBackupMediaDownloadBanner'
+ ),
+ pauseBackupMediaDownload: action('pauseBackupMediaDownload'),
+ resumeBackupMediaDownload: action('resumeBackupMediaDownload'),
+ cancelBackupMediaDownload: action('cancelBackupMediaDownload'),
endConversationSearch: action('endConversationSearch'),
endSearch: action('endSearch'),
getPreferredBadge: () => undefined,
diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx
index 0f9ce5ce6c..ad36d7e2d8 100644
--- a/ts/components/LeftPane.tsx
+++ b/ts/components/LeftPane.tsx
@@ -1,13 +1,7 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import React, {
- useEffect,
- useCallback,
- useMemo,
- useRef,
- useState,
-} from 'react';
+import React, { useEffect, useCallback, useMemo, useRef } from 'react';
import classNames from 'classnames';
import { isNumber } from 'lodash';
@@ -62,10 +56,15 @@ import {
import { ContextMenu } from './ContextMenu';
import { EditState as ProfileEditorEditState } from './ProfileEditor';
import type { UnreadStats } from '../util/countUnreadStats';
-import { BackupMediaDownloadProgressBanner } from './BackupMediaDownloadProgress';
+import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress';
export type PropsType = {
- backupMediaDownloadProgress: { totalBytes: number; downloadedBytes: number };
+ backupMediaDownloadProgress: {
+ totalBytes: number;
+ downloadedBytes: number;
+ isPaused: boolean;
+ downloadBannerDismissed: boolean;
+ };
otherTabsUnreadStats: UnreadStats;
hasExpiredDialog: boolean;
hasFailedStorySends: boolean;
@@ -128,6 +127,10 @@ export type PropsType = {
composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => void;
+ dismissBackupMediaDownloadBanner: () => void;
+ pauseBackupMediaDownload: () => void;
+ resumeBackupMediaDownload: () => void;
+ cancelBackupMediaDownload: () => void;
endConversationSearch: () => void;
endSearch: () => void;
navTabsCollapsed: boolean;
@@ -184,6 +187,7 @@ export function LeftPane({
backupMediaDownloadProgress,
otherTabsUnreadStats,
blockConversation,
+ cancelBackupMediaDownload,
challengeStatus,
clearConversationSearch,
clearGroupCreationError,
@@ -214,6 +218,7 @@ export function LeftPane({
onOutgoingVideoCallInConversation,
openUsernameReservationModal,
+ pauseBackupMediaDownload,
preferredWidthFromStorage,
preloadConversation,
removeConversation,
@@ -226,6 +231,7 @@ export function LeftPane({
renderRelinkDialog,
renderUpdateDialog,
renderToastManager,
+ resumeBackupMediaDownload,
savePreferredLeftPaneWidth,
searchInConversation,
selectedConversationId,
@@ -256,6 +262,7 @@ export function LeftPane({
usernameCorrupted,
usernameLinkCorrupted,
updateSearchTerm,
+ dismissBackupMediaDownloadBanner,
}: PropsType): JSX.Element {
const previousModeSpecificProps = usePrevious(
modeSpecificProps,
@@ -645,27 +652,25 @@ export function LeftPane({
// We'll show the backup media download progress banner if the download is currently or
// was ongoing at some point during the lifecycle of this component
- const [
- hasMediaBackupDownloadBeenOngoing,
- setHasMediaBackupDownloadBeenOngoing,
- ] = useState(false);
- const isMediaBackupDownloadOngoing =
+ const isMediaBackupDownloadIncomplete =
backupMediaDownloadProgress?.totalBytes > 0 &&
backupMediaDownloadProgress.downloadedBytes <
backupMediaDownloadProgress.totalBytes;
-
- if (isMediaBackupDownloadOngoing && !hasMediaBackupDownloadBeenOngoing) {
- setHasMediaBackupDownloadBeenOngoing(true);
- }
-
- if (hasMediaBackupDownloadBeenOngoing) {
+ if (
+ isMediaBackupDownloadIncomplete &&
+ !backupMediaDownloadProgress.downloadBannerDismissed
+ ) {
dialogs.push({
key: 'backupMediaDownload',
dialog: (
-
),
});
diff --git a/ts/components/ProgressBar.stories.tsx b/ts/components/ProgressBar.stories.tsx
new file mode 100644
index 0000000000..a6fb341ae0
--- /dev/null
+++ b/ts/components/ProgressBar.stories.tsx
@@ -0,0 +1,53 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import * as React from 'react';
+
+import { ProgressBar } from './ProgressBar';
+import type { ComponentMeta } from '../storybook/types';
+
+type Props = React.ComponentProps
;
+export default {
+ title: 'Components/ProgressBar',
+ component: ProgressBar,
+ args: {
+ fractionComplete: 0,
+ isRTL: false,
+ },
+} satisfies ComponentMeta;
+
+export function Zero(args: Props): JSX.Element {
+ return ;
+}
+
+export function Thirty(args: Props): JSX.Element {
+ return ;
+}
+
+export function Done(args: Props): JSX.Element {
+ return ;
+}
+
+export function Increasing(args: Props): JSX.Element {
+ const fractionComplete = useIncreasingFractionComplete();
+ return ;
+}
+
+export function RTLIncreasing(args: Props): JSX.Element {
+ const fractionComplete = useIncreasingFractionComplete();
+ return ;
+}
+
+function useIncreasingFractionComplete() {
+ const [fractionComplete, setFractionComplete] = React.useState(0);
+ React.useEffect(() => {
+ if (fractionComplete >= 1) {
+ return;
+ }
+ const timeout = setTimeout(() => {
+ setFractionComplete(cur => Math.min(1, cur + 0.1));
+ }, 300);
+ return () => clearTimeout(timeout);
+ }, [fractionComplete]);
+ return fractionComplete;
+}
diff --git a/ts/components/ProgressBar.tsx b/ts/components/ProgressBar.tsx
index 5c1e28c74d..f73425b276 100644
--- a/ts/components/ProgressBar.tsx
+++ b/ts/components/ProgressBar.tsx
@@ -5,15 +5,17 @@ import React from 'react';
export function ProgressBar({
fractionComplete,
+ isRTL,
}: {
fractionComplete: number;
+ isRTL: boolean;
}): JSX.Element {
return (
diff --git a/ts/components/ProgressCircle.stories.tsx b/ts/components/ProgressCircle.stories.tsx
new file mode 100644
index 0000000000..b05461fc41
--- /dev/null
+++ b/ts/components/ProgressCircle.stories.tsx
@@ -0,0 +1,44 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import * as React from 'react';
+
+import { ProgressCircle } from './ProgressCircle';
+import type { ComponentMeta } from '../storybook/types';
+
+type Props = React.ComponentProps;
+export default {
+ title: 'Components/ProgressCircle',
+ component: ProgressCircle,
+ args: { fractionComplete: 0, width: undefined, strokeWidth: undefined },
+} satisfies ComponentMeta;
+
+export function Zero(args: Props): JSX.Element {
+ return ;
+}
+
+export function Thirty(args: Props): JSX.Element {
+ return ;
+}
+
+export function Done(args: Props): JSX.Element {
+ return ;
+}
+export function Increasing(args: Props): JSX.Element {
+ const fractionComplete = useIncreasingFractionComplete();
+ return ;
+}
+
+function useIncreasingFractionComplete() {
+ const [fractionComplete, setFractionComplete] = React.useState(0);
+ React.useEffect(() => {
+ if (fractionComplete >= 1) {
+ return;
+ }
+ const timeout = setTimeout(() => {
+ setFractionComplete(cur => Math.min(1, cur + 0.1));
+ }, 300);
+ return () => clearTimeout(timeout);
+ }, [fractionComplete]);
+ return fractionComplete;
+}
diff --git a/ts/components/ProgressCircle.tsx b/ts/components/ProgressCircle.tsx
new file mode 100644
index 0000000000..ade93ad1ea
--- /dev/null
+++ b/ts/components/ProgressCircle.tsx
@@ -0,0 +1,41 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+
+export function ProgressCircle({
+ fractionComplete,
+ width = 24,
+ strokeWidth = 3,
+}: {
+ fractionComplete: number;
+ width?: number;
+ strokeWidth?: number;
+}): JSX.Element {
+ const radius = width / 2 - strokeWidth / 2;
+ const circumference = radius * 2 * Math.PI;
+ return (
+
+ );
+}
diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.tsx
index d9c3e0edc2..95e7c0ffb3 100644
--- a/ts/components/installScreen/InstallScreenBackupImportStep.tsx
+++ b/ts/components/installScreen/InstallScreenBackupImportStep.tsx
@@ -9,6 +9,7 @@ import { TitlebarDragArea } from '../TitlebarDragArea';
import { ProgressBar } from '../ProgressBar';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
+import { roundFractionForProgressBar } from '../../util/numbers';
// We can't always use destructuring assignment because of the complexity of this props
// type.
@@ -41,29 +42,26 @@ export function InstallScreenBackupImportStep({
setIsConfirmingCancel(false);
}, [onCancel]);
- let percentage = 0;
let progress: JSX.Element;
let isCancelPossible = true;
if (currentBytes != null && totalBytes != null) {
isCancelPossible = currentBytes !== totalBytes;
- percentage = Math.max(0, Math.min(1, currentBytes / totalBytes));
- if (percentage > 0 && percentage <= 0.01) {
- percentage = 0.01;
- } else if (percentage >= 0.99 && percentage < 1) {
- percentage = 0.99;
- } else {
- percentage = Math.round(percentage * 100) / 100;
- }
+ const fractionComplete = roundFractionForProgressBar(
+ currentBytes / totalBytes
+ );
progress = (
<>
-
+
{i18n('icu:BackupImportScreen__progressbar-hint', {
currentSize: formatFileSize(currentBytes),
totalSize: formatFileSize(totalBytes),
- fractionComplete: percentage,
+ fractionComplete,
})}
>
@@ -71,7 +69,10 @@ export function InstallScreenBackupImportStep({
} else {
progress = (
<>
-
+
{i18n('icu:BackupImportScreen__progressbar-hint--preparing')}
diff --git a/ts/jobs/AttachmentDownloadManager.ts b/ts/jobs/AttachmentDownloadManager.ts
index 982f5dcde0..e22a0e90e7 100644
--- a/ts/jobs/AttachmentDownloadManager.ts
+++ b/ts/jobs/AttachmentDownloadManager.ts
@@ -42,7 +42,7 @@ import {
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { MIMEType } from '../types/MIME';
-import type { AttachmentDownloadSource } from '../sql/Interface';
+import { AttachmentDownloadSource } from '../sql/Interface';
import { drop } from '../util/drop';
import { getAttachmentCiphertextLength } from '../AttachmentCrypto';
@@ -84,6 +84,7 @@ type AttachmentDownloadManagerParamsType = Omit<
getNextJobs: (options: {
limit: number;
prioritizeMessageIds?: Array;
+ sources?: Array;
timestamp?: number;
}) => Promise>;
runDownloadAttachmentJob: (args: {
@@ -139,6 +140,9 @@ export class AttachmentDownloadManager extends JobManager {
await AttachmentDownloadManager.stop();
await DataWriter.removeAllBackupAttachmentDownloadJobs();
- await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0);
- await window.storage.put('backupAttachmentsTotalSizeToDownload', 0);
+ await window.storage.put('backupMediaDownloadCompletedBytes', 0);
+ await window.storage.put('backupMediaDownloadTotalBytes', 0);
return new BackupImportStream();
}
@@ -401,7 +401,7 @@ export class BackupImportStream extends Writable {
reinitializeRedux(getParametersForRedux());
await window.storage.put(
- 'backupAttachmentsTotalSizeToDownload',
+ 'backupMediaDownloadTotalBytes',
await DataReader.getSizeOfPendingBackupAttachmentDownloadJobs()
);
diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts
index 26e5d183ed..672c99cdcc 100644
--- a/ts/sql/Interface.ts
+++ b/ts/sql/Interface.ts
@@ -852,6 +852,7 @@ type WritableInterface = {
getNextAttachmentDownloadJobs: (options: {
limit: number;
prioritizeMessageIds?: Array;
+ sources?: Array;
timestamp?: number;
}) => Array;
saveAttachmentDownloadJob: (job: AttachmentDownloadJobType) => void;
diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts
index 3a35f42af8..9be08df9b6 100644
--- a/ts/sql/Server.ts
+++ b/ts/sql/Server.ts
@@ -4785,18 +4785,28 @@ function getNextAttachmentDownloadJobs(
db: WritableDB,
{
limit = 3,
+ sources,
prioritizeMessageIds,
timestamp = Date.now(),
maxLastAttemptForPrioritizedMessages,
}: {
limit: number;
prioritizeMessageIds?: Array;
+ sources?: Array;
timestamp?: number;
maxLastAttemptForPrioritizedMessages?: number;
}
): Array {
let priorityJobs = [];
+ const sourceWhereFragment = sources
+ ? sqlFragment`
+ source IN (${sqlJoin(sources)})
+ `
+ : sqlFragment`
+ TRUE
+ `;
+
// First, try to get jobs for prioritized messages (e.g. those currently user-visible)
if (prioritizeMessageIds?.length) {
const [priorityQuery, priorityParams] = sql`
@@ -4813,6 +4823,8 @@ function getNextAttachmentDownloadJobs(
})
AND
messageId IN (${sqlJoin(prioritizeMessageIds)})
+ AND
+ ${sourceWhereFragment}
-- 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
@@ -4831,6 +4843,8 @@ function getNextAttachmentDownloadJobs(
active = 0
AND
(retryAfter is NULL OR retryAfter <= ${timestamp})
+ AND
+ ${sourceWhereFragment}
ORDER BY receivedAt DESC
LIMIT ${numJobsRemaining}
`;
diff --git a/ts/sql/migrations/1200-attachment-download-source-index.ts b/ts/sql/migrations/1200-attachment-download-source-index.ts
new file mode 100644
index 0000000000..e60940fd34
--- /dev/null
+++ b/ts/sql/migrations/1200-attachment-download-source-index.ts
@@ -0,0 +1,29 @@
+// 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';
+
+export const version = 1200;
+export function updateToSchemaVersion1200(
+ currentVersion: number,
+ db: Database,
+ logger: LoggerType
+): void {
+ if (currentVersion >= 1200) {
+ return;
+ }
+
+ db.transaction(() => {
+ // The standard getNextAttachmentDownloadJobs query uses active & source conditions,
+ // ordered by received_at
+ db.exec(`
+ CREATE INDEX attachment_downloads_active_source_receivedAt
+ ON attachment_downloads (
+ active, source, receivedAt
+ );
+ `);
+
+ db.pragma('user_version = 1200');
+ })();
+ logger.info('updateToSchemaVersion1200: success!');
+}
diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts
index bfc4bbe495..40dbfa2914 100644
--- a/ts/sql/migrations/index.ts
+++ b/ts/sql/migrations/index.ts
@@ -95,10 +95,11 @@ import { updateToSchemaVersion1150 } from './1150-expire-timer-version';
import { updateToSchemaVersion1160 } from './1160-optimize-calls-unread-count';
import { updateToSchemaVersion1170 } from './1170-update-call-history-unread-index';
import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source';
+import { updateToSchemaVersion1190 } from './1190-call-links-storage';
import {
- updateToSchemaVersion1190,
+ updateToSchemaVersion1200,
version as MAX_VERSION,
-} from './1190-call-links-storage';
+} from './1200-attachment-download-source-index';
function updateToSchemaVersion1(
currentVersion: number,
@@ -2062,6 +2063,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1170,
updateToSchemaVersion1180,
updateToSchemaVersion1190,
+ updateToSchemaVersion1200,
];
export class DBVersionFromFutureError extends Error {
diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts
index 922fe6f729..ed137f3780 100644
--- a/ts/state/selectors/items.ts
+++ b/ts/state/selectors/items.ts
@@ -251,8 +251,17 @@ export const getLocalDeleteWarningShown = createSelector(
export const getBackupMediaDownloadProgress = createSelector(
getItems,
- (state: ItemsStateType): { totalBytes: number; downloadedBytes: number } => ({
- totalBytes: state.backupAttachmentsTotalSizeToDownload ?? 0,
- downloadedBytes: state.backupAttachmentsSuccessfullyDownloadedSize ?? 0,
+ (
+ state: ItemsStateType
+ ): {
+ totalBytes: number;
+ downloadedBytes: number;
+ isPaused: boolean;
+ downloadBannerDismissed: boolean;
+ } => ({
+ totalBytes: state.backupMediaDownloadTotalBytes ?? 0,
+ downloadedBytes: state.backupMediaDownloadCompletedBytes ?? 0,
+ isPaused: state.backupMediaDownloadPaused ?? false,
+ downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false,
})
);
diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx
index ea51a99753..fa6d7a692b 100644
--- a/ts/state/smart/LeftPane.tsx
+++ b/ts/state/smart/LeftPane.tsx
@@ -97,6 +97,12 @@ import { SmartToastManager } from './ToastManager';
import type { PropsType as SmartUnsupportedOSDialogPropsType } from './UnsupportedOSDialog';
import { SmartUnsupportedOSDialog } from './UnsupportedOSDialog';
import { SmartUpdateDialog } from './UpdateDialog';
+import {
+ cancelBackupMediaDownload,
+ dismissBackupMediaDownloadBanner,
+ pauseBackupMediaDownload,
+ resumeBackupMediaDownload,
+} from '../../util/backupMediaDownload';
function renderMessageSearchResult(id: string): JSX.Element {
return ;
@@ -366,6 +372,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
& {
attachmentOverrides?: Partial;
+ jobOverrides?: Partial;
}): AttachmentDownloadJobType {
const digest = `digestFor${messageId}`;
const size = 128;
@@ -53,6 +55,7 @@ function composeJob({
digest: `digestFor${messageId}`,
...attachmentOverrides,
},
+ ...jobOverrides,
};
}
@@ -123,13 +126,19 @@ describe('AttachmentDownloadManager/JobManager', () => {
});
}
async function addJobs(
- num: number
+ num: number,
+ jobOverrides?:
+ | Partial
+ | ((idx: number) => Partial)
): Promise> {
- const jobs = new Array(num)
- .fill(null)
- .map((_, idx) =>
- composeJob({ messageId: `message-${idx}`, receivedAt: idx })
- );
+ const jobs = new Array(num).fill(null).map((_, idx) =>
+ composeJob({
+ messageId: `message-${idx}`,
+ receivedAt: idx,
+ jobOverrides:
+ typeof jobOverrides === 'function' ? jobOverrides(idx) : jobOverrides,
+ })
+ );
for (const job of jobs) {
// eslint-disable-next-line no-await-in-loop
await addJob(job, AttachmentDownloadUrgency.STANDARD);
@@ -392,6 +401,35 @@ describe('AttachmentDownloadManager/JobManager', () => {
// Ensure it's been removed
assert.isUndefined(await DataReader.getAttachmentDownloadJob(jobs[0]));
});
+
+ it('only selects backup_import jobs if the mediaDownload is not paused', async () => {
+ await window.storage.put('backupMediaDownloadPaused', true);
+ const jobs = await addJobs(6, idx => ({
+ source:
+ idx % 2 === 0
+ ? AttachmentDownloadSource.BACKUP_IMPORT
+ : AttachmentDownloadSource.STANDARD,
+ }));
+ // make one of the backup job messages visible to test that code path as well
+ downloadManager?.updateVisibleTimelineMessages(['message-0', 'message-1']);
+ await downloadManager?.start();
+ await waitForJobToBeCompleted(jobs[3]);
+ assertRunJobCalledWith([jobs[1], jobs[5], jobs[3]]);
+ await advanceTime((downloadManager?.tickInterval ?? MINUTE) * 5);
+ assertRunJobCalledWith([jobs[1], jobs[5], jobs[3]]);
+
+ // resume backups
+ await window.storage.put('backupMediaDownloadPaused', false);
+ await advanceTime((downloadManager?.tickInterval ?? MINUTE) * 5);
+ assertRunJobCalledWith([
+ jobs[1],
+ jobs[5],
+ jobs[3],
+ jobs[0],
+ jobs[4],
+ jobs[2],
+ ]);
+ });
});
describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
diff --git a/ts/test-node/sql/migration_1200_test.ts b/ts/test-node/sql/migration_1200_test.ts
new file mode 100644
index 0000000000..6397d0d125
--- /dev/null
+++ b/ts/test-node/sql/migration_1200_test.ts
@@ -0,0 +1,213 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+
+import { AttachmentDownloadSource, type WritableDB } from '../../sql/Interface';
+import { objectToJSON, sql } from '../../sql/util';
+import { createDB, updateToVersion } from './helpers';
+import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
+import { IMAGE_JPEG } from '../../types/MIME';
+
+type UnflattenedAttachmentDownloadJobType = Omit<
+ AttachmentDownloadJobType,
+ 'digest' | 'contentType' | 'size' | 'ciphertextSize'
+>;
+
+function createJob(
+ index: number,
+ overrides?: Partial
+): UnflattenedAttachmentDownloadJobType {
+ return {
+ messageId: `message${index}`,
+ attachmentType: 'attachment',
+ attachment: {
+ digest: `digest${index}`,
+ contentType: IMAGE_JPEG,
+ size: 128,
+ },
+ receivedAt: 100 + index,
+ sentAt: 100 + index,
+ attempts: 0,
+ active: false,
+ retryAfter: null,
+ lastAttemptTimestamp: null,
+ source: AttachmentDownloadSource.STANDARD,
+ ...overrides,
+ };
+}
+function insertJob(
+ db: WritableDB,
+ index: number,
+ overrides?: Partial
+): void {
+ const job = createJob(index, overrides);
+ 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,
+ source
+ )
+ 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},
+ ${job.source}
+ );
+`;
+
+ db.prepare(query).run(params);
+}
+
+const NUM_STANDARD_JOBS = 100;
+
+describe('SQL/updateToSchemaVersion1200', () => {
+ let db: WritableDB;
+
+ after(() => {
+ db.close();
+ });
+
+ before(() => {
+ db = createDB();
+ updateToVersion(db, 1200);
+ db.transaction(() => {
+ for (let i = 0; i < 10_000; i += 1) {
+ insertJob(db, i, {
+ source:
+ i < NUM_STANDARD_JOBS
+ ? AttachmentDownloadSource.STANDARD
+ : AttachmentDownloadSource.BACKUP_IMPORT,
+ });
+ }
+ })();
+ });
+
+ it('uses correct index for standard query', () => {
+ const now = Date.now();
+ const [query, params] = sql`
+ SELECT * FROM attachment_downloads
+ WHERE
+ active = 0
+ AND
+ (retryAfter is NULL OR retryAfter <= ${now})
+ ORDER BY receivedAt DESC
+ LIMIT 3
+ `;
+ const details = db
+ .prepare(`EXPLAIN QUERY PLAN ${query}`)
+ .all(params)
+ .map(step => step.detail)
+ .join(', ');
+ assert.equal(
+ details,
+ 'SEARCH attachment_downloads USING INDEX attachment_downloads_active_receivedAt (active=?)'
+ );
+ });
+
+ it('uses correct index for standard query with sources', () => {
+ const now = Date.now();
+ // query with sources (e.g. when backup-import is paused)
+ const [query, params] = sql`
+ SELECT * FROM attachment_downloads
+ WHERE
+ active IS 0
+ AND
+ source IN ('standard')
+ AND
+ (retryAfter is NULL OR retryAfter <= ${now})
+ ORDER BY receivedAt DESC
+ LIMIT 3
+ `;
+ const details = db
+ .prepare(`EXPLAIN QUERY PLAN ${query}`)
+ .all(params)
+ .map(step => step.detail)
+ .join(', ');
+ assert.equal(
+ details,
+ 'SEARCH attachment_downloads USING INDEX attachment_downloads_active_source_receivedAt (active=? AND source=?)'
+ );
+ });
+
+ it('uses provided index for prioritized query with sources', () => {
+ // prioritize visible messages with sources (e.g. when backup-import is paused)
+ const [query, params] = sql`
+ SELECT * FROM attachment_downloads
+ INDEXED BY attachment_downloads_active_messageId
+ WHERE
+ active IS 0
+ AND
+ messageId IN ('message12', 'message101')
+ AND
+ (lastAttemptTimestamp is NULL OR lastAttemptTimestamp <= ${Date.now()})
+ AND
+ source IN ('standard')
+ ORDER BY receivedAt ASC
+ LIMIT 3
+ `;
+ const result = db.prepare(query).all(params);
+ assert.strictEqual(result.length, 1);
+ assert.deepStrictEqual(result[0].messageId, 'message12');
+ const details = db
+ .prepare(`EXPLAIN QUERY PLAN ${query}`)
+ .all(params)
+ .map(step => step.detail)
+ .join(', ');
+ assert.equal(
+ details,
+ 'SEARCH attachment_downloads USING INDEX attachment_downloads_active_messageId (active=? AND messageId=?), USE TEMP B-TREE FOR ORDER BY'
+ );
+ });
+
+ it('uses existing index to remove all backup jobs ', () => {
+ // prioritize visible messages with sources (e.g. when backup-import is paused)
+ const [query, params] = sql`
+ DELETE FROM attachment_downloads
+ WHERE source = 'backup_import';
+ `;
+
+ const details = db
+ .prepare(`EXPLAIN QUERY PLAN ${query}`)
+ .all(params)
+ .map(step => step.detail)
+ .join(', ');
+ assert.equal(
+ details,
+ 'SEARCH attachment_downloads USING COVERING INDEX attachment_downloads_source_ciphertextSize (source=?)'
+ );
+ db.prepare(query).run(params);
+ assert.equal(
+ db.prepare('SELECT * FROM attachment_downloads').all().length,
+ NUM_STANDARD_JOBS
+ );
+ });
+});
diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts
index 6116512b79..b175d176d0 100644
--- a/ts/types/Storage.d.ts
+++ b/ts/types/Storage.d.ts
@@ -142,8 +142,10 @@ export type StorageAccessType = {
callLinkAuthCredentials: ReadonlyArray;
backupCredentials: ReadonlyArray;
backupCredentialsLastRequestTime: number;
- backupAttachmentsSuccessfullyDownloadedSize: number;
- backupAttachmentsTotalSizeToDownload: number;
+ backupMediaDownloadTotalBytes: number;
+ backupMediaDownloadCompletedBytes: number;
+ backupMediaDownloadPaused: boolean;
+ backupMediaDownloadBannerDismissed: boolean;
setBackupSignatureKey: boolean;
lastReceivedAtCounter: number;
preferredReactionEmoji: ReadonlyArray;
diff --git a/ts/util/backupMediaDownload.ts b/ts/util/backupMediaDownload.ts
new file mode 100644
index 0000000000..cc7d175732
--- /dev/null
+++ b/ts/util/backupMediaDownload.ts
@@ -0,0 +1,34 @@
+// Copyright 2024 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { DataWriter } from '../sql/Client';
+
+export async function pauseBackupMediaDownload(): Promise {
+ await window.storage.put('backupMediaDownloadPaused', true);
+}
+
+export async function resumeBackupMediaDownload(): Promise {
+ await window.storage.put('backupMediaDownloadPaused', false);
+}
+
+export async function resetBackupMediaDownloadItems(): Promise {
+ await Promise.all([
+ window.storage.remove('backupMediaDownloadTotalBytes'),
+ window.storage.remove('backupMediaDownloadCompletedBytes'),
+ window.storage.remove('backupMediaDownloadBannerDismissed'),
+ window.storage.remove('backupMediaDownloadPaused'),
+ ]);
+}
+
+export async function cancelBackupMediaDownload(): Promise {
+ await DataWriter.removeAllBackupAttachmentDownloadJobs();
+ await resetBackupMediaDownloadItems();
+}
+
+export async function resetBackupMediaDownload(): Promise {
+ await resetBackupMediaDownloadItems();
+}
+
+export async function dismissBackupMediaDownloadBanner(): Promise {
+ await window.storage.put('backupMediaDownloadBannerDismissed', true);
+}
diff --git a/ts/util/numbers.ts b/ts/util/numbers.ts
index 4d9a5abd35..67185d5839 100644
--- a/ts/util/numbers.ts
+++ b/ts/util/numbers.ts
@@ -57,3 +57,23 @@ export function safeParseBigint(
}
return BigInt(value);
}
+
+export function roundFractionForProgressBar(fractionComplete: number): number {
+ if (fractionComplete <= 0) {
+ return 0;
+ }
+
+ if (fractionComplete >= 1) {
+ return 1;
+ }
+
+ if (fractionComplete <= 0.01) {
+ return 0.01;
+ }
+
+ if (fractionComplete >= 0.99) {
+ return 0.99;
+ }
+
+ return Math.round(fractionComplete * 100) / 100;
+}