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

import LibSignalClient
import SignalServiceKit
import SignalUI

class ProvisioningNavigationController: OWSNavigationController {
    private(set) var provisioningController: ProvisioningController

    init(provisioningController: ProvisioningController) {
        self.provisioningController = provisioningController
        super.init()
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        let superOrientations = super.supportedInterfaceOrientations
        let provisioningOrientations: UIInterfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait

        return superOrientations.intersection(provisioningOrientations)
    }
}

class ProvisioningController: NSObject {

    private let appReadiness: AppReadinessSetter

    private lazy var registrationWebSocketManager = RegistrationWebSocketManagerImpl(
        chatConnectionManager: DependenciesBridge.shared.chatConnectionManager,
        messagePipelineSupervisor: SSKEnvironment.shared.messagePipelineSupervisorRef,
        messageProcessor: SSKEnvironment.shared.messageProcessorRef,
    )

    private lazy var provisioningCoordinator: ProvisioningCoordinator = {
        return ProvisioningCoordinatorImpl(
            chatConnectionManager: DependenciesBridge.shared.chatConnectionManager,
            db: DependenciesBridge.shared.db,
            identityManager: DependenciesBridge.shared.identityManager,
            linkAndSyncManager: DependenciesBridge.shared.linkAndSyncManager,
            accountKeyStore: DependenciesBridge.shared.accountKeyStore,
            networkManager: SSKEnvironment.shared.networkManagerRef,
            preKeyManager: DependenciesBridge.shared.preKeyManager,
            profileManager: SSKEnvironment.shared.profileManagerImplRef,
            pushRegistrationManager: ProvisioningCoordinatorImpl.Wrappers.PushRegistrationManager(AppEnvironment.shared.pushRegistrationManagerRef),
            receiptManager: ProvisioningCoordinatorImpl.Wrappers.ReceiptManager(SSKEnvironment.shared.receiptManagerRef),
            registrationStateChangeManager: DependenciesBridge.shared.registrationStateChangeManager,
            registrationWebSocketManager: registrationWebSocketManager,
            signalProtocolStoreManager: DependenciesBridge.shared.signalProtocolStoreManager,
            signalService: SSKEnvironment.shared.signalServiceRef,
            storageServiceManager: SSKEnvironment.shared.storageServiceManagerRef,
            svr: DependenciesBridge.shared.svr,
            syncManager: SSKEnvironment.shared.syncManagerRef,
            threadStore: ThreadStoreImpl(),
            tsAccountManager: DependenciesBridge.shared.tsAccountManager,
            udManager: SSKEnvironment.shared.udManagerRef,
        )
    }()

    private let provisioningSocketManager: ProvisioningSocketManager

    private init(
        appReadiness: AppReadinessSetter,
        provisioningSocketManager: ProvisioningSocketManager,
    ) {
        self.appReadiness = appReadiness
        self.provisioningSocketManager = provisioningSocketManager

        super.init()
    }

    @MainActor
    static func presentProvisioningFlow(appReadiness: AppReadinessSetter) {
        let provisioningSocketManager = ProvisioningSocketManager(linkType: .linkDevice)
        let provisioningController = ProvisioningController(
            appReadiness: appReadiness,
            provisioningSocketManager: provisioningSocketManager,
        )
        let navController = ProvisioningNavigationController(provisioningController: provisioningController)
        provisioningController.setUpDebugLogsGesture(on: navController)

        let (backupRestoreState, registrationState) = DependenciesBridge.shared.db.read { tx in
            (
                DependenciesBridge.shared.backupArchiveManager.backupRestoreState(tx: tx),
                DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx),
            )
        }

        switch (backupRestoreState, registrationState) {
        case (.unfinalized, .unregistered), (.finalized, .unregistered):
            // If we started a link'n'sync and terminated after committing
            // the restored backup but before finishing, reset the app data
            // and start over.
            SignalApp.shared.resetAppDataAndExit(
                keyFetcher: SSKEnvironment.shared.databaseStorageRef.keyFetcher,
            )
        default:
            break
        }

        let vc = ProvisioningSplashViewController(provisioningController: provisioningController)
        navController.setViewControllers([vc], animated: false)

        CurrentAppContext().mainWindow?.rootViewController = navController
    }

    static func presentRelinkingFlow(appReadiness: AppReadinessSetter) {
        let provisioningSocketManager = ProvisioningSocketManager(linkType: .linkDevice)
        let provisioningController = ProvisioningController(
            appReadiness: appReadiness,
            provisioningSocketManager: provisioningSocketManager,
        )
        let navController = ProvisioningNavigationController(provisioningController: provisioningController)
        provisioningController.setUpDebugLogsGesture(on: navController)

        let vc = ProvisioningQRCodeViewController(
            provisioningController: provisioningController,
            provisioningSocketManager: provisioningSocketManager,
        )
        navController.setViewControllers([vc], animated: false)
        CurrentAppContext().mainWindow?.rootViewController = navController

        Task {
            await provisioningController.awaitProvisioning(
                from: vc,
                navigationController: navController,
            )
        }
    }

#if DEBUG
    static func preview() -> ProvisioningController {
        ProvisioningController(appReadiness: AppReadinessMock(), provisioningSocketManager: ProvisioningSocketManager(linkType: .linkDevice))
    }
#endif

    private func setUpDebugLogsGesture(
        on navigationController: UINavigationController,
    ) {
        let submitLogsGesture = UITapGestureRecognizer(target: self, action: #selector(submitLogs))
        submitLogsGesture.numberOfTapsRequired = 8
        submitLogsGesture.delaysTouchesEnded = false
        navigationController.view.addGestureRecognizer(submitLogsGesture)
    }

    @objc
    @MainActor
    private func submitLogs() {
        DebugLogs.submitLogs(supportTag: "Onboarding", dumper: .fromGlobals())
    }

    // MARK: - Transitions

    func provisioningSplashRequestedModeSwitch(viewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")

        let view = ProvisioningModeSwitchConfirmationViewController(provisioningController: self)
        viewController.navigationController?.pushViewController(view, animated: true)
    }

    func switchToPrimaryRegistration(viewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")
        let loader = RegistrationCoordinatorLoaderImpl(dependencies: .from(self))
        SignalApp.shared.showRegistration(loader: loader, desiredMode: .registering, appReadiness: appReadiness)
    }

    @MainActor
    func provisioningSplashDidComplete(viewController: UIViewController) async {
        Logger.info("")
        await pushPermissionsViewOrSkipToRegistration(onto: viewController)
    }

    @MainActor
    private func pushPermissionsViewOrSkipToRegistration(onto oldViewController: UIViewController) async {
        // Disable interaction during the asynchronous operation.
        oldViewController.view.isUserInteractionEnabled = false

        let newViewController = ProvisioningPermissionsViewController(provisioningController: self)
        let needsToAskForAnyPermissions = await newViewController.needsToAskForAnyPermissions()

        // Always re-enable interaction in case the user restart registration.
        oldViewController.view.isUserInteractionEnabled = true

        if needsToAskForAnyPermissions {
            oldViewController.navigationController?.pushViewController(newViewController, animated: true)
        } else {
            self.provisioningPermissionsDidComplete(viewController: oldViewController)
        }
    }

    func provisioningPermissionsDidComplete(viewController: UIViewController) {
        AssertIsOnMainThread()

        Logger.info("")

        guard let navigationController = viewController.navigationController else {
            owsFailDebug("navigationController was unexpectedly nil")
            return
        }

        pushTransferChoiceView(onto: navigationController)
    }

    func pushTransferChoiceView(onto navigationController: UINavigationController) {
        AssertIsOnMainThread()

        let view = ProvisioningTransferChoiceViewController(provisioningController: self)
        navigationController.pushViewController(view, animated: true)
    }

    // MARK: - Transfer

    @MainActor
    func transferAccount(fromViewController: UIViewController) async {
        Logger.info("")
        guard let navigationController = fromViewController.navigationController else {
            owsFailDebug("Missing navigationController")
            return
        }

        if navigationController.topViewController is BaseQuickRestoreQRCodeViewController {
            // qr code view is already presented, we don't need to push it again.
            return
        }

        let view = BaseQuickRestoreQRCodeViewController()
        await navigationController.awaitablePush(view, animated: true)
        do {
            let message = try await view.waitForMessage()
            guard let restoreToken = message.restoreMethodToken else {
                throw OWSAssertionError("Missing restore token")
            }

            let transferState = DeviceTransferCoordinator(
                deviceTransferService: AppEnvironment.shared.deviceTransferServiceRef,
                quickRestoreManager: AppEnvironment.shared.quickRestoreManager,
                restoreMethodToken: restoreToken,
                restoreMode: .linked,
            )

            transferState.cancelTransferBlock = { [weak self] in
                self?.pushTransferChoiceView(onto: navigationController)
            }
            transferState.onFailure = { [weak self] _ in
                self?.pushTransferChoiceView(onto: navigationController)
            }

            await navigationController.awaitablePush(
                DeviceTransferStatusViewController(coordinator: transferState),
                animated: true,
            )
        } catch {
            // Display error to the user
            Logger.error("Failed to start transfer")
        }
    }

    // MARK: - Linking

    @MainActor
    func didConfirmSecondaryDevice(from viewController: ProvisioningPrepViewController) async {
        guard let navigationController = viewController.navigationController else {
            owsFailDebug("navigationController was unexpectedly nil")
            return
        }

        let qrCodeViewController = ProvisioningQRCodeViewController(
            provisioningController: self,
            provisioningSocketManager: provisioningSocketManager,
        )

        await navigationController.awaitablePush(qrCodeViewController, animated: true)

        await awaitProvisioning(
            from: qrCodeViewController,
            navigationController: navigationController,
        )
    }

    @MainActor
    private func awaitProvisioning(
        from viewController: ProvisioningQRCodeViewController,
        navigationController: UINavigationController,
    ) async {

        let provisioningMessage = await waitForProvisioningMessage(navigationController: navigationController)

        provisioningSocketManager.stop()

        guard let provisioningMessage else {
            return
        }

        /// Ensure the primary is new enough to link us.
        guard provisioningMessage.provisioningVersion >= LinkingProvisioningMessage.Constants.provisioningVersion else {
            OWSActionSheets.showActionSheet(
                title: OWSLocalizedString(
                    "SECONDARY_LINKING_ERROR_OLD_VERSION_TITLE",
                    comment: "alert title for outdated linking device",
                ),
                message: OWSLocalizedString(
                    "SECONDARY_LINKING_ERROR_OLD_VERSION_MESSAGE",
                    comment: "alert message for outdated linking device",
                ),
            ) { _ in
                navigationController.popViewController(animated: true)
            }
            return
        }

        let progressViewModel = LinkAndSyncSecondaryProgressViewModel()

        performCoordinatorTaskWithModal(
            task: Task {
                try await self.provisioningCoordinator.completeProvisioning(
                    provisionMessage: provisioningMessage,
                    deviceName: UIDevice.current.name,
                    progressViewModel: progressViewModel,
                )
            },
            viewController: viewController,
            navigationController: navigationController,
            willLinkAndSync: provisioningMessage.ephemeralBackupKey != nil,
            progressViewModel: progressViewModel,
        )
    }

    @MainActor
    private func waitForProvisioningMessage(
        navigationController: UINavigationController,
    ) async -> LinkingProvisioningMessage? {
        do {
            return try await provisioningSocketManager.waitForMessage()
        } catch let error {
            Logger.error("Failed to decrypt provision envelope: \(error)")
            let alert = ActionSheetController(
                title: OWSLocalizedString(
                    "SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN",
                    comment: "alert title",
                ),
                message: error.userErrorDescription,
            )
            alert.addAction(ActionSheetAction(
                title: CommonStrings.cancelButton,
                style: .cancel,
                handler: { _ in
                    navigationController.popViewController(animated: true)
                },
            ))

            navigationController.presentActionSheet(alert)
            return nil
        }
    }

    func provisioningDidComplete(from viewController: UIViewController) {
        if viewController.presentedViewController != nil {
            viewController.dismiss(animated: true) {
                self.provisioningDidComplete(from: viewController)
            }
            return
        }
        SignalApp.shared.showConversationSplitView(appReadiness: appReadiness)
    }

    @MainActor
    private func resetBackToQrCodeController(
        from viewController: ProvisioningQRCodeViewController,
        navigationController: UINavigationController,
    ) async {
        Logger.warn("")

        // Reset at the start so it goes while other stuff animates.
        viewController.reset()
        await registrationWebSocketManager.releaseRestrictedWebSocket(isRegistered: false)

        if navigationController.presentedViewController != nil {
            await navigationController.awaitableDismiss(animated: true)
        }
        if viewController.presentedViewController != nil {
            await viewController.awaitableDismiss(animated: true)
        }
        navigationController.popToViewController(viewController, animated: true)

        Task {
            await awaitProvisioning(
                from: viewController,
                navigationController: navigationController,
            )
        }
    }

    @MainActor
    private func performCoordinatorTaskWithModal(
        task: Task<Void, Error>,
        viewController: ProvisioningQRCodeViewController,
        navigationController: UINavigationController,
        willLinkAndSync: Bool,
        progressViewModel: LinkAndSyncSecondaryProgressViewModel,
    ) {
        if willLinkAndSync {
            Task { @MainActor in
                let progressViewController: LinkAndSyncProvisioningProgressViewController
                if let vc = viewController.presentedViewController {
                    if let vc = vc as? LinkAndSyncProvisioningProgressViewController {
                        progressViewController = vc
                    } else {
                        vc.dismiss(animated: true, completion: {
                            self.performCoordinatorTaskWithModal(
                                task: task,
                                viewController: viewController,
                                navigationController: navigationController,
                                willLinkAndSync: willLinkAndSync,
                                progressViewModel: progressViewModel,
                            )
                        })
                        return
                    }
                } else {
                    progressViewController = LinkAndSyncProvisioningProgressViewController(
                        provisioningController: self,
                        viewModel: progressViewModel,
                    )
                }
                progressViewController.linkNSyncTask = task
                viewController.present(progressViewController, animated: false)
                do {
                    try await task.value
                    // Don't dismiss the progress view or it will quickly jump
                    // to that before jumping again to the chat list.
                    self.provisioningDidComplete(from: viewController)
                } catch var error as CompleteProvisioningError {
                    if case let .linkAndSyncError(linkAndSyncError) = error {
                        switch linkAndSyncError.error {
                        case SecondaryLinkNSyncError.primaryFailedBackupExport(let continueWithoutSyncing):
                            if continueWithoutSyncing {
                                do {
                                    try await linkAndSyncError.continueWithoutSyncing()
                                    self.provisioningDidComplete(from: viewController)
                                    return
                                } catch let innerError as CompleteProvisioningError {
                                    error = innerError
                                }
                            } else {
                                // Crash if this fails; things have gone horribly wrong.
                                try! await linkAndSyncError.restartProvisioning()
                                await self.resetBackToQrCodeController(
                                    from: viewController,
                                    navigationController: navigationController,
                                )
                                return
                            }
                        case is CancellationError:
                            // Exit provisioning if we cancelled
                            do {
                                try await linkAndSyncError.continueWithoutSyncing()
                                self.provisioningDidComplete(from: viewController)
                                return
                            } catch let innerError as CompleteProvisioningError {
                                error = innerError
                            }
                        default:
                            break
                        }
                    }
                    let errorActionSheet = self.errorActionSheet(
                        error: error,
                        from: viewController,
                        navigationController: navigationController,
                        progressViewModel: progressViewModel,
                    )
                    if progressViewController.presentedViewController == nil {
                        progressViewController.presentActionSheet(errorActionSheet)
                    }
                }
            }
        } else {
            let presentingController = viewController.presentedViewController ?? viewController
            ModalActivityIndicatorViewController.present(
                fromViewController: presentingController,
                canCancel: false,
            ) { modal async -> Void in
                let result: CompleteProvisioningError?
                do {
                    try await task.value
                    result = nil
                } catch let error {
                    result = error as? CompleteProvisioningError
                }

                let errorActionSheet = result.map {
                    self.errorActionSheet(
                        error: $0,
                        from: viewController,
                        navigationController: navigationController,
                        progressViewModel: progressViewModel,
                    )
                }
                modal.dismiss {
                    if let errorActionSheet {
                        presentingController.presentActionSheet(errorActionSheet)
                    } else {
                        self.provisioningDidComplete(from: viewController)
                    }
                }
            }
        }
    }

    private func errorActionSheet(
        error: CompleteProvisioningError,
        from viewController: ProvisioningQRCodeViewController,
        navigationController: UINavigationController,
        progressViewModel: LinkAndSyncSecondaryProgressViewModel,
    ) -> ActionSheetController {
        let alert: ActionSheetController
        switch error {
        case .previouslyLinkedWithDifferentAccount:
            Logger.warn("was previously linked/registered on different account!")
            let title = OWSLocalizedString(
                "SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_TITLE",
                comment: "Title for error alert indicating that re-linking failed because the account did not match.",
            )
            let message = OWSLocalizedString(
                "SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_MESSAGE",
                comment: "Message for error alert indicating that re-linking failed because the account did not match.",
            )
            alert = ActionSheetController(title: title, message: message)
            alert.addAction(ActionSheetAction(
                title: OWSLocalizedString(
                    "SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_RESET_DEVICE",
                    comment: "Label for the 'reset device' action in the 're-linking failed because the account did not match' alert.",
                ),
                style: .default,
                handler: { _ in
                    Task { @MainActor in
                        await self.resetBackToQrCodeController(
                            from: viewController,
                            navigationController: navigationController,
                        )
                    }
                },
            ))
        case .deviceLimitExceededError(let error):
            alert = ActionSheetController(title: error.errorDescription, message: error.recoverySuggestion)
            alert.addAction(ActionSheetAction(
                title: CommonStrings.okButton,
                handler: { _ in
                    Task { @MainActor in
                        await self.resetBackToQrCodeController(
                            from: viewController,
                            navigationController: navigationController,
                        )
                    }
                },
            ))
        case .obsoleteLinkedDeviceError:
            Logger.warn("obsolete device error")
            let title = OWSLocalizedString(
                "SECONDARY_LINKING_ERROR_OBSOLETE_LINKED_DEVICE_TITLE",
                comment: "Title for error alert indicating that a linked device must be upgraded before it can be linked.",
            )
            let message = OWSLocalizedString(
                "SECONDARY_LINKING_ERROR_OBSOLETE_LINKED_DEVICE_MESSAGE",
                comment: "Message for error alert indicating that a linked device must be upgraded before it can be linked.",
            )
            alert = ActionSheetController(title: title, message: message)

            let updateButtonText = OWSLocalizedString(
                "APP_UPDATE_NAG_ALERT_UPDATE_BUTTON",
                comment: "Label for the 'update' button in the 'new app version available' alert.",
            )
            let updateAction = ActionSheetAction(
                title: updateButtonText,
                style: .default,
            ) { _ in
                let url = TSConstants.appStoreUrl
                UIApplication.shared.open(url, options: [:])
            }
            alert.addAction(updateAction)
        case .genericError(let error):
            let title = OWSLocalizedString("SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN", comment: "alert title")
            let message = error.userErrorDescription
            alert = ActionSheetController(title: title, message: message)
            alert.addAction(ActionSheetAction(
                title: CommonStrings.retryButton,
                style: .default,
                handler: { _ in
                    let isProvisioned = DependenciesBridge.shared.db.read { tx in
                        DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx).isRegistered
                    }
                    if isProvisioned {
                        self.provisioningDidComplete(from: viewController)
                    } else {
                        Task { @MainActor in
                            await self.resetBackToQrCodeController(
                                from: viewController,
                                navigationController: navigationController,
                            )
                        }
                    }
                },
            ))
        case .linkAndSyncError(let error):
            return self.linkAndSyncRetryActionSheet(
                error: error,
                from: viewController,
                navigationController: navigationController,
                progressViewModel: progressViewModel,
            )
        }
        return alert
    }

    private func linkAndSyncRetryActionSheet(
        error: ProvisioningCoordinatorImpl.LinkAndSyncError,
        from viewController: ProvisioningQRCodeViewController,
        navigationController: UINavigationController,
        progressViewModel: LinkAndSyncSecondaryProgressViewModel,
    ) -> ActionSheetController {
        enum ErrorPromptMode {
            case contactSupport
            case networkErrorRetry
            case restartProvisioning
        }

        let errorPromptMode: ErrorPromptMode
        let errorMessage: String?
        if case SecondaryLinkNSyncError.errorRestoringBackup = error.error {
            errorPromptMode = .contactSupport
            errorMessage = nil
        } else if error.error.isNetworkFailureOrTimeout {
            errorPromptMode = .networkErrorRetry
            errorMessage = OWSLocalizedString(
                "SECONDARY_LINKING_SYNCING_NETWORK_ERROR_MESSAGE",
                comment: "Message for action sheet when secondary device fails to sync messages due to network error.",
            )
        } else if case BackupImportError.unsupportedVersion = error.error {
            let actionSheet = ActionSheetController(
                title: OWSLocalizedString(
                    "SECONDARY_LINKING_SYNCING_UPDATE_REQUIRED_ERROR_TITLE",
                    comment: "Title for action sheet when the secondary device fails to sync messages due to an app update being required.",
                ),
                message: OWSLocalizedString(
                    "SECONDARY_LINKING_SYNCING_UPDATE_REQUIRED_ERROR_MESSAGE",
                    comment: "Message for action sheet when the secondary device fails to sync messages due to an app update being required.",
                ),
            )

            actionSheet.addAction(ActionSheetAction(
                title: OWSLocalizedString(
                    "SECONDARY_LINKING_SYNCING_UPDATE_REQUIRED_CHECK_FOR_UPDATE_BUTTON",
                    comment: "Button on an action sheet to open Signal on the App Store.",
                ),
                style: .default,
            ) { _ in
                UIApplication.shared.open(TSConstants.appStoreUrl)
                Task { @MainActor in
                    // Crash if this fails; things have gone horribly wrong.
                    try! await error.restartProvisioning()
                    await self.resetBackToQrCodeController(
                        from: viewController,
                        navigationController: navigationController,
                    )
                }
            })
            return actionSheet
        } else {
            errorPromptMode = .restartProvisioning
            errorMessage = OWSLocalizedString(
                "SECONDARY_LINKING_SYNCING_OTHER_ERROR_MESSAGE",
                comment: "Message for action sheet when secondary device fails to sync messages due to an unspecified error.",
            )
        }

        let retryActionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "SECONDARY_LINKING_SYNCING_ERROR_TITLE",
                comment: "Title for action sheet when secondary device fails to sync messages.",
            ),
            message: errorMessage,
        )
        retryActionSheet.isCancelable = false

        switch errorPromptMode {
        case .contactSupport:
            retryActionSheet.addAction(ActionSheetAction(title: CommonStrings.contactSupport) { _ in
                Task { @MainActor in
                    // Crash if this fails; things have gone horribly wrong.
                    try! await error.restartProvisioning()
                    await self.resetBackToQrCodeController(
                        from: viewController,
                        navigationController: navigationController,
                    )

                    // Wait to present until we've reset back to the QR code
                    // view controller.
                    ContactSupportActionSheet.present(
                        emailFilter: .backupImportFailed,
                        logDumper: .fromGlobals(),
                        fromViewController: viewController,
                    )
                }
            })
        case .networkErrorRetry:
            retryActionSheet.addAction(ActionSheetAction(title: CommonStrings.retryButton) { _ in
                self.performCoordinatorTaskWithModal(
                    task: Task {
                        try await error.retryLinkAndSync()
                    },
                    viewController: viewController,
                    navigationController: navigationController,
                    willLinkAndSync: true,
                    progressViewModel: progressViewModel,
                )
            })
        case .restartProvisioning:
            retryActionSheet.addAction(ActionSheetAction(title: CommonStrings.retryButton) { _ in
                Task { @MainActor in
                    // Crash if this fails; things have gone horribly wrong.
                    try! await error.restartProvisioning()
                    await self.resetBackToQrCodeController(
                        from: viewController,
                        navigationController: navigationController,
                    )
                }
            })
        }

        retryActionSheet.addAction(ActionSheetAction(
            title: CommonStrings.cancelButton,
            style: .cancel,
        ) { _ in
            self.performCoordinatorTaskWithModal(
                task: Task {
                    try await error.continueWithoutSyncing()
                },
                viewController: viewController,
                navigationController: navigationController,
                willLinkAndSync: false,
                progressViewModel: progressViewModel,
            )
        })

        return retryActionSheet
    }
}

private extension CommonStrings {
    static var linkNSyncImportErrorTitle: String {
        OWSLocalizedString(
            "SECONDARY_LINKING_SYNCING_ERROR_TITLE",
            comment: "Title for action sheet when secondary device fails to sync messages.",
        )
    }
}