// // 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 private var deviceIdFuture: Future private var provisionEnvelopePromise: Promise private var provisionEnvelopeFuture: Future 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 { 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? private var awaitProvisionMessage: Promise { 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 { return awaitProvisionMessage.then { [weak self] provisionMessage -> Promise 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 { 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) } }