// // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import AuthenticationServices import SignalServiceKit import SignalUI import SwiftUI class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildController { struct Option: OptionSet { let rawValue: Int /// Show a "continue" button in the view footer. Not compatible with /// `.showCreateNewKeyButton`. static let showContinueButton = Option(rawValue: 1 << 1) /// Show a "create new key" button in the view footer. Not compatible /// with `.showContinueButton`. static let showCreateNewKeyButton = Option(rawValue: 1 << 2) } enum AEPMode { /// The user's current AEP, which must only be viewed after device auth. case current(AccountEntropyPool, LocalDeviceAuthentication.AuthSuccess) /// A new candidate AEP. case newCandidate(AccountEntropyPool) fileprivate var aep: AccountEntropyPool { switch self { case .current(let aep, _): return aep case .newCandidate(let aep): return aep } } } var shouldCancelNavigationBack: Bool { onBackPressedBlock != nil } private let displayableAEP: DisplayableAccountEntropyPool private let onContinuePressedBlock: (BackupRecordKeyViewController) -> Void private let onCreateNewKeyPressedBlock: (BackupRecordKeyViewController) -> Void private let onBackPressedBlock: (() -> Void)? private let options: [Option] /// - Parameter onCreateNewKeyPressed /// Called when the user taps the "create new key" button. Only relevant if /// the `.showCreateNewKeyButton` option is passed. /// - Parameter onContinuePressed /// Called when the user taps the "continue" button. Only relevant if the /// `.showContinueButton` option is passed. init( aepMode: AEPMode, options: [Option], onCreateNewKeyPressed: @escaping (BackupRecordKeyViewController) -> Void = { _ in }, onContinuePressed: @escaping (BackupRecordKeyViewController) -> Void = { _ in }, onBackPressed: (() -> Void)? = nil, ) { self.displayableAEP = DisplayableAccountEntropyPool(aep: aepMode.aep) self.onContinuePressedBlock = onContinuePressed self.onCreateNewKeyPressedBlock = onCreateNewKeyPressed self.onBackPressedBlock = onBackPressed self.options = options super.init() OWSTableViewController2.removeBackButtonText(viewController: self) } var navbarBackgroundColorOverride: UIColor? { .Signal.groupedBackground } // MARK: - override func viewDidLoad() { super.viewDidLoad() let screenLockUI = AppEnvironment.shared.screenLockUI screenLockUI.sensitiveContentDidLoad(inViewController: self) view.backgroundColor = .Signal.groupedBackground if let onBackPressedBlock { navigationItem.hidesBackButton = true navigationItem.leftBarButtonItem = .init( image: UIImage(named: "chevron-left-bold-28"), primaryAction: UIAction { _ in onBackPressedBlock() }, ) isModalInPresentation = true } let heroIconView = UIImageView() heroIconView.image = .backupsLock heroIconView.contentMode = .scaleAspectFit let headlineLabel = UILabel.title1Label(text: OWSLocalizedString( "BACKUP_RECORD_KEY_TITLE", comment: "Title for a view allowing users to record their 'Recovery Key'.", )) let subheadlineLabel = UILabel.explanationTextLabel(text: OWSLocalizedString( "BACKUP_RECORD_KEY_SUBTITLE", comment: "Subtitle for a view allowing users to record their 'Recovery Key'.", )) let aepTextView = AccountEntropyPoolTextView(mode: .display(displayableAEP)) aepTextView.backgroundColor = .Signal.secondaryGroupedBackground var topButtons: [UIButton] = [ UIButton( configuration: .smallSecondary(title: OWSLocalizedString( "BACKUP_RECORD_KEY_COPY_TO_CLIPBOARD_BUTTON_TITLE", comment: "Title for a button allowing users to copy their 'Recovery Key' to the clipboard.", )), primaryAction: UIAction { [weak self] _ in self?.copyToClipboardWithConfirmation() }, ), ] if #available(iOS 26.2, *) { let saveToPasswordManagerButton = UIButton( configuration: .smallSecondary(title: OWSLocalizedString( "BACKUP_RECORD_KEY_PASSWORD_MANAGER_BUTTON_TITLE", comment: "Title for a button allowing users to save their 'Recovery Key' to a password manager.", )), primaryAction: UIAction { [weak self] _ in self?.saveToPasswordManagerWithConfirmation() }, ) topButtons.append(saveToPasswordManagerButton) } var bottomButtons = [UIButton]() if options.contains(.showCreateNewKeyButton) { let createNewKeyButton = UIButton( configuration: .largeSecondary(title: OWSLocalizedString( "BACKUP_RECORD_KEY_CREATE_NEW_KEY_BUTTON_TITLE", comment: "Title for a button allowing users to create a new 'Recovery Key'.", )), primaryAction: UIAction { [weak self] _ in guard let self else { return } onCreateNewKeyPressedBlock(self) }, ) bottomButtons.append(createNewKeyButton) } if options.contains(.showContinueButton) { let continueButton = UIButton( configuration: .largePrimary(title: CommonStrings.continueButton), primaryAction: UIAction { [weak self] _ in guard let self else { return } onContinuePressedBlock(self) }, ) bottomButtons.append(continueButton) } let stackView = addStaticContentStackView( arrangedSubviews: [ heroIconView, headlineLabel, subheadlineLabel, aepTextView, topButtons.enclosedInVerticalStackView(isFullWidthButtons: false), .vStretchingSpacer(), bottomButtons.enclosedInVerticalStackView(isFullWidthButtons: options.contains(.showContinueButton)), ], isScrollable: true, ) stackView.spacing = 24 stackView.setCustomSpacing(32, after: aepTextView) } private func copyToClipboardWithConfirmation() { let warningSheet = BackupNeverShareRecoveryKeySheet( primaryButton: HeroSheetViewController.Button( title: OWSLocalizedString( "BACKUP_RECORD_KEY_COPY_WARNING_SHEET_PRIMARY_BUTTON_TITLE", comment: "Title for the primary button in a warning sheet shown before copying the user's 'Recovery Key' to the clipboard, which acknowledges the warning and proceeds with the copy.", ), action: { sheet in sheet.dismiss(animated: true) { [weak self] in guard let self else { return } copyToClipboard() } }, ), secondaryButton: nil, ) present(warningSheet, animated: true) } private func copyToClipboard() { UIPasteboard.general.setItems( [[UIPasteboard.typeAutomatic: displayableAEP.displayString]], options: [.expirationDate: Date().addingTimeInterval(60)], ) let toast = ToastController( text: OWSLocalizedString( "BACKUP_KEY_COPIED_MESSAGE_TOAST", comment: "Toast indicating that the user has copied their recovery key.", ), image: .copy, ) toast.presentToastView(from: .bottom, of: view, inset: view.safeAreaInsets.bottom + 8) } @available(iOS 26.2, *) private func saveToPasswordManagerWithConfirmation() { guard let window = view.window else { owsFailDebug("Missing window!") return } let actionSheet = ActionSheetController( title: OWSLocalizedString( "BACKUP_RECORD_KEY_PASSWORD_MANAGER_CONFIRM_TITLE", comment: "Title for a confirmation sheet shown before saving the user's 'Recovery Key' to a password manager.", ), message: OWSLocalizedString( "BACKUP_RECORD_KEY_PASSWORD_MANAGER_CONFIRM_MESSAGE", comment: "Message for a confirmation sheet shown before saving the user's 'Recovery Key' to a password manager, advising them to only use a password manager they trust.", ), ) actionSheet.addAction(ActionSheetAction( title: CommonStrings.continueButton, handler: { [self] _ in Task { await _saveToPasswordManager(window: window) } }, )) actionSheet.addAction(.cancel) presentActionSheet(actionSheet) } @available(iOS 26.2, *) private func _saveToPasswordManager(window: ASPresentationAnchor) async { do { let credentialDataManager = ASCredentialDataManager() let credentialName = OWSLocalizedString( "BACKUP_RECORD_KEY_PASSWORD_MANAGER_CREDENTIAL_NAME", comment: "Name used as both the username and title for the user's 'Recovery Key' credential when saving it to a password manager.", ) let password = ASPasswordCredential( user: credentialName, password: displayableAEP.displayString, ) let scope = ASAutoFillURLScope(host: "signal.org") try await credentialDataManager.save( password: password, for: scope, title: credentialName, anchor: window, ) presentToast(text: OWSLocalizedString( "BACKUP_RECORD_KEY_PASSWORD_MANAGER_SUCCESS_TOAST", comment: "Toast shown after the user successfully saves their 'Recovery Key' to a password manager.", )) } catch { Logger.warn("Failed to save to password manager! \(error)") let actionSheet = ActionSheetController( title: OWSLocalizedString( "BACKUP_RECORD_KEY_PASSWORD_MANAGER_ERROR_TITLE", comment: "Title for an error sheet shown when saving the user's 'Recovery Key' to a password manager fails.", ), message: OWSLocalizedString( "BACKUP_RECORD_KEY_PASSWORD_MANAGER_ERROR_MESSAGE", comment: "Message for an error sheet shown when saving the user's 'Recovery Key' to a password manager fails, suggesting that they may not have a supported password manager configured.", ), ) actionSheet.addAction(.ok) presentActionSheet(actionSheet) } } } // MARK: - #if DEBUG private extension BackupRecordKeyViewController { static func forPreview( aepMode: AEPMode, options: [Option], ) -> BackupRecordKeyViewController { return BackupRecordKeyViewController( aepMode: aepMode, options: options, onCreateNewKeyPressed: { _ in print("Create New Key!") }, onContinuePressed: { _ in print("Continue!") }, ) } } @available(iOS 17, *) #Preview("CreateNewKey") { UINavigationController(rootViewController: BackupRecordKeyViewController.forPreview( aepMode: .newCandidate(AccountEntropyPool()), options: [.showCreateNewKeyButton], )) } @available(iOS 17, *) #Preview("ContinueButton") { UINavigationController(rootViewController: BackupRecordKeyViewController.forPreview( aepMode: .newCandidate(AccountEntropyPool()), options: [.showContinueButton], )) } #endif