From 0b55ecc682c37f6ee41942afee0b8f9288062e55 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Mon, 18 Feb 2019 11:02:03 -0500 Subject: [PATCH] Sketch out the 'onboarding 2FA' view. --- Signal.xcodeproj/project.pbxproj | 4 + .../Onboarding2FAViewController.swift | 176 ++++++++++++++++++ .../Registration/OnboardingController.swift | 43 ++++- .../OnboardingPhoneNumberViewController.swift | 2 +- ...OnboardingVerificationViewController.swift | 10 +- .../translations/en.lproj/Localizable.strings | 14 +- 6 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 Signal/src/ViewControllers/Registration/Onboarding2FAViewController.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 6939b7a95..683161dfc 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ 3496957321A301A100DCFE74 /* OWSBackupJob.m in Sources */ = {isa = PBXBuildFile; fileRef = 3496956A21A301A100DCFE74 /* OWSBackupJob.m */; }; 3496957421A301A100DCFE74 /* OWSBackupAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496956B21A301A100DCFE74 /* OWSBackupAPI.swift */; }; 349EA07C2162AEA800F7B17F /* OWS111UDAttributesMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349EA07B2162AEA700F7B17F /* OWS111UDAttributesMigration.swift */; }; + 349ED990221B0194008045B0 /* Onboarding2FAViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 349ED98F221B0194008045B0 /* Onboarding2FAViewController.swift */; }; 34A4C61E221613D00042EF2E /* OnboardingVerificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4C61D221613D00042EF2E /* OnboardingVerificationViewController.swift */; }; 34A4C62022175C5C0042EF2E /* OnboardingProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4C61F22175C5C0042EF2E /* OnboardingProfileViewController.swift */; }; 34A55F3720485465002CC6DE /* OWS2FARegistrationViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34A55F3520485464002CC6DE /* OWS2FARegistrationViewController.m */; }; @@ -848,6 +849,7 @@ 3496956C21A301A100DCFE74 /* OWSBackupImportJob.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupImportJob.h; sourceTree = ""; }; 3496956D21A301A100DCFE74 /* OWSBackupIO.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBackupIO.h; sourceTree = ""; }; 349EA07B2162AEA700F7B17F /* OWS111UDAttributesMigration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWS111UDAttributesMigration.swift; sourceTree = ""; }; + 349ED98F221B0194008045B0 /* Onboarding2FAViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Onboarding2FAViewController.swift; sourceTree = ""; }; 34A4C61D221613D00042EF2E /* OnboardingVerificationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingVerificationViewController.swift; sourceTree = ""; }; 34A4C61F22175C5C0042EF2E /* OnboardingProfileViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingProfileViewController.swift; sourceTree = ""; }; 34A55F3520485464002CC6DE /* OWS2FARegistrationViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWS2FARegistrationViewController.m; sourceTree = ""; }; @@ -1471,6 +1473,7 @@ 3441FD9E21A3604F00BB9542 /* BackupRestoreViewController.swift */, 340FC879204DAC8C007AEB0F /* CodeVerificationViewController.h */, 340FC877204DAC8C007AEB0F /* CodeVerificationViewController.m */, + 349ED98F221B0194008045B0 /* Onboarding2FAViewController.swift */, 3448E1612213585C004B052E /* OnboardingBaseViewController.swift */, 3448E1652215B313004B052E /* OnboardingCaptchaViewController.swift */, 3448E15D221333F5004B052E /* OnboardingController.swift */, @@ -3530,6 +3533,7 @@ 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */, 4CB5F26720F6E1E2004D1B42 /* MenuActionsViewController.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, + 349ED990221B0194008045B0 /* Onboarding2FAViewController.swift in Sources */, 45D231771DC7E8F10034FA89 /* SessionResetJob.swift in Sources */, 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */, 452037D11EE84975004E4CDF /* DebugUISessionState.m in Sources */, diff --git a/Signal/src/ViewControllers/Registration/Onboarding2FAViewController.swift b/Signal/src/ViewControllers/Registration/Onboarding2FAViewController.swift new file mode 100644 index 000000000..0918bd78e --- /dev/null +++ b/Signal/src/ViewControllers/Registration/Onboarding2FAViewController.swift @@ -0,0 +1,176 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +@objc +public class Onboarding2FAViewController: OnboardingBaseViewController { + + private let pinTextField = UITextField() + + private var pinStrokeNormal: UIView? + private var pinStrokeError: UIView? + private let validationWarningLabel = UILabel() + private var isPinInvalid = false { + didSet { + updateValidationWarnings() + } + } + + override public func loadView() { + super.loadView() + + view.backgroundColor = Theme.backgroundColor + view.layoutMargins = .zero + + let titleLabel = self.titleLabel(text: NSLocalizedString("ONBOARDING_2FA_TITLE", comment: "Title of the 'onboarding 2FA' view.")) + + let explanationLabel1 = self.explanationLabel(explanationText: NSLocalizedString("ONBOARDING_2FA_EXPLANATION_1", + comment: "The first explanation in the 'onboarding 2FA' view.")) + let explanationLabel2 = self.explanationLabel(explanationText: NSLocalizedString("ONBOARDING_2FA_EXPLANATION_2", + comment: "The first explanation in the 'onboarding 2FA' view.")) + explanationLabel1.font = UIFont.ows_dynamicTypeCaption1 + explanationLabel2.font = UIFont.ows_dynamicTypeCaption1 + + pinTextField.textAlignment = .center + pinTextField.delegate = self + pinTextField.keyboardType = .numberPad + pinTextField.textColor = Theme.primaryColor + pinTextField.font = UIFont.ows_dynamicTypeBodyClamped + pinTextField.setContentHuggingHorizontalLow() + pinTextField.setCompressionResistanceHorizontalLow() + pinTextField.autoSetDimension(.height, toSize: 40) + + pinStrokeNormal = pinTextField.addBottomStroke() + pinStrokeError = pinTextField.addBottomStroke(color: .ows_destructiveRed, strokeWidth: 2) + + validationWarningLabel.text = NSLocalizedString("ONBOARDING_PHONE_NUMBER_VALIDATION_WARNING", + comment: "Label indicating that the phone number is invalid in the 'onboarding phone number' view.") + validationWarningLabel.textColor = .ows_destructiveRed + validationWarningLabel.font = UIFont.ows_dynamicTypeSubheadlineClamped + validationWarningLabel.textAlignment = .center + + let validationWarningRow = UIView() + validationWarningRow.addSubview(validationWarningLabel) + validationWarningLabel.ows_autoPinToSuperviewEdges() + validationWarningRow.autoSetDimension(.height, toSize: validationWarningLabel.font.lineHeight) + + let forgotPinLink = self.linkButton(title: NSLocalizedString("ONBOARDING_2FA_FORGOT_PIN_LINK", + comment: "Label for the 'forgot 2FA PIN' link in the 'onboarding 2FA' view."), + selector: #selector(forgotPinLinkTapped)) + + let nextButton = self.button(title: NSLocalizedString("BUTTON_NEXT", + comment: "Label for the 'next' button."), + selector: #selector(nextPressed)) + + let topSpacer = UIView.vStretchingSpacer() + let bottomSpacer = UIView.vStretchingSpacer() + + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + UIView.spacer(withHeight: 10), + explanationLabel1, + UIView.spacer(withHeight: 10), + explanationLabel2, + + topSpacer, + pinTextField, + UIView.spacer(withHeight: 10), + validationWarningRow, + bottomSpacer, + forgotPinLink, + UIView.spacer(withHeight: 10), + nextButton + ]) + stackView.axis = .vertical + stackView.alignment = .fill + stackView.layoutMargins = UIEdgeInsets(top: 20, left: 32, bottom: 20, right: 32) + stackView.isLayoutMarginsRelativeArrangement = true + view.addSubview(stackView) + stackView.autoPinWidthToSuperview() + stackView.autoPin(toTopLayoutGuideOf: self, withInset: 0) + autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true) + + // Ensure whitespace is balanced, so inputs are vertically centered. + topSpacer.autoMatch(.height, to: .height, of: bottomSpacer) + + updateValidationWarnings() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + _ = pinTextField.becomeFirstResponder() + } + + // MARK: - Events + + @objc func forgotPinLinkTapped() { + Logger.info("") + + OWSAlerts.showAlert(title: nil, message: NSLocalizedString("REGISTER_2FA_FORGOT_PIN_ALERT_MESSAGE", + comment: "Alert message explaining what happens if you forget your 'two-factor auth pin'.")) + } + + @objc func nextPressed() { + Logger.info("") + + tryToVerify() + } + + private func tryToVerify() { + Logger.info("") + + guard let pin = pinTextField.text?.ows_stripped(), + pin.count > 0 else { + isPinInvalid = true + return + } + + isPinInvalid = false + + onboardingController.update(twoFAPin: pin) + + onboardingController.tryToVerify(fromViewController: self, completion: { (outcome) in + if outcome == .invalid2FAPin { + self.isPinInvalid = true + } else if outcome == .invalidVerificationCode { + owsFailDebug("Invalid verification code in 2FA view.") + } + }) + } + + private func updateValidationWarnings() { + AssertIsOnMainThread() + + pinStrokeNormal?.isHidden = isPinInvalid + pinStrokeError?.isHidden = !isPinInvalid + validationWarningLabel.isHidden = !isPinInvalid + } +} + +// MARK: - + +extension Onboarding2FAViewController: UITextFieldDelegate { + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let newString = string.digitsOnly + var oldText = "" + if let textFieldText = textField.text { + oldText = textFieldText + } + let left = oldText.substring(to: range.location) + let right = oldText.substring(from: range.location + range.length) + textField.text = left + newString + right + + isPinInvalid = false + + // Inform our caller that we took care of performing the change. + return false + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + tryToVerify() + return false + } +} diff --git a/Signal/src/ViewControllers/Registration/OnboardingController.swift b/Signal/src/ViewControllers/Registration/OnboardingController.swift index 4ec897d27..fd8f6124c 100644 --- a/Signal/src/ViewControllers/Registration/OnboardingController.swift +++ b/Signal/src/ViewControllers/Registration/OnboardingController.swift @@ -273,6 +273,10 @@ public class OnboardingController: NSObject { public private(set) var captchaToken: String? + public private(set) var verificationCode: String? + + public private(set) var twoFAPin: String? + @objc public func update(countryState: OnboardingCountryState) { AssertIsOnMainThread() @@ -294,6 +298,20 @@ public class OnboardingController: NSObject { self.captchaToken = captchaToken } + @objc + public func update(verificationCode: String) { + AssertIsOnMainThread() + + self.verificationCode = verificationCode + } + + @objc + public func update(twoFAPin: String) { + AssertIsOnMainThread() + + self.twoFAPin = twoFAPin + } + // MARK: - Debug private static let kKeychainService_LastRegistered = "kKeychainService_LastRegistered" @@ -405,26 +423,35 @@ public class OnboardingController: NSObject { // MARK: - Verification + public enum VerificationOutcome { + case success + case invalidVerificationCode + case invalid2FAPin + } + public func tryToVerify(fromViewController: UIViewController, - verificationCode: String, - pin: String?, - isInvalidCodeCallback : @escaping () -> Void) { + completion : @escaping (VerificationOutcome) -> Void) { AssertIsOnMainThread() guard let phoneNumber = phoneNumber else { owsFailDebug("Missing phoneNumber.") return } + guard let verificationCode = verificationCode else { + completion(.invalidVerificationCode) + return + } // Ensure the account manager state is up-to-date. // // TODO: We could skip this in production. tsAccountManager.phoneNumberAwaitingVerification = phoneNumber.e164 + let twoFAPin = self.twoFAPin ModalActivityIndicatorViewController.present(fromViewController: fromViewController, canCancel: true) { (modal) in - self.accountManager.register(verificationCode: verificationCode, pin: pin) + self.accountManager.register(verificationCode: verificationCode, pin: twoFAPin) .done { (_) in DispatchQueue.main.async { modal.dismiss(completion: { @@ -438,7 +465,7 @@ public class OnboardingController: NSObject { modal.dismiss(completion: { self.verificationFailed(fromViewController: fromViewController, error: error as NSError, - isInvalidCodeCallback: isInvalidCodeCallback) + completion: completion) }) } }).retainUntilComplete() @@ -446,7 +473,7 @@ public class OnboardingController: NSObject { } private func verificationFailed(fromViewController: UIViewController, error: NSError, - isInvalidCodeCallback : @escaping () -> Void) { + completion : @escaping (VerificationOutcome) -> Void) { AssertIsOnMainThread() if error.domain == OWSSignalServiceKitErrorDomain && @@ -454,11 +481,13 @@ public class OnboardingController: NSObject { Logger.info("Missing 2FA PIN.") + completion(.invalid2FAPin) + onboardingDidRequire2FAPin(viewController: fromViewController) } else { if error.domain == OWSSignalServiceKitErrorDomain && error.code == OWSErrorCode.userError.rawValue { - isInvalidCodeCallback() + completion(.invalidVerificationCode) } Logger.verbose("error: \(error.domain) \(error.code)") diff --git a/Signal/src/ViewControllers/Registration/OnboardingPhoneNumberViewController.swift b/Signal/src/ViewControllers/Registration/OnboardingPhoneNumberViewController.swift index ef0e7c791..830999486 100644 --- a/Signal/src/ViewControllers/Registration/OnboardingPhoneNumberViewController.swift +++ b/Signal/src/ViewControllers/Registration/OnboardingPhoneNumberViewController.swift @@ -103,7 +103,7 @@ public class OnboardingPhoneNumberViewController: OnboardingBaseViewController { let validationWarningRow = UIView() validationWarningRow.addSubview(validationWarningLabel) validationWarningLabel.autoPinHeightToSuperview() - validationWarningLabel.autoPinEdge(toSuperviewEdge: .trailing) + validationWarningLabel.autoPinEdge(toSuperviewEdge: .leading) // TODO: Finalize copy. diff --git a/Signal/src/ViewControllers/Registration/OnboardingVerificationViewController.swift b/Signal/src/ViewControllers/Registration/OnboardingVerificationViewController.swift index 48cb07bba..3d21e96d6 100644 --- a/Signal/src/ViewControllers/Registration/OnboardingVerificationViewController.swift +++ b/Signal/src/ViewControllers/Registration/OnboardingVerificationViewController.swift @@ -251,7 +251,6 @@ public class OnboardingVerificationViewController: OnboardingBaseViewController private var codeState = CodeState.sent private var titleLabel: UILabel? - private let phoneNumberTextField = UITextField() private let onboardingCodeView = OnboardingCodeView() private var codeStateLink: OWSFlatButton? private let errorLabel = UILabel() @@ -477,13 +476,18 @@ public class OnboardingVerificationViewController: OnboardingBaseViewController Logger.info("") guard onboardingCodeView.isComplete else { + self.setHasInvalidCode(true) return } setHasInvalidCode(false) - onboardingController.tryToVerify(fromViewController: self, verificationCode: onboardingCodeView.verificationCode, pin: nil, isInvalidCodeCallback: { - self.setHasInvalidCode(true) + onboardingController.update(verificationCode: onboardingCodeView.verificationCode) + + onboardingController.tryToVerify(fromViewController: self, completion: { (outcome) in + if outcome == .invalidVerificationCode { + self.setHasInvalidCode(true) + } }) } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index f61ad1a2c..667a5f55d 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1508,6 +1508,18 @@ /* No comment provided by engineer. */ "OK" = "OK"; +/* The first explanation in the 'onboarding 2FA' view. */ +"ONBOARDING_2FA_EXPLANATION_1" = "This phone number has Registration Lock enabled. Please enter the Registration Lock PIN."; + +/* The first explanation in the 'onboarding 2FA' view. */ +"ONBOARDING_2FA_EXPLANATION_2" = "Your Registration Lock PIN is separate from the automated verification code that was sent to your phone during the last step."; + +/* Label for the 'forgot 2FA PIN' link in the 'onboarding 2FA' view. */ +"ONBOARDING_2FA_FORGOT_PIN_LINK" = "I forgot my PIN"; + +/* Title of the 'onboarding 2FA' view. */ +"ONBOARDING_2FA_TITLE" = "Registration Lock"; + /* Title of the 'onboarding Captcha' view. */ "ONBOARDING_CAPTCHA_TITLE" = "We need to verify that you're human"; @@ -1547,7 +1559,7 @@ /* Label for the link that lets users change their phone number in the onboarding views. */ "ONBOARDING_VERIFICATION_BACK_LINK" = "Wrong number?"; -/* Format for the label of the 'pending code' label of the 'onboarding verification' view. Embeds {{the time until the code can be resent}}. */ +/* Format for the label of the 'sent code' label of the 'onboarding verification' view. Embeds {{the time until the code can be resent}}. */ "ONBOARDING_VERIFICATION_CODE_COUNTDOWN_FORMAT" = "I didn't get a code (available in %@)"; /* Label indicating that the verification code is incorrect in the 'onboarding verification' view. */