Retry dialog for errors during backup download

This commit is contained in:
ayumi-signal 2024-10-07 06:32:31 -07:00 committed by GitHub
parent 6e1fd5958e
commit 12f28448b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 202 additions and 11 deletions

View file

@ -4723,6 +4723,34 @@
"messageformat": "Cancel transfer", "messageformat": "Cancel transfer",
"description": "Text of the confirmation button of the cancel confirmation modal in the backup import screen" "description": "Text of the confirmation button of the cancel confirmation modal in the backup import screen"
}, },
"icu:BackupImportScreen__error__title": {
"messageformat": "Error transferring your messages",
"description": "Title of the error modal in the backup import screen"
},
"icu:BackupImportScreen__error__body": {
"messageformat": "Your messages could not be transferred. Check your internet connection and try again.",
"description": "Body of the error modal in the backup import screen"
},
"icu:BackupImportScreen__error__confirm": {
"messageformat": "Retry",
"description": "Text of the retry button of the error modal in the backup import screen"
},
"icu:BackupImportScreen__skip": {
"messageformat": "Skip",
"description": "Text of the Skip button in the error and skip confirmation modals in the backup import screen"
},
"icu:BackupImportScreen__skip-confirmation__title": {
"messageformat": "Skip message transfer?",
"description": "Title of the cancel confirmation modal in the backup import screen"
},
"icu:BackupImportScreen__skip-confirmation__body": {
"messageformat": "If you choose to skip, you wont have access to any of your messages or media on this device. You can start a new transfer after skipping from Settings > Chats > Transfer.",
"description": "Body of the cancel confirmation modal in the backup import screen"
},
"icu:BackupImportScreen__skip-confirmation__cancel": {
"messageformat": "Cancel",
"description": "Text of the cancel button of the skip confirmation modal in the backup import screen"
},
"icu:BackupMediaDownloadProgress__title-in-progress": { "icu:BackupMediaDownloadProgress__title-in-progress": {
"messageformat": "Restoring media", "messageformat": "Restoring media",
"description": "Label next to a progress bar showing active media (attachment) download progress after restoring from backup" "description": "Label next to a progress bar showing active media (attachment) download progress after restoring from backup"

View file

@ -21,6 +21,7 @@ const Template: StoryFn<PropsType> = (args: PropsType) => (
{...args} {...args}
i18n={i18n} i18n={i18n}
onCancel={action('onCancel')} onCancel={action('onCancel')}
onRetry={action('onRetry')}
/> />
); );
@ -41,3 +42,10 @@ Full.args = {
currentBytes: 1024, currentBytes: 1024,
totalBytes: 1024, totalBytes: 1024,
}; };
export const Error = Template.bind({});
Error.args = {
currentBytes: 500 * 1024,
totalBytes: 1024 * 1024,
hasError: true,
};

View file

@ -18,16 +18,21 @@ export type PropsType = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
currentBytes?: number; currentBytes?: number;
totalBytes?: number; totalBytes?: number;
hasError?: boolean;
onCancel: () => void; onCancel: () => void;
onRetry: () => void;
}>; }>;
export function InstallScreenBackupImportStep({ export function InstallScreenBackupImportStep({
i18n, i18n,
currentBytes, currentBytes,
totalBytes, totalBytes,
hasError,
onCancel, onCancel,
onRetry,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const [isConfirmingCancel, setIsConfirmingCancel] = useState(false); const [isConfirmingCancel, setIsConfirmingCancel] = useState(false);
const [isConfirmingSkip, setIsConfirmingSkip] = useState(false);
const confirmCancel = useCallback(() => { const confirmCancel = useCallback(() => {
setIsConfirmingCancel(true); setIsConfirmingCancel(true);
@ -42,6 +47,24 @@ export function InstallScreenBackupImportStep({
setIsConfirmingCancel(false); setIsConfirmingCancel(false);
}, [onCancel]); }, [onCancel]);
const confirmSkip = useCallback(() => {
setIsConfirmingSkip(true);
}, []);
const abortSkip = useCallback(() => {
setIsConfirmingSkip(false);
}, []);
const onSkipWrap = useCallback(() => {
onCancel();
setIsConfirmingSkip(false);
}, [onCancel]);
const onRetryWrap = useCallback(() => {
onRetry();
setIsConfirmingSkip(false);
}, [onRetry]);
let progress: JSX.Element; let progress: JSX.Element;
let isCancelPossible = true; let isCancelPossible = true;
if (currentBytes != null && totalBytes != null) { if (currentBytes != null && totalBytes != null) {
@ -79,6 +102,7 @@ export function InstallScreenBackupImportStep({
</> </>
); );
} }
return ( return (
<div className="InstallScreenBackupImportStep"> <div className="InstallScreenBackupImportStep">
<TitlebarDragArea /> <TitlebarDragArea />
@ -127,6 +151,44 @@ export function InstallScreenBackupImportStep({
{i18n('icu:BackupImportScreen__cancel-confirmation__body')} {i18n('icu:BackupImportScreen__cancel-confirmation__body')}
</ConfirmationDialog> </ConfirmationDialog>
)} )}
{isConfirmingSkip && (
<ConfirmationDialog
dialogName="InstallScreenBackupImportStep.confirmSkip"
title={i18n('icu:BackupImportScreen__skip-confirmation__title')}
cancelText={i18n('icu:BackupImportScreen__skip-confirmation__cancel')}
actions={[
{
action: onSkipWrap,
style: 'affirmative',
text: i18n('icu:BackupImportScreen__skip'),
},
]}
i18n={i18n}
onClose={abortSkip}
>
{i18n('icu:BackupImportScreen__skip-confirmation__body')}
</ConfirmationDialog>
)}
{hasError && !isConfirmingSkip && (
<ConfirmationDialog
dialogName="InstallScreenBackupImportStep.error"
title={i18n('icu:BackupImportScreen__error__title')}
cancelText={i18n('icu:BackupImportScreen__skip')}
actions={[
{
action: onRetryWrap,
style: 'affirmative',
text: i18n('icu:BackupImportScreen__error__confirm'),
},
]}
i18n={i18n}
onClose={confirmSkip}
>
{i18n('icu:BackupImportScreen__error__body')}
</ConfirmationDialog>
)}
</div> </div>
); );
} }

View file

@ -39,6 +39,9 @@ import { BackupCredentials } from './credentials';
import { BackupAPI, type DownloadOptionsType } from './api'; import { BackupAPI, type DownloadOptionsType } from './api';
import { validateBackup } from './validator'; import { validateBackup } from './validator';
import { BackupType } from './types'; import { BackupType } from './types';
import type { ExplodePromiseResultType } from '../../util/explodePromise';
import { explodePromise } from '../../util/explodePromise';
import type { RetryBackupImportValue } from '../../state/ducks/installer';
export { BackupType }; export { BackupType };
@ -50,6 +53,9 @@ export class BackupsService {
private isStarted = false; private isStarted = false;
private isRunning = false; private isRunning = false;
private downloadController: AbortController | undefined; private downloadController: AbortController | undefined;
private downloadRetryPromise:
| ExplodePromiseResultType<RetryBackupImportValue>
| undefined;
public readonly credentials = new BackupCredentials(); public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials); public readonly api = new BackupAPI(this.credentials);
@ -87,14 +93,50 @@ export class BackupsService {
const absoluteDownloadPath = const absoluteDownloadPath =
window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath); window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath);
let hasBackup = false;
log.info('backups.download: downloading...'); log.info('backups.download: downloading...');
const hasBackup = await this.doDownload(absoluteDownloadPath, options);
// eslint-disable-next-line no-constant-condition
while (true) {
try {
// eslint-disable-next-line no-await-in-loop
hasBackup = await this.doDownload(absoluteDownloadPath, options);
} catch (error) {
log.info('backups.download: error, prompting user to retry');
this.downloadRetryPromise = explodePromise<RetryBackupImportValue>();
window.reduxActions.installer.updateBackupImportProgress({
hasError: true,
});
// eslint-disable-next-line no-await-in-loop
const nextStep = await this.downloadRetryPromise.promise;
if (nextStep === 'retry') {
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await unlink(absoluteDownloadPath);
} catch {
// Best-effort
}
}
break;
}
await window.storage.remove('backupDownloadPath'); await window.storage.remove('backupDownloadPath');
log.info(`backups.download: done, had backup=${hasBackup}`); log.info(`backups.download: done, had backup=${hasBackup}`);
} }
public retryDownload(): void {
if (!this.downloadRetryPromise) {
return;
}
this.downloadRetryPromise.resolve('retry');
}
public async upload(): Promise<void> { public async upload(): Promise<void> {
const fileName = `backup-${randomBytes(32).toString('hex')}`; const fileName = `backup-${randomBytes(32).toString('hex')}`;
const filePath = join(window.BasePaths.temp, fileName); const filePath = join(window.BasePaths.temp, fileName);
@ -169,6 +211,9 @@ export class BackupsService {
log.warn('importBackup: canceling download'); log.warn('importBackup: canceling download');
this.downloadController.abort(); this.downloadController.abort();
this.downloadController = undefined; this.downloadController = undefined;
if (this.downloadRetryPromise) {
this.downloadRetryPromise.resolve('cancel');
}
} else { } else {
log.error('importBackup: not canceling download, not running'); log.error('importBackup: not canceling download, not running');
} }
@ -365,11 +410,7 @@ export class BackupsService {
return false; return false;
} }
try { // Other errors bubble up and can be retried
await unlink(downloadPath);
} catch {
// Best-effort
}
throw error; throw error;
} }

View file

@ -26,6 +26,7 @@ import {
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { backupsService } from '../../services/backups';
const SLEEP_ERROR = new TimeoutError(); const SLEEP_ERROR = new TimeoutError();
@ -63,14 +64,18 @@ export type InstallerStateType = ReadonlyDeep<
step: InstallScreenStep.BackupImport; step: InstallScreenStep.BackupImport;
currentBytes?: number; currentBytes?: number;
totalBytes?: number; totalBytes?: number;
hasError?: boolean;
} }
>; >;
export type RetryBackupImportValue = ReadonlyDeep<'retry' | 'cancel'>;
export const START_INSTALLER = 'installer/START_INSTALLER'; export const START_INSTALLER = 'installer/START_INSTALLER';
const SET_PROVISIONING_URL = 'installer/SET_PROVISIONING_URL'; const SET_PROVISIONING_URL = 'installer/SET_PROVISIONING_URL';
const SET_QR_CODE_ERROR = 'installer/SET_QR_CODE_ERROR'; const SET_QR_CODE_ERROR = 'installer/SET_QR_CODE_ERROR';
const SET_ERROR = 'installer/SET_ERROR'; const SET_ERROR = 'installer/SET_ERROR';
const QR_CODE_SCANNED = 'installer/QR_CODE_SCANNED'; const QR_CODE_SCANNED = 'installer/QR_CODE_SCANNED';
const RETRY_BACKUP_IMPORT = 'installer/RETRY_BACKUP_IMPORT';
const SHOW_LINK_IN_PROGRESS = 'installer/SHOW_LINK_IN_PROGRESS'; const SHOW_LINK_IN_PROGRESS = 'installer/SHOW_LINK_IN_PROGRESS';
export const SHOW_BACKUP_IMPORT = 'installer/SHOW_BACKUP_IMPORT'; export const SHOW_BACKUP_IMPORT = 'installer/SHOW_BACKUP_IMPORT';
const UPDATE_BACKUP_IMPORT_PROGRESS = 'installer/UPDATE_BACKUP_IMPORT_PROGRESS'; const UPDATE_BACKUP_IMPORT_PROGRESS = 'installer/UPDATE_BACKUP_IMPORT_PROGRESS';
@ -103,6 +108,10 @@ type QRCodeScannedActionType = ReadonlyDeep<{
}; };
}>; }>;
type RetryBackupImportActionType = ReadonlyDeep<{
type: typeof RETRY_BACKUP_IMPORT;
}>;
type ShowLinkInProgressActionType = ReadonlyDeep<{ type ShowLinkInProgressActionType = ReadonlyDeep<{
type: typeof SHOW_LINK_IN_PROGRESS; type: typeof SHOW_LINK_IN_PROGRESS;
}>; }>;
@ -113,10 +122,14 @@ export type ShowBackupImportActionType = ReadonlyDeep<{
type UpdateBackupImportProgressActionType = ReadonlyDeep<{ type UpdateBackupImportProgressActionType = ReadonlyDeep<{
type: typeof UPDATE_BACKUP_IMPORT_PROGRESS; type: typeof UPDATE_BACKUP_IMPORT_PROGRESS;
payload: { payload:
currentBytes: number; | {
totalBytes: number; currentBytes: number;
}; totalBytes: number;
}
| {
hasError: boolean;
};
}>; }>;
export type InstallerActionType = ReadonlyDeep< export type InstallerActionType = ReadonlyDeep<
@ -125,6 +138,7 @@ export type InstallerActionType = ReadonlyDeep<
| SetQRCodeErrorActionType | SetQRCodeErrorActionType
| SetErrorActionType | SetErrorActionType
| QRCodeScannedActionType | QRCodeScannedActionType
| RetryBackupImportActionType
| ShowLinkInProgressActionType | ShowLinkInProgressActionType
| ShowBackupImportActionType | ShowBackupImportActionType
| UpdateBackupImportProgressActionType | UpdateBackupImportProgressActionType
@ -134,6 +148,7 @@ export const actions = {
startInstaller, startInstaller,
finishInstall, finishInstall,
updateBackupImportProgress, updateBackupImportProgress,
retryBackupImport,
showBackupImport, showBackupImport,
showLinkInProgress, showLinkInProgress,
}; };
@ -414,6 +429,18 @@ function updateBackupImportProgress(
return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload }; return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload };
} }
function retryBackupImport(): ThunkAction<
void,
RootStateType,
unknown,
RetryBackupImportActionType
> {
return dispatch => {
dispatch({ type: RETRY_BACKUP_IMPORT });
backupsService.retryDownload();
};
}
// Reducer // Reducer
export function getEmptyState(): InstallerStateType { export function getEmptyState(): InstallerStateType {
@ -546,6 +573,13 @@ export function reducer(
return state; return state;
} }
if ('hasError' in action.payload) {
return {
...state,
hasError: action.payload.hasError,
};
}
return { return {
...state, ...state,
currentBytes: action.payload.currentBytes, currentBytes: action.payload.currentBytes,
@ -553,5 +587,20 @@ export function reducer(
}; };
} }
if (action.type === RETRY_BACKUP_IMPORT) {
if (state.step !== InstallScreenStep.BackupImport) {
log.warn(
'ducks/installer: wrong step, not retrying backup import',
state.step
);
return state;
}
return {
...state,
hasError: false,
};
}
return state; return state;
} }

View file

@ -30,7 +30,8 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
const installerState = useSelector(getInstallerState); const installerState = useSelector(getInstallerState);
const updates = useSelector(getUpdatesState); const updates = useSelector(getUpdatesState);
const { openInbox } = useAppActions(); const { openInbox } = useAppActions();
const { startInstaller, finishInstall } = useInstallerActions(); const { startInstaller, finishInstall, retryBackupImport } =
useInstallerActions();
const { startUpdate } = useUpdatesActions(); const { startUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector); const hasExpired = useSelector(hasExpiredSelector);
@ -110,7 +111,9 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
i18n, i18n,
currentBytes: installerState.currentBytes, currentBytes: installerState.currentBytes,
totalBytes: installerState.totalBytes, totalBytes: installerState.totalBytes,
hasError: installerState.hasError,
onCancel: onCancelBackupImport, onCancel: onCancelBackupImport,
onRetry: retryBackupImport,
}, },
}; };
break; break;