From dac804309e2c2db1907c811aa66ee61c9f1dae62 Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 30 Jul 2025 15:19:56 +0100 Subject: [PATCH] Move session related responsibilities from ChatsFlowFlowCoordinator to UserSessionFlowCoordinator. Specifically: onboarding, session verification and logout. --- .../Navigation/NavigationTabCoordinator.swift | 110 +++++++++ .../ChatsFlowCoordinator.swift | 178 +------------- .../ChatsFlowCoordinatorStateMachine.swift | 10 - .../OnboardingFlowCoordinator.swift | 10 +- .../UserSessionFlowCoordinator.swift | 223 ++++++++++++++++-- ...SessionVerificationScreenCoordinator.swift | 4 +- .../SessionVerificationScreenModels.swift | 12 +- .../SessionVerificationScreenViewModel.swift | 2 +- .../View/SessionVerificationScreen.swift | 10 +- 9 files changed, 342 insertions(+), 217 deletions(-) diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index 4b46d3e7e..2af7d0363 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -17,6 +17,8 @@ import SwiftUI let selectedIcon: KeyPath } + // MARK: Tabs + fileprivate struct TabModule: Identifiable { let module: NavigationModule let title: String @@ -58,8 +60,108 @@ import SwiftUI } } + // MARK: Sheets + + fileprivate var sheetModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove sheet", oldValue) + oldValue.tearDown() + } + + if let sheetModule { + logPresentationChange("Set sheet", sheetModule) + sheetModule.coordinator?.start() + } + } + } + + var presentationDetents: Set = [] + + /// The currently presented sheet coordinator. + var sheetCoordinator: (any CoordinatorProtocol)? { + sheetModule?.coordinator + } + + /// Present a sheet on top of the stack. If this NavigationStackCoordinator is embedded within a NavigationSplitCoordinator + /// then the presentation will be proxied to the split + /// - Parameters: + /// - coordinator: the coordinator to display + /// - animated: whether to animate the transition or not. Default is true + + /// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise + func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + sheetModule = nil + return + } + + if sheetModule?.coordinator === coordinator { + fatalError("Cannot use the same coordinator more than once") + } + + var transaction = Transaction() + transaction.disablesAnimations = !animated + + withTransaction(transaction) { + sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + } + + // MARK: Full Screen Cover + + fileprivate var fullScreenCoverModule: NavigationModule? { + didSet { + if let oldValue { + logPresentationChange("Remove fullscreen cover", oldValue) + oldValue.tearDown() + } + + if let fullScreenCoverModule { + logPresentationChange("Set fullscreen cover", fullScreenCoverModule) + fullScreenCoverModule.coordinator?.start() + } + } + } + + /// The currently presented fullscreen cover coordinator + /// Fullscreen covers will be presented through the NavigationSplitCoordinator if provided + var fullScreenCoverCoordinator: (any CoordinatorProtocol)? { + fullScreenCoverModule?.coordinator + } + + /// Present a fullscreen cover on top of the stack. If this NavigationStackCoordinator is embedded within a NavigationSplitCoordinator + /// then the presentation will be proxied to the split + /// - Parameters: + /// - coordinator: the coordinator to display + /// - animated: whether to animate the transition or not. Default is true + /// - dismissalCallback: called when the fullscreen cover has been dismissed, programatically or otherwise + func setFullScreenCoverCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) { + guard let coordinator else { + fullScreenCoverModule = nil + return + } + + if fullScreenCoverModule?.coordinator === coordinator { + fatalError("Cannot use the same coordinator more than once") + } + + var transaction = Transaction() + transaction.disablesAnimations = !animated + + withTransaction(transaction) { + fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) + } + } + // MARK: - CoordinatorProtocol + /// No idea if this is particuarly needed for the TabView but we do this for the NavigationStackCoordinator and NavigationSplitCoordinator so it + /// doesn't seem to harm to also do it here. + func stop() { + tabModules.forEach { $0.module.tearDown() } + } + func toPresentable() -> AnyView { AnyView(NavigationTabCoordinatorView(navigationTabCoordinator: self)) } @@ -99,5 +201,13 @@ private struct NavigationTabCoordinatorView: View { .id(module.id) } } + .sheet(item: $navigationTabCoordinator.sheetModule) { module in + module.coordinator?.toPresentable() + .id(module.id) + } + .fullScreenCover(item: $navigationTabCoordinator.fullScreenCoverModule) { module in + module.coordinator?.toPresentable() + .id(module.id) + } } } diff --git a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift index 54b5f7ce0..0e35f06da 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift @@ -13,8 +13,9 @@ import SwiftUI enum ChatsFlowCoordinatorAction { case logout + case sessionVerification(SessionVerificationScreenFlow) case clearCache - /// Logout without a confirmation. The user forgot their PIN. + /// Logout and disable App Lock without any confirmation. The user forgot their PIN. case forceLogout } @@ -37,8 +38,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { private let settingsFlowCoordinator: SettingsFlowCoordinator - private let onboardingFlowCoordinator: OnboardingFlowCoordinator - // periphery:ignore - retaining purpose private var bugReportFlowCoordinator: BugReportFlowCoordinator? @@ -103,16 +102,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { userIndicatorController: ServiceLocator.shared.userIndicatorController, analytics: analytics)) - onboardingFlowCoordinator = OnboardingFlowCoordinator(userSession: userSession, - appLockService: appLockService, - analyticsService: analytics, - appSettings: appSettings, - notificationManager: notificationManager, - navigationStackCoordinator: detailNavigationStackCoordinator, - userIndicatorController: ServiceLocator.shared.userIndicatorController, - windowManager: appMediator.windowManager, - isNewLogin: isNewLogin) - setupStateMachine() setupObservers() @@ -216,15 +205,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } } - func attemptStartingOnboarding() { - MXLog.info("Attempting to start onboarding") - - if onboardingFlowCoordinator.shouldStart { - clearRoute(animated: false) - onboardingFlowCoordinator.start() - } - } - private func clearPresentedSheets(animated: Bool) async { if navigationSplitCoordinator.sheetCoordinator == nil { return @@ -243,7 +223,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { switch (context.fromState, context.event, context.toState) { case (.initial, .start, .roomList): presentHomeScreen() - attemptStartingOnboarding() case(.roomList(let roomListSelectedRoomID), .selectRoom(let roomID, let via, let entryPoint), .roomList): if roomListSelectedRoomID == roomID, !entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading. @@ -291,11 +270,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { case (.startChatScreen, .dismissedStartChatScreen, .roomList): break - case (.roomList, .showLogoutConfirmationScreen, .logoutConfirmationScreen): - presentSecureBackupLogoutConfirmationScreen() - case (.logoutConfirmationScreen, .dismissedLogoutConfirmationScreen, .roomList): - break - case (.roomList, .showRoomDirectorySearchScreen, .roomDirectorySearchScreen): presentRoomDirectorySearch() case (.roomDirectorySearchScreen, .dismissedRoomDirectorySearchScreen, .roomList): @@ -352,19 +326,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } private func setupObservers() { - userSession.sessionSecurityStatePublisher - .map(\.verificationState) - .filter { $0 != .unknown } - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } - - attemptStartingOnboarding() - - setupSessionVerificationRequestsObserver() - } - .store(in: &cancellables) - settingsFlowCoordinator.actions.sink { [weak self] action in guard let self else { return } @@ -374,7 +335,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { case .dismissedSettings: stateMachine.processEvent(.dismissedSettingsScreen) case .runLogoutFlow: - Task { await self.runLogoutFlow() } + actionsSubject.send(.logout) case .clearCache: actionsSubject.send(.clearCache) case .forceLogout: @@ -413,17 +374,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } } .store(in: &cancellables) - - onboardingFlowCoordinator.actions - .sink { [weak self] action in - guard let self else { return } - - switch action { - case .logout: - logout() - } - } - .store(in: &cancellables) } private func processDecryptionError(_ info: UnableToDecryptInfo) { @@ -455,53 +405,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { wasVisibleToUser: nil) } - private func setupSessionVerificationRequestsObserver() { - userSession.clientProxy.sessionVerificationController?.actions - .receive(on: DispatchQueue.main) - .sink { [weak self] action in - guard let self, case .receivedVerificationRequest(let details) = action else { - return - } - - MXLog.info("Received session verification request") - - if details.senderProfile.userID == userSession.clientProxy.userID { - presentSessionVerificationScreen(flow: .deviceResponder(requestDetails: details)) - } else { - presentSessionVerificationScreen(flow: .userResponder(requestDetails: details)) - } - } - .store(in: &cancellables) - } - - private func presentSessionVerificationScreen(flow: SessionVerificationScreenFlow) { - guard let sessionVerificationController = userSession.clientProxy.sessionVerificationController else { - fatalError("The sessionVerificationController should aways be valid at this point") - } - - let navigationStackCoordinator = NavigationStackCoordinator() - - let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController, - flow: flow, - appSettings: appSettings, - mediaProvider: userSession.mediaProvider) - - let coordinator = SessionVerificationScreenCoordinator(parameters: parameters) - - coordinator.actions - .sink { [weak self] action in - switch action { - case .done: - self?.navigationSplitCoordinator.setSheetCoordinator(nil) - } - } - .store(in: &cancellables) - - navigationStackCoordinator.setRootCoordinator(coordinator) - - navigationSplitCoordinator.setSheetCoordinator(navigationStackCoordinator) - } - private func presentHomeScreen() { let parameters = HomeScreenCoordinatorParameters(userSession: userSession, bugReportService: bugReportService, @@ -543,7 +446,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { case .presentGlobalSearch: presentGlobalSearch() case .logout: - Task { await self.runLogoutFlow() } + actionsSubject.send(.logout) case .presentDeclineAndBlock(let userID, let roomID): stateMachine.processEvent(.presentDeclineAndBlockScreen(userID: userID, roomID: roomID)) } @@ -604,55 +507,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } } - private func runLogoutFlow() async { - let secureBackupController = userSession.clientProxy.secureBackupController - - guard case let .success(isLastDevice) = await userSession.clientProxy.isOnlyDeviceLeft() else { - ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init()) - return - } - - guard isLastDevice else { - logout() - return - } - - guard secureBackupController.recoveryState.value == .enabled else { - ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), - title: L10n.screenSignoutRecoveryDisabledTitle, - message: L10n.screenSignoutRecoveryDisabledSubtitle, - primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in - self?.actionsSubject.send(.logout) - }, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in - self?.settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) - }) - return - } - - guard secureBackupController.keyBackupState.value == .enabled else { - ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), - title: L10n.screenSignoutKeyBackupDisabledTitle, - message: L10n.screenSignoutKeyBackupDisabledSubtitle, - primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in - self?.actionsSubject.send(.logout) - }, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in - self?.settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) - }) - return - } - - presentSecureBackupLogoutConfirmationScreen() - } - - private func logout() { - ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), - title: L10n.screenSignoutConfirmationDialogTitle, - message: L10n.screenSignoutConfirmationDialogContent, - primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in - self?.actionsSubject.send(.logout) - }) - } - // MARK: Room Flow private func startRoomFlow(roomID: String, @@ -680,7 +534,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { // Here we assume that the app is running and the call state is already up to date presentCallScreen(roomProxy: roomProxy, notifyOtherParticipants: !roomProxy.infoPublisher.value.hasRoomCall) case .verifyUser(let userID): - presentSessionVerificationScreen(flow: .userIntiator(userID: userID)) + actionsSubject.send(.sessionVerification(.userInitiator(userID: userID))) case .finished: stateMachine.processEvent(.deselectRoom) } @@ -890,28 +744,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } } - private func presentSecureBackupLogoutConfirmationScreen() { - let coordinator = SecureBackupLogoutConfirmationScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController, - appMediator: appMediator)) - - coordinator.actions - .sink { [weak self] action in - guard let self else { return } - - switch action { - case .cancel: - navigationSplitCoordinator.setSheetCoordinator(nil) - case .settings: - settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) - case .logout: - actionsSubject.send(.logout) - } - } - .store(in: &cancellables) - - navigationSplitCoordinator.setSheetCoordinator(coordinator, animated: true) - } - // MARK: Global search private func presentGlobalSearch() { diff --git a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift index 4bd337272..5033d4471 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift @@ -110,11 +110,6 @@ class ChatsFlowCoordinatorStateMachine { case showStartChatScreen /// Start chat has been dismissed case dismissedStartChatScreen - - /// Logout has been requested and this is the last session - case showLogoutConfirmationScreen - /// Logout has been cancelled - case dismissedLogoutConfirmationScreen /// Request presentation of the room directory search screen. case showRoomDirectorySearchScreen @@ -186,11 +181,6 @@ class ChatsFlowCoordinatorStateMachine { return .startChatScreen(roomListSelectedRoomID: roomListSelectedRoomID) case (.startChatScreen(let roomListSelectedRoomID), .dismissedStartChatScreen): return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) - - case (.roomList(let roomListSelectedRoomID), .showLogoutConfirmationScreen): - return .logoutConfirmationScreen(roomListSelectedRoomID: roomListSelectedRoomID) - case (.logoutConfirmationScreen(let roomListSelectedRoomID), .dismissedLogoutConfirmationScreen): - return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) case (.roomList(let roomListSelectedRoomID), .showRoomDirectorySearchScreen): return .roomDirectorySearchScreen(roomListSelectedRoomID: roomListSelectedRoomID) diff --git a/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift index 60a5d7ed1..13c538457 100644 --- a/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/OnboardingFlowCoordinator.swift @@ -10,6 +10,8 @@ import Foundation import SwiftState enum OnboardingFlowCoordinatorAction { + case requestPresentation(animated: Bool) + case dismiss case logout } @@ -19,7 +21,6 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { private let analyticsService: AnalyticsService private let appSettings: AppSettings private let notificationManager: NotificationManagerProtocol - private let rootNavigationStackCoordinator: NavigationStackCoordinator private let userIndicatorController: UserIndicatorControllerProtocol private let windowManager: WindowManagerProtocol private let isNewLogin: Bool @@ -74,8 +75,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { self.windowManager = windowManager self.isNewLogin = isNewLogin - rootNavigationStackCoordinator = navigationStackCoordinator - self.navigationStackCoordinator = NavigationStackCoordinator() + self.navigationStackCoordinator = navigationStackCoordinator stateMachine = .init(state: .initial) @@ -112,7 +112,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { fatalError("This flow coordinator shouldn't have been started") } - rootNavigationStackCoordinator.setFullScreenCoverCoordinator(navigationStackCoordinator, animated: !isNewLogin) + actionsSubject.send(.requestPresentation(animated: !isNewLogin)) stateMachine.tryEvent(.next) } @@ -224,7 +224,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol { case (_, _, .notificationPermissions): presentNotificationPermissionsScreen() case (_, _, .finished): - rootNavigationStackCoordinator.setFullScreenCoverCoordinator(nil) + actionsSubject.send(.dismiss) stateMachine.tryState(.initial) case (.finished, _, .initial): break diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 10302b9b7..d335d8e0c 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -13,7 +13,7 @@ import SwiftUI enum UserSessionFlowCoordinatorAction { case logout case clearCache - /// Logout without a confirmation. The user forgot their PIN. + /// Logout and disable App Lock without any confirmation. The user forgot their PIN. case forceLogout } @@ -21,7 +21,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let userSession: UserSessionProtocol private let navigationRootCoordinator: NavigationRootCoordinator private let navigationTabCoordinator: NavigationTabCoordinator + private let appMediator: AppMediatorProtocol + private let appSettings: AppSettings + private let onboardingFlowCoordinator: OnboardingFlowCoordinator + private let onboardingStackCoordinator: NavigationStackCoordinator private let chatsFlowCoordinator: ChatsFlowCoordinator private let actionsSubject: PassthroughSubject = .init() @@ -45,6 +49,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { isNewLogin: Bool) { self.userSession = userSession self.navigationRootCoordinator = navigationRootCoordinator + self.appMediator = appMediator + self.appSettings = appSettings navigationTabCoordinator = NavigationTabCoordinator() navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator) @@ -63,28 +69,31 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { notificationManager: notificationManager, isNewLogin: isNewLogin) + onboardingStackCoordinator = NavigationStackCoordinator() + onboardingFlowCoordinator = OnboardingFlowCoordinator(userSession: userSession, + appLockService: appLockService, + analyticsService: analytics, + appSettings: appSettings, + notificationManager: notificationManager, + navigationStackCoordinator: onboardingStackCoordinator, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + windowManager: appMediator.windowManager, + isNewLogin: isNewLogin) + navigationTabCoordinator.setTabs([ .init(coordinator: chatsSplitCoordinator, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid), .init(coordinator: BlankFormCoordinator(), title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid) ]) - chatsFlowCoordinator.actionsPublisher - .sink { [weak self] action in - guard let self else { return } - switch action { - case .logout: - actionsSubject.send(.logout) - case .clearCache: - actionsSubject.send(.clearCache) - case .forceLogout: - actionsSubject.send(.forceLogout) - } - } - .store(in: &cancellables) + setupObservers() } func start() { + #warning("This flow still needs a state machine.") + chatsFlowCoordinator.start() + + attemptStartingOnboarding() } func stop() { @@ -92,6 +101,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { + // There aren't any routes that directly target this flow yet, so pass them directly to the + // chats flow coordinator. + #warning("This should switch tabs to make sure the route is visible.") chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated) } @@ -99,8 +111,189 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { chatsFlowCoordinator.clearRoute(animated: animated) } - #warning("Should this be a publisher instead??") + #warning("This should use a publisher, combining it with the active tab.") func isDisplayingRoomScreen(withRoomID roomID: String) -> Bool { chatsFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID) } + + // MARK: - Private + + private func setupObservers() { + chatsFlowCoordinator.actionsPublisher + .sink { [weak self] action in + guard let self else { return } + switch action { + case .logout: + Task { await self.runLogoutFlow() } + case .sessionVerification(let flow): + presentSessionVerificationScreen(flow: flow) + case .clearCache: + actionsSubject.send(.clearCache) + case .forceLogout: + actionsSubject.send(.forceLogout) + } + } + .store(in: &cancellables) + + userSession.sessionSecurityStatePublisher + .map(\.verificationState) + .filter { $0 != .unknown } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + + attemptStartingOnboarding() + setupSessionVerificationRequestsObserver() + } + .store(in: &cancellables) + + onboardingFlowCoordinator.actions + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .requestPresentation(let animated): + navigationTabCoordinator.setFullScreenCoverCoordinator(onboardingStackCoordinator, animated: animated) + case .dismiss: + navigationTabCoordinator.setFullScreenCoverCoordinator(nil) + case .logout: + logout() + } + } + .store(in: &cancellables) + } + + // MARK: - Onboarding + + func attemptStartingOnboarding() { + MXLog.info("Attempting to start onboarding") + + if onboardingFlowCoordinator.shouldStart { + clearRoute(animated: false) + onboardingFlowCoordinator.start() + } + } + + // MARK: - Session Verification + + private func setupSessionVerificationRequestsObserver() { + userSession.clientProxy.sessionVerificationController?.actions + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + guard let self, case .receivedVerificationRequest(let details) = action else { + return + } + + MXLog.info("Received session verification request") + + if details.senderProfile.userID == userSession.clientProxy.userID { + presentSessionVerificationScreen(flow: .deviceResponder(requestDetails: details)) + } else { + presentSessionVerificationScreen(flow: .userResponder(requestDetails: details)) + } + } + .store(in: &cancellables) + } + + private func presentSessionVerificationScreen(flow: SessionVerificationScreenFlow) { + guard let sessionVerificationController = userSession.clientProxy.sessionVerificationController else { + fatalError("The sessionVerificationController should aways be valid at this point") + } + + let navigationStackCoordinator = NavigationStackCoordinator() + + let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController, + flow: flow, + appSettings: appSettings, + mediaProvider: userSession.mediaProvider) + + let coordinator = SessionVerificationScreenCoordinator(parameters: parameters) + + coordinator.actions + .sink { [weak self] action in + switch action { + case .done: + self?.navigationTabCoordinator.setSheetCoordinator(nil) + } + } + .store(in: &cancellables) + + navigationStackCoordinator.setRootCoordinator(coordinator) + + navigationTabCoordinator.setSheetCoordinator(navigationStackCoordinator) + } + + // MARK: - Logout + + private func runLogoutFlow() async { + let secureBackupController = userSession.clientProxy.secureBackupController + + guard case let .success(isLastDevice) = await userSession.clientProxy.isOnlyDeviceLeft() else { + ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init()) + return + } + + guard isLastDevice else { + logout() + return + } + + guard secureBackupController.recoveryState.value == .enabled else { + ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), + title: L10n.screenSignoutRecoveryDisabledTitle, + message: L10n.screenSignoutRecoveryDisabledSubtitle, + primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in + self?.actionsSubject.send(.logout) + }, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in + self?.chatsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) + }) + return + } + + guard secureBackupController.keyBackupState.value == .enabled else { + ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), + title: L10n.screenSignoutKeyBackupDisabledTitle, + message: L10n.screenSignoutKeyBackupDisabledSubtitle, + primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in + self?.actionsSubject.send(.logout) + }, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in + self?.chatsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) + }) + return + } + + presentSecureBackupLogoutConfirmationScreen() + } + + private func logout() { + ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), + title: L10n.screenSignoutConfirmationDialogTitle, + message: L10n.screenSignoutConfirmationDialogContent, + primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in + self?.actionsSubject.send(.logout) + }) + } + + private func presentSecureBackupLogoutConfirmationScreen() { + let coordinator = SecureBackupLogoutConfirmationScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController, + appMediator: appMediator)) + + coordinator.actions + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .cancel: + navigationTabCoordinator.setSheetCoordinator(nil) + case .settings: + chatsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) + navigationTabCoordinator.setSheetCoordinator(nil) + case .logout: + actionsSubject.send(.logout) + } + } + .store(in: &cancellables) + + navigationTabCoordinator.setSheetCoordinator(coordinator, animated: true) + } } diff --git a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenCoordinator.swift b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenCoordinator.swift index ed15cf9a8..bacc6804d 100644 --- a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenCoordinator.swift @@ -16,12 +16,12 @@ enum SessionVerificationScreenCoordinatorAction { enum SessionVerificationScreenFlow { case deviceInitiator case deviceResponder(requestDetails: SessionVerificationRequestDetails) - case userIntiator(userID: String) + case userInitiator(userID: String) case userResponder(requestDetails: SessionVerificationRequestDetails) var isResponder: Bool { switch self { - case .deviceInitiator, .userIntiator: + case .deviceInitiator, .userInitiator: false case .deviceResponder, .userResponder: true diff --git a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenModels.swift b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenModels.swift index 770bde08e..ce86497a6 100644 --- a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenModels.swift +++ b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenModels.swift @@ -36,7 +36,7 @@ struct SessionVerificationScreenViewState: BindableState { switch flow { case .deviceInitiator, .deviceResponder: return (\.devices, .defaultSolid) - case .userIntiator, .userResponder: + case .userInitiator, .userResponder: return (\.userProfileSolid, .defaultSolid) } case .acceptingVerificationRequest: @@ -74,7 +74,7 @@ struct SessionVerificationScreenViewState: BindableState { switch flow { case .deviceInitiator: return L10n.screenSessionVerificationUseAnotherDeviceTitle - case .userIntiator: + case .userInitiator: return L10n.screenSessionVerificationUserInitiatorTitle case .deviceResponder, .userResponder: return L10n.screenSessionVerificationRequestTitle @@ -108,7 +108,7 @@ struct SessionVerificationScreenViewState: BindableState { switch flow { case .deviceInitiator, .deviceResponder: return L10n.screenSessionVerificationWaitingOtherDeviceTitle - case .userIntiator, .userResponder: + case .userInitiator, .userResponder: return L10n.screenSessionVerificationWaitingOtherUserTitle } } @@ -119,7 +119,7 @@ struct SessionVerificationScreenViewState: BindableState { switch flow { case .deviceInitiator: return L10n.screenSessionVerificationUseAnotherDeviceSubtitle - case .userIntiator: + case .userInitiator: return L10n.screenSessionVerificationUserInitiatorSubtitle case .deviceResponder: return L10n.screenSessionVerificationRequestSubtitle @@ -146,14 +146,14 @@ struct SessionVerificationScreenViewState: BindableState { switch flow { case .deviceInitiator, .deviceResponder: return L10n.screenSessionVerificationCompareEmojisSubtitle - case .userIntiator, .userResponder: + case .userInitiator, .userResponder: return L10n.screenSessionVerificationCompareEmojisUserSubtitle } case .verified: switch flow { case .deviceInitiator, .deviceResponder: return L10n.screenSessionVerificationCompleteSubtitle - case .userIntiator, .userResponder: + case .userInitiator, .userResponder: return L10n.screenSessionVerificationCompleteUserSubtitle } diff --git a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenViewModel.swift b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenViewModel.swift index 2b2ab5329..c9afafbce 100644 --- a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenViewModel.swift +++ b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/SessionVerificationScreenViewModel.swift @@ -178,7 +178,7 @@ class SessionVerificationScreenViewModel: SessionVerificationViewModelType, Sess switch flow { case .deviceInitiator: return await sessionVerificationControllerProxy.requestDeviceVerification() - case .userIntiator(let userID): + case .userInitiator(let userID): return await sessionVerificationControllerProxy.requestUserVerification(userID) default: fatalError("Incorrect API usage.") diff --git a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/View/SessionVerificationScreen.swift b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/View/SessionVerificationScreen.swift index b18b5ca77..331161ea0 100644 --- a/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/View/SessionVerificationScreen.swift +++ b/ElementX/Sources/Screens/Onboarding/SessionVerificationScreen/View/SessionVerificationScreen.swift @@ -43,7 +43,7 @@ struct SessionVerificationScreen: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { switch context.viewState.flow { - case .userIntiator, .userResponder: + case .userInitiator, .userResponder: Button(L10n.actionCancel) { context.send(viewAction: .cancel) } @@ -91,7 +91,7 @@ struct SessionVerificationScreen: View { SessionVerificationRequestDetailsView(details: details, isUserVerification: true, mediaProvider: context.mediaProvider) - case .userIntiator: + case .userInitiator: Button(L10n.actionLearnMore) { UIApplication.shared.open(context.viewState.learnMoreURL) } @@ -129,7 +129,7 @@ struct SessionVerificationScreen: View { switch context.viewState.verificationState { case .initial: switch context.viewState.flow { - case .deviceInitiator, .userIntiator: + case .deviceInitiator, .userInitiator: Button(L10n.actionStartVerification) { context.send(viewAction: .requestVerification) } @@ -152,7 +152,7 @@ struct SessionVerificationScreen: View { } case .cancelled: switch context.viewState.flow { - case .deviceInitiator, .userIntiator: + case .deviceInitiator, .userInitiator: Button(L10n.actionRetry) { context.send(viewAction: .restart) } @@ -214,7 +214,7 @@ struct SessionVerification_Previews: PreviewProvider, TestablePreview { sessionVerificationScreen(state: .initial, flow: .deviceInitiator) .previewDisplayName("Initial - Device Initiator") - sessionVerificationScreen(state: .initial, flow: .userIntiator(userID: "@bob:matrix.org")) + sessionVerificationScreen(state: .initial, flow: .userInitiator(userID: "@bob:matrix.org")) .previewDisplayName("Initial - User Initiator") let details = SessionVerificationRequestDetails(senderProfile: UserProfileProxy(userID: "@bob:matrix.org",