873 lines
36 KiB
Swift
873 lines
36 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import SafariServices
|
|
import SignalCoreKit
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
@objc(OWSPinSetupViewController)
|
|
public class PinSetupViewController: OWSViewController {
|
|
|
|
lazy private var titleLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.textColor = Theme.primaryTextColor
|
|
label.font = UIFont.ows_dynamicTypeTitle1Clamped.ows_semibold
|
|
label.textAlignment = .center
|
|
label.text = titleText
|
|
return label
|
|
}()
|
|
|
|
lazy private var explanationLabel: LinkingTextView = {
|
|
let explanationLabel = LinkingTextView()
|
|
let explanationText: String
|
|
switch mode {
|
|
case .onboardingCreating, .creating:
|
|
explanationText = NSLocalizedString("PIN_CREATION_EXPLANATION",
|
|
comment: "The explanation in the 'pin creation' view.")
|
|
case .changing:
|
|
explanationText = NSLocalizedString("PIN_CREATION_RECREATION_EXPLANATION",
|
|
comment: "The re-creation explanation in the 'pin creation' view.")
|
|
case .confirming:
|
|
explanationText = NSLocalizedString("PIN_CREATION_CONFIRMATION_EXPLANATION",
|
|
comment: "The explanation of confirmation in the 'pin creation' view.")
|
|
}
|
|
|
|
// The font is too long to fit with dynamic type. Design is looking into
|
|
// how to design this page to fit dynamic type. In the meantime, we have
|
|
// to pin the font size.
|
|
let explanationLabelFont = UIFont.systemFont(ofSize: 15)
|
|
|
|
let attributedString = NSMutableAttributedString(
|
|
string: explanationText,
|
|
attributes: [
|
|
.font: explanationLabelFont,
|
|
.foregroundColor: Theme.secondaryTextAndIconColor
|
|
]
|
|
)
|
|
|
|
if !mode.isConfirming {
|
|
explanationLabel.isUserInteractionEnabled = true
|
|
attributedString.append(" ")
|
|
attributedString.append(
|
|
CommonStrings.learnMore,
|
|
attributes: [
|
|
.link: URL(string: "https://support.signal.org/hc/articles/360007059792")!,
|
|
.font: explanationLabelFont
|
|
]
|
|
)
|
|
}
|
|
explanationLabel.attributedText = attributedString
|
|
explanationLabel.textAlignment = .center
|
|
explanationLabel.accessibilityIdentifier = "pinCreation.explanationLabel"
|
|
return explanationLabel
|
|
}()
|
|
|
|
private let topSpacer = UIView.vStretchingSpacer()
|
|
private var proportionalSpacerConstraint: NSLayoutConstraint?
|
|
|
|
private let pinTextField: UITextField = {
|
|
let pinTextField = UITextField()
|
|
pinTextField.textAlignment = .center
|
|
pinTextField.textColor = Theme.primaryTextColor
|
|
|
|
let font = UIFont.systemFont(ofSize: 17)
|
|
pinTextField.font = font
|
|
pinTextField.autoSetDimension(.height, toSize: font.lineHeight + 2 * 8.0)
|
|
|
|
pinTextField.textContentType = .password
|
|
pinTextField.isSecureTextEntry = true
|
|
pinTextField.defaultTextAttributes.updateValue(5, forKey: .kern)
|
|
pinTextField.keyboardAppearance = Theme.keyboardAppearance
|
|
pinTextField.accessibilityIdentifier = "pinCreation.pinTextField"
|
|
return pinTextField
|
|
}()
|
|
|
|
private lazy var pinTypeToggle: OWSFlatButton = {
|
|
let pinTypeToggle = OWSFlatButton()
|
|
pinTypeToggle.setTitle(font: .ows_dynamicTypeSubheadlineClamped, titleColor: Theme.accentBlueColor)
|
|
pinTypeToggle.setBackgroundColors(upColor: .clear)
|
|
|
|
pinTypeToggle.enableMultilineLabel()
|
|
pinTypeToggle.button.clipsToBounds = true
|
|
pinTypeToggle.button.layer.cornerRadius = 8
|
|
pinTypeToggle.contentEdgeInsets = UIEdgeInsets(hMargin: 4, vMargin: 8)
|
|
|
|
pinTypeToggle.addTarget(target: self, selector: #selector(togglePinType))
|
|
pinTypeToggle.accessibilityIdentifier = "pinCreation.pinTypeToggle"
|
|
return pinTypeToggle
|
|
}()
|
|
|
|
private lazy var nextButton: OWSFlatButton = {
|
|
let nextButton = OWSFlatButton()
|
|
nextButton.setTitle(
|
|
title: CommonStrings.nextButton,
|
|
font: UIFont.ows_dynamicTypeBodyClamped.ows_semibold,
|
|
titleColor: .white)
|
|
nextButton.setBackgroundColors(upColor: .ows_accentBlue)
|
|
|
|
nextButton.button.clipsToBounds = true
|
|
nextButton.button.layer.cornerRadius = 14
|
|
nextButton.contentEdgeInsets = UIEdgeInsets(hMargin: 4, vMargin: 14)
|
|
|
|
nextButton.addTarget(target: self, selector: #selector(nextPressed))
|
|
nextButton.accessibilityIdentifier = "pinCreation.nextButton"
|
|
return nextButton
|
|
}()
|
|
|
|
private let validationWarningLabel: UILabel = {
|
|
let validationWarningLabel = UILabel()
|
|
validationWarningLabel.textColor = .ows_accentRed
|
|
validationWarningLabel.textAlignment = .center
|
|
validationWarningLabel.font = UIFont.ows_dynamicTypeFootnoteClamped
|
|
validationWarningLabel.numberOfLines = 0
|
|
validationWarningLabel.accessibilityIdentifier = "pinCreation.validationWarningLabel"
|
|
return validationWarningLabel
|
|
}()
|
|
|
|
private let recommendationLabel: UILabel = {
|
|
let recommendationLabel = UILabel()
|
|
recommendationLabel.textColor = Theme.secondaryTextAndIconColor
|
|
recommendationLabel.textAlignment = .center
|
|
recommendationLabel.font = UIFont.ows_dynamicTypeFootnoteClamped
|
|
recommendationLabel.numberOfLines = 0
|
|
recommendationLabel.accessibilityIdentifier = "pinCreation.recommendationLabel"
|
|
return recommendationLabel
|
|
}()
|
|
|
|
private lazy var backButton: UIButton = {
|
|
let topButtonImage = CurrentAppContext().isRTL ? #imageLiteral(resourceName: "NavBarBackRTL") : #imageLiteral(resourceName: "NavBarBack")
|
|
let backButton = UIButton.withTemplateImage(topButtonImage, tintColor: Theme.secondaryTextAndIconColor)
|
|
|
|
backButton.autoSetDimensions(to: CGSize(square: 40))
|
|
backButton.addTarget(self, action: #selector(navigateBack), for: .touchUpInside)
|
|
return backButton
|
|
}()
|
|
|
|
private lazy var moreButton: UIButton = {
|
|
let moreButton = UIButton.withTemplateImageName("more-horiz-24", tintColor: Theme.primaryIconColor)
|
|
moreButton.autoSetDimensions(to: CGSize(square: 40))
|
|
moreButton.addTarget(self, action: #selector(didTapMoreButton), for: .touchUpInside)
|
|
return moreButton
|
|
}()
|
|
|
|
private lazy var pinStrokeNormal = pinTextField.addBottomStroke()
|
|
private lazy var pinStrokeError = pinTextField.addBottomStroke(color: .ows_accentRed, strokeWidth: 2)
|
|
|
|
enum Mode: Equatable {
|
|
case onboardingCreating
|
|
case creating
|
|
case changing
|
|
case confirming(pinToMatch: String)
|
|
|
|
var isChanging: Bool {
|
|
guard case .changing = self else { return false }
|
|
return true
|
|
}
|
|
|
|
var isConfirming: Bool {
|
|
guard case .confirming = self else { return false }
|
|
return true
|
|
}
|
|
}
|
|
private let mode: Mode
|
|
|
|
private let initialMode: Mode
|
|
|
|
enum ValidationState {
|
|
case valid
|
|
case tooShort
|
|
case mismatch
|
|
case weak
|
|
|
|
var isInvalid: Bool {
|
|
return self != .valid
|
|
}
|
|
}
|
|
private var validationState: ValidationState = .valid {
|
|
didSet {
|
|
updateValidationWarnings()
|
|
}
|
|
}
|
|
|
|
private var pinType: KeyBackupService.PinType {
|
|
didSet {
|
|
updatePinType()
|
|
}
|
|
}
|
|
|
|
// Called once pin setup has finished. Error will be nil upon success
|
|
private let completionHandler: (PinSetupViewController, Error?) -> Void
|
|
|
|
private let enableRegistrationLock: Bool
|
|
|
|
init(
|
|
mode: Mode,
|
|
initialMode: Mode? = nil,
|
|
pinType: KeyBackupService.PinType = .numeric,
|
|
enableRegistrationLock: Bool = OWS2FAManager.shared.isRegistrationLockEnabled,
|
|
completionHandler: @escaping (PinSetupViewController, Error?) -> Void
|
|
) {
|
|
assert(TSAccountManager.shared.isRegisteredPrimaryDevice)
|
|
self.mode = mode
|
|
self.initialMode = initialMode ?? mode
|
|
self.pinType = pinType
|
|
self.enableRegistrationLock = enableRegistrationLock
|
|
self.completionHandler = completionHandler
|
|
super.init()
|
|
|
|
if case .confirming = self.initialMode {
|
|
owsFailDebug("pin setup flow should never start in the confirming state")
|
|
}
|
|
}
|
|
|
|
@objc
|
|
class func creatingRegistrationLock(completionHandler: @escaping (PinSetupViewController, Error?) -> Void) -> PinSetupViewController {
|
|
return .init(mode: .creating, enableRegistrationLock: true, completionHandler: completionHandler)
|
|
}
|
|
|
|
@objc
|
|
class func onboardingCreating(completionHandler: @escaping (PinSetupViewController, Error?) -> Void) -> PinSetupViewController {
|
|
return .init(mode: .onboardingCreating, completionHandler: completionHandler)
|
|
}
|
|
|
|
@objc
|
|
class func creating(completionHandler: @escaping (PinSetupViewController, Error?) -> Void) -> PinSetupViewController {
|
|
return .init(mode: .creating, completionHandler: completionHandler)
|
|
}
|
|
|
|
@objc
|
|
class func changing(completionHandler: @escaping (PinSetupViewController, Error?) -> Void) -> PinSetupViewController {
|
|
return .init(mode: .changing, completionHandler: completionHandler)
|
|
}
|
|
|
|
override public func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
shouldIgnoreKeyboardChanges = false
|
|
|
|
if let navigationBar = navigationController?.navigationBar as? OWSNavigationBar {
|
|
navigationBar.navbarBackgroundColorOverride = backgroundColor
|
|
navigationBar.switchToStyle(.solid, animated: true)
|
|
}
|
|
|
|
// Hide the nav bar when not changing.
|
|
navigationController?.setNavigationBarHidden(!initialMode.isChanging, animated: false)
|
|
title = titleText
|
|
|
|
let topMargin: CGFloat = navigationController?.isNavigationBarHidden == false ? 0 : 32
|
|
let hMargin: CGFloat = UIDevice.current.isIPhone5OrShorter ? 13 : 26
|
|
view.layoutMargins = UIEdgeInsets(top: topMargin, leading: hMargin, bottom: 0, trailing: hMargin)
|
|
|
|
if navigationController?.isNavigationBarHidden == false {
|
|
[backButton, moreButton, titleLabel].forEach { $0.isHidden = true }
|
|
} else {
|
|
// If we're in onboarding mode, don't allow going back
|
|
if case .onboardingCreating = mode {
|
|
backButton.isHidden = true
|
|
} else {
|
|
backButton.isHidden = false
|
|
}
|
|
moreButton.isHidden = mode.isConfirming
|
|
titleLabel.isHidden = false
|
|
}
|
|
}
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
// TODO: Maybe do this in will appear, to avoid the keyboard sliding in when the view is pushed?
|
|
pinTextField.becomeFirstResponder()
|
|
}
|
|
|
|
private var backgroundColor: UIColor {
|
|
presentingViewController == nil ? Theme.backgroundColor : Theme.tableView2PresentedBackgroundColor
|
|
}
|
|
|
|
override public func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
shouldIgnoreKeyboardChanges = true
|
|
|
|
if let navigationBar = navigationController?.navigationBar as? OWSNavigationBar {
|
|
navigationBar.switchToStyle(.default, animated: true)
|
|
}
|
|
|
|
navigationController?.setNavigationBarHidden(false, animated: false)
|
|
}
|
|
|
|
override public var preferredStatusBarStyle: UIStatusBarStyle {
|
|
return Theme.isDarkThemeEnabled ? .lightContent : .default
|
|
}
|
|
|
|
override public func loadView() {
|
|
owsAssertDebug(navigationController != nil, "This view should always be presented in a nav controller")
|
|
view = UIView()
|
|
view.backgroundColor = backgroundColor
|
|
|
|
view.addSubview(backButton)
|
|
backButton.autoPinEdge(toSuperviewSafeArea: .top)
|
|
backButton.autoPinEdge(toSuperviewSafeArea: .leading)
|
|
|
|
view.addSubview(moreButton)
|
|
moreButton.autoPinEdge(toSuperviewSafeArea: .top)
|
|
moreButton.autoPinEdge(toSuperviewSafeArea: .trailing)
|
|
|
|
let titleSpacer = SpacerView(preferredHeight: 12)
|
|
let pinFieldSpacer = SpacerView(preferredHeight: 11)
|
|
let bottomSpacer = SpacerView(preferredHeight: 10)
|
|
let pinToggleSpacer = SpacerView(preferredHeight: 24)
|
|
let buttonSpacer = SpacerView(preferredHeight: 32)
|
|
|
|
let stackView = UIStackView(arrangedSubviews: [
|
|
titleLabel,
|
|
titleSpacer,
|
|
explanationLabel,
|
|
topSpacer,
|
|
pinTextField,
|
|
pinFieldSpacer,
|
|
validationWarningLabel,
|
|
recommendationLabel,
|
|
bottomSpacer,
|
|
pinTypeToggle,
|
|
pinToggleSpacer,
|
|
OnboardingBaseViewController.horizontallyWrap(primaryButton: nextButton),
|
|
buttonSpacer
|
|
])
|
|
stackView.axis = .vertical
|
|
stackView.alignment = .center
|
|
view.addSubview(stackView)
|
|
|
|
stackView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom)
|
|
autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true)
|
|
|
|
[pinTextField, validationWarningLabel, recommendationLabel].forEach {
|
|
$0.autoSetDimension(.width, toSize: 227)
|
|
}
|
|
|
|
[titleLabel, explanationLabel, pinTextField, validationWarningLabel, recommendationLabel, pinTypeToggle, nextButton]
|
|
.forEach { $0.setCompressionResistanceVerticalHigh() }
|
|
|
|
// Reduce priority of compression resistance for the spacer views
|
|
// The array index serves as an ambiguous layout tiebreaker
|
|
[titleSpacer, pinFieldSpacer, bottomSpacer, pinToggleSpacer, buttonSpacer].enumerated().forEach {
|
|
$0.element.setContentCompressionResistancePriority(.defaultHigh - .init($0.offset), for: .vertical)
|
|
}
|
|
|
|
// Bottom spacer is the stack view item that grows when there's extra space
|
|
// Ensure whitespace is balanced, so inputs are vertically centered.
|
|
bottomSpacer.setContentHuggingPriority(.init(100), for: .vertical)
|
|
proportionalSpacerConstraint = topSpacer.autoMatch(.height, to: .height, of: bottomSpacer)
|
|
updateValidationWarnings()
|
|
updatePinType()
|
|
|
|
// Pin text field
|
|
pinTextField.delegate = self
|
|
}
|
|
|
|
public override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
// Don't allow interactive dismissal.
|
|
if #available(iOS 13, *) {
|
|
isModalInPresentation = true
|
|
}
|
|
}
|
|
|
|
var titleText: String {
|
|
if mode.isConfirming {
|
|
return NSLocalizedString("PIN_CREATION_CONFIRM_TITLE", comment: "Title of the 'pin creation' confirmation view.")
|
|
} else if initialMode.isChanging {
|
|
return NSLocalizedString("PIN_CREATION_CHANGING_TITLE", comment: "Title of the 'pin creation' recreation view.")
|
|
} else {
|
|
return NSLocalizedString("PIN_CREATION_TITLE", comment: "Title of the 'pin creation' view.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
@objc func navigateBack() {
|
|
Logger.info("")
|
|
|
|
// If we're in creation mode AND we're the rootViewController, dismiss rather than pop
|
|
if case .creating = mode, self.navigationController?.viewControllers.first == self {
|
|
dismiss(animated: true, completion: nil)
|
|
} else {
|
|
navigationController?.popViewController(animated: true)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
func didTapMoreButton(_ sender: UIButton) {
|
|
let actionSheet = ActionSheetController()
|
|
actionSheet.addAction(OWSActionSheets.cancelAction)
|
|
|
|
proportionalSpacerConstraint?.isActive = false
|
|
let pinnedHeightConstraint = topSpacer.autoSetDimension(.height, toSize: topSpacer.height)
|
|
|
|
let learnMoreAction = ActionSheetAction(
|
|
title: NSLocalizedString(
|
|
"PIN_CREATION_LEARN_MORE",
|
|
comment: "Learn more action on the pin creation view"
|
|
)
|
|
) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
let vc = SFSafariViewController(url: URL(string: "https://support.signal.org/hc/articles/360007059792")!)
|
|
self.present(vc, animated: true) {
|
|
pinnedHeightConstraint.isActive = false
|
|
self.proportionalSpacerConstraint?.isActive = true
|
|
}
|
|
}
|
|
actionSheet.addAction(learnMoreAction)
|
|
|
|
let skipAction = ActionSheetAction(
|
|
title: NSLocalizedString(
|
|
"PIN_CREATION_SKIP",
|
|
comment: "Skip action on the pin creation view"
|
|
)
|
|
) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
Self.disablePinWithConfirmation(fromViewController: self).done { [weak self] pinDisabled in
|
|
guard pinDisabled, let self = self else { return }
|
|
self.completionHandler(self, nil)
|
|
pinnedHeightConstraint.isActive = false
|
|
self.proportionalSpacerConstraint?.isActive = true
|
|
}.catch { [weak self] error in
|
|
guard let self = self else { return }
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString("PIN_DISABLE_ERROR_TITLE",
|
|
comment: "Error title indicating that the attempt to disable a PIN failed."),
|
|
message: NSLocalizedString("PIN_DISABLE_ERROR_MESSAGE",
|
|
comment: "Error body indicating that the attempt to disable a PIN failed.")
|
|
) { _ in
|
|
self.completionHandler(self, error)
|
|
pinnedHeightConstraint.isActive = false
|
|
self.proportionalSpacerConstraint?.isActive = true
|
|
}
|
|
}
|
|
}
|
|
actionSheet.addAction(skipAction)
|
|
|
|
presentActionSheet(actionSheet)
|
|
}
|
|
|
|
@objc func nextPressed() {
|
|
Logger.info("")
|
|
|
|
tryToContinue()
|
|
}
|
|
|
|
private func tryToContinue() {
|
|
Logger.info("")
|
|
|
|
guard let pin = pinTextField.text?.ows_stripped(), pin.count >= kMin2FAv2PinLength else {
|
|
validationState = .tooShort
|
|
return
|
|
}
|
|
|
|
if case .confirming(let pinToMatch) = mode, pinToMatch != pin {
|
|
validationState = .mismatch
|
|
return
|
|
}
|
|
|
|
if isWeakPin(pin) {
|
|
validationState = .weak
|
|
return
|
|
}
|
|
|
|
switch mode {
|
|
case .onboardingCreating, .changing, .creating:
|
|
let confirmingVC = PinSetupViewController(
|
|
mode: .confirming(pinToMatch: pin),
|
|
initialMode: initialMode,
|
|
pinType: pinType,
|
|
enableRegistrationLock: enableRegistrationLock,
|
|
completionHandler: completionHandler
|
|
)
|
|
navigationController?.pushViewController(confirmingVC, animated: true)
|
|
case .confirming:
|
|
enable2FAAndContinue(withPin: pin)
|
|
}
|
|
}
|
|
|
|
private func isWeakPin(_ pin: String) -> Bool {
|
|
let normalizedPin = KeyBackupService.normalizePin(pin)
|
|
|
|
// We only check numeric pins for weakness
|
|
guard normalizedPin.digitsOnly() == normalizedPin else { return false }
|
|
|
|
var allTheSame = true
|
|
var forwardSequential = true
|
|
var reverseSequential = true
|
|
|
|
var previousWholeNumberValue: Int?
|
|
for character in normalizedPin {
|
|
guard let current = character.wholeNumberValue else {
|
|
owsFailDebug("numeric pin unexpectedly contatined non-numeric characters")
|
|
break
|
|
}
|
|
|
|
defer { previousWholeNumberValue = current }
|
|
guard let previous = previousWholeNumberValue else { continue }
|
|
|
|
if previous != current { allTheSame = false }
|
|
if previous + 1 != current { forwardSequential = false }
|
|
if previous - 1 != current { reverseSequential = false }
|
|
|
|
if !allTheSame && !forwardSequential && !reverseSequential { break }
|
|
}
|
|
|
|
return allTheSame || forwardSequential || reverseSequential
|
|
}
|
|
|
|
private func updateValidationWarnings() {
|
|
AssertIsOnMainThread()
|
|
|
|
pinStrokeNormal.isHidden = validationState.isInvalid
|
|
pinStrokeError.isHidden = !validationState.isInvalid
|
|
validationWarningLabel.isHidden = !validationState.isInvalid
|
|
recommendationLabel.isHidden = validationState.isInvalid
|
|
|
|
switch validationState {
|
|
case .tooShort:
|
|
switch pinType {
|
|
case .numeric:
|
|
validationWarningLabel.text = NSLocalizedString("PIN_CREATION_NUMERIC_HINT",
|
|
comment: "Label indicating the user must use at least 4 digits")
|
|
case .alphanumeric:
|
|
validationWarningLabel.text = NSLocalizedString("PIN_CREATION_ALPHANUMERIC_HINT",
|
|
comment: "Label indicating the user must use at least 4 characters")
|
|
}
|
|
case .mismatch:
|
|
validationWarningLabel.text = NSLocalizedString("PIN_CREATION_MISMATCH_ERROR",
|
|
comment: "Label indicating that the attempted PIN does not match the first PIN")
|
|
case .weak:
|
|
validationWarningLabel.text = NSLocalizedString("PIN_CREATION_WEAK_ERROR",
|
|
comment: "Label indicating that the attempted PIN is too weak")
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func updatePinType() {
|
|
AssertIsOnMainThread()
|
|
|
|
pinTextField.text = nil
|
|
validationState = .valid
|
|
|
|
let recommendationLabelText: String
|
|
|
|
switch pinType {
|
|
case .numeric:
|
|
pinTypeToggle.setTitle(title: NSLocalizedString("PIN_CREATION_CREATE_ALPHANUMERIC",
|
|
comment: "Button asking if the user would like to create an alphanumeric PIN"))
|
|
pinTextField.keyboardType = .asciiCapableNumberPad
|
|
recommendationLabelText = NSLocalizedString("PIN_CREATION_NUMERIC_HINT",
|
|
comment: "Label indicating the user must use at least 4 digits")
|
|
case .alphanumeric:
|
|
pinTypeToggle.setTitle(title: NSLocalizedString("PIN_CREATION_CREATE_NUMERIC",
|
|
comment: "Button asking if the user would like to create an numeric PIN"))
|
|
pinTextField.keyboardType = .default
|
|
recommendationLabelText = NSLocalizedString("PIN_CREATION_ALPHANUMERIC_HINT",
|
|
comment: "Label indicating the user must use at least 4 characters")
|
|
}
|
|
|
|
pinTextField.reloadInputViews()
|
|
|
|
if mode.isConfirming {
|
|
pinTypeToggle.isHidden = true
|
|
recommendationLabel.text = NSLocalizedString("PIN_CREATION_PIN_CONFIRMATION_HINT",
|
|
comment: "Label indication the user must confirm their PIN.")
|
|
} else {
|
|
pinTypeToggle.isHidden = false
|
|
recommendationLabel.text = recommendationLabelText
|
|
}
|
|
}
|
|
|
|
@objc func togglePinType() {
|
|
switch pinType {
|
|
case .numeric:
|
|
pinType = .alphanumeric
|
|
case .alphanumeric:
|
|
pinType = .numeric
|
|
}
|
|
}
|
|
|
|
private func enable2FAAndContinue(withPin pin: String) {
|
|
Logger.debug("")
|
|
|
|
pinTextField.resignFirstResponder()
|
|
|
|
let progressView = AnimatedProgressView(
|
|
loadingText: NSLocalizedString("PIN_CREATION_PIN_PROGRESS",
|
|
comment: "Indicates the work we are doing while creating the user's pin")
|
|
)
|
|
view.addSubview(progressView)
|
|
progressView.autoPinWidthToSuperview()
|
|
progressView.autoVCenterInSuperview()
|
|
|
|
progressView.startAnimating {
|
|
self.view.isUserInteractionEnabled = false
|
|
self.nextButton.alpha = 0.5
|
|
self.pinTextField.alpha = 0
|
|
self.validationWarningLabel.alpha = 0
|
|
self.recommendationLabel.alpha = 0
|
|
}
|
|
|
|
enum PinSetupError: Error {
|
|
case networkFailure
|
|
case enable2FA
|
|
case enableRegistrationLock
|
|
}
|
|
|
|
firstly { () -> Promise<Void> in
|
|
OWS2FAManager.shared.requestEnable2FA(withPin: pin, mode: .V2)
|
|
}.recover { error in
|
|
// If we have a network failure before even requesting to enable 2FA, we
|
|
// can just ask the user to retry without altering any state. We can be
|
|
// confident nothing has changed on the server.
|
|
if case OWSHTTPError.networkFailure = error {
|
|
// We only want to stop for network errors if we're past onboarding.
|
|
// During onboarding, we want to let the user continue even when
|
|
// a network issue is encountered during PIN creation.
|
|
if self.initialMode != .onboardingCreating {
|
|
throw PinSetupError.networkFailure
|
|
}
|
|
}
|
|
|
|
owsFailDebug("Failed to enable 2FA with error: \(error)")
|
|
|
|
// The client may have fallen out of sync with the service.
|
|
// Try to get back to a known good state by disabling 2FA
|
|
// whenever enabling it fails.
|
|
OWS2FAManager.shared.disable2FA(success: nil, failure: nil)
|
|
|
|
throw PinSetupError.enable2FA
|
|
}.then { () -> Promise<Void> in
|
|
if self.enableRegistrationLock {
|
|
return OWS2FAManager.shared.enableRegistrationLockV2()
|
|
} else {
|
|
return Promise.value(())
|
|
}
|
|
}.recover { error -> Promise<Void> in
|
|
if error is PinSetupError { throw error }
|
|
|
|
owsFailDebug("Failed to enable registration lock with error: \(error)")
|
|
|
|
// If the registration lock wasn't already enabled, we have to notify
|
|
// the user of the failure and not attempt to enable it later. Otherwise,
|
|
// they would be left thinking they have registration lock enabled when
|
|
// they do not for some window of time.
|
|
guard OWS2FAManager.shared.isRegistrationLockV2Enabled else {
|
|
throw PinSetupError.enableRegistrationLock
|
|
}
|
|
|
|
// Otherwise, attempt to update our account attributes. This may also fail,
|
|
// but if it does it will flag our attributes as dirty so we upload them
|
|
// ASAP (when we have the ability to talk to the service) and get the user
|
|
// switched over to their new PIN for registration lock.
|
|
TSAccountManager.shared.updateAccountAttributes().cauterize()
|
|
return Promise.value(())
|
|
}.done {
|
|
AssertIsOnMainThread()
|
|
|
|
// The completion handler always dismisses this view, so we don't want to animate anything.
|
|
progressView.stopAnimatingImmediately()
|
|
self.completionHandler(self, nil)
|
|
|
|
// Clear the experience upgrade if it was pending.
|
|
SDSDatabaseStorage.shared.asyncWrite { transaction in
|
|
ExperienceUpgradeManager.clearExperienceUpgrade(.introducingPins, transaction: transaction.unwrapGrdbWrite)
|
|
}
|
|
}.catch { error in
|
|
AssertIsOnMainThread()
|
|
|
|
progressView.stopAnimating(success: false) {
|
|
self.nextButton.alpha = 1
|
|
self.pinTextField.alpha = 1
|
|
self.validationWarningLabel.alpha = 1
|
|
self.recommendationLabel.alpha = 1
|
|
} completion: {
|
|
self.view.isUserInteractionEnabled = true
|
|
progressView.removeFromSuperview()
|
|
|
|
guard let error = error as? PinSetupError else {
|
|
return owsFailDebug("Unexpected error during PIN setup \(error)")
|
|
}
|
|
|
|
// Show special reglock themed error message.
|
|
switch error {
|
|
case .enableRegistrationLock:
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString(
|
|
"PIN_CREATION_REGLOCK_ERROR_TITLE",
|
|
comment: "Error title indicating that the attempt to create a PIN succeeded but enabling reglock failed."),
|
|
message: NSLocalizedString(
|
|
"PIN_CREATION_REGLOCK_ERROR_MESSAGE",
|
|
comment: "Error body indicating that the attempt to create a PIN succeeded but enabling reglock failed.")
|
|
) { _ in
|
|
self.completionHandler(self, error)
|
|
}
|
|
case .networkFailure:
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString(
|
|
"PIN_CREATION_NO_NETWORK_ERROR_TITLE",
|
|
comment: "Error title indicating that the attempt to create a PIN failed due to network issues."),
|
|
message: NSLocalizedString(
|
|
"PIN_CREATION_NO_NETWORK_ERROR_MESSAGE",
|
|
comment: "Error body indicating that the attempt to create a PIN failed due to network issues.")
|
|
)
|
|
case .enable2FA:
|
|
switch self.initialMode {
|
|
case .onboardingCreating:
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString(
|
|
"PIN_CREATION_ERROR_TITLE",
|
|
comment: "Error title indicating that the attempt to create a PIN failed."),
|
|
message: NSLocalizedString(
|
|
"PIN_CREATION_ERROR_MESSAGE",
|
|
comment: "Error body indicating that the attempt to create a PIN failed.")
|
|
) { _ in
|
|
self.completionHandler(self, error)
|
|
}
|
|
case .changing:
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString(
|
|
"PIN_CHANGE_ERROR_TITLE",
|
|
comment: "Error title indicating that the attempt to change a PIN failed."),
|
|
message: NSLocalizedString(
|
|
"PIN_CHANGE_ERROR_MESSAGE",
|
|
comment: "Error body indicating that the attempt to change a PIN failed.")
|
|
) { _ in
|
|
self.completionHandler(self, error)
|
|
}
|
|
case .creating:
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString(
|
|
"PIN_RECREATION_ERROR_TITLE",
|
|
comment: "Error title indicating that the attempt to recreate a PIN failed."),
|
|
message: NSLocalizedString(
|
|
"PIN_RECRETION_ERROR_MESSAGE",
|
|
comment: "Error body indicating that the attempt to recreate a PIN failed.")
|
|
) { _ in
|
|
self.completionHandler(self, error)
|
|
}
|
|
case .confirming:
|
|
owsFailDebug("Unexpected initial mode")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension PinSetupViewController: UITextFieldDelegate {
|
|
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
let hasPendingChanges: Bool
|
|
if pinType == .numeric {
|
|
ViewControllerUtils.ows2FAPINTextField(textField, shouldChangeCharactersIn: range, replacementString: string)
|
|
hasPendingChanges = false
|
|
} else {
|
|
hasPendingChanges = true
|
|
}
|
|
|
|
// Reset the validation state to clear errors, since the user is trying again
|
|
validationState = .valid
|
|
|
|
// Inform our caller whether we took care of performing the change.
|
|
return hasPendingChanges
|
|
}
|
|
}
|
|
|
|
extension PinSetupViewController {
|
|
public class func disablePinWithConfirmation(fromViewController: UIViewController) -> Promise<Bool> {
|
|
guard !OWS2FAManager.shared.isRegistrationLockV2Enabled else {
|
|
return showRegistrationLockConfirmation(fromViewController: fromViewController)
|
|
}
|
|
|
|
let (promise, future) = Promise<Bool>.pending()
|
|
|
|
let actionSheet = ActionSheetController(
|
|
title: NSLocalizedString("PIN_CREATION_DISABLE_CONFIRMATION_TITLE",
|
|
comment: "Title of the 'pin disable' action sheet."),
|
|
message: NSLocalizedString("PIN_CREATION_DISABLE_CONFIRMATION_MESSAGE",
|
|
comment: "Message of the 'pin disable' action sheet.")
|
|
)
|
|
|
|
let cancelAction = ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel) { _ in
|
|
future.resolve(false)
|
|
}
|
|
actionSheet.addAction(cancelAction)
|
|
|
|
let disableAction = ActionSheetAction(
|
|
title: NSLocalizedString("PIN_CREATION_DISABLE_CONFIRMATION_ACTION",
|
|
comment: "Action of the 'pin disable' action sheet."),
|
|
style: .destructive
|
|
) { _ in
|
|
ModalActivityIndicatorViewController.present(
|
|
fromViewController: fromViewController,
|
|
canCancel: false
|
|
) { modal in
|
|
SDSDatabaseStorage.shared.asyncWrite { transaction in
|
|
KeyBackupService.useDeviceLocalMasterKey(transaction: transaction)
|
|
|
|
transaction.addAsyncCompletionOnMain {
|
|
modal.dismiss { future.resolve(true) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
actionSheet.addAction(disableAction)
|
|
|
|
fromViewController.presentActionSheet(actionSheet)
|
|
|
|
return promise
|
|
}
|
|
|
|
private class func showRegistrationLockConfirmation(fromViewController: UIViewController) -> Promise<Bool> {
|
|
let (promise, future) = Promise<Bool>.pending()
|
|
|
|
let actionSheet = ActionSheetController(
|
|
title: NSLocalizedString("PIN_CREATION_REGLOCK_CONFIRMATION_TITLE",
|
|
comment: "Title of the 'pin disable' reglock action sheet."),
|
|
message: NSLocalizedString("PIN_CREATION_REGLOCK_CONFIRMATION_MESSAGE",
|
|
comment: "Message of the 'pin disable' reglock action sheet.")
|
|
)
|
|
|
|
let cancelAction = ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel) { _ in
|
|
future.resolve(false)
|
|
}
|
|
actionSheet.addAction(cancelAction)
|
|
|
|
let disableAction = ActionSheetAction(
|
|
title: NSLocalizedString("PIN_CREATION_REGLOCK_CONFIRMATION_ACTION",
|
|
comment: "Action of the 'pin disable' reglock action sheet."),
|
|
style: .destructive
|
|
) { _ in
|
|
ModalActivityIndicatorViewController.present(
|
|
fromViewController: fromViewController,
|
|
canCancel: false
|
|
) { modal in
|
|
OWS2FAManager.shared.disableRegistrationLockV2().then {
|
|
Guarantee { resolve in
|
|
modal.dismiss { resolve(()) }
|
|
}
|
|
}.then { () -> Promise<Bool> in
|
|
disablePinWithConfirmation(fromViewController: fromViewController)
|
|
}.done { success in
|
|
future.resolve(success)
|
|
}.catch { error in
|
|
modal.dismiss { future.reject(error) }
|
|
}
|
|
}
|
|
}
|
|
actionSheet.addAction(disableAction)
|
|
|
|
fromViewController.presentActionSheet(actionSheet)
|
|
|
|
return promise
|
|
}
|
|
}
|