916 lines
33 KiB
Swift
916 lines
33 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
@objc
|
|
public class OnboardingCountryState: NSObject {
|
|
public let countryName: String
|
|
public let callingCode: String
|
|
public let countryCode: String
|
|
|
|
@objc
|
|
public init(countryName: String,
|
|
callingCode: String,
|
|
countryCode: String) {
|
|
self.countryName = countryName
|
|
self.callingCode = callingCode
|
|
self.countryCode = countryCode
|
|
}
|
|
|
|
public static var defaultValue: OnboardingCountryState {
|
|
AssertIsOnMainThread()
|
|
|
|
var countryCode: String = PhoneNumber.defaultCountryCode()
|
|
if let lastRegisteredCountryCode = OnboardingController.lastRegisteredCountryCode(),
|
|
lastRegisteredCountryCode.count > 0 {
|
|
countryCode = lastRegisteredCountryCode
|
|
}
|
|
|
|
let callingCodeNumber: NSNumber = phoneNumberUtil.getCountryCode(forRegion: countryCode)
|
|
let callingCode = "\(COUNTRY_CODE_PREFIX)\(callingCodeNumber)"
|
|
|
|
var countryName = NSLocalizedString("UNKNOWN_COUNTRY_NAME", comment: "Label for unknown countries.")
|
|
if let countryNameDerived = PhoneNumberUtil.countryName(fromCountryCode: countryCode) {
|
|
countryName = countryNameDerived
|
|
}
|
|
|
|
return OnboardingCountryState(countryName: countryName, callingCode: callingCode, countryCode: countryCode)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public class OnboardingPhoneNumber: NSObject {
|
|
public let e164: String
|
|
public let userInput: String
|
|
|
|
@objc
|
|
public init(e164: String,
|
|
userInput: String) {
|
|
self.e164 = e164
|
|
self.userInput = userInput
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public class OnboardingNavigationController: OWSNavigationController {
|
|
let onboardingController: OnboardingController
|
|
|
|
@objc
|
|
public init(onboardingController: OnboardingController) {
|
|
self.onboardingController = onboardingController
|
|
super.init()
|
|
if let nextMilestone = onboardingController.nextMilestone {
|
|
setViewControllers([onboardingController.nextViewController(milestone: nextMilestone)], animated: false)
|
|
}
|
|
}
|
|
|
|
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
let superOrientations = super.supportedInterfaceOrientations
|
|
let onboardingOrientations: UIInterfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait
|
|
|
|
return superOrientations.intersection(onboardingOrientations)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public class OnboardingController: NSObject {
|
|
|
|
public enum OnboardingMode {
|
|
case provisioning
|
|
case registering
|
|
}
|
|
|
|
public static let defaultOnboardingMode: OnboardingMode = UIDevice.current.isIPad ? .provisioning : .registering
|
|
public var onboardingMode: OnboardingMode
|
|
public var isOnboardingModeOverriden: Bool {
|
|
return onboardingMode != OnboardingController.defaultOnboardingMode
|
|
}
|
|
|
|
public class func ascertainOnboardingMode() -> OnboardingMode {
|
|
if tsAccountManager.isRegisteredPrimaryDevice {
|
|
return .registering
|
|
} else if tsAccountManager.isRegistered {
|
|
return .provisioning
|
|
} else {
|
|
return defaultOnboardingMode
|
|
}
|
|
}
|
|
|
|
convenience override init() {
|
|
let onboardingMode = OnboardingController.ascertainOnboardingMode()
|
|
self.init(onboardingMode: onboardingMode)
|
|
}
|
|
|
|
init(onboardingMode: OnboardingMode) {
|
|
self.onboardingMode = onboardingMode
|
|
super.init()
|
|
Logger.info("onboardingMode: \(onboardingMode), requiredMilestones: \(requiredMilestones), completedMilestones: \(completedMilestones), nextMilestone: \(nextMilestone as Optional)")
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
enum OnboardingMilestone {
|
|
case verifiedPhoneNumber
|
|
case verifiedLinkedDevice
|
|
case restorePin
|
|
case phoneNumberDiscoverability
|
|
case setupProfile
|
|
case setupPin
|
|
}
|
|
|
|
var requiredMilestones: [OnboardingMilestone] {
|
|
switch onboardingMode {
|
|
case .provisioning:
|
|
return [.verifiedLinkedDevice]
|
|
case .registering:
|
|
var milestones: [OnboardingMilestone] = [.verifiedPhoneNumber, .phoneNumberDiscoverability, .setupProfile]
|
|
|
|
let hasPendingPinRestoration = databaseStorage.read {
|
|
KeyBackupService.hasPendingRestoration(transaction: $0)
|
|
}
|
|
|
|
if hasPendingPinRestoration {
|
|
milestones.insert(.restorePin, at: 0)
|
|
}
|
|
|
|
if FeatureFlags.pinsForNewUsers || hasPendingPinRestoration {
|
|
let hasBackupKeyRequestFailed = databaseStorage.read {
|
|
KeyBackupService.hasBackupKeyRequestFailed(transaction: $0)
|
|
}
|
|
|
|
if hasBackupKeyRequestFailed {
|
|
Logger.info("skipping setupPin since a previous request failed")
|
|
} else if hasPendingPinRestoration {
|
|
milestones.insert(.setupPin, at: 1)
|
|
} else {
|
|
milestones.append(.setupPin)
|
|
}
|
|
}
|
|
|
|
return milestones
|
|
}
|
|
}
|
|
|
|
@objc
|
|
var isComplete: Bool {
|
|
guard !tsAccountManager.isOnboarded() else {
|
|
Logger.debug("previously completed onboarding")
|
|
return true
|
|
}
|
|
|
|
guard nextMilestone != nil else {
|
|
Logger.debug("no remaining milestones")
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
var nextMilestone: OnboardingMilestone? {
|
|
requiredMilestones.first { !completedMilestones.contains($0) }
|
|
}
|
|
|
|
var completedMilestones: [OnboardingMilestone] {
|
|
var milestones: [OnboardingMilestone] = []
|
|
|
|
if tsAccountManager.isRegisteredPrimaryDevice {
|
|
milestones.append(.verifiedPhoneNumber)
|
|
} else if tsAccountManager.isRegistered {
|
|
milestones.append(.verifiedLinkedDevice)
|
|
}
|
|
|
|
if !FeatureFlags.phoneNumberDiscoverability || tsAccountManager.hasDefinedIsDiscoverableByPhoneNumber() {
|
|
milestones.append(.phoneNumberDiscoverability)
|
|
}
|
|
|
|
if profileManager.hasProfileName {
|
|
milestones.append(.setupProfile)
|
|
}
|
|
|
|
if KeyBackupService.hasMasterKey {
|
|
milestones.append(.restorePin)
|
|
milestones.append(.setupPin)
|
|
}
|
|
|
|
return milestones
|
|
}
|
|
|
|
@objc
|
|
public func markAsOnboarded() {
|
|
guard !tsAccountManager.isOnboarded() else { return }
|
|
self.databaseStorage.asyncWrite {
|
|
Logger.info("completed onboarding")
|
|
self.tsAccountManager.setIsOnboarded(true, transaction: $0)
|
|
}
|
|
}
|
|
|
|
func showNextMilestone(navigationController: UINavigationController) {
|
|
guard let nextMilestone = nextMilestone else {
|
|
SignalApp.shared().showConversationSplitView()
|
|
markAsOnboarded()
|
|
return
|
|
}
|
|
|
|
let viewController = nextViewController(milestone: nextMilestone)
|
|
|
|
// *replace* the existing VC's. There's no going back once you've passed a milestone.
|
|
navigationController.setViewControllers([viewController], animated: true)
|
|
}
|
|
|
|
fileprivate func nextViewController(milestone: OnboardingMilestone) -> UIViewController {
|
|
Logger.info("milestone: \(milestone)")
|
|
switch milestone {
|
|
case .verifiedPhoneNumber, .verifiedLinkedDevice:
|
|
if SSKPreferences.didDropYdb() {
|
|
return OnboardingDroppedYdbViewController(onboardingController: self)
|
|
} else {
|
|
return OnboardingSplashViewController(onboardingController: self)
|
|
}
|
|
case .setupProfile:
|
|
return OnboardingProfileCreationViewController(onboardingController: self)
|
|
case .restorePin:
|
|
return Onboarding2FAViewController(onboardingController: self, isUsingKBS: true)
|
|
case .setupPin:
|
|
return buildPinSetupViewController()
|
|
case .phoneNumberDiscoverability:
|
|
return OnboardingPhoneNumberDiscoverabilityViewController(onboardingController: self)
|
|
}
|
|
}
|
|
|
|
// MARK: - Transitions
|
|
|
|
public func onboardingSplashDidComplete(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
let view = OnboardingPermissionsViewController(onboardingController: self)
|
|
viewController.navigationController?.pushViewController(view, animated: true)
|
|
}
|
|
|
|
public func onboardingSplashRequestedModeSwitch(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
let view = OnboardingModeSwitchConfirmationViewController(onboardingController: self)
|
|
viewController.navigationController?.pushViewController(view, animated: true)
|
|
}
|
|
|
|
public func toggleModeSwitch(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
let wasOverridden = isOnboardingModeOverriden
|
|
switch onboardingMode {
|
|
case .provisioning:
|
|
onboardingMode = .registering
|
|
case .registering:
|
|
onboardingMode = .provisioning
|
|
}
|
|
|
|
if wasOverridden {
|
|
onboardingMode = OnboardingController.defaultOnboardingMode
|
|
viewController.navigationController?.popToRootViewController(animated: true)
|
|
} else {
|
|
let view = OnboardingPermissionsViewController(onboardingController: self)
|
|
viewController.navigationController?.pushViewController(view, animated: true)
|
|
}
|
|
}
|
|
|
|
public func onboardingPermissionsWasSkipped(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("navigationController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
pushStartDeviceRegistrationView(onto: navigationController)
|
|
}
|
|
|
|
public func onboardingPermissionsDidComplete(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("navigationController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
pushStartDeviceRegistrationView(onto: navigationController)
|
|
}
|
|
|
|
func pushStartDeviceRegistrationView(onto navigationController: UINavigationController) {
|
|
AssertIsOnMainThread()
|
|
|
|
if onboardingMode == .provisioning {
|
|
let view = OnboardingTransferChoiceViewController(onboardingController: self)
|
|
navigationController.pushViewController(view, animated: true)
|
|
} else {
|
|
let view = OnboardingPhoneNumberViewController(onboardingController: self)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
}
|
|
|
|
public func requestingVerificationDidSucceed(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
// TODO: Once notification work is complete, uncomment this.
|
|
// Self.notificationPresenter.cancelIncompleteRegistrationNotification()
|
|
|
|
let view = OnboardingVerificationViewController(onboardingController: self)
|
|
viewController.navigationController?.pushViewController(view, animated: true)
|
|
}
|
|
|
|
public func onboardingDidRequireCaptcha(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("Missing navigationController.")
|
|
return
|
|
}
|
|
|
|
// The service could demand CAPTCHA from the "phone number" view or later
|
|
// from the "code verification" view. The "Captcha" view should always appear
|
|
// immediately after the "phone number" view.
|
|
while navigationController.viewControllers.count > 1 &&
|
|
!(navigationController.topViewController is OnboardingPhoneNumberViewController) {
|
|
navigationController.popViewController(animated: false)
|
|
}
|
|
|
|
let view = OnboardingCaptchaViewController(onboardingController: self)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
|
|
@objc
|
|
public func verificationDidComplete(fromView view: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
Logger.info("")
|
|
guard let navigationController = view.navigationController else {
|
|
owsFailDebug("navigationController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
// At this point, the user has been prompted for contact access
|
|
// and has valid service credentials.
|
|
// We start the contact fetch/intersection now so that by the time
|
|
// they get to conversation list we can show meaningful contact in
|
|
// the suggested contact bubble.
|
|
contactsManagerImpl.fetchSystemContactsOnceIfAlreadyAuthorized()
|
|
|
|
showNextMilestone(navigationController: navigationController)
|
|
}
|
|
|
|
public func linkingDidComplete(from viewController: UIViewController) {
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("navigationController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
showNextMilestone(navigationController: navigationController)
|
|
}
|
|
|
|
func buildPinSetupViewController() -> PinSetupViewController {
|
|
return PinSetupViewController.creating { [weak self] pinSetupVC, _ in
|
|
guard let self = self else { return }
|
|
|
|
guard let navigationController = pinSetupVC.navigationController else {
|
|
owsFailDebug("navigationController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
self.showNextMilestone(navigationController: navigationController)
|
|
}
|
|
}
|
|
|
|
public func onboardingDidRequire2FAPin(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
guard !(navigationController.topViewController is Onboarding2FAViewController) else {
|
|
// 2fa view is already presented, we don't need to push it again.
|
|
return
|
|
}
|
|
|
|
let view = Onboarding2FAViewController(onboardingController: self, isUsingKBS: kbsAuth != nil)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
|
|
@objc
|
|
public func profileWasSkipped(fromView view: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
showConversationSplitView(view: view)
|
|
}
|
|
|
|
@objc
|
|
public func profileDidComplete(fromView view: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
showConversationSplitView(view: view)
|
|
}
|
|
|
|
private func showConversationSplitView(view: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let navigationController = view.navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
// In production, this view will never be presented in a modal.
|
|
// During testing (debug UI, etc.), it may be a modal.
|
|
let isModal = navigationController.presentingViewController != nil
|
|
if isModal {
|
|
view.dismiss(animated: true, completion: {
|
|
SignalApp.shared().showConversationSplitView()
|
|
})
|
|
} else {
|
|
SignalApp.shared().showConversationSplitView()
|
|
}
|
|
}
|
|
|
|
// MARK: - State
|
|
|
|
public private(set) var countryState: OnboardingCountryState = .defaultValue
|
|
|
|
public private(set) var phoneNumber: OnboardingPhoneNumber?
|
|
|
|
public private(set) var captchaToken: String?
|
|
|
|
public private(set) var verificationCode: String?
|
|
|
|
public private(set) var twoFAPin: String?
|
|
|
|
private var kbsAuth: RemoteAttestationAuth?
|
|
|
|
public private(set) var verificationRequestCount: UInt = 0
|
|
|
|
@objc
|
|
public func update(countryState: OnboardingCountryState) {
|
|
AssertIsOnMainThread()
|
|
|
|
self.countryState = countryState
|
|
}
|
|
|
|
@objc
|
|
public func update(phoneNumber: OnboardingPhoneNumber) {
|
|
AssertIsOnMainThread()
|
|
|
|
self.phoneNumber = phoneNumber
|
|
}
|
|
|
|
@objc
|
|
public func update(captchaToken: String) {
|
|
AssertIsOnMainThread()
|
|
|
|
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"
|
|
private static let kKeychainKey_LastRegisteredCountryCode = "kKeychainKey_LastRegisteredCountryCode"
|
|
private static let kKeychainKey_LastRegisteredPhoneNumber = "kKeychainKey_LastRegisteredPhoneNumber"
|
|
|
|
private class func debugValue(forKey key: String) -> String? {
|
|
AssertIsOnMainThread()
|
|
|
|
guard OWSIsDebugBuild() else {
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
let value = try CurrentAppContext().keychainStorage().string(forService: kKeychainService_LastRegistered, key: key)
|
|
return value
|
|
} catch {
|
|
// The value may not be present in the keychain.
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private class func setDebugValue(_ value: String, forKey key: String) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard OWSIsDebugBuild() else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
try CurrentAppContext().keychainStorage().set(string: value, service: kKeychainService_LastRegistered, key: key)
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
}
|
|
|
|
public class func lastRegisteredCountryCode() -> String? {
|
|
return debugValue(forKey: kKeychainKey_LastRegisteredCountryCode)
|
|
}
|
|
|
|
private class func setLastRegisteredCountryCode(value: String) {
|
|
setDebugValue(value, forKey: kKeychainKey_LastRegisteredCountryCode)
|
|
}
|
|
|
|
public class func lastRegisteredPhoneNumber() -> String? {
|
|
return debugValue(forKey: kKeychainKey_LastRegisteredPhoneNumber)
|
|
}
|
|
|
|
private class func setLastRegisteredPhoneNumber(value: String) {
|
|
setDebugValue(value, forKey: kKeychainKey_LastRegisteredPhoneNumber)
|
|
}
|
|
|
|
// MARK: - Registration
|
|
|
|
public func presentPhoneNumberConfirmationSheet(from vc: UIViewController, number: String, completion: @escaping (_ didApprove: Bool) -> Void) {
|
|
let titleFormat = NSLocalizedString(
|
|
"REGISTRATION_VIEW_PHONE_NUMBER_CONFIRMATION_ALERT_TITLE_FORMAT",
|
|
comment: "Title for confirmation alert during phone number registration. Embeds {{phone number}}.")
|
|
let message = NSLocalizedString(
|
|
"REGISTRATION_VIEW_PHONE_NUMBER_CONFIRMATION_ALERT_MESSAGE",
|
|
comment: "Message for confirmation alert during phone number registration.")
|
|
let editButtonTitle = NSLocalizedString(
|
|
"REGISTRATION_VIEW_PHONE_NUMBER_CONFIRMATION_EDIT_BUTTON",
|
|
comment: "A button allowing user to cancel registration and edit a phone number")
|
|
|
|
let sheet = ActionSheetController(title: String(format: titleFormat, number), message: message)
|
|
sheet.addAction(ActionSheetAction(title: CommonStrings.yesButton, style: .default, handler: { _ in
|
|
completion(true)
|
|
}))
|
|
sheet.addAction(ActionSheetAction(title: editButtonTitle, style: .default, handler: { _ in
|
|
completion(false)
|
|
}))
|
|
vc.present(sheet, animated: true, completion: nil)
|
|
}
|
|
|
|
public func requestVerification(
|
|
fromViewController: UIViewController,
|
|
isSMS: Bool,
|
|
completion: ((_ willTransition: Bool, _ error: Error?) -> Void)?) {
|
|
|
|
AssertIsOnMainThread()
|
|
|
|
guard let phoneNumber = phoneNumber else {
|
|
owsFailDebug("Missing phoneNumber.")
|
|
return
|
|
}
|
|
|
|
// We eagerly update this state, regardless of whether or not the
|
|
// registration request succeeds.
|
|
OnboardingController.setLastRegisteredCountryCode(value: countryState.countryCode)
|
|
OnboardingController.setLastRegisteredPhoneNumber(value: phoneNumber.userInput)
|
|
|
|
let captchaToken = self.captchaToken
|
|
self.verificationRequestCount += 1
|
|
|
|
firstly { () -> Promise<Void> in
|
|
return self.accountManager.requestAccountVerification(recipientId: phoneNumber.e164,
|
|
captchaToken: captchaToken,
|
|
isSMS: isSMS)
|
|
}.done {
|
|
completion?(true, nil)
|
|
self.requestingVerificationDidSucceed(viewController: fromViewController)
|
|
|
|
}.catch { error in
|
|
Logger.error("Error: \(error)")
|
|
|
|
switch error {
|
|
case let error where error.httpStatusCode == 400:
|
|
completion?(false, error)
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString("REGISTRATION_ERROR", comment: ""),
|
|
message: NSLocalizedString("REGISTRATION_NON_VALID_NUMBER", comment: ""))
|
|
|
|
case let error where error.httpStatusCode == 413:
|
|
completion?(false, error)
|
|
OWSActionSheets.showActionSheet(
|
|
title: nil,
|
|
message: NSLocalizedString("REGISTER_RATE_LIMITING_BODY", comment: "action sheet body"))
|
|
|
|
case let error where error.isNetworkFailureOrTimeout:
|
|
completion?(false, error)
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString("REGISTRATION_ERROR_NETWORK_FAILURE_ALERT_TITLE",
|
|
comment: "Alert title for network failure during registration"),
|
|
message: NSLocalizedString("REGISTRATION_ERROR_NETWORK_FAILURE_ALERT_BODY",
|
|
comment: "Alert body for network failure during registration"))
|
|
|
|
case AccountServiceClientError.captchaRequired:
|
|
completion?(true, error)
|
|
self.onboardingDidRequireCaptcha(viewController: fromViewController)
|
|
|
|
default:
|
|
owsFailDebug("unexpected error: \(error)")
|
|
completion?(false, error)
|
|
OWSActionSheets.showActionSheet(title: error.userErrorDescription,
|
|
message: (error as NSError).localizedRecoverySuggestion)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Transfer
|
|
|
|
func transferAccount(fromViewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = fromViewController.navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
guard !(navigationController.topViewController is OnboardingTransferQRCodeViewController) else {
|
|
// qr code view is already presented, we don't need to push it again.
|
|
return
|
|
}
|
|
|
|
let view = OnboardingTransferQRCodeViewController(onboardingController: self)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
|
|
func accountTransferInProgress(fromViewController: UIViewController, progress: Progress) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = fromViewController.navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
guard !(navigationController.topViewController is OnboardingTransferProgressViewController) else {
|
|
// qr code view is already presented, we don't need to push it again.
|
|
return
|
|
}
|
|
|
|
let view = OnboardingTransferProgressViewController(onboardingController: self, progress: progress)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
|
|
public func presentTransferOptions(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
guard !(navigationController.topViewController is OnboardingTransferChoiceViewController) else {
|
|
// transfer view is already presented, we don't need to push it again.
|
|
return
|
|
}
|
|
|
|
let view = OnboardingTransferChoiceViewController(onboardingController: self)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
|
|
// MARK: - Verification
|
|
|
|
public enum VerificationOutcome: Equatable {
|
|
case success
|
|
case invalidVerificationCode
|
|
case invalid2FAPin
|
|
case invalidV2RegistrationLockPin(remainingAttempts: UInt32)
|
|
case exhaustedV2RegistrationLockAttempts
|
|
}
|
|
|
|
private var hasPendingRestoration: Bool {
|
|
databaseStorage.read { KeyBackupService.hasPendingRestoration(transaction: $0) }
|
|
}
|
|
|
|
public func submitVerification(fromViewController: UIViewController,
|
|
checkForAvailableTransfer: Bool = true,
|
|
showModal: Bool = true,
|
|
completion : @escaping (VerificationOutcome) -> Void) {
|
|
AssertIsOnMainThread()
|
|
|
|
// If we have credentials for KBS auth or we're trying to verify
|
|
// after registering, we need to restore our keys from KBS.
|
|
if kbsAuth != nil || hasPendingRestoration {
|
|
guard let twoFAPin = twoFAPin else {
|
|
owsFailDebug("We expected a 2fa attempt, but we don't have a code to try")
|
|
return completion(.invalid2FAPin)
|
|
}
|
|
|
|
// Clear all cached values before doing restores during onboarding,
|
|
// they could be stale from previous registrations.
|
|
databaseStorage.write { KeyBackupService.clearKeys(transaction: $0) }
|
|
|
|
KeyBackupService.restoreKeys(with: twoFAPin, and: self.kbsAuth).done {
|
|
// If we restored successfully clear out KBS auth, the server will give it
|
|
// to us again if we still need to do KBS operations.
|
|
self.kbsAuth = nil
|
|
|
|
if self.hasPendingRestoration {
|
|
firstly {
|
|
self.accountManager.performInitialStorageServiceRestore()
|
|
}.recover { error in
|
|
Logger.warn("Timed out performing storage service restore: \(error). Ignoring...")
|
|
}.done {
|
|
completion(.success)
|
|
}
|
|
|
|
} else {
|
|
// We've restored our keys, we can now re-run this method to post our registration token
|
|
// We need to first mark reglock as enabled so we know to include the reglock token in our
|
|
// registration attempt.
|
|
self.databaseStorage.write { transaction in
|
|
self.ows2FAManager.markRegistrationLockV2Enabled(transaction: transaction)
|
|
}
|
|
self.submitVerification(fromViewController: fromViewController, completion: completion)
|
|
}
|
|
}.catch { error in
|
|
guard let error = error as? KeyBackupService.KBSError else {
|
|
owsFailDebug("unexpected response from KBS")
|
|
return completion(.invalid2FAPin)
|
|
}
|
|
|
|
switch error {
|
|
case .assertion:
|
|
owsFailDebug("unexpected response from KBS")
|
|
completion(.invalid2FAPin)
|
|
case .invalidPin(let remainingAttempts):
|
|
Logger.warn("Invalid V2 PIN, \(remainingAttempts) attempt(s) remaining")
|
|
completion(.invalidV2RegistrationLockPin(remainingAttempts: remainingAttempts))
|
|
case .backupMissing:
|
|
Logger.error("Invalid V2 PIN, attempts exhausted")
|
|
// We don't have a backup for this person, it probably
|
|
// was deleted due to too many failed attempts. They'll
|
|
// have to retry after the registration lock window expires.
|
|
completion(.exhaustedV2RegistrationLockAttempts)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
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
|
|
|
|
let promise = firstly {
|
|
self.accountManager.register(
|
|
verificationCode: verificationCode,
|
|
pin: twoFAPin,
|
|
checkForAvailableTransfer: checkForAvailableTransfer)
|
|
|
|
}.then { () -> Promise<Void> in
|
|
// Re-enable 2FA and RegLock with the registered pin, if any
|
|
if let pin = twoFAPin {
|
|
self.databaseStorage.write { transaction in
|
|
OWS2FAManager.shared.markEnabled(pin: pin, transaction: transaction)
|
|
}
|
|
if OWS2FAManager.shared.mode == .V2 {
|
|
return OWS2FAManager.shared.enableRegistrationLockV2()
|
|
}
|
|
}
|
|
return Promise.value(())
|
|
}
|
|
|
|
if showModal {
|
|
ModalActivityIndicatorViewController.present(fromViewController: fromViewController, canCancel: true) { modal in
|
|
promise.done {
|
|
modal.dismiss {
|
|
self.verificationDidComplete(fromView: fromViewController)
|
|
}
|
|
}.catch { error in
|
|
modal.dismiss(completion: {
|
|
Logger.warn("Error: \(error)")
|
|
|
|
self.verificationFailed(
|
|
fromViewController: fromViewController,
|
|
error: error as NSError,
|
|
completion: completion)
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
promise.done {
|
|
self.verificationDidComplete(fromView: fromViewController)
|
|
}.catch { error in
|
|
Logger.warn("Error: \(error)")
|
|
|
|
self.verificationFailed(
|
|
fromViewController: fromViewController,
|
|
error: error as NSError,
|
|
completion: completion)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func verificationFailed(fromViewController: UIViewController,
|
|
error: NSError,
|
|
completion : @escaping (VerificationOutcome) -> Void) {
|
|
AssertIsOnMainThread()
|
|
|
|
if error.domain == OWSSignalServiceKitErrorDomain &&
|
|
error.code == OWSErrorCode.registrationMissing2FAPIN.rawValue {
|
|
|
|
Logger.info("Missing 2FA PIN.")
|
|
|
|
// If we were provided KBS auth, we'll need to re-register using reg lock v2,
|
|
// store this for that path.
|
|
kbsAuth = error.userInfo[TSRemoteAttestationAuthErrorKey] as? RemoteAttestationAuth
|
|
|
|
// Since we were told we need 2fa, clear out any stored KBS keys so we can
|
|
// do a fresh verification.
|
|
SDSDatabaseStorage.shared.write { transaction in
|
|
KeyBackupService.clearKeys(transaction: transaction)
|
|
self.ows2FAManager.markRegistrationLockV2Disabled(transaction: transaction)
|
|
}
|
|
|
|
completion(.invalid2FAPin)
|
|
|
|
onboardingDidRequire2FAPin(viewController: fromViewController)
|
|
} else if error.domain == OWSSignalServiceKitErrorDomain &&
|
|
error.code == OWSErrorCode.registrationTransferAvailable.rawValue {
|
|
Logger.info("Transfer available")
|
|
|
|
presentTransferOptions(viewController: fromViewController)
|
|
|
|
completion(.success)
|
|
} else {
|
|
if error.domain == OWSSignalServiceKitErrorDomain &&
|
|
error.code == OWSErrorCode.userError.rawValue {
|
|
completion(.invalidVerificationCode)
|
|
}
|
|
|
|
Logger.verbose("error: \(error.domain) \(error.code)")
|
|
OWSActionSheets.showActionSheet(title: NSLocalizedString("REGISTRATION_VERIFICATION_FAILED_TITLE", comment: "Alert view title"),
|
|
message: error.userErrorDescription,
|
|
fromViewController: fromViewController)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public extension UIView {
|
|
func addBottomStroke() -> UIView {
|
|
return addBottomStroke(color: Theme.middleGrayColor, strokeWidth: CGHairlineWidth())
|
|
}
|
|
|
|
func addBottomStroke(color: UIColor, strokeWidth: CGFloat) -> UIView {
|
|
let strokeView = UIView()
|
|
strokeView.backgroundColor = color
|
|
addSubview(strokeView)
|
|
strokeView.autoSetDimension(.height, toSize: strokeWidth)
|
|
strokeView.autoPinWidthToSuperview()
|
|
strokeView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
return strokeView
|
|
}
|
|
}
|