929 lines
34 KiB
Swift
929 lines
34 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import SafariServices
|
|
import SignalUI
|
|
import SignalServiceKit
|
|
|
|
public enum RegistrationPinCharacterSet {
|
|
case digitsOnly
|
|
case alphanumeric
|
|
}
|
|
|
|
/// A blob provided when confirming the PIN, which should be passed
|
|
/// back in to the confirm step controller.
|
|
/// Fields should not be inspected outside of this class.
|
|
public struct RegistrationPinConfirmationBlob: Equatable {
|
|
fileprivate let characterSet: RegistrationPinCharacterSet
|
|
fileprivate let pinToConfirm: String
|
|
|
|
#if TESTABLE_BUILD
|
|
public static func stub() -> Self {
|
|
return RegistrationPinConfirmationBlob(characterSet: .digitsOnly, pinToConfirm: "1234")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
public enum RegistrationPinValidationError: Equatable {
|
|
case wrongPin(wrongPin: String)
|
|
case serverError
|
|
}
|
|
|
|
// MARK: - RegistrationPinState
|
|
|
|
public struct RegistrationPinState: Equatable {
|
|
public enum Skippability: Equatable {
|
|
/// The user cannot skip PIN entry due to reglock.
|
|
case unskippable
|
|
/// The user can skip PIN entry for now but may require the PIN
|
|
/// later for registration lock and thus may not be able to create a new one.
|
|
case canSkip
|
|
/// The user can skip PIN entry and will be able to create a new PIN.
|
|
case canSkipAndCreateNew
|
|
|
|
public var canSkip: Bool {
|
|
switch self {
|
|
case .unskippable: return false
|
|
case .canSkip, .canSkipAndCreateNew: return true
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum RegistrationPinOperation: Equatable {
|
|
case creatingNewPin
|
|
case confirmingNewPin(RegistrationPinConfirmationBlob)
|
|
case enteringExistingPin(
|
|
skippability: Skippability,
|
|
/// The number of PIN attempts that the user has. If `nil`, the count is unknown.
|
|
remainingAttempts: UInt?
|
|
)
|
|
}
|
|
|
|
let operation: RegistrationPinOperation
|
|
let error: RegistrationPinValidationError?
|
|
let contactSupportMode: ContactSupportRegistrationPINMode
|
|
|
|
public enum ExitConfiguration: Equatable {
|
|
case noExitAllowed
|
|
case exitReRegistration
|
|
case exitChangeNumber
|
|
}
|
|
|
|
let exitConfiguration: ExitConfiguration
|
|
}
|
|
|
|
// MARK: - RegistrationPinPresenter
|
|
|
|
protocol RegistrationPinPresenter: AnyObject {
|
|
func cancelPinConfirmation()
|
|
|
|
/// Should ask for the pin confirmation next with the provided blob.
|
|
func askUserToConfirmPin(_ blob: RegistrationPinConfirmationBlob)
|
|
|
|
func submitPinCode(_ code: String)
|
|
func submitWithSkippedPin()
|
|
func submitWithCreateNewPinInstead()
|
|
|
|
func exitRegistration()
|
|
}
|
|
|
|
// MARK: - RegistrationPinViewController
|
|
|
|
class RegistrationPinViewController: OWSViewController {
|
|
private var learnMoreAboutPinsURL: URL { URL(string: "https://support.signal.org/hc/articles/360007059792")! }
|
|
|
|
public init(
|
|
state: RegistrationPinState,
|
|
presenter: RegistrationPinPresenter
|
|
) {
|
|
self.state = state
|
|
self.presenter = presenter
|
|
|
|
self.pinCharacterSet = {
|
|
switch state.operation {
|
|
case .creatingNewPin, .enteringExistingPin:
|
|
return .digitsOnly
|
|
case .confirmingNewPin(let blob):
|
|
return blob.characterSet
|
|
}
|
|
}()
|
|
|
|
super.init()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
public override init() {
|
|
owsFail("This should not be called")
|
|
}
|
|
|
|
// MARK: Internal state
|
|
|
|
public private(set) var state: RegistrationPinState {
|
|
didSet { render() }
|
|
}
|
|
|
|
private weak var presenter: RegistrationPinPresenter?
|
|
|
|
private var pinCharacterSet: RegistrationPinCharacterSet {
|
|
didSet { render() }
|
|
}
|
|
|
|
private var pin: String { pinTextField.text ?? "" }
|
|
|
|
private var canSubmit: Bool { pin.count >= kMin2FAv2PinLength }
|
|
|
|
private var previouslyWarnedAboutAttemptCount: UInt?
|
|
|
|
public func updateState(_ state: RegistrationPinState) {
|
|
self.state = state
|
|
}
|
|
|
|
// MARK: Rendering
|
|
|
|
private lazy var moreButton: ContextMenuButton = {
|
|
let result = ContextMenuButton(empty: ())
|
|
result.autoSetDimensions(to: .square(40))
|
|
return result
|
|
}()
|
|
|
|
private lazy var moreBarButton = UIBarButtonItem(
|
|
customView: moreButton,
|
|
accessibilityIdentifier: "registration.pin.disablePinButton"
|
|
)
|
|
|
|
private lazy var backButton: UIButton = {
|
|
let result = UIButton()
|
|
result.autoSetDimensions(to: CGSize(square: 40))
|
|
result.addTarget(self, action: #selector(didTapBack), for: .touchUpInside)
|
|
return result
|
|
}()
|
|
|
|
private lazy var backBarButton = UIBarButtonItem(
|
|
customView: backButton,
|
|
accessibilityIdentifier: "registration.pin.backButton"
|
|
)
|
|
|
|
private lazy var nextBarButton = UIBarButtonItem(
|
|
title: CommonStrings.nextButton,
|
|
style: .done,
|
|
target: self,
|
|
action: #selector(didTapNext),
|
|
accessibilityIdentifier: "registration.pin.nextButton"
|
|
)
|
|
|
|
private lazy var stackView: UIStackView = {
|
|
let result = UIStackView()
|
|
result.axis = .vertical
|
|
result.distribution = .fill
|
|
result.spacing = 12
|
|
result.setCustomSpacing(24, after: explanationView)
|
|
result.layoutMargins = .init(top: 0, leading: 0, bottom: 16, trailing: 0)
|
|
result.isLayoutMarginsRelativeArrangement = true
|
|
return result
|
|
}()
|
|
|
|
private lazy var titleLabel: UILabel = {
|
|
let result = UILabel.titleLabelForRegistration(text: {
|
|
switch state.operation {
|
|
case .creatingNewPin:
|
|
return OWSLocalizedString(
|
|
"REGISTRATION_PIN_CREATE_TITLE",
|
|
comment: "During registration, users are asked to create a PIN code. This is the title on the screen where this happens."
|
|
)
|
|
case .confirmingNewPin:
|
|
return OWSLocalizedString(
|
|
"REGISTRATION_PIN_CONFIRM_TITLE",
|
|
comment: "During registration, users are asked to create a PIN code. They'll be taken to a screen to confirm their PIN, much like confirming a password. This is the title on the screen where this happens."
|
|
)
|
|
case .enteringExistingPin:
|
|
return OWSLocalizedString(
|
|
"REGISTRATION_PIN_ENTER_EXISTING_TITLE",
|
|
comment: "During re-registration, users may be asked to re-enter their PIN code. This is the title on the screen where this happens."
|
|
)
|
|
}
|
|
}())
|
|
result.accessibilityIdentifier = "registration.pin.titleLabel"
|
|
return result
|
|
}()
|
|
|
|
private lazy var explanationView: LinkingTextView = {
|
|
let result = LinkingTextView()
|
|
result.attributedText = NSAttributedString.composed(
|
|
of: {
|
|
switch state.operation {
|
|
case .creatingNewPin:
|
|
return [
|
|
OWSLocalizedString(
|
|
"REGISTRATION_PIN_CREATE_SUBTITLE",
|
|
comment: "During registration, users are asked to create a PIN code. This is the subtitle on the screen where this happens. A \"learn more\" link will be added to the end of this string."
|
|
),
|
|
CommonStrings.learnMore.styled(
|
|
with: StringStyle.Part.link(learnMoreAboutPinsURL)
|
|
)
|
|
]
|
|
case .confirmingNewPin:
|
|
return [OWSLocalizedString(
|
|
"REGISTRATION_PIN_CONFIRM_SUBTITLE",
|
|
comment: "During registration, users are asked to create a PIN code. They'll be taken to a screen to confirm their PIN, much like confirming a password. This is the title on the screen where this happens."
|
|
)]
|
|
case .enteringExistingPin:
|
|
return [OWSLocalizedString(
|
|
"REGISTRATION_PIN_ENTER_EXISTING_SUBTITLE",
|
|
comment: "During re-registration, users may be asked to re-enter their PIN code. This is the subtitle on the screen where this happens. A \"learn more\" link will be added to the end of this string."
|
|
)]
|
|
}
|
|
}(),
|
|
separator: " "
|
|
)
|
|
result.font = .fontForRegistrationExplanationLabel
|
|
result.textAlignment = .center
|
|
result.delegate = self
|
|
result.accessibilityIdentifier = "registration.pin.explanationLabel"
|
|
return result
|
|
}()
|
|
|
|
private lazy var pinTextField: UITextField = {
|
|
let result = UITextField()
|
|
|
|
let font = UIFont.systemFont(ofSize: 22)
|
|
result.font = font
|
|
result.autoSetDimension(.height, toSize: font.lineHeight + 2 * 8.0)
|
|
result.textAlignment = .center
|
|
|
|
result.layer.cornerRadius = 10
|
|
|
|
result.textContentType = .password
|
|
result.isSecureTextEntry = true
|
|
result.defaultTextAttributes.updateValue(5, forKey: .kern)
|
|
result.accessibilityIdentifier = "registration.pin.pinTextField"
|
|
|
|
result.delegate = self
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var pinValidationLabel: UILabel = {
|
|
let result = UILabel()
|
|
result.textAlignment = .center
|
|
result.font = .dynamicTypeCaption1Clamped
|
|
return result
|
|
}()
|
|
|
|
private lazy var needHelpWithExistingPinButton: OWSFlatButton = {
|
|
let result = Self.flatButton()
|
|
result.setTitle(title: OWSLocalizedString(
|
|
"ONBOARDING_2FA_FORGOT_PIN_LINK",
|
|
comment: "Label for the 'forgot 2FA PIN' link in the 'onboarding 2FA' view."
|
|
))
|
|
result.addTarget(target: self, selector: #selector(showExistingPinEntryHelpUi))
|
|
result.accessibilityIdentifier = "registration.pin.needHelpButton"
|
|
return result
|
|
}()
|
|
|
|
private lazy var togglePinCharacterSetButton: OWSFlatButton = {
|
|
let result = Self.flatButton()
|
|
result.addTarget(target: self, selector: #selector(togglePinCharacterSet))
|
|
result.accessibilityIdentifier = "registration.pin.togglePinCharacterSetButton"
|
|
return result
|
|
}()
|
|
|
|
private static func flatButton() -> OWSFlatButton {
|
|
let result = OWSFlatButton()
|
|
result.setTitle(font: .dynamicTypeSubheadlineClamped)
|
|
result.setBackgroundColors(upColor: .clear)
|
|
result.enableMultilineLabel()
|
|
result.button.clipsToBounds = true
|
|
result.button.layer.cornerRadius = 8
|
|
result.contentEdgeInsets = UIEdgeInsets(hMargin: 4, vMargin: 8)
|
|
return result
|
|
}
|
|
|
|
private func exitAction() -> UIAction? {
|
|
let exitTitle: String
|
|
switch state.exitConfiguration {
|
|
case .noExitAllowed:
|
|
return nil
|
|
case .exitReRegistration:
|
|
exitTitle = OWSLocalizedString(
|
|
"EXIT_REREGISTRATION",
|
|
comment: "Button to exit re-registration, shown in context menu."
|
|
)
|
|
case .exitChangeNumber:
|
|
exitTitle = OWSLocalizedString(
|
|
"EXIT_CHANGE_NUMBER",
|
|
comment: "Button to exit change number, shown in context menu."
|
|
)
|
|
}
|
|
return UIAction(
|
|
title: exitTitle,
|
|
handler: { [weak self] _ in
|
|
self?.presenter?.exitRegistration()
|
|
}
|
|
)
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
initialRender()
|
|
}
|
|
|
|
private var isViewAppeared = false
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
if !UIDevice.current.isIPhone5OrShorter {
|
|
// Small devices may obscure parts of the UI behind the keyboard, especially with larger
|
|
// font sizes.
|
|
pinTextField.becomeFirstResponder()
|
|
}
|
|
|
|
isViewAppeared = true
|
|
|
|
render()
|
|
}
|
|
|
|
public override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
isViewAppeared = false
|
|
}
|
|
|
|
public override func themeDidChange() {
|
|
super.themeDidChange()
|
|
render()
|
|
}
|
|
|
|
private func initialRender() {
|
|
navigationItem.setHidesBackButton(true, animated: false)
|
|
|
|
let scrollView = UIScrollView()
|
|
view.addSubview(scrollView)
|
|
scrollView.autoPinWidthToSuperviewMargins()
|
|
scrollView.autoPinEdge(.top, to: .top, of: keyboardLayoutGuideViewSafeArea)
|
|
scrollView.autoPinEdge(.bottom, to: .bottom, of: keyboardLayoutGuideViewSafeArea)
|
|
|
|
scrollView.addSubview(stackView)
|
|
stackView.autoPinWidth(toWidthOf: scrollView)
|
|
stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor).isActive = true
|
|
|
|
stackView.addArrangedSubview(titleLabel)
|
|
stackView.addArrangedSubview(explanationView)
|
|
stackView.addArrangedSubview(pinTextField)
|
|
|
|
render()
|
|
}
|
|
|
|
private func render() {
|
|
switch state.operation {
|
|
case .creatingNewPin:
|
|
renderForCreatingNewPin()
|
|
case .confirmingNewPin:
|
|
renderForConfirmingNewPin()
|
|
case let .enteringExistingPin(skippability, remainingAttempts):
|
|
renderForEnteringExistingPin(skippability: skippability, remainingAttempts: remainingAttempts)
|
|
}
|
|
|
|
navigationItem.rightBarButtonItem = canSubmit ? nextBarButton : nil
|
|
|
|
let previousKeyboardType = pinTextField.keyboardType
|
|
switch pinCharacterSet {
|
|
case .digitsOnly:
|
|
pinTextField.keyboardType = .numberPad
|
|
case .alphanumeric:
|
|
pinTextField.keyboardType = .default
|
|
}
|
|
if previousKeyboardType != pinTextField.keyboardType {
|
|
pinTextField.reloadInputViews()
|
|
}
|
|
|
|
view.backgroundColor = Theme.backgroundColor
|
|
moreButton.setImage(Theme.iconImage(.buttonMore), for: .normal)
|
|
moreButton.tintColor = Theme.accentBlueColor
|
|
backButton.setTemplateImage(
|
|
UIImage(imageLiteralResourceName: "NavBarBack"),
|
|
tintColor: Theme.accentBlueColor
|
|
)
|
|
nextBarButton.tintColor = Theme.accentBlueColor
|
|
titleLabel.textColor = .colorForRegistrationTitleLabel
|
|
explanationView.textColor = .colorForRegistrationExplanationLabel
|
|
explanationView.linkTextAttributes = [
|
|
.foregroundColor: Theme.accentBlueColor,
|
|
.underlineColor: UIColor.clear
|
|
]
|
|
pinTextField.textColor = Theme.primaryTextColor
|
|
pinTextField.backgroundColor = Theme.secondaryBackgroundColor
|
|
pinTextField.keyboardAppearance = Theme.keyboardAppearance
|
|
togglePinCharacterSetButton.setTitleColor(Theme.accentBlueColor)
|
|
}
|
|
|
|
private func renderForCreatingNewPin() {
|
|
navigationItem.leftBarButtonItem = moreBarButton
|
|
moreButton.setActions(actions: [
|
|
UIAction(
|
|
title: OWSLocalizedString(
|
|
"PIN_CREATION_LEARN_MORE",
|
|
comment: "Learn more action on the pin creation view"
|
|
),
|
|
handler: { [weak self] _ in
|
|
self?.showCreatingNewPinLearnMoreUi()
|
|
}
|
|
),
|
|
UIAction(
|
|
title: OWSLocalizedString(
|
|
"PIN_CREATION_SKIP",
|
|
comment: "Skip action on the pin creation view"
|
|
),
|
|
handler: { [weak self] _ in
|
|
self?.showSkipCreatingNewPinUi()
|
|
}
|
|
),
|
|
exitAction()
|
|
].compacted())
|
|
|
|
switch pinCharacterSet {
|
|
case .digitsOnly:
|
|
pinValidationLabel.text = OWSLocalizedString(
|
|
"PIN_CREATION_NUMERIC_HINT",
|
|
comment: "Label indicating the user must use at least 4 digits"
|
|
)
|
|
togglePinCharacterSetButton.setTitle(title: OWSLocalizedString(
|
|
"PIN_CREATION_CREATE_ALPHANUMERIC",
|
|
comment: "Button asking if the user would like to create an alphanumeric PIN"
|
|
))
|
|
case .alphanumeric:
|
|
pinValidationLabel.text = OWSLocalizedString(
|
|
"PIN_CREATION_ALPHANUMERIC_HINT",
|
|
comment: "Label indicating the user must use at least 4 characters"
|
|
)
|
|
togglePinCharacterSetButton.setTitle(title: OWSLocalizedString(
|
|
"PIN_CREATION_CREATE_NUMERIC",
|
|
comment: "Button asking if the user would like to create an numeric PIN"
|
|
))
|
|
}
|
|
pinValidationLabel.textColor = .colorForRegistrationExplanationLabel
|
|
|
|
replaceViewsAfterTextField(with: [
|
|
pinValidationLabel,
|
|
UIView.vStretchingSpacer(),
|
|
togglePinCharacterSetButton
|
|
])
|
|
}
|
|
|
|
private func renderForConfirmingNewPin() {
|
|
navigationItem.leftBarButtonItem = backBarButton
|
|
|
|
replaceViewsAfterTextField(with: [UIView.vStretchingSpacer()])
|
|
}
|
|
|
|
private func renderForEnteringExistingPin(
|
|
skippability: RegistrationPinState.Skippability,
|
|
remainingAttempts: UInt?
|
|
) {
|
|
if skippability.canSkip {
|
|
navigationItem.leftBarButtonItem = moreBarButton
|
|
moreButton.setActions(actions: [
|
|
UIAction(
|
|
title: OWSLocalizedString(
|
|
"PIN_ENTER_EXISTING_SKIP",
|
|
comment: "If the user is re-registering, they need to enter their PIN to restore all their data. In some cases, they can skip this entry and lose some data. This text is shown on a button that lets them begin to do this."
|
|
),
|
|
handler: { [weak self] _ in
|
|
self?.didRequestToSkipEnteringExistingPin()
|
|
}
|
|
),
|
|
exitAction()
|
|
].compacted())
|
|
} else {
|
|
navigationItem.leftBarButtonItem = nil
|
|
}
|
|
|
|
showAttemptWarningIfNecessary(
|
|
remainingAttempts: remainingAttempts,
|
|
warnAt: skippability.canSkip ? [3, 1] : [5, 3, 1],
|
|
canSkip: skippability.canSkip
|
|
)
|
|
|
|
var newViewsAtTheBottom: [UIView] = []
|
|
|
|
switch state.error {
|
|
case nil:
|
|
break
|
|
case .wrongPin:
|
|
switch remainingAttempts {
|
|
case 1:
|
|
pinValidationLabel.text = OWSLocalizedString(
|
|
"ONBOARDING_2FA_INVALID_PIN_LAST_ATTEMPT",
|
|
comment: "Label indicating that the 2fa pin is invalid in the 'onboarding 2fa' view, and you only have one more attempt"
|
|
)
|
|
default:
|
|
pinValidationLabel.text = OWSLocalizedString(
|
|
"ONBOARDING_2FA_INVALID_PIN",
|
|
comment: "Label indicating that the 2fa pin is invalid in the 'onboarding 2fa' view."
|
|
)
|
|
}
|
|
newViewsAtTheBottom.append(pinValidationLabel)
|
|
case .serverError:
|
|
pinValidationLabel.text = OWSLocalizedString(
|
|
"SOMETHING_WENT_WRONG_TRY_AGAIN_LATER_ERROR",
|
|
comment: "An error message generically indicating that something went wrong, and that the user should try again later."
|
|
)
|
|
newViewsAtTheBottom.append(pinValidationLabel)
|
|
}
|
|
pinValidationLabel.textColor = .ows_accentRed
|
|
|
|
needHelpWithExistingPinButton.setTitleColor(Theme.accentBlueColor)
|
|
|
|
switch pinCharacterSet {
|
|
case .digitsOnly:
|
|
togglePinCharacterSetButton.setTitle(title: OWSLocalizedString(
|
|
"ONBOARDING_2FA_ENTER_ALPHANUMERIC",
|
|
comment: "Button asking if the user would like to enter an alphanumeric PIN"
|
|
))
|
|
case .alphanumeric:
|
|
togglePinCharacterSetButton.setTitle(title: OWSLocalizedString(
|
|
"ONBOARDING_2FA_ENTER_NUMERIC",
|
|
comment: "Button asking if the user would like to enter an numeric PIN"
|
|
))
|
|
}
|
|
|
|
replaceViewsAfterTextField(with: [
|
|
pinValidationLabel,
|
|
needHelpWithExistingPinButton,
|
|
UIView.vStretchingSpacer(),
|
|
togglePinCharacterSetButton
|
|
])
|
|
}
|
|
|
|
private func showAttemptWarningIfNecessary(
|
|
remainingAttempts: UInt?,
|
|
warnAt: Set<UInt>,
|
|
canSkip: Bool
|
|
) {
|
|
guard
|
|
isViewAppeared,
|
|
let remainingAttempts,
|
|
warnAt.contains(remainingAttempts),
|
|
remainingAttempts < (previouslyWarnedAboutAttemptCount ?? UInt.max)
|
|
else { return }
|
|
|
|
defer {
|
|
previouslyWarnedAboutAttemptCount = remainingAttempts
|
|
}
|
|
|
|
let title: String?
|
|
if state.error == nil {
|
|
// It's unlikely, but we could hit this case if we return to this screen without
|
|
// recently guessing a PIN. We don't want to show an "incorrect PIN" title because you
|
|
// didn't just enter one, but we do still want to tell the user that they don't have
|
|
// many guesses left.
|
|
title = nil
|
|
} else {
|
|
title = OWSLocalizedString(
|
|
"REGISTER_2FA_INVALID_PIN_ALERT_TITLE",
|
|
comment: "Alert title explaining what happens if you forget your 'two-factor auth pin'."
|
|
)
|
|
}
|
|
|
|
let message: NSAttributedString = {
|
|
let attemptRemainingFormat = OWSLocalizedString(
|
|
"REREGISTER_INVALID_PIN_ATTEMPT_COUNT_%d",
|
|
tableName: "PluralAware",
|
|
comment: "If the user is re-registering, they may need to enter their PIN to restore all their data. If they enter the incorrect PIN, they may be warned that they only have a certain number of attempts remaining. That warning will tell the user how many attempts they have in bold text. This is that bold text, which is inserted into the larger string."
|
|
)
|
|
let attemptRemainingString = String.localizedStringWithFormat(
|
|
attemptRemainingFormat,
|
|
remainingAttempts
|
|
)
|
|
|
|
let format: String
|
|
if canSkip {
|
|
format = OWSLocalizedString(
|
|
"REREGISTER_INVALID_PIN_WARNING_SKIPPABLE_FORMAT",
|
|
comment: "If the user is re-registering, they may need to enter their PIN to restore all their data. If they enter the incorrect PIN, they will be shown a warning. In some cases (such as for this string), the user has the option to skip PIN entry and will lose some data. Embeds {{ number of attempts }}, such as \"3 attempts\"."
|
|
)
|
|
} else {
|
|
format = OWSLocalizedString(
|
|
"REREGISTER_INVALID_PIN_WARNING_UNSKIPPABLE_FORMAT",
|
|
comment: "If the user is re-registering, they may need to enter their PIN to restore all their data. If they enter the incorrect PIN, they will be shown a warning. Embeds {{ number of attempts }}, such as \"3 attempts\"."
|
|
)
|
|
}
|
|
|
|
return NSAttributedString.make(
|
|
fromFormat: format,
|
|
attributedFormatArgs: [.string(
|
|
attemptRemainingString,
|
|
attributes: [.font: ActionSheetController.messageLabelFont.semibold()]
|
|
)],
|
|
defaultAttributes: [.font: ActionSheetController.messageLabelFont]
|
|
)
|
|
}()
|
|
|
|
OWSActionSheets.showActionSheet(title: title, message: message)
|
|
}
|
|
|
|
private func replaceViewsAfterTextField(with views: [UIView]) {
|
|
stackView.removeArrangedSubviewsAfter(pinTextField)
|
|
stackView.addArrangedSubviews(views)
|
|
}
|
|
|
|
// MARK: Sheets
|
|
|
|
private func showCreatingNewPinLearnMoreUi() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"PIN_CREATION_LEARN_MORE_TITLE",
|
|
comment: "Users can create PINs to restore their account data later. They can learn more about this on a sheet. This is the title on that sheet."
|
|
),
|
|
message: OWSLocalizedString(
|
|
"PIN_CREATION_LEARN_MORE_TEXT",
|
|
comment: "Users can create PINs to restore their account data later. They can learn more about this on a sheet. This is the text on that sheet."
|
|
)
|
|
)
|
|
|
|
actionSheet.addAction(.init(title: CommonStrings.learnMore) { [weak self] _ in
|
|
guard let self else { return }
|
|
self.present(SFSafariViewController(url: self.learnMoreAboutPinsURL), animated: true)
|
|
})
|
|
|
|
actionSheet.addAction(.init(title: CommonStrings.okayButton))
|
|
|
|
OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
|
|
}
|
|
|
|
@objc
|
|
private func showExistingPinEntryHelpUi() {
|
|
let message: String
|
|
switch state.operation {
|
|
case .creatingNewPin, .confirmingNewPin:
|
|
owsFail("Invalid state. This method should not be called")
|
|
case let .enteringExistingPin(skippability, _):
|
|
switch skippability {
|
|
case .unskippable:
|
|
message = OWSLocalizedString(
|
|
"REGISTER_2FA_FORGOT_SVR_PIN_ALERT_MESSAGE",
|
|
comment: "Alert body for a forgotten SVR (V2) PIN"
|
|
)
|
|
case .canSkip:
|
|
message = OWSLocalizedString(
|
|
"REGISTER_2FA_FORGOT_SVR_PIN_WITHOUT_REGLOCK_ALERT_MESSAGE",
|
|
comment: "Alert body for a forgotten SVR (V2) PIN when the user doesn't have reglock and they cannot necessarily create a new PIN"
|
|
)
|
|
case .canSkipAndCreateNew:
|
|
message = OWSLocalizedString(
|
|
"REGISTER_2FA_FORGOT_SVR_PIN_WITHOUT_REGLOCK_AND_CAN_CREATE_NEW_PIN_ALERT_MESSAGE",
|
|
comment: "Alert body for a forgotten SVR (V2) PIN when the user doesn't have reglock and they can create a new PIN"
|
|
)
|
|
}
|
|
}
|
|
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"REGISTER_2FA_FORGOT_PIN_ALERT_TITLE",
|
|
comment: "Alert title explaining what happens if you forget your 'two-factor auth pin'."
|
|
),
|
|
message: message
|
|
)
|
|
|
|
switch state.operation {
|
|
case .creatingNewPin, .confirmingNewPin:
|
|
owsFail("Invalid state. This method should not be called")
|
|
case let .enteringExistingPin(skippability, _):
|
|
switch skippability {
|
|
case .unskippable:
|
|
break
|
|
case .canSkip:
|
|
let skipButtonTitle = OWSLocalizedString(
|
|
"PIN_ENTER_EXISTING_SKIP",
|
|
comment: "If the user is re-registering, they need to enter their PIN to restore all their data. In some cases, they can skip this entry and lose some data. This text is shown on a button that lets them begin to do this."
|
|
)
|
|
actionSheet.addAction(.init(title: skipButtonTitle, style: .destructive) { [weak self] _ in
|
|
self?.presenter?.submitWithSkippedPin()
|
|
})
|
|
case .canSkipAndCreateNew:
|
|
let skipButtonTitle = OWSLocalizedString(
|
|
"ONBOARDING_2FA_SKIP_AND_CREATE_NEW_PIN",
|
|
comment: "Label for the 'skip and create new pin' button when reglock is disabled during onboarding."
|
|
)
|
|
actionSheet.addAction(.init(title: skipButtonTitle, style: .destructive) { [weak self] _ in
|
|
self?.presenter?.submitWithCreateNewPinInstead()
|
|
})
|
|
}
|
|
}
|
|
|
|
actionSheet.addAction(.init(title: CommonStrings.contactSupport) { [weak self] _ in
|
|
guard let self else { return }
|
|
ContactSupportAlert.showForRegistrationPINMode(self.state.contactSupportMode, from: self)
|
|
})
|
|
|
|
actionSheet.addAction(OWSActionSheets.cancelAction)
|
|
|
|
OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
|
|
}
|
|
|
|
private func showSkipCreatingNewPinUi() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"PIN_CREATION_DISABLE_CONFIRMATION_TITLE",
|
|
comment: "Title of the 'pin disable' action sheet."
|
|
),
|
|
message: OWSLocalizedString(
|
|
"PIN_CREATION_DISABLE_CONFIRMATION_MESSAGE",
|
|
comment: "Message of the 'pin disable' action sheet."
|
|
)
|
|
)
|
|
|
|
actionSheet.addAction(.init(
|
|
title: OWSLocalizedString(
|
|
"PIN_CREATION_DISABLE_CONFIRMATION_ACTION",
|
|
comment: "Action of the 'pin disable' action sheet."
|
|
),
|
|
style: .destructive
|
|
) { [weak self] _ in
|
|
self?.presenter?.submitWithSkippedPin()
|
|
})
|
|
|
|
actionSheet.addAction(.init(title: CommonStrings.cancelButton, style: .cancel))
|
|
|
|
OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
|
|
}
|
|
|
|
// MARK: Events
|
|
|
|
@objc
|
|
private func didTapBack() {
|
|
Logger.info("")
|
|
|
|
presenter?.cancelPinConfirmation()
|
|
}
|
|
|
|
@objc
|
|
private func didTapNext() {
|
|
Logger.info("")
|
|
|
|
guard canSubmit else { return }
|
|
|
|
submit()
|
|
}
|
|
|
|
@objc
|
|
private func togglePinCharacterSet() {
|
|
Logger.info("")
|
|
|
|
switch pinCharacterSet {
|
|
case .digitsOnly: pinCharacterSet = .alphanumeric
|
|
case .alphanumeric: pinCharacterSet = .digitsOnly
|
|
}
|
|
|
|
pinTextField.text = ""
|
|
|
|
render()
|
|
}
|
|
|
|
private func didRequestToSkipEnteringExistingPin() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"ONBOARDING_2FA_SKIP_PIN_ENTRY_TITLE",
|
|
comment: "Title for the skip pin entry action sheet during onboarding."
|
|
),
|
|
message: NSAttributedString.composed(
|
|
of: [
|
|
OWSLocalizedString(
|
|
"ONBOARDING_2FA_SKIP_PIN_ENTRY_MESSAGE",
|
|
comment: "Explanation for the skip pin entry action sheet during onboarding."
|
|
),
|
|
CommonStrings.learnMore.styled(with: .link(learnMoreAboutPinsURL))
|
|
],
|
|
baseStyle: ActionSheetController.messageBaseStyle,
|
|
separator: " "
|
|
)
|
|
)
|
|
|
|
actionSheet.addAction(.init(
|
|
title: OWSLocalizedString(
|
|
"ONBOARDING_2FA_SKIP_AND_CREATE_NEW_PIN",
|
|
comment: "Label for the 'skip and create new pin' button when reglock is disabled during onboarding."
|
|
),
|
|
style: .destructive
|
|
) { [weak self] _ in
|
|
self?.presenter?.submitWithSkippedPin()
|
|
})
|
|
|
|
actionSheet.addAction(OWSActionSheets.cancelAction)
|
|
|
|
OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
|
|
}
|
|
|
|
private func submit() {
|
|
Logger.info("")
|
|
|
|
switch state.operation {
|
|
case .creatingNewPin:
|
|
if OWS2FAManager.isWeakPin(pin) {
|
|
showWeakPinErrorUi()
|
|
} else {
|
|
presenter?.askUserToConfirmPin(RegistrationPinConfirmationBlob(
|
|
characterSet: pinCharacterSet,
|
|
pinToConfirm: pin
|
|
))
|
|
}
|
|
case let .confirmingNewPin(blob):
|
|
if pin == blob.pinToConfirm {
|
|
presenter?.submitPinCode(blob.pinToConfirm)
|
|
} else {
|
|
showMismatchedPinUi()
|
|
}
|
|
case .enteringExistingPin:
|
|
presenter?.submitPinCode(pin)
|
|
}
|
|
}
|
|
|
|
private func showWeakPinErrorUi() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"PIN_CREATION_WEAK_ERROR",
|
|
comment: "Label indicating that the attempted PIN is too weak"
|
|
),
|
|
message: OWSLocalizedString(
|
|
"PIN_CREATION_WEAK_ERROR_MESSAGE",
|
|
comment: "If your attempted PIN is too weak, you'll see an error message. This is the text on the error dialog."
|
|
)
|
|
)
|
|
|
|
actionSheet.addAction(.init(title: CommonStrings.okayButton))
|
|
|
|
presentActionSheet(actionSheet)
|
|
}
|
|
|
|
private func showMismatchedPinUi() {
|
|
let actionSheet = ActionSheetController(
|
|
message: OWSLocalizedString(
|
|
"PIN_CREATION_MISMATCH_ERROR",
|
|
comment: "Label indicating that the attempted PIN does not match the first PIN"
|
|
)
|
|
)
|
|
|
|
actionSheet.addAction(.init(title: CommonStrings.okayButton))
|
|
|
|
presentActionSheet(actionSheet)
|
|
}
|
|
}
|
|
|
|
// MARK: - UITextViewDelegate
|
|
|
|
extension RegistrationPinViewController: UITextViewDelegate {
|
|
func textView(
|
|
_ textView: UITextView,
|
|
shouldInteractWith URL: URL,
|
|
in characterRange: NSRange,
|
|
interaction: UITextItemInteraction
|
|
) -> Bool {
|
|
if textView == explanationView {
|
|
switch state.operation {
|
|
case .creatingNewPin:
|
|
showCreatingNewPinLearnMoreUi()
|
|
case .confirmingNewPin, .enteringExistingPin:
|
|
owsFailBeta("There shouldn't be links during these operations")
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - UITextFieldDelegate
|
|
|
|
extension RegistrationPinViewController: UITextFieldDelegate {
|
|
func textField(
|
|
_ textField: UITextField,
|
|
shouldChangeCharactersIn range: NSRange,
|
|
replacementString: String
|
|
) -> Bool {
|
|
let result: Bool
|
|
switch pinCharacterSet {
|
|
case .digitsOnly:
|
|
TextFieldFormatting.ows2FAPINTextField(
|
|
textField,
|
|
changeCharactersIn: range,
|
|
replacementString: replacementString
|
|
)
|
|
result = false
|
|
case .alphanumeric:
|
|
result = true
|
|
}
|
|
|
|
render()
|
|
|
|
return result
|
|
}
|
|
|
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
Logger.info("")
|
|
|
|
if canSubmit { submit() }
|
|
|
|
return false
|
|
}
|
|
}
|