// // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI import UIKit class EnterAccountEntropyPoolViewController: OWSViewController, OWSNavigationChildController { 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() { view.backgroundColor = colorConfig.background navigationItem.rightBarButtonItem = nextBarButtonItem let stackView = UIStackView(arrangedSubviews: [ usernameTextField, titleLabel, subtitleLabel, aepTextView, aepIssueLabel, footerButton, ]) self.view.addSubview(stackView) stackView.axis = .vertical stackView.spacing = 24 stackView.setCustomSpacing(16, after: aepTextView) stackView.setCustomSpacing(20, after: aepIssueLabel) stackView.setCustomSpacing(12, after: titleLabel) stackView.autoPinWidthToSuperview(withMargin: 20) stackView.autoPinEdge(toSuperviewMargin: .top) let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) view.addGestureRecognizer(tapGesture) onTextViewUpdated() } // MARK: - private lazy var nextBarButtonItem = UIBarButtonItem( title: CommonStrings.nextButton, style: .done, target: self, action: #selector(didTapNext) ) private lazy var aepTextView = { let textView = AccountEntropyPoolTextView(onTextViewUpdated: { [weak self] in self?.onTextViewUpdated() }) textView.backgroundColor = colorConfig.aepEntryBackground return textView }() private lazy var titleLabel: UILabel = { let label = UILabel() label.text = headerStrings.title label.textAlignment = .center label.font = .dynamicTypeTitle1.semibold() label.numberOfLines = 0 return label }() private lazy var subtitleLabel: UILabel = { let label = UILabel() label.text = headerStrings.subtitle label.textColor = .secondaryLabel label.textAlignment = .center label.font = .dynamicTypeBody label.numberOfLines = 0 return label }() 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 }() private lazy var footerButton: UIButton = { let button = UIButton(type: .system) button.setTitle(footerButtonConfig.title, for: .normal) button.titleLabel?.font = .dynamicTypeBody.semibold() button.setTitleColor(UIColor.Signal.ultramarine, for: .normal) button.addTarget(self, action: #selector(didTapNoKeyButton), for: .touchUpInside) return button }() private lazy var usernameTextField: UITextField = { let usernameField = UITextField() usernameField.font = UIFont.systemFont(ofSize: 1) usernameField.autoSetDimension(.height, toSize: 1) usernameField.textContentType = .username usernameField.backgroundColor = view.backgroundColor usernameField.textColor = view.backgroundColor usernameField.text = OWSLocalizedString( "AUTOFILL_BACKUP_KEY_USERNAME", comment: "Username for recovery key autofill" ) return usernameField }() // MARK: - @objc private func didTapNoKeyButton() { footerButtonConfig.action() } @objc private func dismissKeyboard() { view.endEditing(true) } // MARK: - private enum AEPValidationResult { case notFullyEntered case malformedAEP case wellFormedButMismatched case success(AccountEntropyPool) } private func validateAEPText() -> AEPValidationResult { let enteredAepText = aepTextView.text.filter { $0.isNumber || $0.isLetter } guard enteredAepText.count == AccountEntropyPool.Constants.byteLength else { return .notFullyEntered } guard let enteredAep = try? AccountEntropyPool(key: enteredAepText) else { return .malformedAEP } switch aepValidationPolicy! { case .acceptAnyWellFormed: return .success(enteredAep) case .acceptOnly(let expectedAep): if enteredAep.rawData == expectedAep.rawData { return .success(enteredAep) } else { return .wellFormedButMismatched } } } private func onTextViewUpdated() { switch validateAEPText() { case .notFullyEntered: nextBarButtonItem.isEnabled = false aepIssueLabel.alpha = 0 case .malformedAEP: nextBarButtonItem.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: nextBarButtonItem.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: nextBarButtonItem.isEnabled = true aepIssueLabel.alpha = 0 } } @objc private func didTapNext() { switch validateAEPText() { case .notFullyEntered, .malformedAEP, .wellFormedButMismatched: owsFailDebug("Next button should be disabled!") case .success(let aep): aepTextView.resignFirstResponder() onEntryConfirmed(aep) } } } // MARK: - private class AccountEntropyPoolTextView: UIView { private enum Constants { static let chunkSize = 4 static let chunksPerRow = 4 static let rowCount = 4 static let spacesBetweenChunks = 4 static var charactersPerRow: Int { let chunkChars = Constants.chunkSize * Constants.chunksPerRow let spaceChars = Constants.spacesBetweenChunks * (Constants.chunksPerRow - 1) return chunkChars + spaceChars } private static let aepLengthPrecondition: Void = { let characterCount = chunkSize * chunksPerRow * rowCount owsPrecondition(characterCount == AccountEntropyPool.Constants.byteLength) }() } private let textView = TextViewWithPlaceholder() private lazy var heightConstraint = textView.autoSetDimension(.height, toSize: 400) var text: String { textView.text ?? "" } let onTextViewUpdated: () -> Void init(onTextViewUpdated: @escaping () -> Void) { self.onTextViewUpdated = onTextViewUpdated super.init(frame: .zero) layer.cornerRadius = 10 layoutMargins = .init(hMargin: 20, vMargin: 14) addSubview(textView) textView.delegate = self textView.spellCheckingType = .no textView.autocorrectionType = .no textView.textContainerInset = .zero textView.keyboardType = .asciiCapable textView.autoPinEdgesToSuperviewMargins() textView.placeholderText = OWSLocalizedString( "BACKUP_KEY_PLACEHOLDER", comment: "Text used as placeholder in recovery key text view." ) textView.setSecureTextEntry(val: true) textView.setTextContentType(val: .password) self.translatesAutoresizingMaskIntoConstraints = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - override func layoutSubviews() { super.layoutSubviews() let width = self.width - self.layoutMargins.totalWidth let referenceFontSizePts: CGFloat = 17 // Any character will do because font is monospaced. let referenceFontSize = "0".size(withAttributes: [ .font: UIFont.monospacedSystemFont( ofSize: referenceFontSizePts, weight: .regular ) ]) let characterWidth = width / CGFloat(Constants.charactersPerRow) let fontSize = (characterWidth / referenceFontSize.width) * referenceFontSizePts let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) self.textView.editorFont = font let sizingString = Array(repeating: "0", count: Constants.rowCount).joined(separator: "\n") let sizingAttributedString = self.attributedString(for: sizingString) self.heightConstraint.constant = sizingAttributedString.boundingRect( with: CGSize(width: width, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil ).size.ceil.height } private func attributedString(for string: String) -> NSAttributedString { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = 14 return NSAttributedString( string: string, attributes: [ .font: textView.editorFont ?? UIFont.monospacedDigitFont(ofSize: 17), .foregroundColor: UIColor.Signal.label, .paragraphStyle: paragraphStyle, ] ) } } // MARK: - extension AccountEntropyPoolTextView: TextViewWithPlaceholderDelegate { func textViewDidUpdateText(_ textView: TextViewWithPlaceholder) { // For autofill, the text is set without first passing through the formatting code. // Detect if the text is not formatted by looking for spaced chunks, and call the // formatting function if not. let formattedSpace = String(repeating: " ", count: Constants.spacesBetweenChunks) if let t = textView.text, !t.isEmpty, t.count > Constants.spacesBetweenChunks, !t.contains(formattedSpace) { textView.reformatText(replacementText: t) } onTextViewUpdated() } func textView( _ textView: TextViewWithPlaceholder, uiTextView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String ) -> Bool { defer { // This isn't called when this function returns false, but // we need it to to show and hide the placeholder text textView.textViewDidChange(uiTextView) } _ = FormattedNumberField.textField( uiTextView, shouldChangeCharactersIn: range, replacementString: text, allowedCharacters: .alphanumeric, maxCharacters: AccountEntropyPool.Constants.byteLength, format: { unformatted in return unformatted.lowercased() .enumerated() .map { index, char -> String in if index > 0, index % Constants.chunkSize == 0 { return String(repeating: " ", count: Constants.spacesBetweenChunks) + String(char) } else { return String(char) } } .joined() } ) let selectedTextRange = uiTextView.selectedTextRange uiTextView.attributedText = self.attributedString(for: uiTextView.text) uiTextView.selectedTextRange = selectedTextRange return false } } // 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.rawData)") } ) return viewController } } @available(iOS 17, *) #Preview { return EnterAccountEntropyPoolViewController.forPreview() } #endif