diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 89ebe3c8e..4f7caf444 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4803,6 +4803,10 @@ "messageformat": "Finalizing message transfer...", "description": "Hint under the progressbar in the backup import screen shown after the backup file is downloaded and while backup data is being processed" }, + "icu:BackupImportScreen__progressbar-hint--canceling": { + "messageformat": "Canceling...", + "description": "Hint under the progressbar in the backup import screen after user cancels" + }, "icu:BackupImportScreen__progressbar-hint--preparing": { "messageformat": "Preparing to download...", "description": "Hint under the progressbar in the backup import screen when download size is not yet known" @@ -4820,19 +4824,19 @@ "description": "Text shown while importing messages & chats from the user's primary device." }, "icu:BackupImportScreen__cancel-confirmation__title": { - "messageformat": "Cancel transfer?", + "messageformat": "Cancel device linking?", "description": "Title of the cancel confirmation modal in the backup import screen" }, "icu:BackupImportScreen__cancel-confirmation__body": { - "messageformat": "Your messages and media have not completed restoring. If you choose to cancel, your device will be linked without your message history.", + "messageformat": "If you choose to cancel, this device will not be linked and no messages or media will be transferred.", "description": "Body of the cancel confirmation modal in the backup import screen" }, "icu:BackupImportScreen__cancel-confirmation__cancel": { - "messageformat": "Continue transfer", + "messageformat": "Continue linking", "description": "Text of the continue button of the cancel confirmation modal in the backup import screen" }, "icu:BackupImportScreen__cancel-confirmation__confirm": { - "messageformat": "Cancel transfer", + "messageformat": "Cancel linking", "description": "Text of the confirmation button of the cancel confirmation modal in the backup import screen" }, "icu:BackupImportScreen__error__title": { diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx index a6d9e876f..e569f386b 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx @@ -147,6 +147,14 @@ FatalError.args = { error: InstallScreenBackupError.Fatal, }; +export const Canceled = Template.bind({}); +Canceled.args = { + backupStep: InstallScreenBackupStep.Process, + currentBytes: 500 * 1024, + totalBytes: 1024 * 1024, + error: InstallScreenBackupError.Canceled, +}; + export const UnsupportedVersion = Template.bind({}); UnsupportedVersion.args = { backupStep: InstallScreenBackupStep.Process, diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.tsx index ff8ca4169..f6629c26e 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.tsx @@ -69,7 +69,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element { } = props; const [isConfirmingCancel, setIsConfirmingCancel] = useState(false); - const [isConfirmingSkip, setIsConfirmingSkip] = useState(false); const confirmCancel = useCallback(() => { setIsConfirmingCancel(true); @@ -84,22 +83,9 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element { setIsConfirmingCancel(false); }, [onCancel]); - const confirmSkip = useCallback(() => { - setIsConfirmingSkip(true); - }, []); - - const abortSkip = useCallback(() => { - setIsConfirmingSkip(false); - }, []); - - const onSkipWrap = useCallback(() => { - onCancel(); - setIsConfirmingSkip(false); - }, [onCancel]); - const onRetryWrap = useCallback(() => { onRetry(); - setIsConfirmingSkip(false); + setIsConfirmingCancel(false); }, [onRetry]); const learnMoreLink = (parts: Array) => ( @@ -109,7 +95,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element { ); let errorElem: JSX.Element | undefined; - if (error == null) { + if (error == null || error === InstallScreenBackupError.Canceled) { // no-op } else if (error === InstallScreenBackupError.UnsupportedVersion) { errorElem = ( @@ -120,17 +106,19 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element { startUpdate={startUpdate} forceUpdate={forceUpdate} currentVersion={currentVersion} - onClose={confirmSkip} + onClose={confirmCancel} OS={OS} /> ); } else if (error === InstallScreenBackupError.Retriable) { - if (!isConfirmingSkip) { + if (!isConfirmingCancel) { errorElem = ( {i18n('icu:BackupImportScreen__error__body')} @@ -170,6 +158,24 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element { throw missingCaseError(error); } + const isCanceled = error === InstallScreenBackupError.Canceled; + let cancelButton: JSX.Element | undefined; + if ( + !isCanceled && + (backupStep === InstallScreenBackupStep.Download || + backupStep === InstallScreenBackupStep.Process) + ) { + cancelButton = ( + + ); + } + return (
@@ -178,10 +184,12 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {

{i18n('icu:BackupImportScreen__title')}

- -
- {i18n('icu:BackupImportScreen__description')} -
+ + {!isCanceled && ( +
+ {i18n('icu:BackupImportScreen__description')} +
+ )}
@@ -195,15 +203,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
- {backupStep === InstallScreenBackupStep.Download && ( - - )} + {cancelButton} {isConfirmingCancel && ( @@ -229,25 +229,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element { )} - {isConfirmingSkip && ( - - {i18n('icu:BackupImportScreen__skip-confirmation__body')} - - )} - {errorElem} ); @@ -256,6 +237,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element { type ProgressBarPropsType = Readonly< { i18n: LocalizerType; + isCanceled: boolean; } & ( | { backupStep: InstallScreenBackupStep.WaitForBackup; @@ -271,7 +253,7 @@ type ProgressBarPropsType = Readonly< >; function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element { - const { backupStep, i18n } = props; + const { backupStep, i18n, isCanceled } = props; if (backupStep === InstallScreenBackupStep.WaitForBackup) { return ( <> @@ -292,6 +274,20 @@ function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element { currentBytes / totalBytes ); + if (isCanceled) { + return ( + <> + +
+ {i18n('icu:BackupImportScreen__progressbar-hint--canceling')} +
+ + ); + } + if (backupStep === InstallScreenBackupStep.Download) { return ( <> diff --git a/ts/services/backups/errors.ts b/ts/services/backups/errors.ts index 55b76755c..d5f82e870 100644 --- a/ts/services/backups/errors.ts +++ b/ts/services/backups/errors.ts @@ -14,6 +14,8 @@ export class BackupDownloadFailedError extends Error {} export class BackupProcessingError extends Error {} +export class BackupImportCanceledError extends Error {} + export class RelinkRequestedError extends Error {} export class ContinueWithoutSyncingError extends Error {} diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index ca2adf447..18668b52f 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -51,6 +51,7 @@ import { validateBackup } from './validator'; import { BackupType } from './types'; import { BackupDownloadFailedError, + BackupImportCanceledError, BackupProcessingError, ContinueWithoutSyncingError, RelinkRequestedError, @@ -93,6 +94,7 @@ export type ImportOptionsType = Readonly<{ export class BackupsService { #isStarted = false; #isRunning: 'import' | 'export' | false = false; + #importController: AbortController | undefined; #downloadController: AbortController | undefined; #downloadRetryPromise: @@ -152,14 +154,14 @@ export class BackupsService { this.#downloadRetryPromise = explodePromise(); let installerError: InstallScreenBackupError; + let shouldUnlinkAndDeleteData = false; if (error instanceof RelinkRequestedError) { installerError = InstallScreenBackupError.Fatal; log.error( 'backups.downloadAndImport: primary requested relink; unlinking & deleting data', Errors.toLogFormat(error) ); - // eslint-disable-next-line no-await-in-loop - await this.#unlinkAndDeleteAllData(); + shouldUnlinkAndDeleteData = true; } else if (error instanceof UnsupportedBackupVersion) { installerError = InstallScreenBackupError.UnsupportedVersion; log.error( @@ -178,8 +180,13 @@ export class BackupsService { 'backups.downloadAndImport: fatal error during processing; unlinking & deleting data', Errors.toLogFormat(error) ); - // eslint-disable-next-line no-await-in-loop - await this.#unlinkAndDeleteAllData(); + shouldUnlinkAndDeleteData = true; + } else if (error instanceof BackupImportCanceledError) { + installerError = InstallScreenBackupError.Canceled; + log.info( + 'backups.downloadAndImport: Processing canceled by user; unlinking & deleting data' + ); + shouldUnlinkAndDeleteData = true; } else { log.error( 'backups.downloadAndImport: unknown error, prompting user to retry' @@ -191,10 +198,20 @@ export class BackupsService { error: installerError, }); + // Deleting data takes some time + if (shouldUnlinkAndDeleteData) { + // eslint-disable-next-line no-await-in-loop + await this.#unlinkAndDeleteAllData(); + } + + // For download errors, wait for user confirmation to retry or unlink // eslint-disable-next-line no-await-in-loop const nextStep = await this.#downloadRetryPromise.promise; if (nextStep === 'retry') { continue; + } else if (nextStep === 'cancel') { + // eslint-disable-next-line no-await-in-loop + await this.#unlinkAndDeleteAllData(); } try { @@ -211,6 +228,7 @@ export class BackupsService { await window.storage.remove('backupEphemeralKey'); await window.storage.put('isRestoredFromBackup', hasBackup); + // If the primary cancels sync on their end, then we can link without sync if (!hasBackup) { window.reduxActions.installer.handleMissingBackup(); } @@ -318,16 +336,27 @@ export class BackupsService { return this.importBackup(() => createReadStream(backupFile), options); } - public cancelDownload(): void { + public cancelDownloadAndImport(): void { + if (!this.#downloadController && !this.#importController) { + log.error( + 'cancelDownloadAndImport: not canceling, download or import is not running' + ); + return; + } + if (this.#downloadController) { - log.warn('importBackup: canceling download'); + log.warn('cancelDownloadAndImport: canceling download'); this.#downloadController.abort(); this.#downloadController = undefined; if (this.#downloadRetryPromise) { this.#downloadRetryPromise.resolve('cancel'); } - } else { - log.error('importBackup: not canceling download, not running'); + } + + if (this.#importController) { + log.warn('cancelDownloadAndImport: canceling import processing'); + this.#importController.abort(); + this.#importController = undefined; } } @@ -350,6 +379,11 @@ export class BackupsService { await DataWriter.disableMessageInsertTriggers(); try { + const controller = new AbortController(); + + this.#importController?.abort(); + this.#importController = controller; + window.ConversationController.setReadOnly(true); const importStream = await BackupImportStream.create(backupType); @@ -378,6 +412,10 @@ export class BackupsService { sink ); + if (controller.signal.aborted) { + throw new BackupImportCanceledError(); + } + onProgress?.(0, totalBytes); strictAssert(theirMac != null, 'importBackup: Missing MAC'); @@ -405,7 +443,8 @@ export class BackupsService { getIvAndDecipher(aesKey), createGunzip(), new DelimitedStream(), - importStream + importStream, + { signal: controller.signal } ); strictAssert( @@ -432,7 +471,12 @@ export class BackupsService { log.info('importBackup: finished...'); } catch (error) { - log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`); + if (error.name === 'AbortError') { + log.info('importBackup: canceled by user'); + throw new BackupImportCanceledError(); + } + + log.error(`importBackup: failed, error: ${Errors.toLogFormat(error)}`); if (isNightly(window.getVersion()) || isAdhoc(window.getVersion())) { window.reduxActions.toast.showToast({ @@ -444,6 +488,8 @@ export class BackupsService { } finally { window.ConversationController.setReadOnly(false); this.#isRunning = false; + this.#importController = undefined; + await DataWriter.enableMessageInsertTriggersAndBackfill(); window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' }); @@ -531,7 +577,7 @@ export class BackupsService { await ensureFile(downloadPath); if (controller.signal.aborted) { - return false; + throw new BackupImportCanceledError(); } let stream: Readable; @@ -551,7 +597,7 @@ export class BackupsService { } } catch (error) { if (controller.signal.aborted) { - return false; + throw new BackupImportCanceledError(); } // No backup on the server @@ -580,7 +626,7 @@ export class BackupsService { } if (controller.signal.aborted) { - return false; + throw new BackupImportCanceledError(); } await pipeline( @@ -592,14 +638,14 @@ export class BackupsService { ); if (controller.signal.aborted) { - return false; + throw new BackupImportCanceledError(); } this.#downloadController = undefined; try { - // Too late to cancel now, make sure we are unlinked if the process - // is aborted due to error or restart. + // Import and start writing to the DB. Make sure we are unlinked + // if the import process is aborted due to error or restart. const password = window.storage.get('password'); strictAssert(password != null, 'Must be registered to import backup'); @@ -619,15 +665,19 @@ export class BackupsService { // Restore password on success await window.storage.put('password', password); } catch (e) { - // Error during import; this is non-retriable - throw new BackupProcessingError(); + // Error or manual cancel during import; this is non-retriable + if (e instanceof BackupImportCanceledError) { + throw e; + } else { + throw new BackupProcessingError(); + } } finally { await unlink(downloadPath); } } catch (error) { // Download canceled if (error.name === 'AbortError') { - return false; + throw new BackupImportCanceledError(); } // Other errors bubble up and can be retried @@ -731,6 +781,10 @@ export class BackupsService { Errors.toLogFormat(e) ); } + + // The QR code should be regenerated only after all data is cleared to prevent + // a race where the QR code doesn't show the backup capability + window.reduxActions.installer.startInstaller(); } public isImportRunning(): boolean { diff --git a/ts/state/smart/InstallScreen.tsx b/ts/state/smart/InstallScreen.tsx index e6a381410..250e6951f 100644 --- a/ts/state/smart/InstallScreen.tsx +++ b/ts/state/smart/InstallScreen.tsx @@ -8,7 +8,6 @@ import { useSelector } from 'react-redux'; import { getIntl } from '../selectors/user'; import { getUpdatesState } from '../selectors/updates'; import { getInstallerState } from '../selectors/installer'; -import { useAppActions } from '../ducks/app'; import { useInstallerActions } from '../ducks/installer'; import { useUpdatesActions } from '../ducks/updates'; import { hasExpired as hasExpiredSelector } from '../selectors/expiration'; @@ -29,7 +28,6 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { const i18n = useSelector(getIntl); const installerState = useSelector(getInstallerState); const updates = useSelector(getUpdatesState); - const { openInbox } = useAppActions(); const { startInstaller, finishInstall, retryBackupImport } = useInstallerActions(); const { startUpdate, forceUpdate } = useUpdatesActions(); @@ -56,11 +54,8 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { }, [backupFile, deviceName, finishInstall]); const onCancelBackupImport = useCallback((): void => { - backupsService.cancelDownload(); - if (installerState.step === InstallScreenStep.BackupImport) { - openInbox(); - } - }, [installerState.step, openInbox]); + backupsService.cancelDownloadAndImport(); + }, []); const suggestedDeviceName = installerState.step === InstallScreenStep.ChoosingDeviceName diff --git a/ts/types/InstallScreen.ts b/ts/types/InstallScreen.ts index a1e006400..76c0168e9 100644 --- a/ts/types/InstallScreen.ts +++ b/ts/types/InstallScreen.ts @@ -22,6 +22,7 @@ export enum InstallScreenBackupError { UnsupportedVersion = 'UnsupportedVersion', Retriable = 'Retriable', Fatal = 'Fatal', + Canceled = 'Canceled', } export enum InstallScreenError {