// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import LibSignalClient import SignalServiceKit import SignalUI class ProvisioningNavigationController: OWSNavigationController { private(set) var provisioningController: ProvisioningController init(provisioningController: ProvisioningController) { self.provisioningController = provisioningController super.init() } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { let superOrientations = super.supportedInterfaceOrientations let provisioningOrientations: UIInterfaceOrientationMask = UIDevice.current.isIPad ? .all : .portrait return superOrientations.intersection(provisioningOrientations) } } class ProvisioningController: NSObject { private let appReadiness: AppReadinessSetter private lazy var registrationWebSocketManager = RegistrationWebSocketManagerImpl( chatConnectionManager: DependenciesBridge.shared.chatConnectionManager, messagePipelineSupervisor: SSKEnvironment.shared.messagePipelineSupervisorRef, messageProcessor: SSKEnvironment.shared.messageProcessorRef, ) private lazy var provisioningCoordinator: ProvisioningCoordinator = { return ProvisioningCoordinatorImpl( chatConnectionManager: DependenciesBridge.shared.chatConnectionManager, db: DependenciesBridge.shared.db, identityManager: DependenciesBridge.shared.identityManager, linkAndSyncManager: DependenciesBridge.shared.linkAndSyncManager, accountKeyStore: DependenciesBridge.shared.accountKeyStore, networkManager: SSKEnvironment.shared.networkManagerRef, preKeyManager: DependenciesBridge.shared.preKeyManager, profileManager: SSKEnvironment.shared.profileManagerImplRef, pushRegistrationManager: ProvisioningCoordinatorImpl.Wrappers.PushRegistrationManager(AppEnvironment.shared.pushRegistrationManagerRef), receiptManager: ProvisioningCoordinatorImpl.Wrappers.ReceiptManager(SSKEnvironment.shared.receiptManagerRef), registrationStateChangeManager: DependenciesBridge.shared.registrationStateChangeManager, registrationWebSocketManager: registrationWebSocketManager, signalProtocolStoreManager: DependenciesBridge.shared.signalProtocolStoreManager, signalService: SSKEnvironment.shared.signalServiceRef, storageServiceManager: SSKEnvironment.shared.storageServiceManagerRef, svr: DependenciesBridge.shared.svr, svrLocalStorage: DependenciesBridge.shared.svrLocalStorage, syncManager: SSKEnvironment.shared.syncManagerRef, threadStore: ThreadStoreImpl(), tsAccountManager: DependenciesBridge.shared.tsAccountManager, udManager: SSKEnvironment.shared.udManagerRef, ) }() private let provisioningSocketManager: ProvisioningSocketManager private init( appReadiness: AppReadinessSetter, provisioningSocketManager: ProvisioningSocketManager, ) { self.appReadiness = appReadiness self.provisioningSocketManager = provisioningSocketManager super.init() } @MainActor static func presentProvisioningFlow(appReadiness: AppReadinessSetter) { let provisioningSocketManager = ProvisioningSocketManager(linkType: .linkDevice) let provisioningController = ProvisioningController( appReadiness: appReadiness, provisioningSocketManager: provisioningSocketManager, ) let navController = ProvisioningNavigationController(provisioningController: provisioningController) provisioningController.setUpDebugLogsGesture(on: navController) let (backupRestoreState, registrationState) = DependenciesBridge.shared.db.read { tx in ( DependenciesBridge.shared.backupArchiveManager.backupRestoreState(tx: tx), DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx), ) } switch (backupRestoreState, registrationState) { case (.unfinalized, .unregistered), (.finalized, .unregistered): // If we started a link'n'sync and terminated after committing // the restored backup but before finishing, reset the app data // and start over. SignalApp.shared.resetAppDataAndExit( keyFetcher: SSKEnvironment.shared.databaseStorageRef.keyFetcher, ) default: break } let vc = ProvisioningSplashViewController(provisioningController: provisioningController) navController.setViewControllers([vc], animated: false) CurrentAppContext().mainWindow?.rootViewController = navController } static func presentRelinkingFlow(appReadiness: AppReadinessSetter) { let provisioningSocketManager = ProvisioningSocketManager(linkType: .linkDevice) let provisioningController = ProvisioningController( appReadiness: appReadiness, provisioningSocketManager: provisioningSocketManager, ) let navController = ProvisioningNavigationController(provisioningController: provisioningController) provisioningController.setUpDebugLogsGesture(on: navController) let vc = ProvisioningQRCodeViewController( provisioningController: provisioningController, provisioningSocketManager: provisioningSocketManager, ) navController.setViewControllers([vc], animated: false) CurrentAppContext().mainWindow?.rootViewController = navController Task { await provisioningController.awaitProvisioning( from: vc, navigationController: navController, ) } } #if DEBUG static func preview() -> ProvisioningController { ProvisioningController(appReadiness: AppReadinessMock(), provisioningSocketManager: ProvisioningSocketManager(linkType: .linkDevice)) } #endif 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 @MainActor private func submitLogs() { guard let viewController = CurrentAppContext().frontmostViewController() else { return } let logs = DebugLogs(dumper: .fromGlobals()) logs.promptToSubmitLogs(from: viewController, supportTag: "Onboarding") } // MARK: - Transitions func provisioningSplashRequestedModeSwitch(viewController: UIViewController) { AssertIsOnMainThread() Logger.info("") let view = ProvisioningModeSwitchConfirmationViewController(provisioningController: self) viewController.navigationController?.pushViewController(view, animated: true) } func switchToPrimaryRegistration(viewController: UIViewController) { AssertIsOnMainThread() Logger.info("") let loader = RegistrationCoordinatorLoaderImpl(dependencies: .from(self)) SignalApp.shared.showRegistration(loader: loader, desiredMode: .registering, appReadiness: appReadiness) } @MainActor func provisioningSplashDidComplete(viewController: UIViewController) async { Logger.info("") await pushPermissionsViewOrSkipToRegistration(onto: viewController) } @MainActor private func pushPermissionsViewOrSkipToRegistration(onto oldViewController: UIViewController) async { // Disable interaction during the asynchronous operation. oldViewController.view.isUserInteractionEnabled = false let newViewController = ProvisioningPermissionsViewController(provisioningController: self) let needsToAskForAnyPermissions = await newViewController.needsToAskForAnyPermissions() // 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) } } 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 @MainActor func transferAccount(fromViewController: UIViewController) async { Logger.info("") guard let navigationController = fromViewController.navigationController else { owsFailDebug("Missing navigationController") return } if navigationController.topViewController is BaseQuickRestoreQRCodeViewController { // qr code view is already presented, we don't need to push it again. return } let view = BaseQuickRestoreQRCodeViewController() await navigationController.awaitablePush(view, animated: true) do { let message = try await view.waitForMessage() guard let restoreToken = message.restoreMethodToken else { throw OWSAssertionError("Missing restore token") } let transferState = DeviceTransferCoordinator( deviceTransferService: AppEnvironment.shared.deviceTransferServiceRef, quickRestoreManager: AppEnvironment.shared.quickRestoreManager, restoreMethodToken: restoreToken, restoreMode: .linked, ) transferState.cancelTransferBlock = { [weak self] in self?.pushTransferChoiceView(onto: navigationController) } transferState.onFailure = { [weak self] _ in self?.pushTransferChoiceView(onto: navigationController) } await navigationController.awaitablePush( DeviceTransferStatusViewController(coordinator: transferState), animated: true, ) } catch { // Display error to the user Logger.error("Failed to start transfer") } } // MARK: - Linking @MainActor func didConfirmSecondaryDevice(from viewController: ProvisioningPrepViewController) async { guard let navigationController = viewController.navigationController else { owsFailDebug("navigationController was unexpectedly nil") return } let qrCodeViewController = ProvisioningQRCodeViewController( provisioningController: self, provisioningSocketManager: provisioningSocketManager, ) await navigationController.awaitablePush(qrCodeViewController, animated: true) await awaitProvisioning( from: qrCodeViewController, navigationController: navigationController, ) } @MainActor private func awaitProvisioning( from viewController: ProvisioningQRCodeViewController, navigationController: UINavigationController, ) async { let provisioningMessage = await waitForProvisioningMessage(navigationController: navigationController) provisioningSocketManager.stop() guard let provisioningMessage else { return } /// Ensure the primary is new enough to link us. guard provisioningMessage.provisioningVersion >= LinkingProvisioningMessage.Constants.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 navigationController.popViewController(animated: true) } return } let progressViewModel = LinkAndSyncSecondaryProgressViewModel() performCoordinatorTaskWithModal( task: Task { try await self.provisioningCoordinator.completeProvisioning( provisionMessage: provisioningMessage, deviceName: UIDevice.current.name, progressViewModel: progressViewModel, ) }, viewController: viewController, navigationController: navigationController, willLinkAndSync: provisioningMessage.ephemeralBackupKey != nil, progressViewModel: progressViewModel, ) } @MainActor private func waitForProvisioningMessage( navigationController: UINavigationController, ) async -> LinkingProvisioningMessage? { do { return try await provisioningSocketManager.waitForMessage() } catch let error { Logger.error("Failed to decrypt provision envelope: \(error)") let alert = ActionSheetController( title: OWSLocalizedString( "SECONDARY_LINKING_ERROR_WAITING_FOR_SCAN", comment: "alert title", ), message: error.userErrorDescription, ) alert.addAction(ActionSheetAction( title: CommonStrings.cancelButton, style: .cancel, handler: { _ in navigationController.popViewController(animated: true) }, )) navigationController.presentActionSheet(alert) return nil } } func provisioningDidComplete(from viewController: UIViewController) { if viewController.presentedViewController != nil { viewController.dismiss(animated: true) { self.provisioningDidComplete(from: viewController) } return } SignalApp.shared.showConversationSplitView(appReadiness: appReadiness) } @MainActor private func resetBackToQrCodeController( from viewController: ProvisioningQRCodeViewController, navigationController: UINavigationController, ) async { Logger.warn("") // Reset at the start so it goes while other stuff animates. viewController.reset() await registrationWebSocketManager.releaseRestrictedWebSocket(isRegistered: false) if navigationController.presentedViewController != nil { await navigationController.awaitableDismiss(animated: true) } if viewController.presentedViewController != nil { await viewController.awaitableDismiss(animated: true) } navigationController.popToViewController(viewController, animated: true) Task { await awaitProvisioning( from: viewController, navigationController: navigationController, ) } } @MainActor private func performCoordinatorTaskWithModal( task: Task, viewController: ProvisioningQRCodeViewController, navigationController: UINavigationController, willLinkAndSync: Bool, progressViewModel: LinkAndSyncSecondaryProgressViewModel, ) { if willLinkAndSync { Task { @MainActor in let progressViewController: LinkAndSyncProvisioningProgressViewController if let vc = viewController.presentedViewController { if let vc = vc as? LinkAndSyncProvisioningProgressViewController { progressViewController = vc } else { vc.dismiss(animated: true, completion: { self.performCoordinatorTaskWithModal( task: task, viewController: viewController, navigationController: navigationController, willLinkAndSync: willLinkAndSync, progressViewModel: progressViewModel, ) }) return } } else { progressViewController = LinkAndSyncProvisioningProgressViewController( provisioningController: self, viewModel: progressViewModel, ) } progressViewController.linkNSyncTask = task viewController.present(progressViewController, animated: false) do { try await task.value // Don't dismiss the progress view or it will quickly jump // to that before jumping again to the chat list. self.provisioningDidComplete(from: viewController) } catch var error as CompleteProvisioningError { if case let .linkAndSyncError(linkAndSyncError) = error { switch linkAndSyncError.error { case SecondaryLinkNSyncError.primaryFailedBackupExport(let continueWithoutSyncing): if continueWithoutSyncing { do { try await linkAndSyncError.continueWithoutSyncing() self.provisioningDidComplete(from: viewController) return } catch let innerError as CompleteProvisioningError { error = innerError } } else { // Crash if this fails; things have gone horribly wrong. try! await linkAndSyncError.restartProvisioning() await self.resetBackToQrCodeController( from: viewController, navigationController: navigationController, ) return } case is CancellationError: // Exit provisioning if we cancelled do { try await linkAndSyncError.continueWithoutSyncing() self.provisioningDidComplete(from: viewController) return } catch let innerError as CompleteProvisioningError { error = innerError } default: break } } let errorActionSheet = self.errorActionSheet( error: error, from: viewController, navigationController: navigationController, progressViewModel: progressViewModel, ) if progressViewController.presentedViewController == nil { progressViewController.presentActionSheet(errorActionSheet) } } } } else { let presentingController = viewController.presentedViewController ?? viewController ModalActivityIndicatorViewController.present( fromViewController: presentingController, canCancel: false, ) { modal async -> Void in let result: CompleteProvisioningError? do { try await task.value result = nil } catch let error { result = error as? CompleteProvisioningError } let errorActionSheet = result.map { self.errorActionSheet( error: $0, from: viewController, navigationController: navigationController, progressViewModel: progressViewModel, ) } modal.dismiss { if let errorActionSheet { presentingController.presentActionSheet(errorActionSheet) } else { self.provisioningDidComplete(from: viewController) } } } } } private func errorActionSheet( error: CompleteProvisioningError, from viewController: ProvisioningQRCodeViewController, navigationController: UINavigationController, progressViewModel: LinkAndSyncSecondaryProgressViewModel, ) -> ActionSheetController { let alert: ActionSheetController switch error { 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.", ), style: .default, handler: { _ in Task { @MainActor in await self.resetBackToQrCodeController( from: viewController, navigationController: navigationController, ) } }, )) case .deviceLimitExceededError(let error): alert = ActionSheetController(title: error.errorDescription, message: error.recoverySuggestion) alert.addAction(ActionSheetAction( title: CommonStrings.okButton, handler: { _ in Task { @MainActor in await self.resetBackToQrCodeController( from: viewController, navigationController: navigationController, ) } }, )) 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, 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, style: .default, handler: { _ in let isProvisioned = DependenciesBridge.shared.db.read { tx in DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx).isRegistered } if isProvisioned { self.provisioningDidComplete(from: viewController) } else { Task { @MainActor in await self.resetBackToQrCodeController( from: viewController, navigationController: navigationController, ) } } }, )) case .linkAndSyncError(let error): return self.linkAndSyncRetryActionSheet( error: error, from: viewController, navigationController: navigationController, progressViewModel: progressViewModel, ) } return alert } private func linkAndSyncRetryActionSheet( error: ProvisioningCoordinatorImpl.LinkAndSyncError, from viewController: ProvisioningQRCodeViewController, navigationController: UINavigationController, progressViewModel: LinkAndSyncSecondaryProgressViewModel, ) -> ActionSheetController { enum ErrorPromptMode { case contactSupport case networkErrorRetry case restartProvisioning } let errorPromptMode: ErrorPromptMode let errorMessage: String? if case SecondaryLinkNSyncError.errorRestoringBackup = error.error { errorPromptMode = .contactSupport errorMessage = nil } else if error.error.isNetworkFailureOrTimeout { errorPromptMode = .networkErrorRetry errorMessage = OWSLocalizedString( "SECONDARY_LINKING_SYNCING_NETWORK_ERROR_MESSAGE", comment: "Message for action sheet when secondary device fails to sync messages due to network error.", ) } else if case BackupImportError.unsupportedVersion = error.error { let actionSheet = ActionSheetController( title: OWSLocalizedString( "SECONDARY_LINKING_SYNCING_UPDATE_REQUIRED_ERROR_TITLE", comment: "Title for action sheet when the secondary device fails to sync messages due to an app update being required.", ), message: OWSLocalizedString( "SECONDARY_LINKING_SYNCING_UPDATE_REQUIRED_ERROR_MESSAGE", comment: "Message for action sheet when the secondary device fails to sync messages due to an app update being required.", ), ) actionSheet.addAction(ActionSheetAction( title: OWSLocalizedString( "SECONDARY_LINKING_SYNCING_UPDATE_REQUIRED_CHECK_FOR_UPDATE_BUTTON", comment: "Button on an action sheet to open Signal on the App Store.", ), style: .default, ) { _ in UIApplication.shared.open(TSConstants.appStoreUrl) Task { @MainActor in // Crash if this fails; things have gone horribly wrong. try! await error.restartProvisioning() await self.resetBackToQrCodeController( from: viewController, navigationController: navigationController, ) } }) return actionSheet } else { errorPromptMode = .restartProvisioning errorMessage = OWSLocalizedString( "SECONDARY_LINKING_SYNCING_OTHER_ERROR_MESSAGE", comment: "Message for action sheet when secondary device fails to sync messages due to an unspecified error.", ) } let retryActionSheet = ActionSheetController( title: OWSLocalizedString( "SECONDARY_LINKING_SYNCING_ERROR_TITLE", comment: "Title for action sheet when secondary device fails to sync messages.", ), message: errorMessage, ) retryActionSheet.isCancelable = false switch errorPromptMode { case .contactSupport: retryActionSheet.addAction(ActionSheetAction(title: CommonStrings.contactSupport) { _ in Task { @MainActor in // Crash if this fails; things have gone horribly wrong. try! await error.restartProvisioning() await self.resetBackToQrCodeController( from: viewController, navigationController: navigationController, ) // Wait to present until we've reset back to the QR code // view controller. ContactSupportActionSheet.present( emailFilter: .backupImportFailed, logDumper: .fromGlobals(), fromViewController: viewController, ) } }) case .networkErrorRetry: retryActionSheet.addAction(ActionSheetAction(title: CommonStrings.retryButton) { _ in self.performCoordinatorTaskWithModal( task: Task { try await error.retryLinkAndSync() }, viewController: viewController, navigationController: navigationController, willLinkAndSync: true, progressViewModel: progressViewModel, ) }) case .restartProvisioning: retryActionSheet.addAction(ActionSheetAction(title: CommonStrings.retryButton) { _ in Task { @MainActor in // Crash if this fails; things have gone horribly wrong. try! await error.restartProvisioning() await self.resetBackToQrCodeController( from: viewController, navigationController: navigationController, ) } }) } retryActionSheet.addAction(ActionSheetAction( title: CommonStrings.cancelButton, style: .cancel, ) { _ in self.performCoordinatorTaskWithModal( task: Task { try await error.continueWithoutSyncing() }, viewController: viewController, navigationController: navigationController, willLinkAndSync: false, progressViewModel: progressViewModel, ) }) return retryActionSheet } } private extension CommonStrings { static var linkNSyncImportErrorTitle: String { OWSLocalizedString( "SECONDARY_LINKING_SYNCING_ERROR_TITLE", comment: "Title for action sheet when secondary device fails to sync messages.", ) } }