Signal-iOS/Signal/Provisioning/UserInterface/ProvisioningController.swift
2024-03-25 13:26:31 -05:00

457 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
// crashingthat'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) {
deviceIdFuture.reject(error)
provisionEnvelopeFuture.reject(error)
}
}