Move the Settings flow from the Chats flow up one level to the UserSession flow. (#4390)

* Add the basis for a state machine to UserSessionFlowCoordinator.

* Move the Settings flow from the Chats flow to UserSession flow.

It now works from the Spaces tab.

* Add a StateMachineFactory and use it to publish the state in the tests.
This commit is contained in:
Doug
2025-08-07 13:13:02 +01:00
committed by GitHub
parent c47ec8a7cc
commit c5cd3ea79e
10 changed files with 388 additions and 164 deletions
+16
View File
@@ -519,6 +519,7 @@
61941DEE5F3834765770BE01 /* InviteUsersScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F32E0B4B83D2A11EE8D011 /* InviteUsersScreenSelectedItem.swift */; }; 61941DEE5F3834765770BE01 /* InviteUsersScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10F32E0B4B83D2A11EE8D011 /* InviteUsersScreenSelectedItem.swift */; };
61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E93A1BE7D8A2EBCAD51EEB4 /* Array.swift */; }; 61A36B9BB2ADE36CEFF5E98C /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E93A1BE7D8A2EBCAD51EEB4 /* Array.swift */; };
62684AECDFC5C7DC989CBD9E /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 7B6BC3219ADD8AA0311D2B86 /* SnapshotTesting */; }; 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 */; }; 6298AB0906DDD3525CD78C6B /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 81DB3AB6CE996AB3954F4F03 /* KZFileWatchers */; };
62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */; }; 62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */; };
62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.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 */; }; E79D79CDAFE8BEBCC3AECA54 /* AppLockScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08283301736A6FE9D558B2CB /* AppLockScreenViewModelProtocol.swift */; };
E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E543072DE58E751F028998 /* TimelineProxy.swift */; }; E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E543072DE58E751F028998 /* TimelineProxy.swift */; };
E84ADFE9696936C18C2424B5 /* SecureBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.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 */; }; E8B290CBB7E5FF5E3C1B6124 /* KnockRequestsListEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 627A8B5E798CC778C363655E /* KnockRequestsListEmptyStateView.swift */; };
E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; }; E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; };
E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.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 = "<group>"; }; 4E7F7A975514E850A834B29F /* PaginationIndicatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineView.swift; sourceTree = "<group>"; };
4F5F0662483ED69791D63B16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = et; path = et.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; 4F5F0662483ED69791D63B16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = et; path = et.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = "<group>"; }; 4F75EF13F49DD2204E760910 /* FileRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileRoomTimelineView.swift; sourceTree = "<group>"; };
4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinatorTests.swift; sourceTree = "<group>"; };
4FAC770857E525B51E277D8C /* SpaceListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceListScreenModels.swift; sourceTree = "<group>"; }; 4FAC770857E525B51E277D8C /* SpaceListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceListScreenModels.swift; sourceTree = "<group>"; };
4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinatorTests.swift; sourceTree = "<group>"; }; 4FCB2126C091EEF2454B4D56 /* RoomFlowCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomFlowCoordinatorTests.swift; sourceTree = "<group>"; };
4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyProtocol.swift; sourceTree = "<group>"; }; 4FDD775CFD72DD2D3C8A8390 /* NotificationSettingsProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsProxyProtocol.swift; sourceTree = "<group>"; };
@@ -2300,6 +2303,7 @@
AC43313F21511C853D34544E /* SoftLogoutScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelTests.swift; sourceTree = "<group>"; }; AC43313F21511C853D34544E /* SoftLogoutScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModelTests.swift; sourceTree = "<group>"; };
AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerTests.swift; sourceTree = "<group>"; }; AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerTests.swift; sourceTree = "<group>"; };
AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = "<group>"; }; AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = "<group>"; };
AC73285743189AE4498A51DD /* StateMachineFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateMachineFactory.swift; sourceTree = "<group>"; };
AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = "<group>"; }; AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = "<group>"; };
ACA11F7F50A4A3887A18CA5A /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; }; ACA11F7F50A4A3887A18CA5A /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
ACD7BD6BEE21264F6677904A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = "<group>"; }; ACD7BD6BEE21264F6677904A /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -2947,6 +2951,7 @@
BDCEF7C3BF6D09F5611CFC8B /* SecureBackup */, BDCEF7C3BF6D09F5611CFC8B /* SecureBackup */,
82D5AD3EAE3A5C1068A44A88 /* Session */, 82D5AD3EAE3A5C1068A44A88 /* Session */,
5329E48968EB951235E83DAE /* SessionVerification */, 5329E48968EB951235E83DAE /* SessionVerification */,
AA94873C78BBB969CE65EE52 /* StateMachine */,
FCDF06BDB123505F0334B4F9 /* Timeline */, FCDF06BDB123505F0334B4F9 /* Timeline */,
E4E42F93A69AE52E6FAE9412 /* Users */, E4E42F93A69AE52E6FAE9412 /* Users */,
CBBF6127C313A5412E438BC6 /* UserSession */, CBBF6127C313A5412E438BC6 /* UserSession */,
@@ -4522,6 +4527,7 @@
2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */, 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */,
BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */, BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */,
71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */, 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */,
4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */,
283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */, 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */,
AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */, AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */,
D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */, D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */,
@@ -5368,6 +5374,14 @@
path = DevelopmentAssets; path = DevelopmentAssets;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA94873C78BBB969CE65EE52 /* StateMachine */ = {
isa = PBXGroup;
children = (
AC73285743189AE4498A51DD /* StateMachineFactory.swift */,
);
path = StateMachine;
sourceTree = "<group>";
};
AAFDD509929A0CCF8BCE51EB /* Authentication */ = { AAFDD509929A0CCF8BCE51EB /* Authentication */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -7187,6 +7201,7 @@
A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */, A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */,
04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */, 04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */,
73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */, 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */,
627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */,
81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */, 81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */,
21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */, 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */,
44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */, 44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */,
@@ -8023,6 +8038,7 @@
C051475DFF4C8EBDDF4DC8E4 /* StartChatScreenModels.swift in Sources */, C051475DFF4C8EBDDF4DC8E4 /* StartChatScreenModels.swift in Sources */,
4A85928E27D4C1A548A06EE9 /* StartChatScreenViewModel.swift in Sources */, 4A85928E27D4C1A548A06EE9 /* StartChatScreenViewModel.swift in Sources */,
B402708F8728DD0DB7C324E2 /* StartChatScreenViewModelProtocol.swift in Sources */, B402708F8728DD0DB7C324E2 /* StartChatScreenViewModelProtocol.swift in Sources */,
E8AE3BE5C5D2E6C91751A2A6 /* StateMachineFactory.swift in Sources */,
CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */, CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */,
3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */, 3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */,
B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */, B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */,
@@ -641,6 +641,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
} }
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession, let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
isNewLogin: isNewLogin,
navigationRootCoordinator: navigationRootCoordinator, navigationRootCoordinator: navigationRootCoordinator,
appLockService: appLockFlowCoordinator.appLockService, appLockService: appLockFlowCoordinator.appLockService,
bugReportService: ServiceLocator.shared.bugReportService, bugReportService: ServiceLocator.shared.bugReportService,
@@ -651,7 +652,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
appHooks: appHooks, appHooks: appHooks,
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager, notificationManager: notificationManager,
isNewLogin: isNewLogin) stateMachineFactory: StateMachineFactory())
userSessionFlowCoordinator.actionsPublisher userSessionFlowCoordinator.actionsPublisher
.sink { [weak self] action in .sink { [weak self] action in
@@ -12,11 +12,10 @@ import MatrixRustSDK
import SwiftUI import SwiftUI
enum ChatsFlowCoordinatorAction { enum ChatsFlowCoordinatorAction {
case logout case showSettings
case showChatBackupSettings
case sessionVerification(SessionVerificationScreenFlow) case sessionVerification(SessionVerificationScreenFlow)
case clearCache case logout
/// Logout and disable App Lock without any confirmation. The user forgot their PIN.
case forceLogout
} }
class ChatsFlowCoordinator: FlowCoordinatorProtocol { class ChatsFlowCoordinator: FlowCoordinatorProtocol {
@@ -36,8 +35,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
private var roomFlowCoordinator: RoomFlowCoordinator? private var roomFlowCoordinator: RoomFlowCoordinator?
private let timelineControllerFactory: TimelineControllerFactoryProtocol private let timelineControllerFactory: TimelineControllerFactoryProtocol
private let settingsFlowCoordinator: SettingsFlowCoordinator
// periphery:ignore - retaining purpose // periphery:ignore - retaining purpose
private var bugReportFlowCoordinator: BugReportFlowCoordinator? private var bugReportFlowCoordinator: BugReportFlowCoordinator?
@@ -59,10 +56,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
/// For testing purposes.
var statePublisher: AnyPublisher<ChatsFlowCoordinatorStateMachine.State, Never> { stateMachine.statePublisher }
init(userSession: UserSessionProtocol, init(userSession: UserSessionProtocol,
isNewLogin: Bool,
navigationSplitCoordinator: NavigationSplitCoordinator, navigationSplitCoordinator: NavigationSplitCoordinator,
appLockService: AppLockServiceProtocol, appLockService: AppLockServiceProtocol,
bugReportService: BugReportServiceProtocol, bugReportService: BugReportServiceProtocol,
@@ -73,8 +68,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
appHooks: AppHooks, appHooks: AppHooks,
analytics: AnalyticsService, analytics: AnalyticsService,
notificationManager: NotificationManagerProtocol, notificationManager: NotificationManagerProtocol,
isNewLogin: Bool) { stateMachineFactory: StateMachineFactoryProtocol) {
stateMachine = ChatsFlowCoordinatorStateMachine() stateMachine = stateMachineFactory.makeChatsFlowStateMachine()
self.userSession = userSession self.userSession = userSession
self.navigationSplitCoordinator = navigationSplitCoordinator self.navigationSplitCoordinator = navigationSplitCoordinator
self.bugReportService = bugReportService self.bugReportService = bugReportService
@@ -88,22 +83,9 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator) 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() setupStateMachine()
setupObservers() setupObservers()
} }
@@ -189,8 +171,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
Task { await presentCallScreen(roomID: roomID) } Task { await presentCallScreen(roomID: roomID) }
case .genericCallLink(let url): case .genericCallLink(let url):
presentCallScreen(genericCallLink: url) presentCallScreen(genericCallLink: url)
case .settings, .chatBackupSettings:
settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
case .share(let payload): case .share(let payload):
if let roomID = payload.roomID { if let roomID = payload.roomID {
stateMachine.processEvent(.selectRoom(roomID: roomID, stateMachine.processEvent(.selectRoom(roomID: roomID,
@@ -200,14 +180,14 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
} else { } else {
stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated)) stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated))
} }
case .accountProvisioningLink:
break // We always ignore this flow when logged in.
case .transferOwnership(let roomID): case .transferOwnership(let roomID):
if stateMachine.state.roomListSelectedRoomID == roomID { if stateMachine.state.roomListSelectedRoomID == roomID {
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
} else { } else {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership)) stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership))
} }
case .accountProvisioningLink, .settings, .chatBackupSettings:
break // These routes cannot be handled.
} }
} }
@@ -248,11 +228,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
case(.roomList, .deselectRoom, .roomList): case(.roomList, .deselectRoom, .roomList):
dismissRoomFlow(animated: animated) dismissRoomFlow(animated: animated)
case (.roomList, .showSettingsScreen, .settingsScreen):
break
case (.settingsScreen, .dismissedSettingsScreen, .roomList):
break
case (.roomList, .feedbackScreen, .feedbackScreen): case (.roomList, .feedbackScreen, .feedbackScreen):
bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(sidebarNavigationStackCoordinator), bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(sidebarNavigationStackCoordinator),
userIndicatorController: ServiceLocator.shared.userIndicatorController, userIndicatorController: ServiceLocator.shared.userIndicatorController,
@@ -333,24 +308,6 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
} }
private func setupObservers() { 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 userSession.clientProxy.actionsPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] action in .sink { [weak self] action in
@@ -439,11 +396,11 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
clearRoute(animated: true) clearRoute(animated: true)
} }
case .presentSettingsScreen: case .presentSettingsScreen:
settingsFlowCoordinator.handleAppRoute(.settings, animated: true) actionsSubject.send(.showSettings)
case .presentFeedbackScreen: case .presentFeedbackScreen:
stateMachine.processEvent(.feedbackScreen) stateMachine.processEvent(.feedbackScreen)
case .presentSecureBackupSettings: case .presentSecureBackupSettings:
settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) actionsSubject.send(.showChatBackupSettings)
case .presentRecoveryKeyScreen: case .presentRecoveryKeyScreen:
stateMachine.processEvent(.showRecoveryKeyScreen) stateMachine.processEvent(.showRecoveryKeyScreen)
case .presentEncryptionResetScreen: case .presentEncryptionResetScreen:
@@ -21,9 +21,6 @@ class ChatsFlowCoordinatorStateMachine {
/// Showing the feedback screen. /// Showing the feedback screen.
case feedbackScreen(roomListSelectedRoomID: String?) case feedbackScreen(roomListSelectedRoomID: String?)
/// Showing the settings screen
case settingsScreen(roomListSelectedRoomID: String?)
/// Showing the recovery key screen. /// Showing the recovery key screen.
case recoveryKeyScreen(roomListSelectedRoomID: String?) case recoveryKeyScreen(roomListSelectedRoomID: String?)
@@ -56,7 +53,6 @@ class ChatsFlowCoordinatorStateMachine {
nil nil
case .roomList(let roomListSelectedRoomID), case .roomList(let roomListSelectedRoomID),
.feedbackScreen(let roomListSelectedRoomID), .feedbackScreen(let roomListSelectedRoomID),
.settingsScreen(let roomListSelectedRoomID),
.recoveryKeyScreen(let roomListSelectedRoomID), .recoveryKeyScreen(let roomListSelectedRoomID),
.encryptionResetFlow(let roomListSelectedRoomID), .encryptionResetFlow(let roomListSelectedRoomID),
.startChatScreen(let roomListSelectedRoomID), .startChatScreen(let roomListSelectedRoomID),
@@ -86,11 +82,6 @@ class ChatsFlowCoordinatorStateMachine {
/// The room screen has been dismissed /// The room screen has been dismissed
case deselectRoom case deselectRoom
/// Request presentation of the settings screen
case showSettingsScreen
/// The settings screen has been dismissed
case dismissedSettingsScreen
/// Request presentation of the feedback screen /// Request presentation of the feedback screen
case feedbackScreen case feedbackScreen
/// The feedback screen has been dismissed /// The feedback screen has been dismissed
@@ -137,11 +128,6 @@ class ChatsFlowCoordinatorStateMachine {
stateMachine.state stateMachine.state
} }
var stateSubject = PassthroughSubject<State, Never>()
var statePublisher: AnyPublisher<State, Never> {
stateSubject.eraseToAnyPublisher()
}
init() { init() {
stateMachine = StateMachine(state: .initial) stateMachine = StateMachine(state: .initial)
configure() configure()
@@ -157,11 +143,6 @@ class ChatsFlowCoordinatorStateMachine {
case (.roomList, .deselectRoom): case (.roomList, .deselectRoom):
return .roomList(roomListSelectedRoomID: nil) 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): case (.roomList(let roomListSelectedRoomID), .feedbackScreen):
return .feedbackScreen(roomListSelectedRoomID: roomListSelectedRoomID) return .feedbackScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.feedbackScreen(let roomListSelectedRoomID), .dismissedFeedbackScreen): case (.feedbackScreen(let roomListSelectedRoomID), .dismissedFeedbackScreen):
@@ -219,10 +200,6 @@ class ChatsFlowCoordinatorStateMachine {
MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`") 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 /// Attempt to move the state machine to another state through an event
@@ -9,10 +9,9 @@ import Combine
import SwiftUI import SwiftUI
enum SettingsFlowCoordinatorAction { enum SettingsFlowCoordinatorAction {
case presentedSettings case dismiss
case dismissedSettings
case runLogoutFlow
case clearCache case clearCache
case runLogoutFlow
/// Logout without a confirmation. The user forgot their PIN. /// Logout without a confirmation. The user forgot their PIN.
case forceLogout case forceLogout
} }
@@ -25,17 +24,14 @@ struct SettingsFlowCoordinatorParameters {
let notificationSettings: NotificationSettingsProxyProtocol let notificationSettings: NotificationSettingsProxyProtocol
let secureBackupController: SecureBackupControllerProtocol let secureBackupController: SecureBackupControllerProtocol
let appSettings: AppSettings let appSettings: AppSettings
let navigationSplitCoordinator: NavigationSplitCoordinator let navigationStackCoordinator: NavigationStackCoordinator
let userIndicatorController: UserIndicatorControllerProtocol let userIndicatorController: UserIndicatorControllerProtocol
let analytics: AnalyticsService let analytics: AnalyticsService
} }
class SettingsFlowCoordinator: FlowCoordinatorProtocol { class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private let parameters: SettingsFlowCoordinatorParameters private let parameters: SettingsFlowCoordinatorParameters
private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator }
private var navigationStackCoordinator: NavigationStackCoordinator!
private var cancellables = Set<AnyCancellable>()
// periphery:ignore - retaining purpose // periphery:ignore - retaining purpose
private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator? private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator?
@@ -44,6 +40,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - retaining purpose // periphery:ignore - retaining purpose
private var encryptionSettingsFlowCoordinator: EncryptionSettingsFlowCoordinator? private var encryptionSettingsFlowCoordinator: EncryptionSettingsFlowCoordinator?
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SettingsFlowCoordinatorAction, Never> = .init() private let actionsSubject: PassthroughSubject<SettingsFlowCoordinatorAction, Never> = .init()
var actions: AnyPublisher<SettingsFlowCoordinatorAction, Never> { var actions: AnyPublisher<SettingsFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
@@ -62,15 +60,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
case .settings: case .settings:
presentSettingsScreen(animated: animated) presentSettingsScreen(animated: animated)
case .chatBackupSettings: case .chatBackupSettings:
if navigationStackCoordinator == nil { startEncryptionSettingsFlow(animated: animated)
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)
}
default: default:
break break
} }
@@ -83,8 +73,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Private // MARK: - Private
private func presentSettingsScreen(animated: Bool) { private func presentSettingsScreen(animated: Bool) {
navigationStackCoordinator = NavigationStackCoordinator()
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: parameters.userSession, let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: parameters.userSession,
appSettings: parameters.appSettings, appSettings: parameters.appSettings,
isBugReportServiceEnabled: parameters.bugReportService.isEnabled)) isBugReportServiceEnabled: parameters.bugReportService.isEnabled))
@@ -95,14 +83,9 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .dismiss: case .dismiss:
parameters.navigationSplitCoordinator.setSheetCoordinator(nil) actionsSubject.send(.dismiss)
case .logout: case .logout:
parameters.navigationSplitCoordinator.setSheetCoordinator(nil) actionsSubject.send(.runLogoutFlow)
// The settings sheet needs to be dismissed before the alert can be shown
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.actionsSubject.send(.runLogoutFlow)
}
case .secureBackup: case .secureBackup:
startEncryptionSettingsFlow(animated: true) startEncryptionSettingsFlow(animated: true)
case .userDetails: case .userDetails:
@@ -136,15 +119,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables) .store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator, animated: animated) 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) { private func startEncryptionSettingsFlow(animated: Bool) {
@@ -174,13 +148,13 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: parameters.userIndicatorController, userIndicatorController: parameters.userIndicatorController,
appSettings: parameters.appSettings)) appSettings: parameters.appSettings))
navigationStackCoordinator?.push(coordinator) navigationStackCoordinator.push(coordinator)
} }
private func presentAnalyticsScreen() { private func presentAnalyticsScreen() {
let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: parameters.appSettings, let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: parameters.appSettings,
analytics: parameters.analytics)) analytics: parameters.analytics))
navigationStackCoordinator?.push(coordinator) navigationStackCoordinator.push(coordinator)
} }
private func presentAppLockSetupFlow() { private func presentAppLockSetupFlow() {
@@ -8,6 +8,7 @@
import Combine import Combine
import Compound import Compound
import MatrixRustSDK import MatrixRustSDK
import SwiftState
import SwiftUI import SwiftUI
enum UserSessionFlowCoordinatorAction { enum UserSessionFlowCoordinatorAction {
@@ -23,8 +24,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let userSession: UserSessionProtocol private let userSession: UserSessionProtocol
private let navigationRootCoordinator: NavigationRootCoordinator private let navigationRootCoordinator: NavigationRootCoordinator
private let navigationTabCoordinator: NavigationTabCoordinator<HomeTab> private let navigationTabCoordinator: NavigationTabCoordinator<HomeTab>
private let appLockService: AppLockServiceProtocol
private let bugReportService: BugReportServiceProtocol
private let appMediator: AppMediatorProtocol private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings private let appSettings: AppSettings
private let analytics: AnalyticsService
private let onboardingFlowCoordinator: OnboardingFlowCoordinator private let onboardingFlowCoordinator: OnboardingFlowCoordinator
private let onboardingStackCoordinator: NavigationStackCoordinator private let onboardingStackCoordinator: NavigationStackCoordinator
@@ -33,14 +37,38 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let spaceExplorerFlowCoordinator: SpaceExplorerFlowCoordinator private let spaceExplorerFlowCoordinator: SpaceExplorerFlowCoordinator
private let spacesTabDetails: NavigationTabCoordinator<HomeTab>.TabDetails private let spacesTabDetails: NavigationTabCoordinator<HomeTab>.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<State, Event>
private var cancellables: Set<AnyCancellable> = []
private let actionsSubject: PassthroughSubject<UserSessionFlowCoordinatorAction, Never> = .init() private let actionsSubject: PassthroughSubject<UserSessionFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<UserSessionFlowCoordinatorAction, Never> { var actionsPublisher: AnyPublisher<UserSessionFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
private var cancellables: Set<AnyCancellable> = []
init(userSession: UserSessionProtocol, init(userSession: UserSessionProtocol,
isNewLogin: Bool,
navigationRootCoordinator: NavigationRootCoordinator, navigationRootCoordinator: NavigationRootCoordinator,
appLockService: AppLockServiceProtocol, appLockService: AppLockServiceProtocol,
bugReportService: BugReportServiceProtocol, bugReportService: BugReportServiceProtocol,
@@ -51,17 +79,21 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
appHooks: AppHooks, appHooks: AppHooks,
analytics: AnalyticsService, analytics: AnalyticsService,
notificationManager: NotificationManagerProtocol, notificationManager: NotificationManagerProtocol,
isNewLogin: Bool) { stateMachineFactory: StateMachineFactoryProtocol) {
self.userSession = userSession self.userSession = userSession
self.navigationRootCoordinator = navigationRootCoordinator self.navigationRootCoordinator = navigationRootCoordinator
self.appLockService = appLockService
self.bugReportService = bugReportService
self.appMediator = appMediator self.appMediator = appMediator
self.appSettings = appSettings self.appSettings = appSettings
self.analytics = analytics
navigationTabCoordinator = NavigationTabCoordinator() navigationTabCoordinator = NavigationTabCoordinator()
navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator) navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator)
let chatsSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator()) let chatsSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator())
chatsFlowCoordinator = ChatsFlowCoordinator(userSession: userSession, chatsFlowCoordinator = ChatsFlowCoordinator(userSession: userSession,
isNewLogin: isNewLogin,
navigationSplitCoordinator: chatsSplitCoordinator, navigationSplitCoordinator: chatsSplitCoordinator,
appLockService: appLockService, appLockService: appLockService,
bugReportService: bugReportService, bugReportService: bugReportService,
@@ -72,7 +104,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
appHooks: appHooks, appHooks: appHooks,
analytics: analytics, analytics: analytics,
notificationManager: notificationManager, notificationManager: notificationManager,
isNewLogin: isNewLogin) stateMachineFactory: stateMachineFactory)
chatsTabDetails = .init(tag: HomeTab.chats, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid) chatsTabDetails = .init(tag: HomeTab.chats, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid)
chatsTabDetails.barVisibility = .hidden chatsTabDetails.barVisibility = .hidden
@@ -98,16 +130,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
.init(coordinator: spacesSplitCoordinator, details: spacesTabDetails) .init(coordinator: spacesSplitCoordinator, details: spacesTabDetails)
]) ])
stateMachine = stateMachineFactory.makeUserSessionFlowStateMachine(state: .initial)
configureStateMachine()
setupObservers() setupObservers()
} }
func start() { func start() {
#warning("This flow still needs a state machine.") stateMachine.tryEvent(.start)
chatsFlowCoordinator.start()
spaceExplorerFlowCoordinator.start()
attemptStartingOnboarding()
} }
func stop() { func stop() {
@@ -115,13 +145,31 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} }
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
// There aren't any routes that directly target this flow yet, so pass them directly to the switch appRoute {
// chats flow coordinator. 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) chatsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
navigationTabCoordinator.selectedTab = .chats navigationTabCoordinator.selectedTab = .chats
} }
}
func clearRoute(animated: Bool) { func clearRoute(animated: Bool) {
switch stateMachine.state {
case .initial, .tabBar:
break
case .settingsScreen:
navigationTabCoordinator.setSheetCoordinator(nil, animated: animated)
}
chatsFlowCoordinator.clearRoute(animated: animated) chatsFlowCoordinator.clearRoute(animated: animated)
} }
@@ -132,19 +180,40 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Private // 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() { private func setupObservers() {
chatsFlowCoordinator.actionsPublisher chatsFlowCoordinator.actionsPublisher
.sink { [weak self] action in .sink { [weak self] action in
guard let self else { return } guard let self else { return }
switch action { switch action {
case .logout: case .showSettings:
Task { await self.runLogoutFlow() } handleAppRoute(.settings, animated: true)
case .showChatBackupSettings:
handleAppRoute(.chatBackupSettings, animated: true)
case .sessionVerification(let flow): case .sessionVerification(let flow):
presentSessionVerificationScreen(flow: flow) presentSessionVerificationScreen(flow: flow)
case .clearCache: case .logout:
actionsSubject.send(.clearCache) Task { await self.runLogoutFlow() }
case .forceLogout:
actionsSubject.send(.forceLogout)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -154,7 +223,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .showSettings: case .showSettings:
break stateMachine.tryEvent(.showSettingsScreen)
} }
} }
.store(in: &cancellables) .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 // MARK: - Session Verification
private func setupSessionVerificationRequestsObserver() { private func setupSessionVerificationRequestsObserver() {
@@ -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<UserSessionFlowCoordinator.State, UserSessionFlowCoordinator.Event>
func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine
}
struct StateMachineFactory: StateMachineFactoryProtocol {
func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine<UserSessionFlowCoordinator.State, UserSessionFlowCoordinator.Event> {
.init(state: state)
}
func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine {
.init()
}
}
// MARK: For testing
class PublishedStateMachineFactory: StateMachineFactoryProtocol {
let baseFactory = StateMachineFactory()
// MARK: UserSessionFlowCoordinator
let userSessionFlowStatePublisher = PassthroughSubject<UserSessionFlowCoordinator.State, Never>()
func makeUserSessionFlowStateMachine(state: UserSessionFlowCoordinator.State) -> StateMachine<UserSessionFlowCoordinator.State, UserSessionFlowCoordinator.Event> {
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<ChatsFlowCoordinatorStateMachine.State, Never>()
func makeChatsFlowStateMachine() -> ChatsFlowCoordinatorStateMachine {
let stateMachine = baseFactory.makeChatsFlowStateMachine()
stateMachine.addTransitionHandler { [weak self] in self?.chatsFlowStatePublisher.send($0.toState) }
return stateMachine
}
}
@@ -589,6 +589,7 @@ class MockScreen: Identifiable {
appMediator.underlyingWindowManager = windowManager appMediator.underlyingWindowManager = windowManager
let flowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)), let flowCoordinator = UserSessionFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationRootCoordinator: navigationRootCoordinator, navigationRootCoordinator: navigationRootCoordinator,
appLockService: AppLockService(keychainController: KeychainControllerMock(), appLockService: AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings), appSettings: ServiceLocator.shared.settings),
@@ -600,7 +601,7 @@ class MockScreen: Identifiable {
appHooks: AppHooks(), appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
notificationManager: NotificationManagerMock(), notificationManager: NotificationManagerMock(),
isNewLogin: false) stateMachineFactory: StateMachineFactory())
flowCoordinator.start() flowCoordinator.start()
@@ -746,6 +747,7 @@ class MockScreen: Identifiable {
appSettings: ServiceLocator.shared.settings) appSettings: ServiceLocator.shared.settings)
let flowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)), let flowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationSplitCoordinator: navigationSplitCoordinator, navigationSplitCoordinator: navigationSplitCoordinator,
appLockService: AppLockService(keychainController: KeychainControllerMock(), appLockService: AppLockService(keychainController: KeychainControllerMock(),
appSettings: ServiceLocator.shared.settings), appSettings: ServiceLocator.shared.settings),
@@ -757,7 +759,7 @@ class MockScreen: Identifiable {
appHooks: AppHooks(), appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
notificationManager: NotificationManagerMock(), notificationManager: NotificationManagerMock(),
isNewLogin: false) stateMachineFactory: StateMachineFactory())
flowCoordinator.start() flowCoordinator.start()
@@ -17,6 +17,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
var chatsFlowCoordinator: ChatsFlowCoordinator! var chatsFlowCoordinator: ChatsFlowCoordinator!
var splitCoordinator: NavigationSplitCoordinator! var splitCoordinator: NavigationSplitCoordinator!
var notificationManager: NotificationManagerMock! var notificationManager: NotificationManagerMock!
let stateMachineFactory = PublishedStateMachineFactory()
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
@@ -33,6 +34,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
notificationManager = NotificationManagerMock() notificationManager = NotificationManagerMock()
chatsFlowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)), chatsFlowCoordinator = ChatsFlowCoordinator(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
isNewLogin: false,
navigationSplitCoordinator: splitCoordinator, navigationSplitCoordinator: splitCoordinator,
appLockService: AppLockServiceMock(), appLockService: AppLockServiceMock(),
bugReportService: BugReportServiceMock(.init()), bugReportService: BugReportServiceMock(.init()),
@@ -43,9 +45,9 @@ class ChatsFlowCoordinatorTests: XCTestCase {
appHooks: AppHooks(), appHooks: AppHooks(),
analytics: ServiceLocator.shared.analytics, analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager, 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() chatsFlowCoordinator.start()
try await deferred.fulfill() try await deferred.fulfill()
} }
@@ -127,7 +129,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) 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) chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill() try await unexpectedFulfillment.fulfill()
@@ -150,7 +152,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) 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) chatsFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill() 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.") "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 { func testShareMediaRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
@@ -264,17 +255,6 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) 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 { func testShareTextRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
@@ -291,7 +271,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
private func process(route: AppRoute, expectedState: ChatsFlowCoordinatorStateMachine.State) async throws { private func process(route: AppRoute, expectedState: ChatsFlowCoordinatorStateMachine.State) async throws {
// Sometimes the state machine's state changes before the coordinators have updated the stack. // 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 } let deferred = deferFulfillment(delayedPublisher) { $0 == expectedState }
chatsFlowCoordinator.handleAppRoute(route, animated: true) chatsFlowCoordinator.handleAppRoute(route, animated: true)
@@ -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<AnyCancellable>()
var tabCoordinator: NavigationTabCoordinator<UserSessionFlowCoordinator.HomeTab>? { 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()
}
}