diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 9a3be80ab..ca0420bb9 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -519,6 +519,7 @@ 61941DEE5F3834765770BE01 /* InviteUsersScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F32E0B4B83D2A11EE8D011 /* InviteUsersScreenSelectedItem.swift */; }; 61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E93A1BE7D8A2EBCAD51EEB4 /* Array.swift */; }; 62684AECDFC5C7DC989CBD9E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 7B6BC3219ADD8AA0311D2B86 /* SnapshotTesting */; }; + 627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */; }; 6298AB0906DDD3525CD78C6B /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 81DB3AB6CE996AB3954F4F03 /* KZFileWatchers */; }; 62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */; }; 62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.swift */; }; @@ -1215,6 +1216,7 @@ E79D79CDAFE8BEBCC3AECA54 /* AppLockScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */; }; E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E543072DE58E751F028998 /* TimelineProxy.swift */; }; E84ADFE9696936C18C2424B5 /* SecureBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.swift */; }; + E8AE3BE5C5D2E6C91751A2A6 /* StateMachineFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73285743189AE4498A51DD /* StateMachineFactory.swift */; }; E8B290CBB7E5FF5E3C1B6124 /* KnockRequestsListEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */; }; E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; }; E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; }; @@ -1849,6 +1851,7 @@ 4E7F7A975514E850A834B29F /* PaginationIndicatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineView.swift; sourceTree = ""; }; 4F5F0662483ED69791D63B16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = et; path = et.lproj/Localizable.stringsdict; sourceTree = ""; }; 4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = ""; }; + 4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorTests.swift; sourceTree = ""; }; 4FAC770857E525B51E277D8C /* SpaceListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceListScreenModels.swift; sourceTree = ""; }; 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinatorTests.swift; sourceTree = ""; }; 4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyProtocol.swift; sourceTree = ""; }; @@ -2300,6 +2303,7 @@ AC43313F21511C853D34544E /* SoftLogoutScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelTests.swift; sourceTree = ""; }; AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerTests.swift; sourceTree = ""; }; AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = ""; }; + AC73285743189AE4498A51DD /* StateMachineFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateMachineFactory.swift; sourceTree = ""; }; AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = ""; }; ACA11F7F50A4A3887A18CA5A /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; ACD7BD6BEE21264F6677904A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2947,6 +2951,7 @@ BDCEF7C3BF6D09F5611CFC8B /* SecureBackup */, 82D5AD3EAE3A5C1068A44A88 /* Session */, 5329E48968EB951235E83DAE /* SessionVerification */, + AA94873C78BBB969CE65EE52 /* StateMachine */, FCDF06BDB123505F0334B4F9 /* Timeline */, E4E42F93A69AE52E6FAE9412 /* Users */, CBBF6127C313A5412E438BC6 /* UserSession */, @@ -4522,6 +4527,7 @@ 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */, BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */, 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */, + 4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */, 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */, AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */, D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */, @@ -5368,6 +5374,14 @@ path = DevelopmentAssets; sourceTree = ""; }; + AA94873C78BBB969CE65EE52 /* StateMachine */ = { + isa = PBXGroup; + children = ( + AC73285743189AE4498A51DD /* StateMachineFactory.swift */, + ); + path = StateMachine; + sourceTree = ""; + }; AAFDD509929A0CCF8BCE51EB /* Authentication */ = { isa = PBXGroup; children = ( @@ -7187,6 +7201,7 @@ A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */, 04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */, 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */, + 627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */, 81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */, 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */, 44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */, @@ -8023,6 +8038,7 @@ C051475DFF4C8EBDDF4DC8E4 /* StartChatScreenModels.swift in Sources */, 4A85928E27D4C1A548A06EE9 /* StartChatScreenViewModel.swift in Sources */, B402708F8728DD0DB7C324E2 /* StartChatScreenViewModelProtocol.swift in Sources */, + E8AE3BE5C5D2E6C91751A2A6 /* StateMachineFactory.swift in Sources */, CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */, 3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */, B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 76d4de04f..54bcc3862 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -641,6 +641,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession, + isNewLogin: isNewLogin, navigationRootCoordinator: navigationRootCoordinator, appLockService: appLockFlowCoordinator.appLockService, bugReportService: ServiceLocator.shared.bugReportService, @@ -651,7 +652,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg appHooks: appHooks, analytics: ServiceLocator.shared.analytics, notificationManager: notificationManager, - isNewLogin: isNewLogin) + stateMachineFactory: StateMachineFactory()) userSessionFlowCoordinator.actionsPublisher .sink { [weak self] action in diff --git a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift index 9754aa437..ce0815855 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinator.swift @@ -12,11 +12,10 @@ import MatrixRustSDK import SwiftUI enum ChatsFlowCoordinatorAction { - case logout + case showSettings + case showChatBackupSettings case sessionVerification(SessionVerificationScreenFlow) - case clearCache - /// Logout and disable App Lock without any confirmation. The user forgot their PIN. - case forceLogout + case logout } class ChatsFlowCoordinator: FlowCoordinatorProtocol { @@ -36,8 +35,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { private var roomFlowCoordinator: RoomFlowCoordinator? private let timelineControllerFactory: TimelineControllerFactoryProtocol - private let settingsFlowCoordinator: SettingsFlowCoordinator - // periphery:ignore - retaining purpose private var bugReportFlowCoordinator: BugReportFlowCoordinator? @@ -59,10 +56,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { actionsSubject.eraseToAnyPublisher() } - /// For testing purposes. - var statePublisher: AnyPublisher { stateMachine.statePublisher } - init(userSession: UserSessionProtocol, + isNewLogin: Bool, navigationSplitCoordinator: NavigationSplitCoordinator, appLockService: AppLockServiceProtocol, bugReportService: BugReportServiceProtocol, @@ -73,8 +68,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { appHooks: AppHooks, analytics: AnalyticsService, notificationManager: NotificationManagerProtocol, - isNewLogin: Bool) { - stateMachine = ChatsFlowCoordinatorStateMachine() + stateMachineFactory: StateMachineFactoryProtocol) { + stateMachine = stateMachineFactory.makeChatsFlowStateMachine() self.userSession = userSession self.navigationSplitCoordinator = navigationSplitCoordinator self.bugReportService = bugReportService @@ -88,22 +83,9 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) - navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) - settingsFlowCoordinator = SettingsFlowCoordinator(parameters: .init(userSession: userSession, - windowManager: appMediator.windowManager, - appLockService: appLockService, - bugReportService: bugReportService, - notificationSettings: userSession.clientProxy.notificationSettings, - secureBackupController: userSession.clientProxy.secureBackupController, - appSettings: appSettings, - navigationSplitCoordinator: navigationSplitCoordinator, - userIndicatorController: ServiceLocator.shared.userIndicatorController, - analytics: analytics)) - setupStateMachine() - setupObservers() } @@ -189,8 +171,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { Task { await presentCallScreen(roomID: roomID) } case .genericCallLink(let url): presentCallScreen(genericCallLink: url) - case .settings, .chatBackupSettings: - settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated) case .share(let payload): if let roomID = payload.roomID { stateMachine.processEvent(.selectRoom(roomID: roomID, @@ -200,14 +180,14 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } else { stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated)) } - case .accountProvisioningLink: - break // We always ignore this flow when logged in. case .transferOwnership(let roomID): if stateMachine.state.roomListSelectedRoomID == roomID { roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) } else { stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership)) } + case .accountProvisioningLink, .settings, .chatBackupSettings: + break // These routes cannot be handled. } } @@ -247,11 +227,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { hideCallScreenOverlay() // Turn any active call into a PiP so that navigation from a notification is visible to the user. case(.roomList, .deselectRoom, .roomList): dismissRoomFlow(animated: animated) - - case (.roomList, .showSettingsScreen, .settingsScreen): - break - case (.settingsScreen, .dismissedSettingsScreen, .roomList): - break case (.roomList, .feedbackScreen, .feedbackScreen): bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(sidebarNavigationStackCoordinator), @@ -333,24 +308,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { } private func setupObservers() { - settingsFlowCoordinator.actions.sink { [weak self] action in - guard let self else { return } - - switch action { - case .presentedSettings: - stateMachine.processEvent(.showSettingsScreen) - case .dismissedSettings: - stateMachine.processEvent(.dismissedSettingsScreen) - case .runLogoutFlow: - actionsSubject.send(.logout) - case .clearCache: - actionsSubject.send(.clearCache) - case .forceLogout: - actionsSubject.send(.forceLogout) - } - } - .store(in: &cancellables) - userSession.clientProxy.actionsPublisher .receive(on: DispatchQueue.main) .sink { [weak self] action in @@ -439,11 +396,11 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol { clearRoute(animated: true) } case .presentSettingsScreen: - settingsFlowCoordinator.handleAppRoute(.settings, animated: true) + actionsSubject.send(.showSettings) case .presentFeedbackScreen: stateMachine.processEvent(.feedbackScreen) case .presentSecureBackupSettings: - settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) + actionsSubject.send(.showChatBackupSettings) case .presentRecoveryKeyScreen: stateMachine.processEvent(.showRecoveryKeyScreen) case .presentEncryptionResetScreen: diff --git a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift index 5033d4471..d0ff6e3c0 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsFlowCoordinatorStateMachine.swift @@ -21,9 +21,6 @@ class ChatsFlowCoordinatorStateMachine { /// Showing the feedback screen. case feedbackScreen(roomListSelectedRoomID: String?) - /// Showing the settings screen - case settingsScreen(roomListSelectedRoomID: String?) - /// Showing the recovery key screen. case recoveryKeyScreen(roomListSelectedRoomID: String?) @@ -56,7 +53,6 @@ class ChatsFlowCoordinatorStateMachine { nil case .roomList(let roomListSelectedRoomID), .feedbackScreen(let roomListSelectedRoomID), - .settingsScreen(let roomListSelectedRoomID), .recoveryKeyScreen(let roomListSelectedRoomID), .encryptionResetFlow(let roomListSelectedRoomID), .startChatScreen(let roomListSelectedRoomID), @@ -86,11 +82,6 @@ class ChatsFlowCoordinatorStateMachine { /// The room screen has been dismissed case deselectRoom - /// Request presentation of the settings screen - case showSettingsScreen - /// The settings screen has been dismissed - case dismissedSettingsScreen - /// Request presentation of the feedback screen case feedbackScreen /// The feedback screen has been dismissed @@ -137,11 +128,6 @@ class ChatsFlowCoordinatorStateMachine { stateMachine.state } - var stateSubject = PassthroughSubject() - var statePublisher: AnyPublisher { - stateSubject.eraseToAnyPublisher() - } - init() { stateMachine = StateMachine(state: .initial) configure() @@ -156,11 +142,6 @@ class ChatsFlowCoordinatorStateMachine { return .roomList(roomListSelectedRoomID: roomID) case (.roomList, .deselectRoom): return .roomList(roomListSelectedRoomID: nil) - - case (.roomList(let roomListSelectedRoomID), .showSettingsScreen): - return .settingsScreen(roomListSelectedRoomID: roomListSelectedRoomID) - case (.settingsScreen(let roomListSelectedRoomID), .dismissedSettingsScreen): - return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) case (.roomList(let roomListSelectedRoomID), .feedbackScreen): return .feedbackScreen(roomListSelectedRoomID: roomListSelectedRoomID) @@ -219,10 +200,6 @@ class ChatsFlowCoordinatorStateMachine { MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`") } } - - addTransitionHandler { [weak self] context in - self?.stateSubject.send(context.toState) - } } /// Attempt to move the state machine to another state through an event diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index c1ecb51d3..ed0278425 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -9,10 +9,9 @@ import Combine import SwiftUI enum SettingsFlowCoordinatorAction { - case presentedSettings - case dismissedSettings - case runLogoutFlow + case dismiss case clearCache + case runLogoutFlow /// Logout without a confirmation. The user forgot their PIN. case forceLogout } @@ -25,17 +24,14 @@ struct SettingsFlowCoordinatorParameters { let notificationSettings: NotificationSettingsProxyProtocol let secureBackupController: SecureBackupControllerProtocol let appSettings: AppSettings - let navigationSplitCoordinator: NavigationSplitCoordinator + let navigationStackCoordinator: NavigationStackCoordinator let userIndicatorController: UserIndicatorControllerProtocol let analytics: AnalyticsService } class SettingsFlowCoordinator: FlowCoordinatorProtocol { private let parameters: SettingsFlowCoordinatorParameters - - private var navigationStackCoordinator: NavigationStackCoordinator! - - private var cancellables = Set() + private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator } // periphery:ignore - retaining purpose private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator? @@ -44,6 +40,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { // periphery:ignore - retaining purpose private var encryptionSettingsFlowCoordinator: EncryptionSettingsFlowCoordinator? + private var cancellables = Set() + private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() @@ -62,15 +60,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { case .settings: presentSettingsScreen(animated: animated) case .chatBackupSettings: - if navigationStackCoordinator == nil { - presentSettingsScreen(animated: animated) - } - - // The navigation stack doesn't like it if the root and the push happen - // on the same loop run - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - self.startEncryptionSettingsFlow(animated: animated) - } + startEncryptionSettingsFlow(animated: animated) default: break } @@ -83,8 +73,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { // MARK: - Private private func presentSettingsScreen(animated: Bool) { - navigationStackCoordinator = NavigationStackCoordinator() - let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: parameters.userSession, appSettings: parameters.appSettings, isBugReportServiceEnabled: parameters.bugReportService.isEnabled)) @@ -95,14 +83,9 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { switch action { case .dismiss: - parameters.navigationSplitCoordinator.setSheetCoordinator(nil) + actionsSubject.send(.dismiss) case .logout: - parameters.navigationSplitCoordinator.setSheetCoordinator(nil) - - // The settings sheet needs to be dismissed before the alert can be shown - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.actionsSubject.send(.runLogoutFlow) - } + actionsSubject.send(.runLogoutFlow) case .secureBackup: startEncryptionSettingsFlow(animated: true) case .userDetails: @@ -136,15 +119,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { .store(in: &cancellables) navigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator, animated: animated) - - parameters.navigationSplitCoordinator.setSheetCoordinator(navigationStackCoordinator) { [weak self] in - guard let self else { return } - - navigationStackCoordinator = nil - actionsSubject.send(.dismissedSettings) - } - - actionsSubject.send(.presentedSettings) } private func startEncryptionSettingsFlow(animated: Bool) { @@ -174,13 +148,13 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { userIndicatorController: parameters.userIndicatorController, appSettings: parameters.appSettings)) - navigationStackCoordinator?.push(coordinator) + navigationStackCoordinator.push(coordinator) } private func presentAnalyticsScreen() { let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: parameters.appSettings, analytics: parameters.analytics)) - navigationStackCoordinator?.push(coordinator) + navigationStackCoordinator.push(coordinator) } private func presentAppLockSetupFlow() { diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 99644b26c..d15520eb5 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -8,6 +8,7 @@ import Combine import Compound import MatrixRustSDK +import SwiftState import SwiftUI enum UserSessionFlowCoordinatorAction { @@ -23,8 +24,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let userSession: UserSessionProtocol private let navigationRootCoordinator: NavigationRootCoordinator private let navigationTabCoordinator: NavigationTabCoordinator + private let appLockService: AppLockServiceProtocol + private let bugReportService: BugReportServiceProtocol private let appMediator: AppMediatorProtocol private let appSettings: AppSettings + private let analytics: AnalyticsService private let onboardingFlowCoordinator: OnboardingFlowCoordinator private let onboardingStackCoordinator: NavigationStackCoordinator @@ -33,14 +37,38 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let spaceExplorerFlowCoordinator: SpaceExplorerFlowCoordinator private let spacesTabDetails: NavigationTabCoordinator.TabDetails + // periphery:ignore - retaining purpose + private var settingsFlowCoordinator: SettingsFlowCoordinator? + + enum State: StateType { + /// The state machine hasn't started. + case initial + /// The root screen for this flow. + case tabBar + /// Showing the settings screen. + case settingsScreen + } + + enum Event: EventType { + /// The flow is being started. + case start + + /// Request presentation of the settings screen. + case showSettingsScreen + /// The settings screen has been dismissed. + case dismissedSettingsScreen + } + + private let stateMachine: StateMachine + private var cancellables: Set = [] + private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - private var cancellables: Set = [] - init(userSession: UserSessionProtocol, + isNewLogin: Bool, navigationRootCoordinator: NavigationRootCoordinator, appLockService: AppLockServiceProtocol, bugReportService: BugReportServiceProtocol, @@ -51,17 +79,21 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { appHooks: AppHooks, analytics: AnalyticsService, notificationManager: NotificationManagerProtocol, - isNewLogin: Bool) { + stateMachineFactory: StateMachineFactoryProtocol) { self.userSession = userSession self.navigationRootCoordinator = navigationRootCoordinator + self.appLockService = appLockService + self.bugReportService = bugReportService self.appMediator = appMediator self.appSettings = appSettings + self.analytics = analytics navigationTabCoordinator = NavigationTabCoordinator() navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator) let chatsSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator()) chatsFlowCoordinator = ChatsFlowCoordinator(userSession: userSession, + isNewLogin: isNewLogin, navigationSplitCoordinator: chatsSplitCoordinator, appLockService: appLockService, bugReportService: bugReportService, @@ -72,7 +104,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { appHooks: appHooks, analytics: analytics, notificationManager: notificationManager, - isNewLogin: isNewLogin) + stateMachineFactory: stateMachineFactory) chatsTabDetails = .init(tag: HomeTab.chats, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid) chatsTabDetails.barVisibility = .hidden @@ -98,16 +130,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { .init(coordinator: spacesSplitCoordinator, details: spacesTabDetails) ]) + stateMachine = stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial) + configureStateMachine() + setupObservers() } func start() { - #warning("This flow still needs a state machine.") - - chatsFlowCoordinator.start() - spaceExplorerFlowCoordinator.start() - - attemptStartingOnboarding() + stateMachine.tryEvent(.start) } func stop() { @@ -115,13 +145,31 @@ 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. - chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated) - navigationTabCoordinator.selectedTab = .chats + switch appRoute { + case .accountProvisioningLink: + break // We always ignore this flow when logged in. + case .settings, .chatBackupSettings: + if stateMachine.state != .settingsScreen { + stateMachine.tryEvent(.showSettingsScreen) + } + settingsFlowCoordinator?.handleAppRoute(appRoute, animated: animated) + case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, + .roomDetails, .roomMemberDetails, .userProfile, + .event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, + .call, .genericCallLink, .share, .transferOwnership: + clearRoute(animated: animated) // Make sure the presented route is visible. + chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated) + navigationTabCoordinator.selectedTab = .chats + } } func clearRoute(animated: Bool) { + switch stateMachine.state { + case .initial, .tabBar: + break + case .settingsScreen: + navigationTabCoordinator.setSheetCoordinator(nil, animated: animated) + } chatsFlowCoordinator.clearRoute(animated: animated) } @@ -132,19 +180,40 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { // MARK: - Private + private func configureStateMachine() { + stateMachine.addRoutes(event: .start, transitions: [.initial => .tabBar]) { [weak self] _ in + guard let self else { return } + + chatsFlowCoordinator.start() + spaceExplorerFlowCoordinator.start() + attemptStartingOnboarding() + } + + stateMachine.addRoutes(event: .showSettingsScreen, transitions: [.tabBar => .settingsScreen]) { [weak self] _ in + self?.startSettingsFlow() + } + stateMachine.addRoutes(event: .dismissedSettingsScreen, transitions: [.settingsScreen => .tabBar]) { [weak self] _ in + self?.settingsFlowCoordinator = nil + } + + stateMachine.addErrorHandler { context in + fatalError("Unexpected transition: \(context)") + } + } + private func setupObservers() { chatsFlowCoordinator.actionsPublisher .sink { [weak self] action in guard let self else { return } switch action { - case .logout: - Task { await self.runLogoutFlow() } + case .showSettings: + handleAppRoute(.settings, animated: true) + case .showChatBackupSettings: + handleAppRoute(.chatBackupSettings, animated: true) case .sessionVerification(let flow): presentSessionVerificationScreen(flow: flow) - case .clearCache: - actionsSubject.send(.clearCache) - case .forceLogout: - actionsSubject.send(.forceLogout) + case .logout: + Task { await self.runLogoutFlow() } } } .store(in: &cancellables) @@ -154,7 +223,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { guard let self else { return } switch action { case .showSettings: - break + stateMachine.tryEvent(.showSettingsScreen) } } .store(in: &cancellables) @@ -198,6 +267,51 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } + // MARK: - Settings + + private func startSettingsFlow() { + let navigationStackCoordinator = NavigationStackCoordinator() + let coordinator = SettingsFlowCoordinator(parameters: .init(userSession: userSession, + windowManager: appMediator.windowManager, + appLockService: appLockService, + bugReportService: bugReportService, + notificationSettings: userSession.clientProxy.notificationSettings, + secureBackupController: userSession.clientProxy.secureBackupController, + appSettings: appSettings, + navigationStackCoordinator: navigationStackCoordinator, + userIndicatorController: ServiceLocator.shared.userIndicatorController, + analytics: analytics)) + + coordinator.actions.sink { [weak self] action in + guard let self else { return } + + switch action { + case .dismiss: + navigationTabCoordinator.setSheetCoordinator(nil) + case .clearCache: + actionsSubject.send(.clearCache) + case .runLogoutFlow: + Task { + self.navigationTabCoordinator.setSheetCoordinator(nil) + + // The sheet needs to be dismissed before the alert can be shown + try await Task.sleep(for: .milliseconds(100)) + await self.runLogoutFlow() + } + case .forceLogout: + actionsSubject.send(.forceLogout) + } + } + .store(in: &cancellables) + + settingsFlowCoordinator = coordinator + coordinator.handleAppRoute(.settings, animated: false) + + navigationTabCoordinator.setSheetCoordinator(navigationStackCoordinator) { [weak self] in + self?.stateMachine.tryEvent(.dismissedSettingsScreen) + } + } + // MARK: - Session Verification private func setupSessionVerificationRequestsObserver() { diff --git a/ElementX/Sources/Services/StateMachine/StateMachineFactory.swift b/ElementX/Sources/Services/StateMachine/StateMachineFactory.swift new file mode 100644 index 000000000..56cb1cc24 --- /dev/null +++ b/ElementX/Sources/Services/StateMachine/StateMachineFactory.swift @@ -0,0 +1,51 @@ +// +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import Combine +import Foundation +import SwiftState + +protocol StateMachineFactoryProtocol { + func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine + func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine +} + +struct StateMachineFactory: StateMachineFactoryProtocol { + func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine { + .init(state: state) + } + + func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine { + .init() + } +} + +// MARK: For testing + +class PublishedStateMachineFactory: StateMachineFactoryProtocol { + let baseFactory = StateMachineFactory() + + // MARK: UserSessionFlowCoordinator + + let userSessionFlowStatePublisher = PassthroughSubject() + + func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine { + let stateMachine = baseFactory.makeUserSessionFlowStateMachine(state: state) + stateMachine.addAnyHandler(.any => .any) { [weak self] in self?.userSessionFlowStatePublisher.send($0.toState) } + return stateMachine + } + + // MARK: ChatsFlowCoordinator + + let chatsFlowStatePublisher = PassthroughSubject() + + func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine { + let stateMachine = baseFactory.makeChatsFlowStateMachine() + stateMachine.addTransitionHandler { [weak self] in self?.chatsFlowStatePublisher.send($0.toState) } + return stateMachine + } +} diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 9054655e6..5ee8bee62 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -589,6 +589,7 @@ class MockScreen: Identifiable { appMediator.underlyingWindowManager = windowManager let flowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)), + isNewLogin: false, navigationRootCoordinator: navigationRootCoordinator, appLockService: AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings), @@ -600,7 +601,7 @@ class MockScreen: Identifiable { appHooks: AppHooks(), analytics: ServiceLocator.shared.analytics, notificationManager: NotificationManagerMock(), - isNewLogin: false) + stateMachineFactory: StateMachineFactory()) flowCoordinator.start() @@ -746,6 +747,7 @@ class MockScreen: Identifiable { appSettings: ServiceLocator.shared.settings) let flowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)), + isNewLogin: false, navigationSplitCoordinator: navigationSplitCoordinator, appLockService: AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings), @@ -757,7 +759,7 @@ class MockScreen: Identifiable { appHooks: AppHooks(), analytics: ServiceLocator.shared.analytics, notificationManager: NotificationManagerMock(), - isNewLogin: false) + stateMachineFactory: StateMachineFactory()) flowCoordinator.start() diff --git a/UnitTests/Sources/ChatsFlowCoordinatorTests.swift b/UnitTests/Sources/ChatsFlowCoordinatorTests.swift index b24fbc461..a14fa8f00 100644 --- a/UnitTests/Sources/ChatsFlowCoordinatorTests.swift +++ b/UnitTests/Sources/ChatsFlowCoordinatorTests.swift @@ -17,6 +17,7 @@ class ChatsFlowCoordinatorTests: XCTestCase { var chatsFlowCoordinator: ChatsFlowCoordinator! var splitCoordinator: NavigationSplitCoordinator! var notificationManager: NotificationManagerMock! + let stateMachineFactory = PublishedStateMachineFactory() var cancellables = Set() @@ -33,6 +34,7 @@ class ChatsFlowCoordinatorTests: XCTestCase { notificationManager = NotificationManagerMock() chatsFlowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)), + isNewLogin: false, navigationSplitCoordinator: splitCoordinator, appLockService: AppLockServiceMock(), bugReportService: BugReportServiceMock(.init()), @@ -43,9 +45,9 @@ class ChatsFlowCoordinatorTests: XCTestCase { appHooks: AppHooks(), analytics: ServiceLocator.shared.analytics, notificationManager: notificationManager, - isNewLogin: false) + stateMachineFactory: stateMachineFactory) - let deferred = deferFulfillment(chatsFlowCoordinator.statePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) } + let deferred = deferFulfillment(stateMachineFactory.chatsFlowStatePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) } chatsFlowCoordinator.start() try await deferred.fulfill() } @@ -127,7 +129,7 @@ class ChatsFlowCoordinatorTests: XCTestCase { XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertNotNil(detailCoordinator) - let unexpectedFulfillment = deferFailure(chatsFlowCoordinator.statePublisher, timeout: 1) { _ in true } + let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsFlowStatePublisher, timeout: 1) { _ in true } chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) try await unexpectedFulfillment.fulfill() @@ -150,7 +152,7 @@ class ChatsFlowCoordinatorTests: XCTestCase { XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertNotNil(detailCoordinator) - let unexpectedFulfillment = deferFailure(chatsFlowCoordinator.statePublisher, timeout: 1) { _ in true } + let unexpectedFulfillment = deferFailure(stateMachineFactory.chatsFlowStatePublisher, timeout: 1) { _ in true } chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true) try await unexpectedFulfillment.fulfill() @@ -241,17 +243,6 @@ class ChatsFlowCoordinatorTests: XCTestCase { "A new timeline should be created for the same room ID, so that the screen isn't stale while loading.") } - func testShareMediaRouteWithoutRoom() async throws { - try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil)) - XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) - - let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: nil, mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)]) - try await process(route: .share(sharePayload), - expectedState: .shareExtensionRoomList(sharePayload: sharePayload)) - - XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator) - } - func testShareMediaRouteWithRoom() async throws { try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) @@ -264,17 +255,6 @@ class ChatsFlowCoordinatorTests: XCTestCase { XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) } - func testShareTextRouteWithoutRoom() async throws { - try await process(route: .settings, expectedState: .settingsScreen(roomListSelectedRoomID: nil)) - XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) - - let sharePayload: ShareExtensionPayload = .text(roomID: nil, text: "Important Text") - try await process(route: .share(sharePayload), - expectedState: .shareExtensionRoomList(sharePayload: sharePayload)) - - XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator) - } - func testShareTextRouteWithRoom() async throws { try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) @@ -291,7 +271,7 @@ class ChatsFlowCoordinatorTests: XCTestCase { private func process(route: AppRoute, expectedState: ChatsFlowCoordinatorStateMachine.State) async throws { // Sometimes the state machine's state changes before the coordinators have updated the stack. - let delayedPublisher = chatsFlowCoordinator.statePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main) + let delayedPublisher = stateMachineFactory.chatsFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main) let deferred = deferFulfillment(delayedPublisher) { $0 == expectedState } chatsFlowCoordinator.handleAppRoute(route, animated: true) diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift new file mode 100644 index 000000000..c1c34ff58 --- /dev/null +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -0,0 +1,152 @@ +// +// Copyright 2023, 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import XCTest + +import Combine +@testable import ElementX + +@MainActor +class UserSessionFlowCoordinatorTests: XCTestCase { + var clientProxy: ClientProxyMock! + var timelineControllerFactory: TimelineControllerFactoryMock! + var userSessionFlowCoordinator: UserSessionFlowCoordinator! + var rootCoordinator: NavigationRootCoordinator! + var notificationManager: NotificationManagerMock! + let stateMachineFactory = PublishedStateMachineFactory() + + var cancellables = Set() + + var tabCoordinator: NavigationTabCoordinator? { rootCoordinator?.rootCoordinator as? NavigationTabCoordinator } + var chatsSplitCoordinator: NavigationSplitCoordinator? { tabCoordinator?.tabCoordinators.first as? NavigationSplitCoordinator } + var detailCoordinator: CoordinatorProtocol? { chatsSplitCoordinator?.detailCoordinator } + var detailNavigationStack: NavigationStackCoordinator? { detailCoordinator as? NavigationStackCoordinator } + + override func setUp() async throws { + cancellables.removeAll() + clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))))) + timelineControllerFactory = TimelineControllerFactoryMock(.init()) + + rootCoordinator = NavigationRootCoordinator() + + notificationManager = NotificationManagerMock() + + userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)), + isNewLogin: false, + navigationRootCoordinator: rootCoordinator, + appLockService: AppLockServiceMock(), + bugReportService: BugReportServiceMock(.init()), + elementCallService: ElementCallServiceMock(.init()), + timelineControllerFactory: timelineControllerFactory, + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analytics: ServiceLocator.shared.analytics, + notificationManager: notificationManager, + stateMachineFactory: stateMachineFactory) + + userSessionFlowCoordinator.start() + } + + func testInitialState() async throws { + XCTAssertNotNil(chatsSplitCoordinator) + XCTAssertNil(detailCoordinator) + } + + func testSettingsPresentation() async throws { + try await process(route: .settings, expectedUserSessionState: .settingsScreen) + XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) + } + + func testRoomPresentation() async throws { + try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNotNil(detailCoordinator) + } + + func testShareMediaRouteWithoutRoom() async throws { + try await process(route: .settings, expectedUserSessionState: .settingsScreen) + XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) + XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator) + + let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: nil, mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)]) + try await process(route: .share(sharePayload), + expectedUserSessionState: .tabBar, + expectedChatsState: .shareExtensionRoomList(sharePayload: sharePayload)) + XCTAssertNil(tabCoordinator?.sheetCoordinator) + XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator) + } + + func testShareMediaRouteWithRoom() async throws { + try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNil(tabCoordinator?.sheetCoordinator) + XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator) + + let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)]) + try await process(route: .share(sharePayload), + expectedChatsState: .roomList(roomListSelectedRoomID: "2")) + + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNil(tabCoordinator?.sheetCoordinator) + XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) + } + + func testShareTextRouteWithoutRoom() async throws { + try await process(route: .settings, expectedUserSessionState: .settingsScreen) + XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) + XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator) + + let sharePayload: ShareExtensionPayload = .text(roomID: nil, text: "Important Text") + try await process(route: .share(sharePayload), + expectedUserSessionState: .tabBar, + expectedChatsState: .shareExtensionRoomList(sharePayload: sharePayload)) + XCTAssertNil(tabCoordinator?.sheetCoordinator) + XCTAssertTrue((chatsSplitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is RoomSelectionScreenCoordinator) + } + + func testShareTextRouteWithRoom() async throws { + try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNil(tabCoordinator?.sheetCoordinator) + XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator) + + let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text") + try await process(route: .share(sharePayload), + expectedChatsState: .roomList(roomListSelectedRoomID: "2")) + + XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) + XCTAssertNil(tabCoordinator?.sheetCoordinator) + XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") + } + + // MARK: - Helpers + + private func process(route: AppRoute, + expectedUserSessionState: UserSessionFlowCoordinator.State? = nil, + expectedChatsState: ChatsFlowCoordinatorStateMachine.State? = nil) async throws { + let deferredUserSession: DeferredFulfillment? = if let expectedUserSessionState { + deferFulfillment(stateMachineFactory.userSessionFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)) { + $0 == expectedUserSessionState + } + } else { + nil + } + + let deferredChatsState: DeferredFulfillment? = if let expectedChatsState { + deferFulfillment(stateMachineFactory.chatsFlowStatePublisher.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)) { + $0 == expectedChatsState + } + } else { + nil + } + + userSessionFlowCoordinator.handleAppRoute(route, animated: true) + try await deferredUserSession?.fulfill() + try await deferredChatsState?.fulfill() + } +}