Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Registration/RegistrationCoordinatorBackupErrorPresenter.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
public import SafariServices
import SignalServiceKit
import SignalUI

public enum RegistrationBackupRestoreError {
    case generic
    case backupNotFound
    case incorrectRecoveryKey
    case versionMismatch
    case retryableSVRBError
    case unretryableSVRBError
    case networkError
    case rateLimited
    case cancellation
}

public enum RegistrationBackupErrorNextStep {
    case skipRestore
    case incorrectRecoveryKey
    case tryAgain
    case restartQuickRestore
    case rateLimited
}

public protocol RegistrationCoordinatorBackupErrorPresenter {
    func mapToRegistrationError(
        error: Error,
    ) -> RegistrationBackupRestoreError

    func presentError(
        error: RegistrationBackupRestoreError,
        isQuickRestore: Bool,
    ) async -> RegistrationBackupErrorNextStep?
}

public class RegistrationCoordinatorBackupErrorPresenterImpl:
    NSObject,
    RegistrationCoordinatorBackupErrorPresenter,
    SFSafariViewControllerDelegate
{
    public func mapToRegistrationError(error: Error) -> RegistrationBackupRestoreError {
        switch error {
        case let httpError as OWSHTTPError where httpError.responseStatusCode == 429:
            return .rateLimited
        case _ where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse:
            return .networkError
        case _ where error is BackupKeyMaterialError:
            // Missing recovery key
            return .incorrectRecoveryKey
        case BackupAuthCredentialFetchError.noExistingBackupId:
            // Usually because backups haven't been set up yet.
            return .backupNotFound
        case SignalError.verificationFailed:
            return .incorrectRecoveryKey
        case _ where error is SignalError:
            // LibSignalError (e.g. - creating credentials)
            return .incorrectRecoveryKey
        case let httpError as OWSHTTPError where httpError.responseStatusCode == 401:
            // Incorrect AEP
            // -- and/or --
            // No public key registered for this backupID (AEP+ACI)
            return .incorrectRecoveryKey
        case let httpError as OWSHTTPError where httpError.responseStatusCode == 404:
            // No backup found in the CDN
            return .backupNotFound
        case _ where error is DecodingError:
            fallthrough
        case _ where error is EncodingError:
            return .generic
        case _ where error is CancellationError:
            // Consider this a timeout, try again
            return .cancellation
        case BackupImportError.unsupportedVersion:
            return .versionMismatch
        case let error as SVRBError:
            switch error {
            case .retryableAutomatically, .retryableByUser:
                return .retryableSVRBError
            case .unrecoverable:
                return .unretryableSVRBError
            case .incorrectRecoveryKey:
                return .incorrectRecoveryKey
            case .cancellationError:
                return .cancellation
            }
        default:
            return .generic
        }
    }

    @MainActor
    public func presentError(
        error: RegistrationBackupRestoreError,
        isQuickRestore: Bool,
    ) async -> RegistrationBackupErrorNextStep? {
        guard let topMostVc = UIApplication.shared.frontmostViewController else {
            return nil
        }
        return await withCheckedContinuation { continuation in
            presentError(
                error: error,
                isQuickRestore: isQuickRestore,
                from: topMostVc,
                continuation: continuation,
            )
        }
    }

    @MainActor
    private func presentError(
        error: RegistrationBackupRestoreError,
        isQuickRestore: Bool,
        from presenter: UIViewController,
        continuation: CheckedContinuation<RegistrationBackupErrorNextStep?, Never>,
    ) {
        let title: String
        let message: String
        let tryAgainString = OWSLocalizedString(
            "REGISTRATION_BACKUP_RESTORE_ERROR_TRY_AGAIN_ACTION",
            comment: "Try again action label for backup restore error recovery.",
        )
        let skipRestoreString = OWSLocalizedString(
            "REGISTRATION_BACKUP_RESTORE_ERROR_SKIP_RESTORE_ACTION",
            comment: "Skip restore action label for backup restore error recovery.",
        )
        let checkUpdateString = OWSLocalizedString(
            "REGISTRATION_BACKUP_RESTORE_ERROR_CHECK_UPDATE_ACTION",
            comment: "Check for update action label for backup restore error recovery.",
        )

        var actions = [ActionSheetAction]()

        switch error {
        case
            .generic where isQuickRestore,
            .incorrectRecoveryKey where isQuickRestore:
            // If this is QuickRestore flow, present message about re-scanning
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_GENERIC_RETRY_TITLE",
                comment: "Title for a sheet warning users about a generic backup restore failure.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_GENERIC_RETRY_BODY",
                comment: "Body for a sheet warning users about a generic backup restore failure.",
            )
            actions.append(ActionSheetAction(title: CommonStrings.okButton) { _ in
                continuation.resume(returning: .restartQuickRestore)
            })
        case .generic:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_GENERIC_TITLE",
                comment: "Title for a sheet warning users about a generic backup manual restore failure.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_GENERIC_BODY",
                comment: "Body for a sheet warning users about a generic backup manual restore failure.",
            )
            actions.append(ActionSheetAction(title: tryAgainString) { _ in
                continuation.resume(returning: .tryAgain)
            })
            actions.append(ActionSheetAction(title: skipRestoreString) { _ in
                continuation.resume(returning: .skipRestore)
            })
        case .backupNotFound:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_BACKUP_NOT_FOUND_TITLE",
                comment: "Title for a sheet warning users about a missing backup.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_BACKUP_NOT_FOUND_BODY",
                comment: "Body for a sheet warning users about a missing backup.",
            )
            actions.append(ActionSheetAction(title: skipRestoreString) { _ in
                continuation.resume(returning: .skipRestore)
            })
            actions.append(ActionSheetAction(title: tryAgainString) { _ in
                continuation.resume(returning: .incorrectRecoveryKey)
            })
        case .rateLimited:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_INCORRECT_KEY_TITLE",
                comment: "Title for a sheet warning users about an incorrect recovery key.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_INCORRECT_KEY_RATE_LIMIT_BODY",
                comment: "Body for a sheet warning users about an incorrect recovery key and having hit a rate limit on retries.",
            )
            actions.append(ActionSheetAction(title: CommonStrings.okButton) { _ in
                continuation.resume(returning: .rateLimited)
            })
            actions.append(ActionSheetAction(title: CommonStrings.help) { _ in
                self.presentSupportArticle(
                    url: URL.Support.backups,
                    presenter: presenter,
                ) {
                    self.presentError(
                        error: error,
                        isQuickRestore: isQuickRestore,
                        from: presenter,
                        continuation: continuation,
                    )
                }
            })
        case .incorrectRecoveryKey:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_INCORRECT_KEY_TITLE",
                comment: "Title for a sheet warning users about an incorrect recovery key.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_INCORRECT_KEY_BODY",
                comment: "Body for a sheet warning users about an incorrect recovery key.",
            )
            actions.append(ActionSheetAction(title: tryAgainString) { _ in
                continuation.resume(returning: .incorrectRecoveryKey)
            })
            actions.append(ActionSheetAction(title: CommonStrings.help) { _ in
                self.presentSupportArticle(
                    url: URL.Support.backups,
                    presenter: presenter,
                ) {
                    self.presentError(
                        error: error,
                        isQuickRestore: isQuickRestore,
                        from: presenter,
                        continuation: continuation,
                    )
                }
            })
        case .versionMismatch:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_UNSUPPORTED_BACKUP_VERSION_TITLE",
                comment: "Title for a sheet warning users about an incompatible backup version.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_UNSUPPORTED_BACKUP_VERSION_BODY",
                comment: "Body for a sheet warning users about an incompatible backup version.",
            )

            actions.append(ActionSheetAction(title: checkUpdateString) { _ in
                self.presentAppStorePage {
                    self.presentError(
                        error: error,
                        isQuickRestore: isQuickRestore,
                        from: presenter,
                        continuation: continuation,
                    )
                }
            })
            if !isQuickRestore {
                actions.append(ActionSheetAction(title: skipRestoreString) { _ in
                    continuation.resume(returning: .skipRestore)
                })
            }
            actions.append(ActionSheetAction(title: CommonStrings.learnMore) { _ in
                self.presentSupportArticle(
                    url: URL.Support.backups,
                    presenter: presenter,
                ) {
                    self.presentError(
                        error: error,
                        isQuickRestore: isQuickRestore,
                        from: presenter,
                        continuation: continuation,
                    )
                }
            })
        case .retryableSVRBError, .cancellation:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_RETRYABLE_SERVER_ERROR_TITLE",
                comment: "Title for a sheet telling users to try restoring a backup again after a server error.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_RETRYABLE_SERVER_ERROR_BODY",
                comment: "Body for a sheet telling users to try restoring a backup again after a server error.",
            )

            actions.append(ActionSheetAction(title: tryAgainString) { _ in
                continuation.resume(returning: .tryAgain)
            })
            actions.append(ActionSheetAction(title: CommonStrings.cancelButton) { _ in
                continuation.resume(returning: .skipRestore)
            })
        case .unretryableSVRBError:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_UNRETRYABLE_SERVER_ERROR_TITLE",
                comment: "Title for a sheet telling users restoring a backup unrecoverably failed.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_UNRETRYABLE_SERVER_ERROR_BODY",
                comment: "Body for a sheet telling users restoring a backup unrecoverably failed.",
            )

            actions.append(ActionSheetAction(title: CommonStrings.contactSupport) { @MainActor _ in
                Task { @MainActor in
                    self.presentContactSupportSheet(
                        emailFilter: .backupImportFailed,
                        presenter: presenter,
                    ) {
                        continuation.resume(returning: .skipRestore)
                    }
                }
            })
            actions.append(ActionSheetAction(title: CommonStrings.okButton) { _ in
                continuation.resume(returning: .skipRestore)
            })
        case .networkError:
            title = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_NETWORK_TITLE",
                comment: "Title for a sheet warning users about a network error during backup restore.",
            )
            message = OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_NETWORK_BODY",
                comment: "Body for a sheet warning users about a network error during backup restore.",
            )

            actions.append(ActionSheetAction(title: tryAgainString) { _ in
                continuation.resume(returning: .tryAgain)
            })
            actions.append(ActionSheetAction(title: skipRestoreString) { _ in
                continuation.resume(returning: .skipRestore)
            })
        }

        let actionSheet = ActionSheetController(title: title, message: message)
        actions.forEach { actionSheet.addAction($0) }
        OWSActionSheets.showActionSheet(actionSheet, fromViewController: presenter)
    }

    @MainActor
    private func presentContactSupportSheet(
        emailFilter: ContactSupportActionSheet.EmailFilter,
        presenter: UIViewController,
        completion: @escaping () -> Void,
    ) {
        let title = OWSLocalizedString(
            "REGISTRATION_BACKUP_RESTORE_ERROR_CONTACT_SUPPORT_TITLE",
            comment: "Title for a sheet informing users about contacting support due to an error during backup restore.",
        )
        let message = OWSLocalizedString(
            "REGISTRATION_BACKUP_RESTORE_ERROR_CONTACT_SUPPORT_BODY",
            comment: "Body for a sheet informing users about contacting support due to an error during backup restore.",
        )
        let actionSheet = ActionSheetController(title: title, message: message)
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_CONTACT_SUPPORT_WITH_LOGS_ACTION",
                comment: "Label for action to contact support with logs.",
            ),
        ) { _ in
            ContactSupportActionSheet.present(
                emailFilter: emailFilter,
                logDumper: .fromGlobals(),
                fromViewController: presenter,
                completion: completion,
            )
        })
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "REGISTRATION_BACKUP_RESTORE_ERROR_CONTACT_SUPPORT_WITHOUT_LOGS_ACTION",
                comment: "Label for action to contact support without logs.",
            ),
        ) { _ in
            ContactSupportActionSheet.present(
                emailFilter: emailFilter,
                logDumper: .fromGlobals(),
                fromViewController: presenter,
                completion: completion,
            )
        })
        actionSheet.addAction(ActionSheetAction(title: CommonStrings.cancelButton) { _ in
            completion()
        })

        OWSActionSheets.showActionSheet(actionSheet, fromViewController: presenter)
    }

    private var presentSupportCompletion: (() -> Void)?
    private func presentSupportArticle(
        url: URL,
        presenter: UIViewController,
        completion: @escaping () -> Void,
    ) {
        let vc = SFSafariViewController(url: url)
        vc.delegate = self
        presentSupportCompletion = completion
        presenter.present(vc, animated: true, completion: nil)
    }

    private func presentAppStorePage(
        completion: @escaping () -> Void,
    ) {
        CurrentAppContext().open(
            TSConstants.appStoreUrl,
            completion: { _ in completion() },
        )
    }

    @objc
    public func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
        presentSupportCompletion?()
        presentSupportCompletion = nil
    }
}

#if DEBUG
public class RegistrationCoordinatorBackupErrorPresenterMock: RegistrationCoordinatorBackupErrorPresenter {
    public func mapToRegistrationError(error: any Error) -> RegistrationBackupRestoreError {
        return .generic
    }

    public func presentError(error: RegistrationBackupRestoreError, isQuickRestore: Bool) async -> RegistrationBackupErrorNextStep? {
        return nil
    }
}
#endif