// // 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 public 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() } ) } public 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