458 lines
21 KiB
Swift
458 lines
21 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
public class ProvisioningNavigationController: OWSNavigationController {
|
|
private(set) var provisioningController: ProvisioningController
|
|
|
|
public init(provisioningController: ProvisioningController) {
|
|
self.provisioningController = provisioningController
|
|
super.init()
|
|
}
|
|
|
|
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
let superOrientations = super.supportedInterfaceOrientations
|
|
let provisioningOrientations: UIInterfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait
|
|
|
|
return superOrientations.intersection(provisioningOrientations)
|
|
}
|
|
}
|
|
|
|
public class ProvisioningController: NSObject {
|
|
|
|
private let provisioningCipher: ProvisioningCipher
|
|
private let provisioningSocket: ProvisioningSocket
|
|
|
|
private var deviceIdPromise: Promise<String>
|
|
private var deviceIdFuture: Future<String>
|
|
|
|
private var provisionEnvelopePromise: Promise<ProvisioningProtoProvisionEnvelope>
|
|
private var provisionEnvelopeFuture: Future<ProvisioningProtoProvisionEnvelope>
|
|
|
|
private lazy var provisioningCoordinator: ProvisioningCoordinator = {
|
|
return ProvisioningCoordinatorImpl(
|
|
chatConnectionManager: DependenciesBridge.shared.chatConnectionManager,
|
|
db: DependenciesBridge.shared.db,
|
|
identityManager: DependenciesBridge.shared.identityManager,
|
|
messageFactory: ProvisioningCoordinatorImpl.Wrappers.MessageFactory(),
|
|
preKeyManager: DependenciesBridge.shared.preKeyManager,
|
|
profileManager: ProvisioningCoordinatorImpl.Wrappers.ProfileManager(self.profileManagerImpl),
|
|
pushRegistrationManager: ProvisioningCoordinatorImpl.Wrappers.PushRegistrationManager(
|
|
self.pushRegistrationManager
|
|
),
|
|
receiptManager: ProvisioningCoordinatorImpl.Wrappers.ReceiptManager(OWSReceiptManager.shared),
|
|
registrationStateChangeManager: DependenciesBridge.shared.registrationStateChangeManager,
|
|
signalService: self.signalService,
|
|
storageServiceManager: self.storageServiceManager,
|
|
svr: DependenciesBridge.shared.svr,
|
|
syncManager: ProvisioningCoordinatorImpl.Wrappers.SyncManager(OWSSyncManager.shared),
|
|
threadStore: ThreadStoreImpl(),
|
|
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
|
|
udManager: ProvisioningCoordinatorImpl.Wrappers.UDManager(self.udManager)
|
|
)
|
|
}()
|
|
|
|
private override init() {
|
|
provisioningCipher = ProvisioningCipher.generate()
|
|
|
|
(self.deviceIdPromise, self.deviceIdFuture) = Promise.pending()
|
|
(self.provisionEnvelopePromise, self.provisionEnvelopeFuture) = Promise.pending()
|
|
|
|
provisioningSocket = ProvisioningSocket()
|
|
|
|
super.init()
|
|
|
|
provisioningSocket.delegate = self
|
|
}
|
|
|
|
public func resetPromises() {
|
|
_awaitProvisionMessage = nil
|
|
(self.deviceIdPromise, self.deviceIdFuture) = Promise.pending()
|
|
(self.provisionEnvelopePromise, self.provisionEnvelopeFuture) = Promise.pending()
|
|
}
|
|
|
|
public static func presentProvisioningFlow() {
|
|
let provisioningController = ProvisioningController()
|
|
let navController = ProvisioningNavigationController(provisioningController: provisioningController)
|
|
provisioningController.setUpDebugLogsGesture(on: navController)
|
|
|
|
let vc = ProvisioningSplashViewController(provisioningController: provisioningController)
|
|
navController.setViewControllers([vc], animated: false)
|
|
|
|
CurrentAppContext().mainWindow?.rootViewController = navController
|
|
}
|
|
|
|
public static func presentRelinkingFlow() {
|
|
let provisioningController = ProvisioningController()
|
|
let navController = ProvisioningNavigationController(provisioningController: provisioningController)
|
|
provisioningController.setUpDebugLogsGesture(on: navController)
|
|
|
|
let vc = ProvisioningQRCodeViewController(provisioningController: provisioningController)
|
|
navController.setViewControllers([vc], animated: false)
|
|
|
|
provisioningController.awaitProvisioning(from: vc, navigationController: navController)
|
|
CurrentAppContext().mainWindow?.rootViewController = navController
|
|
}
|
|
|
|
private func setUpDebugLogsGesture(
|
|
on navigationController: UINavigationController
|
|
) {
|
|
let submitLogsGesture = UITapGestureRecognizer(target: self, action: #selector(submitLogs))
|
|
submitLogsGesture.numberOfTapsRequired = 8
|
|
submitLogsGesture.delaysTouchesEnded = false
|
|
navigationController.view.addGestureRecognizer(submitLogsGesture)
|
|
}
|
|
|
|
@objc
|
|
private func submitLogs() {
|
|
DebugLogs.submitLogsWithSupportTag("Onboarding")
|
|
}
|
|
|
|
// MARK: - Transitions
|
|
|
|
public func provisioningSplashRequestedModeSwitch(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
let view = ProvisioningModeSwitchConfirmationViewController(provisioningController: self)
|
|
viewController.navigationController?.pushViewController(view, animated: true)
|
|
}
|
|
|
|
public func switchToPrimaryRegistration(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
let loader = RegistrationCoordinatorLoaderImpl(dependencies: .from(self))
|
|
SignalApp.shared.showRegistration(loader: loader, desiredMode: .registering)
|
|
}
|
|
|
|
public func provisioningSplashDidComplete(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
pushPermissionsViewOrSkipToRegistration(onto: viewController)
|
|
}
|
|
|
|
private func pushPermissionsViewOrSkipToRegistration(
|
|
onto oldViewController: UIViewController
|
|
) {
|
|
// Disable interaction during the asynchronous operation.
|
|
oldViewController.view.isUserInteractionEnabled = false
|
|
|
|
let newViewController = ProvisioningPermissionsViewController(provisioningController: self)
|
|
|
|
firstly(on: DispatchQueue.sharedUserInitiated) {
|
|
newViewController.needsToAskForAnyPermissions()
|
|
}.timeout(
|
|
// If we don't get an answer quickly, assume we need to ask. We don't
|
|
// expect to hit this timeout, but we really don't want to keep users
|
|
// waiting during registration.
|
|
seconds: 1,
|
|
substituteValue: true
|
|
).recover(on: DispatchQueue.main) { error in
|
|
// This could only happen if something rejects, which we don't expect.
|
|
// However, because it's registration, we assume we need to ask instead of
|
|
// crashing—that's better than preventing registration.
|
|
owsFailDebug("\(error)")
|
|
return .value(true)
|
|
}.done(on: DispatchQueue.main) { (needsToAskForAnyPermissions: Bool) in
|
|
// Always re-enable interaction in case the user restart registration.
|
|
oldViewController.view.isUserInteractionEnabled = true
|
|
|
|
if needsToAskForAnyPermissions {
|
|
oldViewController.navigationController?.pushViewController(newViewController, animated: true)
|
|
} else {
|
|
self.provisioningPermissionsDidComplete(viewController: oldViewController)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func provisioningPermissionsDidComplete(viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("navigationController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
pushTransferChoiceView(onto: navigationController)
|
|
}
|
|
|
|
func pushTransferChoiceView(onto navigationController: UINavigationController) {
|
|
AssertIsOnMainThread()
|
|
|
|
let view = ProvisioningTransferChoiceViewController(provisioningController: self)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
|
|
// MARK: - Transfer
|
|
|
|
func transferAccount(fromViewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.info("")
|
|
|
|
guard let navigationController = fromViewController.navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
guard !(navigationController.topViewController is ProvisioningTransferQRCodeViewController) else {
|
|
// qr code view is already presented, we don't need to push it again.
|
|
return
|
|
}
|
|
|
|
let view = ProvisioningTransferQRCodeViewController(provisioningController: 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 ProvisioningTransferProgressViewController) else {
|
|
// qr code view is already presented, we don't need to push it again.
|
|
return
|
|
}
|
|
|
|
let view = ProvisioningTransferProgressViewController(provisioningController: self, progress: progress)
|
|
navigationController.pushViewController(view, animated: true)
|
|
}
|
|
|
|
// MARK: - Linking
|
|
|
|
func didConfirmSecondaryDevice(from viewController: ProvisioningPrepViewController) {
|
|
guard let navigationController = viewController.navigationController else {
|
|
owsFailDebug("navigationController was unexpectedly nil")
|
|
return
|
|
}
|
|
|
|
let qrCodeViewController = ProvisioningQRCodeViewController(provisioningController: self)
|
|
navigationController.pushViewController(qrCodeViewController, animated: true)
|
|
|
|
awaitProvisioning(from: qrCodeViewController, navigationController: navigationController)
|
|
}
|
|
|
|
private func awaitProvisioning(from viewController: ProvisioningQRCodeViewController,
|
|
navigationController: UINavigationController) {
|
|
|
|
awaitProvisionMessage.done { [weak self, weak navigationController] message in
|
|
guard let self = self else { throw PromiseError.cancelled }
|
|
guard let navigationController = navigationController else { throw PromiseError.cancelled }
|
|
|
|
// Verify the primary device is new enough to link us. Right now this is a simple check
|
|
// of >= the latest version, but when we bump the version we may need to be more specific
|
|
// if we have some backwards compatible support and allow a limited linking with an old
|
|
// version of the app.
|
|
guard let provisioningVersion = message.provisioningVersion,
|
|
provisioningVersion >= OWSDeviceProvisionerConstant.provisioningVersion else {
|
|
OWSActionSheets.showActionSheet(
|
|
title: OWSLocalizedString("SECONDARY_LINKING_ERROR_OLD_VERSION_TITLE",
|
|
comment: "alert title for outdated linking device"),
|
|
message: OWSLocalizedString("SECONDARY_LINKING_ERROR_OLD_VERSION_MESSAGE",
|
|
comment: "alert message for outdated linking device")
|
|
) { _ in
|
|
self.resetPromises()
|
|
navigationController.popViewController(animated: true)
|
|
}
|
|
return
|
|
}
|
|
|
|
let confirmVC = ProvisioningSetDeviceNameViewController(provisioningController: self)
|
|
navigationController.pushViewController(confirmVC, animated: true)
|
|
}.catch { error in
|
|
switch error {
|
|
case PromiseError.cancelled:
|
|
Logger.info("cancelled")
|
|
default:
|
|
Logger.warn("error: \(error)")
|
|
let alert = ActionSheetController(title: OWSLocalizedString("SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN", comment: "alert title"),
|
|
message: error.userErrorDescription)
|
|
alert.addAction(ActionSheetAction(title: CommonStrings.retryButton,
|
|
accessibilityIdentifier: "alert.retry",
|
|
style: .default,
|
|
handler: { _ in
|
|
self.resetPromises()
|
|
navigationController.popViewController(animated: true)
|
|
}))
|
|
navigationController.presentActionSheet(alert)
|
|
}
|
|
}
|
|
}
|
|
|
|
func didSetDeviceName(_ deviceName: String, from viewController: UIViewController) {
|
|
let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { modal in
|
|
self.completeLinking(deviceName: deviceName).done { result in
|
|
let alert: ActionSheetController
|
|
switch result {
|
|
case .success:
|
|
modal.dismiss {
|
|
self.provisioningDidComplete(from: viewController)
|
|
}
|
|
return
|
|
case .previouslyLinkedWithDifferentAccount:
|
|
Logger.warn("was previously linked/registered on different account!")
|
|
let title = OWSLocalizedString("SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_TITLE",
|
|
comment: "Title for error alert indicating that re-linking failed because the account did not match.")
|
|
let message = OWSLocalizedString("SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_MESSAGE",
|
|
comment: "Message for error alert indicating that re-linking failed because the account did not match.")
|
|
alert = ActionSheetController(title: title, message: message)
|
|
alert.addAction(ActionSheetAction(title: OWSLocalizedString("SECONDARY_LINKING_ERROR_DIFFERENT_ACCOUNT_RESET_DEVICE",
|
|
comment: "Label for the 'reset device' action in the 're-linking failed because the account did not match' alert."),
|
|
accessibilityIdentifier: "alert.reset_device",
|
|
style: .default,
|
|
handler: { _ in
|
|
Self.resetDeviceState()
|
|
}))
|
|
case .deviceLimitExceededError(let error):
|
|
alert = ActionSheetController(title: error.errorDescription, message: error.recoverySuggestion)
|
|
alert.addAction(ActionSheetAction(title: CommonStrings.okButton))
|
|
case .obsoleteLinkedDeviceError:
|
|
Logger.warn("obsolete device error")
|
|
let title = OWSLocalizedString("SECONDARY_LINKING_ERROR_OBSOLETE_LINKED_DEVICE_TITLE",
|
|
comment: "Title for error alert indicating that a linked device must be upgraded before it can be linked.")
|
|
let message = OWSLocalizedString("SECONDARY_LINKING_ERROR_OBSOLETE_LINKED_DEVICE_MESSAGE",
|
|
comment: "Message for error alert indicating that a linked device must be upgraded before it can be linked.")
|
|
alert = ActionSheetController(title: title, message: message)
|
|
|
|
let updateButtonText = OWSLocalizedString("APP_UPDATE_NAG_ALERT_UPDATE_BUTTON", comment: "Label for the 'update' button in the 'new app version available' alert.")
|
|
let updateAction = ActionSheetAction(title: updateButtonText,
|
|
accessibilityIdentifier: "alert.update",
|
|
style: .default) { _ in
|
|
let url = TSConstants.appStoreUrl
|
|
UIApplication.shared.open(url, options: [:])
|
|
}
|
|
alert.addAction(updateAction)
|
|
case .genericError(let error):
|
|
let title = OWSLocalizedString("SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN", comment: "alert title")
|
|
let message = error.userErrorDescription
|
|
alert = ActionSheetController(title: title, message: message)
|
|
alert.addAction(ActionSheetAction(title: CommonStrings.retryButton,
|
|
accessibilityIdentifier: "alert.retry",
|
|
style: .default,
|
|
handler: { _ in
|
|
self.didSetDeviceName(deviceName, from: viewController)
|
|
}))
|
|
}
|
|
modal.dismiss {
|
|
viewController.presentActionSheet(alert)
|
|
}
|
|
}
|
|
}
|
|
|
|
ModalActivityIndicatorViewController.present(fromViewController: viewController,
|
|
canCancel: false,
|
|
backgroundBlock: backgroundBlock)
|
|
}
|
|
|
|
public func provisioningDidComplete(from viewController: UIViewController) {
|
|
SignalApp.shared.showConversationSplitView()
|
|
}
|
|
|
|
private static func resetDeviceState() {
|
|
Logger.warn("")
|
|
|
|
SignalApp.resetAppDataWithUI()
|
|
}
|
|
|
|
public func getProvisioningURL() -> Promise<URL> {
|
|
return getDeviceId().map { [weak self] deviceId in
|
|
guard let self = self else { throw PromiseError.cancelled }
|
|
|
|
return try self.buildProvisioningUrl(deviceId: deviceId)
|
|
}
|
|
}
|
|
|
|
private var _awaitProvisionMessage: Promise<ProvisionMessage>?
|
|
private var awaitProvisionMessage: Promise<ProvisionMessage> {
|
|
if _awaitProvisionMessage == nil {
|
|
_awaitProvisionMessage = provisionEnvelopePromise.map { [weak self] envelope in
|
|
guard let self = self else { throw PromiseError.cancelled }
|
|
return try self.provisioningCipher.decrypt(envelope: envelope)
|
|
}
|
|
}
|
|
return _awaitProvisionMessage!
|
|
}
|
|
|
|
private func completeLinking(deviceName: String) -> Guarantee<CompleteProvisioningResult> {
|
|
return awaitProvisionMessage.then { [weak self] provisionMessage -> Promise<CompleteProvisioningResult> in
|
|
guard let self = self else { throw PromiseError.cancelled }
|
|
|
|
return Promise.wrapAsync {
|
|
await self.provisioningCoordinator.completeProvisioning(
|
|
provisionMessage: provisionMessage,
|
|
deviceName: deviceName
|
|
)
|
|
}
|
|
}.recover {
|
|
return .value(.genericError($0))
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private func buildProvisioningUrl(deviceId: String) throws -> URL {
|
|
let base64PubKey: String = Data(
|
|
provisioningCipher.secondaryDevicePublicKey.serialize()
|
|
).base64EncodedString()
|
|
guard let encodedPubKey = base64PubKey.encodeURIComponent else {
|
|
throw OWSAssertionError("Failed to url encode query params")
|
|
}
|
|
|
|
// We don't use URLComponents to generate this URL as it encodes '+' and '/'
|
|
// in the base64 pub_key in a way the Android doesn't tolerate.
|
|
let urlString = "\(UrlOpener.Constants.sgnlPrefix)://\(DeviceProvisioningURL.Constants.linkDeviceHost)?uuid=\(deviceId)&pub_key=\(encodedPubKey)"
|
|
guard let url = URL(string: urlString) else {
|
|
throw OWSAssertionError("invalid url: \(urlString)")
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
private func getDeviceId() -> Promise<String> {
|
|
assert(provisioningSocket.state != .open)
|
|
// TODO send Keep-Alive or ping frames at regular intervals
|
|
// iOS uses ping frames elsewhere, but moxie seemed surprised we weren't
|
|
// using the keepalive endpoint. Waiting to here back from him before proceeding.
|
|
// (If it's sufficient, my preference would be to do like we do elsewhere and
|
|
// use the ping frames)
|
|
provisioningSocket.connect()
|
|
return deviceIdPromise
|
|
}
|
|
}
|
|
|
|
extension ProvisioningController: ProvisioningSocketDelegate {
|
|
public func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveDeviceId deviceId: String) {
|
|
owsAssertDebug(!deviceIdPromise.isSealed)
|
|
deviceIdFuture.resolve(deviceId)
|
|
}
|
|
|
|
public func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didReceiveEnvelope envelope: ProvisioningProtoProvisionEnvelope) {
|
|
// After receiving the provisioning message, there's nothing else to retrieve from the provisioning socket
|
|
provisioningSocket.disconnect(code: .normalClosure)
|
|
|
|
owsAssertDebug(!provisionEnvelopePromise.isSealed)
|
|
return provisionEnvelopeFuture.resolve(envelope)
|
|
}
|
|
|
|
public func provisioningSocket(_ provisioningSocket: ProvisioningSocket, didError error: Error) {
|
|
Logger.warn("\(error)")
|
|
deviceIdFuture.reject(error)
|
|
provisionEnvelopeFuture.reject(error)
|
|
}
|
|
}
|