296 lines
10 KiB
Swift
296 lines
10 KiB
Swift
//
|
|
// Copyright 2024 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
import SwiftUI
|
|
|
|
protocol RegistrationEnterBackupKeyPresenter: AnyObject {
|
|
func next(accountEntropyPool: AccountEntropyPool)
|
|
}
|
|
|
|
class RegistrationEnterBackupKeyViewController: OWSViewController, OWSNavigationChildController {
|
|
private weak var presenter: RegistrationEnterBackupKeyPresenter?
|
|
|
|
init(presenter: RegistrationEnterBackupKeyPresenter) {
|
|
self.presenter = presenter
|
|
super.init()
|
|
|
|
// TODO: [Backups] Disable this next button until the input is valid
|
|
navigationItem.rightBarButtonItem = nextBarButton
|
|
|
|
self.view.backgroundColor = UIColor.Signal.background
|
|
|
|
let stackView = UIStackView(arrangedSubviews: [
|
|
titleLabel,
|
|
descriptionLabel,
|
|
codeEntry,
|
|
noKeyButton,
|
|
])
|
|
self.view.addSubview(stackView)
|
|
stackView.axis = .vertical
|
|
stackView.spacing = 24
|
|
stackView.setCustomSpacing(12, after: titleLabel)
|
|
stackView.autoPinWidthToSuperview(withMargin: 20)
|
|
stackView.autoPinEdge(toSuperviewMargin: .top)
|
|
|
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
|
|
view.addGestureRecognizer(tapGesture)
|
|
}
|
|
|
|
// MARK: OWSNavigationChildController
|
|
|
|
public var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
|
|
|
|
public var navbarBackgroundColorOverride: UIColor? { .clear }
|
|
|
|
// MARK: UI
|
|
|
|
private lazy var codeEntry = BackupCodeEntry(frame: .zero)
|
|
|
|
private let titleLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.text = OWSLocalizedString(
|
|
"REGISTRATION_ENTER_BACKUP_KEY_TITLE",
|
|
comment: "Title for the screen that allows users to enter their backup key."
|
|
)
|
|
label.textAlignment = .center
|
|
label.font = .dynamicTypeTitle1.semibold()
|
|
label.numberOfLines = 0
|
|
return label
|
|
}()
|
|
|
|
private let descriptionLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.text = OWSLocalizedString(
|
|
"REGISTRATION_ENTER_BACKUP_KEY_DESCRIPTION",
|
|
comment: "Description for the screen that allows users to enter their backup key."
|
|
)
|
|
label.textColor = .secondaryLabel
|
|
label.textAlignment = .center
|
|
label.numberOfLines = 0
|
|
return label
|
|
}()
|
|
|
|
private lazy var noKeyButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.setTitle(
|
|
OWSLocalizedString(
|
|
"REGISTRATION_NO_BACKUP_KEY_BUTTON_TITLE",
|
|
comment: "Title of button to tap if you do not have a backup key during registration."
|
|
),
|
|
for: .normal
|
|
)
|
|
button.titleLabel?.font = .dynamicTypeBody.semibold()
|
|
button.setTitleColor(UIColor.Signal.ultramarine, for: .normal)
|
|
button.addTarget(self, action: #selector(didTapNoKeyButton), for: .touchUpInside)
|
|
return button
|
|
}()
|
|
|
|
@objc
|
|
private func didTapNoKeyButton() {
|
|
let sheet = HeroSheetViewController(
|
|
hero: .circleIcon(
|
|
icon: UIImage(named: "key")!,
|
|
iconSize: 35,
|
|
tintColor: UIColor.Signal.label,
|
|
backgroundColor: UIColor.Signal.background
|
|
),
|
|
title: OWSLocalizedString(
|
|
"REGISTRATION_NO_BACKUP_KEY_SHEET_TITLE",
|
|
comment: "Title for sheet with info for what to do if you don't have a backup key"
|
|
),
|
|
body: OWSLocalizedString(
|
|
"REGISTRATION_NO_BACKUP_KEY_SHEET_BODY",
|
|
comment: "Body text on a sheet with info for what to do if you don't have a backup key"
|
|
),
|
|
primaryButton: .init(title: OWSLocalizedString(
|
|
"REGISTRATION_NO_BACKUP_KEY_SKIP_RESTORE_BUTTON_TITLE",
|
|
comment: "Title for button on sheet for when you don't have a backup key"
|
|
)) { [weak self] in
|
|
// [Backups] TODO: Implement
|
|
self?.dismiss(animated: true)
|
|
},
|
|
secondaryButton: .init(title: CommonStrings.learnMore) { [weak self] in
|
|
// [Backups] TODO: Implement
|
|
self?.dismiss(animated: true)
|
|
}
|
|
)
|
|
self.present(sheet, animated: true)
|
|
}
|
|
|
|
@objc
|
|
private func dismissKeyboard() {
|
|
view.endEditing(true)
|
|
}
|
|
|
|
private lazy var nextBarButton = UIBarButtonItem(
|
|
title: CommonStrings.nextButton,
|
|
style: .done,
|
|
target: self,
|
|
action: #selector(didTapNext)
|
|
)
|
|
|
|
@objc
|
|
private func didTapNext() {
|
|
guard canSubmit else { return }
|
|
codeEntry.resignFirstResponder()
|
|
guard let aep = try? AccountEntropyPool(key: codeEntry.text.filter({!$0.isWhitespace})) else {
|
|
// TODO: [Backups] Present an error about invalid AEP entry here
|
|
return
|
|
}
|
|
self.presenter?.next(accountEntropyPool: aep)
|
|
}
|
|
|
|
private var canSubmit: Bool {
|
|
let codeCharCount = codeEntry.text.filter {
|
|
$0.isNumber || $0.isLetter
|
|
}.count
|
|
|
|
let expectedCodeCharCount = BackupCodeEntry.Constants.maxCharacterCount
|
|
|
|
return codeCharCount == expectedCodeCharCount
|
|
}
|
|
}
|
|
|
|
private class BackupCodeEntry: UIView {
|
|
fileprivate enum Constants {
|
|
static let chunkSize = 4
|
|
static let chunksPerRow = 4
|
|
static let spacesBetweenChunks = 4
|
|
static let totalChunks = 16
|
|
|
|
static var maxCharacterCount: Int {
|
|
Constants.totalChunks * Constants.chunkSize
|
|
}
|
|
}
|
|
|
|
private let textView = TextViewWithPlaceholder()
|
|
private lazy var heightConstraint = textView.autoSetDimension(.height, toSize: 400)
|
|
|
|
var text: String { textView.text ?? "" }
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
backgroundColor = UIColor.Signal.quaternaryFill
|
|
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 backup key text view."
|
|
)
|
|
|
|
self.translatesAutoresizingMaskIntoConstraints = false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
self.updateSizing()
|
|
}
|
|
|
|
private func updateSizing() {
|
|
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 charactersPerLine: Int = Constants.chunkSize * Constants.chunksPerRow + Constants.spacesBetweenChunks * (Constants.chunksPerRow - 1)
|
|
|
|
let characterWidth = width / CGFloat(charactersPerLine)
|
|
let fontSize = (characterWidth / referenceFontSize.width) * referenceFontSizePts
|
|
|
|
let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
|
self.textView.editorFont = font
|
|
|
|
let maxLineCount = Constants.totalChunks / Constants.chunksPerRow
|
|
let sizingString = Array(repeating: "0", count: maxLineCount).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
|
|
}
|
|
}
|
|
|
|
extension BackupCodeEntry: TextViewWithPlaceholderDelegate {
|
|
private nonisolated static func formatBackupKey(unformatted: String) -> String {
|
|
unformatted.lowercased().formattedWithSpaces(
|
|
every: Constants.chunkSize,
|
|
separator: String(repeating: " ", count: Constants.spacesBetweenChunks)
|
|
)
|
|
}
|
|
|
|
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: Constants.maxCharacterCount,
|
|
format: Self.formatBackupKey(unformatted:)
|
|
)
|
|
|
|
let selectedTextRange = uiTextView.selectedTextRange
|
|
uiTextView.attributedText = self.attributedString(for: uiTextView.text)
|
|
uiTextView.selectedTextRange = selectedTextRange
|
|
|
|
return false
|
|
}
|
|
|
|
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,
|
|
]
|
|
)
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
func formattedWithSpaces(every n: Int, separator: String) -> String {
|
|
guard n > 0 else { return self }
|
|
return self.enumerated().map { $0.offset % n == 0 && $0.offset != 0 ? "\(separator)\($0.element)" : "\($0.element)" }.joined()
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
private class PreviewRegistrationEnterBackupKeyPresenter: RegistrationEnterBackupKeyPresenter {
|
|
func next(accountEntropyPool: AccountEntropyPool) {
|
|
print("next")
|
|
}
|
|
}
|
|
|
|
@available(iOS 17, *)
|
|
#Preview {
|
|
let presenter = PreviewRegistrationEnterBackupKeyPresenter()
|
|
return RegistrationEnterBackupKeyViewController(presenter: presenter)
|
|
}
|
|
#endif
|