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

import Foundation
import SignalServiceKit
import SignalUI

protocol RegistrationChooseRestoreMethodPresenter: AnyObject {
    func didChooseRestoreMethod(method: RegistrationRestoreMethod)
    func didCancelRestoreMethodSelection()
}

public enum RegistrationRestoreMethod {
    case deviceTransfer
    case local
    case remote
    case declined
}

class RegistrationChooseRestoreMethodViewController: OWSViewController {

    private weak var presenter: RegistrationChooseRestoreMethodPresenter?
    private let restorePath: RegistrationStep.RestorePath

    init(
        presenter: RegistrationChooseRestoreMethodPresenter,
        restorePath: RegistrationStep.RestorePath,
    ) {
        self.presenter = presenter
        self.restorePath = restorePath

        super.init()

        navigationItem.hidesBackButton = true
    }

    // MARK: UI

    private func prominentRestoreButton() -> UIButton {
        return UIButton.registrationChoiceButton(
            title: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_BACKUPS_TITLE",
                comment: "The title for the device transfer 'choice' view 'restore backup' option",
            ),
            subtitle: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_BACKUPS_BODY",
                comment: "The body for the device transfer 'choice' view 'restore backup' option",
            ),
            iconName: "signal-backups-48",
            primaryAction: UIAction { [weak self] _ in
                self?.didSelectRestoreFromBackup()
            },
        )
    }

    private func prominentTransferButton() -> UIButton {
        return UIButton.registrationChoiceButton(
            title: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_TRANSFER_TITLE",
                comment: "The title for the device transfer 'choice' view 'transfer' option",
            ),
            subtitle: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_TRANSFER_BODY",
                comment: "The body for the device transfer 'choice' view 'transfer' option",
            ),
            iconName: "transfer-48",
            primaryAction: UIAction { [weak self] _ in
                self?.didSelectDeviceTransfer()
            },
        )
    }

    private func prominentSkipRestoreButton() -> UIButton {
        return UIButton.registrationChoiceButton(
            title: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_SKIP_RESTORE_TITLE",
                comment: "The title for the skip restore 'choice' option",
            ),
            subtitle: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_SKIP_RESTORE_BODY",
                comment: "The body for the skip restore 'choice' option",
            ),
            iconName: "continue-48",
            primaryAction: UIAction { [weak self] _ in
                self?.didSkipRestore()
            },
        )
    }

    private func skipRestoreButton(isLargeButton: Bool) -> UIButton {
        let buttonTitle = OWSLocalizedString(
            "ONBOARDING_CHOOSE_RESTORE_METHOD_SKIP_RESTORE_SMALL_TITLE",
            comment: "The title for a less-prominent skip restore 'choice' option",
        )
        let buttonConfiguration: UIButton.Configuration
        if isLargeButton {
            buttonConfiguration = .largeSecondary(title: buttonTitle)
        } else {
            buttonConfiguration = .mediumSecondary(title: buttonTitle)
        }
        return UIButton(
            configuration: buttonConfiguration,
            primaryAction: UIAction { [weak self] _ in
                self?.didSkipRestore()
            },
        )
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .Signal.background

        // Content view.
        let stackView = addStaticContentStackView(arrangedSubviews: [], isScrollable: true)
        switch self.restorePath {
        case .quickRestore(let tier, let platform) where platform == .android:
            switch tier {
            case .free, .paid:
                addDefaultTitle(to: stackView)
                stackView.addArrangedSubviews([
                    prominentRestoreButton(),
                    prominentSkipRestoreButton(),
                    .vStretchingSpacer(),
                ])
            case .none:
                addNoRestoreOptionViews(to: stackView)
            }
        case .quickRestore(let tier, _):
            addDefaultTitle(to: stackView)
            switch tier {
            case .free:
                let bottomButton = skipRestoreButton(isLargeButton: false)
                stackView.addArrangedSubviews([
                    prominentTransferButton(),
                    prominentRestoreButton(),
                    .vStretchingSpacer(),
                    bottomButton.enclosedInVerticalStackView(isFullWidthButton: false),
                ])

            case .paid:
                let bottomButton = skipRestoreButton(isLargeButton: false)
                stackView.addArrangedSubviews([
                    prominentRestoreButton(),
                    prominentTransferButton(),
                    .vStretchingSpacer(),
                    bottomButton.enclosedInVerticalStackView(isFullWidthButton: false),
                ])

            case .none:
                stackView.addArrangedSubviews([
                    prominentTransferButton(),
                    prominentSkipRestoreButton(),
                    .vStretchingSpacer(),
                ])
            }
        case .manualRestore:
            addDefaultTitle(to: stackView)
            let bottomButton = UIButton(
                configuration: .mediumSecondary(title: CommonStrings.cancelButton),
                primaryAction: UIAction { [weak self] _ in
                    self?.didTapCancel()
                },
            )
            stackView.addArrangedSubviews([
                prominentRestoreButton(),
                prominentSkipRestoreButton(),
                .vStretchingSpacer(),
                bottomButton.enclosedInVerticalStackView(isFullWidthButton: false),
            ])
        case .unspecified:
            addDefaultTitle(to: stackView)
            stackView.addArrangedSubviews([
                prominentTransferButton(),
                prominentRestoreButton(),
                prominentSkipRestoreButton(),
                .vStretchingSpacer(),
            ])
        }
    }

    private func addDefaultTitle(to stackView: UIStackView) {
        let titleLabel = UILabel.titleLabelForRegistration(
            text: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_TITLE",
                comment: "If a user is installing Signal on a new phone, they may be asked whether they want to restore their device from a backup.",
            ),
        )
        let explanationLabel = UILabel.explanationLabelForRegistration(
            text: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_DESCRIPTION",
                comment: "If a user is installing Signal on a new phone, they may be asked whether they want to restore their device from a backup. This is a description of that question.",
            ),
        )
        stackView.addArrangedSubviews([
            titleLabel,
            explanationLabel,
        ])
        stackView.setCustomSpacing(24, after: explanationLabel)
    }

    private func addNoRestoreOptionViews(to stackView: UIStackView) {
        let title = UILabel.titleLabelForRegistration(
            text: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_NONE_AVAILABLE_TITLE",
                comment: "Title displayed to a user during registration if there are no restore options available.",
            ),
        )
        let body = UILabel.explanationLabelForRegistration(
            text: OWSLocalizedString(
                "ONBOARDING_CHOOSE_RESTORE_METHOD_NONE_AVAILABLE_BODY",
                comment: "Message body displayed to a user during registration if there are no restore options available.",
            ),
        )
        stackView.addArrangedSubviews([
            title,
            body,
        ])
        stackView.setCustomSpacing(32, after: body)

        func labelWithImage(imageName: String, text: String) -> UIView {
            let image = UIImageView(image: UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate))
            image.tintColor = .Signal.secondaryLabel
            let label = UILabel.explanationLabelForRegistration(text: text)
            label.textAlignment = .natural
            let stackView = UIStackView(
                arrangedSubviews: [
                    image,
                    label,
                    SpacerView(),
                ],
            )
            stackView.axis = .horizontal
            stackView.alignment = .firstBaseline
            stackView.isLayoutMarginsRelativeArrangement = true
            stackView.spacing = 16
            stackView.directionalLayoutMargins = .init(top: 6, leading: 30, bottom: 6, trailing: 30)
            return stackView
        }

        stackView.addArrangedSubviews([
            labelWithImage(imageName: "device-phone", text: OWSLocalizedString(
                "REGISTRATION_RESTORE_METHOD_MAKE_BACKUP_TUTORIAL_OPEN_SIGNAL",
                comment: "First step in directions for how to make a backup",
            )),
            labelWithImage(imageName: "backup", text: OWSLocalizedString(
                "REGISTRATION_RESTORE_METHOD_MAKE_BACKUP_TUTORIAL_TAP_SETTINGS",
                comment: "Second step in directions for how to make a backup",
            )),
            labelWithImage(imageName: "check-circle", text: OWSLocalizedString(
                "REGISTRATION_RESTORE_METHOD_MAKE_BACKUP_TUTORIAL_ENABLE_BACKUPS",
                comment: "Third step in directions for how to make a backup",
            )),
        ])

        // Show large "No backup to restore" and "Skip Restore"
        let continueButton = UIButton(
            configuration: .largePrimary(title: CommonStrings.okayButton),
            primaryAction: UIAction { [weak self] _ in
                self?.didTapCancel()
            },
        )
        let skipRestoreButton = skipRestoreButton(isLargeButton: true)

        stackView.addArrangedSubviews([
            .vStretchingSpacer(),
            [continueButton, skipRestoreButton].enclosedInVerticalStackView(isFullWidthButtons: true),
        ])
    }

    // MARK: Events

    private func didSelectRestoreFromBackup() {
        presenter?.didChooseRestoreMethod(method: .remote)
    }

    private func didSelectDeviceTransfer() {
        presenter?.didChooseRestoreMethod(method: .deviceTransfer)
    }

    private func didSkipRestore() {
        // Add a bit of friction by having the user confirm they want to skip restoring.
        var actions = [ActionSheetAction]()
        let title = OWSLocalizedString(
            "ONBOARDING_CHOOSE_RESTORE_METHOD_CONFIRM_SKIP_RESTORE_TITLE",
            comment: "Title for a sheet warning users about skipping restore.",
        )
        let message = OWSLocalizedString(
            "ONBOARDING_CHOOSE_RESTORE_METHOD_CONFIRM_SKIP_RESTORE_BODY",
            comment: "Body for a sheet warning users about skipping restore.",
        )
        let actionTitle = OWSLocalizedString(
            "REGISTRATION_BACKUP_RESTORE_ERROR_SKIP_RESTORE_ACTION",
            comment: "Skip restore action label for backup restore error recovery.",
        )
        actions.append(ActionSheetAction(title: actionTitle) { [weak self] _ in
            self?.presenter?.didChooseRestoreMethod(method: .declined)
        })
        actions.append(ActionSheetAction.cancel)
        let actionSheet = ActionSheetController(title: title, message: message)
        actions.forEach { actionSheet.addAction($0) }
        OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
    }

    private func didTapCancel() {
        presenter?.didCancelRestoreMethodSelection()
    }
}

#if DEBUG
private class PreviewRegistrationChooseRestoreMethodPresenter: RegistrationChooseRestoreMethodPresenter {
    func didChooseRestoreMethod(method: RegistrationRestoreMethod) {
        print("restore method: \(method)")
    }

    func didCancelRestoreMethodSelection() {
        print("did cancel")
    }
}

// Need to hold a reference to this since it's held weakly by the VC
private let presenter = PreviewRegistrationChooseRestoreMethodPresenter()

@available(iOS 17, *)
#Preview("Quick Restore iOS free") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .quickRestore(.free, .ios),
        ),
    )
}

@available(iOS 17, *)
#Preview("Quick Restore iOS paid") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .quickRestore(.paid, .ios),
        ),
    )
}

@available(iOS 17, *)
#Preview("Quick Restore iOS no backups") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .quickRestore(nil, .ios),
        ),
    )
}

@available(iOS 17, *)
#Preview("Quick Restore Android source, free") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .quickRestore(.free, .android),
        ),
    )
}

@available(iOS 17, *)
#Preview("Quick Restore Android source, paid") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .quickRestore(.paid, .android),
        ),
    )
}

@available(iOS 17, *)
#Preview("Quick Restore Android source, no backup") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .quickRestore(nil, .android),
        ),
    )
}

@available(iOS 17, *)
#Preview("Manual Restore") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .manualRestore,
        ),
    )
}

@available(iOS 17, *)
#Preview("Unspecified") {
    OWSNavigationController(
        rootViewController: RegistrationChooseRestoreMethodViewController(
            presenter: presenter,
            restorePath: .unspecified,
        ),
    )
}
#endif