// // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI import UIKit class EnterAccountEntropyPoolViewController: OWSViewController { enum AEPValidationPolicy { case acceptAnyWellFormed case acceptOnly(AccountEntropyPool) } struct ColorConfig { let background: UIColor let aepEntryBackground: UIColor } struct HeaderStrings { let title: String let subtitle: String } struct FooterButtonConfig { let title: String let action: () -> Void } private var aepValidationPolicy: AEPValidationPolicy! private var colorConfig: ColorConfig! private var footerButtonConfig: FooterButtonConfig? private var headerStrings: HeaderStrings! private var onEntryConfirmed: ((AccountEntropyPool) -> Void)! func configure( aepValidationPolicy: AEPValidationPolicy, colorConfig: ColorConfig, headerStrings: HeaderStrings, footerButtonConfig: FooterButtonConfig?, onEntryConfirmed: @escaping (AccountEntropyPool) -> Void, ) { self.aepValidationPolicy = aepValidationPolicy self.colorConfig = colorConfig self.headerStrings = headerStrings self.footerButtonConfig = footerButtonConfig self.onEntryConfirmed = onEntryConfirmed } override func viewDidLoad() { super.viewDidLoad() let screenLockUI = AppEnvironment.shared.screenLockUI screenLockUI.sensitiveContentDidLoad(inViewController: self) view.backgroundColor = colorConfig.background navigationItem.rightBarButtonItem = UIBarButtonItem( title: CommonStrings.nextButton, style: .done, target: self, action: #selector(didTapNext), ) let scrollView = UIScrollView() view.addSubview(scrollView) scrollView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor), ]) let titleLabel = UILabel.titleLabelForRegistration(text: headerStrings.title) let subtitleLabel = UILabel.explanationLabelForRegistration(text: headerStrings.subtitle) let footerButton: UIButton? if let footerButtonConfig { footerButton = UIButton( configuration: .mediumSecondary(title: footerButtonConfig.title), primaryAction: UIAction { _ in footerButtonConfig.action() }, ) } else { footerButton = nil } let stackView = addStaticContentStackView( arrangedSubviews: [ titleLabel, subtitleLabel, aepTextView, aepIssueLabel, footerButton?.enclosedInVerticalStackView(isFullWidthButton: false), .vStretchingSpacer(), ].compacted(), isScrollable: true, shouldAvoidKeyboard: true, ) stackView.spacing = 24 stackView.setCustomSpacing(16, after: aepTextView) stackView.setCustomSpacing(20, after: aepIssueLabel) stackView.setCustomSpacing(12, after: titleLabel) let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) view.addGestureRecognizer(tapGesture) onTextViewUpdated() } // MARK: - private lazy var aepTextView = { let textView = AccountEntropyPoolTextView(mode: .entry(onTextViewChanged: { [weak self] in self?.onTextViewUpdated() })) textView.backgroundColor = colorConfig.aepEntryBackground return textView }() private lazy var aepIssueLabel: UILabel = { let label = UILabel() label.text = "This is never visible!" // Set in `onTextViewUpdated()` label.textColor = .ows_accentRed label.textAlignment = .center label.font = .dynamicTypeBody label.numberOfLines = 0 return label }() // MARK: - @objc private func dismissKeyboard() { view.endEditing(true) } // MARK: - private enum AEPValidationResult { case notFullyEntered case malformedAEP case wellFormedButMismatched case success(DisplayableAccountEntropyPool) } private func validateAEPText() -> AEPValidationResult { let enteredAep: DisplayableAccountEntropyPool switch aepTextView.aepContents { case .partialEntry: return .notFullyEntered case .malformed: return .malformedAEP case .valid(let displayableAEP): enteredAep = displayableAEP } switch aepValidationPolicy! { case .acceptAnyWellFormed: return .success(enteredAep) case .acceptOnly(let expectedAep): if enteredAep.rawValue == expectedAep { return .success(enteredAep) } else { return .wellFormedButMismatched } } } private func onTextViewUpdated() { switch validateAEPText() { case .notFullyEntered: navigationItem.rightBarButtonItem?.isEnabled = false aepIssueLabel.alpha = 0 case .malformedAEP: navigationItem.rightBarButtonItem?.isEnabled = false aepIssueLabel.text = OWSLocalizedString( "ENTER_ACCOUNT_ENTROPY_POOL_VIEW_MALFORMED_AEP_LABEL", comment: "Label explaining that an entered 'Recovery Key' is malformed.", ) aepIssueLabel.alpha = 1 case .wellFormedButMismatched: navigationItem.rightBarButtonItem?.isEnabled = false aepIssueLabel.text = OWSLocalizedString( "ENTER_ACCOUNT_ENTROPY_POOL_VIEW_INCORRECT_AEP_LABEL", comment: "Label explaining that an entered 'Recovery Key' is incorrect.", ) aepIssueLabel.alpha = 1 case .success: navigationItem.rightBarButtonItem?.isEnabled = true aepIssueLabel.alpha = 0 } } @objc private func didTapNext() { switch validateAEPText() { case .notFullyEntered, .malformedAEP, .wellFormedButMismatched: owsFailDebug("Next button should be disabled!") case .success(let displayableAEP): dismissKeyboard() onEntryConfirmed(displayableAEP.rawValue) } } } // MARK: - #if DEBUG private extension EnterAccountEntropyPoolViewController { static func forPreview() -> EnterAccountEntropyPoolViewController { let viewController = EnterAccountEntropyPoolViewController() viewController.configure( aepValidationPolicy: .acceptAnyWellFormed, colorConfig: ColorConfig( background: UIColor.Signal.background, aepEntryBackground: UIColor.Signal.quaternaryFill, ), headerStrings: HeaderStrings( title: "This is a Title", subtitle: "And this, longer, less important string, is a subtitle!", ), footerButtonConfig: FooterButtonConfig( title: "Footer Button", action: { print("Footer button!") }, ), onEntryConfirmed: { print("Confirmed: \($0.rawString)") }, ) return viewController } } @available(iOS 17, *) #Preview { return UINavigationController( rootViewController: EnterAccountEntropyPoolViewController.forPreview(), ) } #endif