Signal-iOS/Signal/Backups/BackupRecordKeyViewController.swift
2026-06-08 20:47:21 -07:00

354 lines
13 KiB
Swift

//
// 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 bodyText: NSAttributedString = OWSLocalizedString(
"BACKUP_RECORD_KEY_COPY_WARNING_SHEET_BODY",
comment: "Body for a warning sheet shown before copying the user's 'Recovery Key' to the clipboard, warning them not to share it with anyone.",
).styled(
with: .font(.dynamicTypeSubheadline),
.xmlRules([.style("bold", StringStyle(.font(.dynamicTypeSubheadline.bold())))]),
)
let warningSheet = HeroSheetViewController(
hero: .circleIcon(
icon: .errorTriangle,
iconSize: 40,
tintColor: .Signal.red,
backgroundColor: UIColor(rgbHex: 0xF8E0D9),
),
title: OWSLocalizedString(
"BACKUP_RECORD_KEY_COPY_WARNING_SHEET_TITLE",
comment: "Title for a warning sheet shown before copying the user's 'Recovery Key' to the clipboard.",
),
body: HeroSheetViewController.Body(
textContent: .attributed(bodyText),
),
primary: .button(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()
}
},
)),
secondary: .button(HeroSheetViewController.Button(
title: CommonStrings.learnMore,
style: .secondary,
action: .custom({ sheet in
UIApplication.shared.open(.Support.phishingPrevention)
}),
)),
)
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