From 60db3644365e4672fa96bc0383492308365979db Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:07:39 +0200 Subject: [PATCH] Introduce a user session manager (#6295) --- ElementX.xcodeproj/project.pbxproj | 16 ++ .../Sources/Application/AppCoordinator.swift | 62 +++--- .../Application/FlowCoordinatorProtocol.swift | 3 + .../Settings/AccountSettings.swift | 6 + .../ChatsTabFlowCoordinator.swift | 1 + .../Mocks/Generated/GeneratedMocks.swift | 209 ++++++++++++++++-- .../HomeScreen/HomeScreenCoordinator.swift | 2 + .../HomeScreen/HomeScreenViewModel.swift | 9 + .../Screens/HomeScreen/View/HomeScreen.swift | 1 + .../View/HomeScreenEmptyStateView.swift | 1 + .../View/HomeScreenInviteCell.swift | 1 + .../View/HomeScreenKnockedCell.swift | 1 + ...eScreenRecoveryKeyConfirmationBanner.swift | 1 + .../UserSession/UserSessionDetails.swift | 17 ++ .../UserSession/UserSessionManager.swift | 107 +++++++++ .../UserSessionManagerProtocol.swift | 42 ++++ .../UserSession/UserSessionStore.swift | 8 +- .../UserSessionStoreProtocol.swift | 6 +- .../UITests/UITestsAppCoordinator.swift | 4 + .../ChatsTabFlowCoordinatorTests.swift | 1 + .../Sources/HomeScreenViewModelTests.swift | 18 +- .../Sources/RoomFlowCoordinatorTests.swift | 4 +- .../RoomMembersFlowCoordinatorTests.swift | 1 + .../UserSessionFlowCoordinatorTests.swift | 1 + .../Sources/UserSessionManagerTests.swift | 119 ++++++++++ UnitTests/Sources/UserSessionStoreTests.swift | 19 +- 26 files changed, 594 insertions(+), 66 deletions(-) create mode 100644 ElementX/Sources/Services/UserSession/UserSessionDetails.swift create mode 100644 ElementX/Sources/Services/UserSession/UserSessionManager.swift create mode 100644 ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift create mode 100644 UnitTests/Sources/UserSessionManagerTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index ec53c8ddc..4ec95ac65 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -174,6 +174,7 @@ 18FDE4ED6D83B0771452B43D /* RoomSelectionScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F104596B0620CEFE5DFD31B1 /* RoomSelectionScreenCoordinator.swift */; }; 192A3CDCD0174AD1E4A128E4 /* AudioRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2441E2424E78A40FC95DBA76 /* AudioRecorderTests.swift */; }; 194585F6CD77242B36D4ADF1 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADECBBB672497BCD4822468 /* Result.swift */; }; + 1948348595A636C3B2C36836 /* UserSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278B47B68F3079389DD34D35 /* UserSessionManagerTests.swift */; }; 1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */; }; 197441F1EF23A5DABACCA79F /* StickerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5338450E6783A576B5C16DD /* StickerRoomTimelineView.swift */; }; 19946B8EEE158356EB5E1107 /* LocationShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9406BD5F99685C5ABDEDD93 /* LocationShareSheet.swift */; }; @@ -619,6 +620,7 @@ 6298AB0906DDD3525CD78C6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 0DD568A494247444A4B56031 /* Kingfisher */; }; 62A7FC3A0191BC7181AA432B /* AudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907FA4DE17DEA1A3738EFB83 /* AudioRecorder.swift */; }; 62C5876C4254C58C2086F0DE /* HomeScreenContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3B4B58B79A6FA250B24A1EC /* HomeScreenContent.swift */; }; + 63129D147B94BAD61A2C1CA1 /* UserSessionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6AEB10558A35EC5D86FF1F5 /* UserSessionDetails.swift */; }; 633400018E07D2DC7175B16E /* LiveLocationShareProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA12A7F5EF5C6D0B992869ED /* LiveLocationShareProxy.swift */; }; 633501761094E09DFBEBFFAD /* CopyTextButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B682FE2C44C5E163E7023B05 /* CopyTextButton.swift */; }; 6386EA3C898AD1A4BC1DC8A5 /* TimelineMediaPreviewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */; }; @@ -873,6 +875,7 @@ 877D3CE8680536DB430DE6A2 /* TimelineItemIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E48C91C8BE55CAE1A3DBC3BC /* TimelineItemIdentifier.swift */; }; 878070573C7BF19E735707B4 /* RoomTimelineItemProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */; }; 879013B1A7DA779C7E0C56A4 /* TrackedUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 004397AC54CAE108B6E2385D /* TrackedUserDefaults.swift */; }; + 87DCBD9BC3A00D3E5849460B /* UserSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C309DD8B55DA72E69CB652FC /* UserSessionManager.swift */; }; 87E8C31FCF9276F15CB0B408 /* ThreadTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C1C19A4BE46EDE1411ECCE /* ThreadTimelineScreenViewModelProtocol.swift */; }; 8810A2A30A68252EBB54EE05 /* HomeScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */; }; 88272A714D81BC507F76A1EE /* RageshakeConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FC598338E7CF41107293AB5 /* RageshakeConfiguration.swift */; }; @@ -1036,6 +1039,7 @@ 9FBE1FB20171012260A32492 /* TimelineSenderAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D53FCCE44F96E0BC411A6CF0 /* TimelineSenderAvatarView.swift */; }; 9FBF0078657CA4F2A9747E98 /* SpaceRoomListProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DB2A5FF1E68BA580A20D405 /* SpaceRoomListProxyMock.swift */; }; 9FC79DA30AE0E1502DAEBD51 /* StartChatFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477A8C656B7B26E99C35927F /* StartChatFlowCoordinator.swift */; }; + 9FCE652911C1CDA6DC51924B /* UserSessionManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED58B554B4D31EC752C0E6DB /* UserSessionManagerProtocol.swift */; }; A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */; }; A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; }; A0601810597769B81C2358AF /* EncryptionResetPasswordScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2B5274C1D3D2999D643786 /* EncryptionResetPasswordScreenViewModelProtocol.swift */; }; @@ -2035,6 +2039,7 @@ 2734CCFE59E5EDC216602910 /* EncryptionSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionSDKMock.swift; sourceTree = ""; }; 2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = ""; }; 276833816F5DEB1CE3B8BE1E /* ReportRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreen.swift; sourceTree = ""; }; + 278B47B68F3079389DD34D35 /* UserSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionManagerTests.swift; sourceTree = ""; }; 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; 27A5FBB74981BF8EFFDAE90D /* HTMLFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFixtures.swift; sourceTree = ""; }; 27A6C38B29A60E187D6A4B0A /* RoomSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSDKMock.swift; sourceTree = ""; }; @@ -2962,6 +2967,7 @@ C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = ""; }; C2B8AA3199BAAD066E36CB07 /* RoomListSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListSDKMock.swift; sourceTree = ""; }; C2E9B841EE4878283ECDB554 /* InviteUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreen.swift; sourceTree = ""; }; + C309DD8B55DA72E69CB652FC /* UserSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionManager.swift; sourceTree = ""; }; C30F45308428A4D9FFDB2FB8 /* BannedRoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxy.swift; sourceTree = ""; }; C3141EE5B2CBEC45270E01EB /* CreateRoomSpaceSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomSpaceSelectionSheet.swift; sourceTree = ""; }; C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlowCoordinator.swift; sourceTree = ""; }; @@ -3223,6 +3229,7 @@ ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = ""; }; ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = ""; }; ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = ""; }; + ED58B554B4D31EC752C0E6DB /* UserSessionManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionManagerProtocol.swift; sourceTree = ""; }; ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreen.swift; sourceTree = ""; }; EDDE826EAB1BAB80C1104980 /* SpaceFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceFlowCoordinator.swift; sourceTree = ""; }; EDE997F400545F3265935B53 /* UserSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsTests.swift; sourceTree = ""; }; @@ -3283,6 +3290,7 @@ F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderProtocol.swift; sourceTree = ""; }; F5F7E6F5D5A54C68DC2EDD71 /* UserSessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreTests.swift; sourceTree = ""; }; F64A8582F65567AC38C2976A /* PollFormScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFormScreenViewModel.swift; sourceTree = ""; }; + F6AEB10558A35EC5D86FF1F5 /* UserSessionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionDetails.swift; sourceTree = ""; }; F70373EF23A73181D2568D79 /* SyncServiceBuilderSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncServiceBuilderSDKMock.swift; sourceTree = ""; }; F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = ""; }; F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackSpeed.swift; sourceTree = ""; }; @@ -5429,6 +5437,7 @@ BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */, 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */, 4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */, + 278B47B68F3079389DD34D35 /* UserSessionManagerTests.swift */, F5F7E6F5D5A54C68DC2EDD71 /* UserSessionStoreTests.swift */, EDE997F400545F3265935B53 /* UserSettingsTests.swift */, 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */, @@ -6885,6 +6894,9 @@ children = ( 3558A15CFB934F9229301527 /* RestorationToken.swift */, 43C2067FF58B4996323EB40C /* SessionDirectories.swift */, + F6AEB10558A35EC5D86FF1F5 /* UserSessionDetails.swift */, + C309DD8B55DA72E69CB652FC /* UserSessionManager.swift */, + ED58B554B4D31EC752C0E6DB /* UserSessionManagerProtocol.swift */, 0E8BDC092D817B68CD9040C5 /* UserSessionStore.swift */, BEBA759D1347CFFB3D84ED1F /* UserSessionStoreProtocol.swift */, ); @@ -8588,6 +8600,7 @@ 04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */, 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */, 627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */, + 1948348595A636C3B2C36836 /* UserSessionManagerTests.swift in Sources */, E3E1B656F2E8F2D8075F6558 /* UserSessionStoreTests.swift in Sources */, 51DBB7D725080FDAA4AB1412 /* UserSettingsTests.swift in Sources */, 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */, @@ -9803,8 +9816,11 @@ 46A183C6125A669AEB005699 /* UserProfileScreenViewModelProtocol.swift in Sources */, 69A9B430397C15075D86193F /* UserPropertiesExt.swift in Sources */, 8AB8ED1051216546CB35FA0E /* UserSession.swift in Sources */, + 63129D147B94BAD61A2C1CA1 /* UserSessionDetails.swift in Sources */, 4A618590DEB72C4F186BFED4 /* UserSessionFlowCoordinator.swift in Sources */, 91C590378C591DCA3E40416A /* UserSessionHook.swift in Sources */, + 87DCBD9BC3A00D3E5849460B /* UserSessionManager.swift in Sources */, + 9FCE652911C1CDA6DC51924B /* UserSessionManagerProtocol.swift in Sources */, 6586E1F1D5F0651D0638FFAF /* UserSessionMock.swift in Sources */, 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */, 7E91BAC17963ED41208F489B /* UserSessionStore.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 10148177a..3c308573d 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -19,6 +19,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private let stateMachine: AppCoordinatorStateMachine private let navigationRootCoordinator: NavigationRootCoordinator private let userSessionStore: UserSessionStoreProtocol + private let userSessionManager: UserSessionManagerProtocol // periphery:ignore - retaining purpose private let targetConfiguration: Target.ConfigurationResult private let appMediator: AppMediator @@ -38,18 +39,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private var userSessionMigrationsOldVersion: Version? private var userSession: UserSessionProtocol? { - didSet { - userSessionObserver?.cancel() - if let userSession { - configureElementCallService() - configureNotificationManager() - observeUserSessionChanges() - Task { - await resumeClientServices() - await appHooks.configure(with: userSession) - } - } - } + userSessionManager.activeSession } private var authenticationFlowCoordinator: AuthenticationFlowCoordinator? @@ -132,6 +122,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg analyticsService: analyticsService, appHooks: appHooks, networkMonitor: networkMonitor) + userSessionManager = UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings) let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings) let appLockNavigationCoordinator = NavigationRootCoordinator() @@ -216,7 +207,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg return } - guard userSessionStore.hasSessions else { + guard !userSessionManager.userIDs.isEmpty else { stateMachine.processEvent(.startWithAuthentication) return } @@ -379,7 +370,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg // MARK: - AuthenticationFlowCoordinatorDelegate func authenticationFlowCoordinator(didLoginWithSession userSession: UserSessionProtocol) { - self.userSession = userSession + userSessionManager.add(userSession) + configureActiveSession() authenticationFlowCoordinator = nil stateMachine.processEvent(.createdUserSession) } @@ -580,7 +572,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg appSettings.resetAllSettings() appLockFlowCoordinator.appLockService.disable() } - userSessionStore.reset() + userSessionManager.reset() } /// Manually cleans up any files in the app group's `tmp` directory. @@ -673,10 +665,11 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private func restoreUserSession() { Task { - switch await userSessionStore.restoreUserSession() { + switch await userSessionManager.restoreActiveSession() { case .success(let userSession): await self.performUserSessionMigrations(userSession) - self.userSession = userSession + userSessionManager.add(userSession) + configureActiveSession() stateMachine.processEvent(.createdUserSession) case .failure: MXLog.error("Failed to restore an existing session.") @@ -765,7 +758,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg switch action { case .signedIn(let session): - self.userSession = session + self.userSessionManager.add(session) + self.configureActiveSession() self.softLogoutCoordinator = nil stateMachine.processEvent(.createdUserSession) case .clearAllData: @@ -793,6 +787,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } let flowParameters = CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: userSessionManager.sessionsPublisher.map { $0.map(UserSessionDetails.init) }, bugReportService: bugReportService, elementCallService: elementCallService, timelineControllerFactory: TimelineControllerFactory(userSettings: userSession.userSettings), @@ -867,7 +862,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg await userSession.clientProxy.logout() // Regardless of the result, clear user data - userSessionStore.logout(userSession: userSession) + userSessionManager.remove(userID: userSession.clientProxy.userID) tearDownUserSession() userSession.userSettings.account.reset() @@ -886,7 +881,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private func tearDownUserSession() { userIndicatorController.retractAllIndicators() - userSession = nil + userSessionObserver?.cancel() userSessionFlowCoordinator = nil @@ -912,15 +907,28 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } } - private func configureElementCallService() { + /// Points the app-wide services at the active account's session. + private func configureActiveSession() { guard let userSession else { fatalError("User session not setup") } - elementCallService.setUserSession(userSession) + configureServices(for: userSession) + Task { + await resumeClientServices() + await appHooks.configure(with: userSession) + } } - private func configureNotificationManager() { + /// Wires the services that work for every signed in account, not only the active one. They only hold a single session for now. + private func configureServices(for userSession: UserSessionProtocol) { + userSessionObserver?.cancel() + elementCallService.setUserSession(userSession) + configureNotificationManager(for: userSession) + observeUserSessionChanges(userSession) + } + + private func configureNotificationManager(for userSession: UserSessionProtocol) { notificationManager.setUserSession(userSession) appDelegateObserver = appDelegate.callbacks @@ -935,11 +943,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } } - private func observeUserSessionChanges() { - guard let userSession else { - fatalError("User session not setup") - } - + private func observeUserSessionChanges(_ userSession: UserSessionProtocol) { userSessionObserver = userSession.callbacks .receive(on: DispatchQueue.main) .sink { [weak self] callback in @@ -1354,7 +1358,7 @@ private extension AppCoordinator { /// When iOS relaunches a terminated app for a background task, the session needs restoring before the task can run. private func waitForSessionRestore() async { // A background launch doesn't necessarily connect a scene, which is what normally calls `start()`. - if stateMachine.state == .initial, userSessionStore.hasSessions { + if stateMachine.state == .initial, !userSessionManager.userIDs.isEmpty { start() } diff --git a/ElementX/Sources/Application/FlowCoordinatorProtocol.swift b/ElementX/Sources/Application/FlowCoordinatorProtocol.swift index 936c8b0b2..e63dc5262 100644 --- a/ElementX/Sources/Application/FlowCoordinatorProtocol.swift +++ b/ElementX/Sources/Application/FlowCoordinatorProtocol.swift @@ -24,7 +24,10 @@ extension FlowCoordinatorProtocol { /// /// Please do **not** pass this type directly to screen coordinators/view models. struct CommonFlowParameters { + /// The session of the account this flow tree belongs to. It never changes, even when another account becomes active. let userSession: UserSessionProtocol + /// Every signed in account, most recently active first, so the first one is `userSession`'s. + let availableSessionsPublisher: CurrentValuePublisher<[UserSessionDetails], Never> let bugReportService: BugReportServiceProtocol let elementCallService: ElementCallServiceProtocol let timelineControllerFactory: TimelineControllerFactoryProtocol diff --git a/ElementX/Sources/Application/Settings/AccountSettings.swift b/ElementX/Sources/Application/Settings/AccountSettings.swift index 73fead295..ff2ddbdb3 100644 --- a/ElementX/Sources/Application/Settings/AccountSettings.swift +++ b/ElementX/Sources/Application/Settings/AccountSettings.swift @@ -14,6 +14,12 @@ final nonisolated class AccountSettings: @unchecked Sendable { private let store: AccountUserDefaults private let userID: String + // MARK: Accounts + + /// When this account was last selected, used to order the accounts most recently selected first. + @UserPreference + var lastSelectedDate: Date? + // MARK: Session @UserPreference(defaultValue: false) diff --git a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift index 9c385f4a7..e337fb724 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift @@ -375,6 +375,7 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { private func presentHomeScreen() { let parameters = HomeScreenCoordinatorParameters(userSession: userSession, + availableSessionsPublisher: flowParameters.availableSessionsPublisher, bugReportService: flowParameters.bugReportService, selectedRoomPublisher: selectedRoomSubject.asCurrentValuePublisher(), analyticsService: flowParameters.analytics, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 2c0660d82..c77a31f4e 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -14810,6 +14810,165 @@ nonisolated class UserNotificationCenterMock: UserNotificationCenterProtocol, @u } } } +nonisolated class UserSessionManagerMock: UserSessionManagerProtocol, @unchecked Sendable { + nonisolated(unsafe) var userIDs: [String] = [] + var sessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never> { + get { return underlyingSessionsPublisher } + set(value) { underlyingSessionsPublisher = value } + } + nonisolated(unsafe) var underlyingSessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never>! + nonisolated(unsafe) var activeSession: UserSessionProtocol? + + //MARK: - session + + private let sessionForCallsCountLock = NSLock() + private nonisolated(unsafe) var sessionForUnderlyingCallsCount = 0 + var sessionForCallsCount: Int { + get { sessionForCallsCountLock.withLock { sessionForUnderlyingCallsCount } } + set { sessionForCallsCountLock.withLock { sessionForUnderlyingCallsCount = newValue } } + } + var sessionForCalled: Bool { + return sessionForCallsCount > 0 + } + private let sessionForReceivedUserIDLock = NSLock() + private nonisolated(unsafe) var sessionForUnderlyingReceivedUserID: String? + var sessionForReceivedUserID: String? { + get { sessionForReceivedUserIDLock.withLock { sessionForUnderlyingReceivedUserID } } + set { sessionForReceivedUserIDLock.withLock { sessionForUnderlyingReceivedUserID = newValue } } + } + private let sessionForReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var sessionForUnderlyingReceivedInvocations: [String] = [] + var sessionForReceivedInvocations: [String] { + get { sessionForReceivedInvocationsLock.withLock { sessionForUnderlyingReceivedInvocations } } + set { sessionForReceivedInvocationsLock.withLock { sessionForUnderlyingReceivedInvocations = newValue } } + } + + private let sessionForReturnValueLock = NSLock() + private nonisolated(unsafe) var sessionForUnderlyingReturnValue: UserSessionProtocol? + var sessionForReturnValue: UserSessionProtocol? { + get { sessionForReturnValueLock.withLock { sessionForUnderlyingReturnValue } } + set { sessionForReturnValueLock.withLock { sessionForUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var sessionForClosure: ((String) -> UserSessionProtocol?)? + + func session(for userID: String) -> UserSessionProtocol? { + sessionForCallsCountLock.withLock { sessionForUnderlyingCallsCount += 1 } + sessionForReceivedUserID = userID + sessionForReceivedInvocationsLock.withLock { sessionForUnderlyingReceivedInvocations.append(userID) } + if let sessionForClosure = sessionForClosure { + return sessionForClosure(userID) + } else { + return sessionForReturnValue + } + } + //MARK: - restoreActiveSession + + private let restoreActiveSessionCallsCountLock = NSLock() + private nonisolated(unsafe) var restoreActiveSessionUnderlyingCallsCount = 0 + var restoreActiveSessionCallsCount: Int { + get { restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount } } + set { restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount = newValue } } + } + var restoreActiveSessionCalled: Bool { + return restoreActiveSessionCallsCount > 0 + } + + private let restoreActiveSessionReturnValueLock = NSLock() + private nonisolated(unsafe) var restoreActiveSessionUnderlyingReturnValue: Result! + var restoreActiveSessionReturnValue: Result! { + get { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue } } + set { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var restoreActiveSessionClosure: (() async -> Result)? + + @concurrent func restoreActiveSession() async -> Result { + restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount += 1 } + if let restoreActiveSessionClosure = restoreActiveSessionClosure { + return await restoreActiveSessionClosure() + } else { + return restoreActiveSessionReturnValue + } + } + //MARK: - add + + private let addCallsCountLock = NSLock() + private nonisolated(unsafe) var addUnderlyingCallsCount = 0 + var addCallsCount: Int { + get { addCallsCountLock.withLock { addUnderlyingCallsCount } } + set { addCallsCountLock.withLock { addUnderlyingCallsCount = newValue } } + } + var addCalled: Bool { + return addCallsCount > 0 + } + private let addReceivedUserSessionLock = NSLock() + private nonisolated(unsafe) var addUnderlyingReceivedUserSession: UserSessionProtocol? + var addReceivedUserSession: UserSessionProtocol? { + get { addReceivedUserSessionLock.withLock { addUnderlyingReceivedUserSession } } + set { addReceivedUserSessionLock.withLock { addUnderlyingReceivedUserSession = newValue } } + } + private let addReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var addUnderlyingReceivedInvocations: [UserSessionProtocol] = [] + var addReceivedInvocations: [UserSessionProtocol] { + get { addReceivedInvocationsLock.withLock { addUnderlyingReceivedInvocations } } + set { addReceivedInvocationsLock.withLock { addUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var addClosure: ((UserSessionProtocol) -> Void)? + + func add(_ userSession: UserSessionProtocol) { + addCallsCountLock.withLock { addUnderlyingCallsCount += 1 } + addReceivedUserSession = userSession + addReceivedInvocationsLock.withLock { addUnderlyingReceivedInvocations.append(userSession) } + addClosure?(userSession) + } + //MARK: - remove + + private let removeUserIDCallsCountLock = NSLock() + private nonisolated(unsafe) var removeUserIDUnderlyingCallsCount = 0 + var removeUserIDCallsCount: Int { + get { removeUserIDCallsCountLock.withLock { removeUserIDUnderlyingCallsCount } } + set { removeUserIDCallsCountLock.withLock { removeUserIDUnderlyingCallsCount = newValue } } + } + var removeUserIDCalled: Bool { + return removeUserIDCallsCount > 0 + } + private let removeUserIDReceivedUserIDLock = NSLock() + private nonisolated(unsafe) var removeUserIDUnderlyingReceivedUserID: String? + var removeUserIDReceivedUserID: String? { + get { removeUserIDReceivedUserIDLock.withLock { removeUserIDUnderlyingReceivedUserID } } + set { removeUserIDReceivedUserIDLock.withLock { removeUserIDUnderlyingReceivedUserID = newValue } } + } + private let removeUserIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var removeUserIDUnderlyingReceivedInvocations: [String] = [] + var removeUserIDReceivedInvocations: [String] { + get { removeUserIDReceivedInvocationsLock.withLock { removeUserIDUnderlyingReceivedInvocations } } + set { removeUserIDReceivedInvocationsLock.withLock { removeUserIDUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var removeUserIDClosure: ((String) -> Void)? + + func remove(userID: String) { + removeUserIDCallsCountLock.withLock { removeUserIDUnderlyingCallsCount += 1 } + removeUserIDReceivedUserID = userID + removeUserIDReceivedInvocationsLock.withLock { removeUserIDUnderlyingReceivedInvocations.append(userID) } + removeUserIDClosure?(userID) + } + //MARK: - reset + + private let resetCallsCountLock = NSLock() + private nonisolated(unsafe) var resetUnderlyingCallsCount = 0 + var resetCallsCount: Int { + get { resetCallsCountLock.withLock { resetUnderlyingCallsCount } } + set { resetCallsCountLock.withLock { resetUnderlyingCallsCount = newValue } } + } + var resetCalled: Bool { + return resetCallsCount > 0 + } + nonisolated(unsafe) var resetClosure: (() -> Void)? + + func reset() { + resetCallsCountLock.withLock { resetUnderlyingCallsCount += 1 } + resetClosure?() + } +} nonisolated class UserSessionMock: UserSessionProtocol, @unchecked Sendable { var clientProxy: ClientProxyProtocol { get { return underlyingClientProxy } @@ -14881,30 +15040,44 @@ nonisolated class UserSessionStoreMock: UserSessionStoreProtocol, @unchecked Sen } //MARK: - restoreUserSession - private let restoreUserSessionCallsCountLock = NSLock() - private nonisolated(unsafe) var restoreUserSessionUnderlyingCallsCount = 0 - var restoreUserSessionCallsCount: Int { - get { restoreUserSessionCallsCountLock.withLock { restoreUserSessionUnderlyingCallsCount } } - set { restoreUserSessionCallsCountLock.withLock { restoreUserSessionUnderlyingCallsCount = newValue } } + private let restoreUserSessionUserIDCallsCountLock = NSLock() + private nonisolated(unsafe) var restoreUserSessionUserIDUnderlyingCallsCount = 0 + var restoreUserSessionUserIDCallsCount: Int { + get { restoreUserSessionUserIDCallsCountLock.withLock { restoreUserSessionUserIDUnderlyingCallsCount } } + set { restoreUserSessionUserIDCallsCountLock.withLock { restoreUserSessionUserIDUnderlyingCallsCount = newValue } } } - var restoreUserSessionCalled: Bool { - return restoreUserSessionCallsCount > 0 + var restoreUserSessionUserIDCalled: Bool { + return restoreUserSessionUserIDCallsCount > 0 + } + private let restoreUserSessionUserIDReceivedUserIDLock = NSLock() + private nonisolated(unsafe) var restoreUserSessionUserIDUnderlyingReceivedUserID: String? + var restoreUserSessionUserIDReceivedUserID: String? { + get { restoreUserSessionUserIDReceivedUserIDLock.withLock { restoreUserSessionUserIDUnderlyingReceivedUserID } } + set { restoreUserSessionUserIDReceivedUserIDLock.withLock { restoreUserSessionUserIDUnderlyingReceivedUserID = newValue } } + } + private let restoreUserSessionUserIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var restoreUserSessionUserIDUnderlyingReceivedInvocations: [String] = [] + var restoreUserSessionUserIDReceivedInvocations: [String] { + get { restoreUserSessionUserIDReceivedInvocationsLock.withLock { restoreUserSessionUserIDUnderlyingReceivedInvocations } } + set { restoreUserSessionUserIDReceivedInvocationsLock.withLock { restoreUserSessionUserIDUnderlyingReceivedInvocations = newValue } } } - private let restoreUserSessionReturnValueLock = NSLock() - private nonisolated(unsafe) var restoreUserSessionUnderlyingReturnValue: Result! - var restoreUserSessionReturnValue: Result! { - get { restoreUserSessionReturnValueLock.withLock { restoreUserSessionUnderlyingReturnValue } } - set { restoreUserSessionReturnValueLock.withLock { restoreUserSessionUnderlyingReturnValue = newValue } } + private let restoreUserSessionUserIDReturnValueLock = NSLock() + private nonisolated(unsafe) var restoreUserSessionUserIDUnderlyingReturnValue: Result! + var restoreUserSessionUserIDReturnValue: Result! { + get { restoreUserSessionUserIDReturnValueLock.withLock { restoreUserSessionUserIDUnderlyingReturnValue } } + set { restoreUserSessionUserIDReturnValueLock.withLock { restoreUserSessionUserIDUnderlyingReturnValue = newValue } } } - nonisolated(unsafe) var restoreUserSessionClosure: (() async -> Result)? + nonisolated(unsafe) var restoreUserSessionUserIDClosure: ((String) async -> Result)? - @concurrent func restoreUserSession() async -> Result { - restoreUserSessionCallsCountLock.withLock { restoreUserSessionUnderlyingCallsCount += 1 } - if let restoreUserSessionClosure = restoreUserSessionClosure { - return await restoreUserSessionClosure() + @concurrent func restoreUserSession(userID: String) async -> Result { + restoreUserSessionUserIDCallsCountLock.withLock { restoreUserSessionUserIDUnderlyingCallsCount += 1 } + restoreUserSessionUserIDReceivedUserID = userID + restoreUserSessionUserIDReceivedInvocationsLock.withLock { restoreUserSessionUserIDUnderlyingReceivedInvocations.append(userID) } + if let restoreUserSessionUserIDClosure = restoreUserSessionUserIDClosure { + return await restoreUserSessionUserIDClosure(userID) } else { - return restoreUserSessionReturnValue + return restoreUserSessionUserIDReturnValue } } //MARK: - userSession diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index e9bfe4300..c2483984f 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -11,6 +11,7 @@ import SwiftUI struct HomeScreenCoordinatorParameters { let userSession: UserSessionProtocol + let availableSessionsPublisher: CurrentValuePublisher<[UserSessionDetails], Never> let bugReportService: BugReportServiceProtocol let selectedRoomPublisher: CurrentValuePublisher let analyticsService: AnalyticsServiceProtocol @@ -48,6 +49,7 @@ final class HomeScreenCoordinator: CoordinatorProtocol { init(parameters: HomeScreenCoordinatorParameters) { viewModel = HomeScreenViewModel(userSession: parameters.userSession, + availableSessionsPublisher: parameters.availableSessionsPublisher, selectedRoomPublisher: parameters.selectedRoomPublisher, analyticsService: parameters.analyticsService, bugReportService: parameters.bugReportService, diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 9f84d29b8..7d46bb2bd 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -15,6 +15,7 @@ typealias HomeScreenViewModelType = StateStoreViewModel private let spaceFilterSubject: CurrentValueSubject private let analyticsService: AnalyticsServiceProtocol private let bugReportService: BugReportServiceProtocol @@ -31,12 +32,14 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol // swiftlint:disable:next function_body_length init(userSession: UserSessionProtocol, + availableSessionsPublisher: CurrentValuePublisher<[UserSessionDetails], Never>, selectedRoomPublisher: CurrentValuePublisher, analyticsService: AnalyticsServiceProtocol, bugReportService: BugReportServiceProtocol, notificationManager: NotificationManagerProtocol, userIndicatorController: UserIndicatorControllerProtocol) { self.userSession = userSession + self.availableSessionsPublisher = availableSessionsPublisher self.analyticsService = analyticsService self.bugReportService = bugReportService userSettings = userSession.userSettings @@ -486,6 +489,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol // MARK: Multi-account announcement private func presentMultiAccountAnnouncementIfNeeded() { + // Never announce it to someone who already uses several accounts, even once they've removed some. + if availableSessionsPublisher.value.count > 1, !userSettings.app.hasSeenMultiAccountAnnouncement { + MXLog.info("Several accounts are signed in, marking the multi-account announcement as seen.") + userSettings.app.hasSeenMultiAccountAnnouncement = true + } + // An unverified session gets the identity confirmation cover first, the room list appears again once it's dismissed. guard userSettings.app.multiAccountEnabled, !userSettings.app.hasSeenMultiAccountAnnouncement, diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index afa685a1e..e5a29fbb5 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -236,6 +236,7 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview { let userSession = UserSessionMock(.init(clientProxy: clientProxy)) return HomeScreenViewModel(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift index b69c527fb..2574550db 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift @@ -144,6 +144,7 @@ struct HomeScreenEmptyStateView_Previews: PreviewProvider, TestablePreview { let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@user:example.com", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))))))) return HomeScreenViewModel(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift index e6e5097d9..576c826d3 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInviteCell.swift @@ -200,6 +200,7 @@ struct HomeScreenInviteCell_Previews: PreviewProvider, TestablePreview { let userSession = UserSessionMock(.init(clientProxy: clientProxy)) return HomeScreenViewModel(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift index 9d056bd0b..4fbf96f0d 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenKnockedCell.swift @@ -132,6 +132,7 @@ struct HomeScreenKnockedCell_Previews: PreviewProvider, TestablePreview { let userSession = UserSessionMock(.init(clientProxy: clientProxy)) return HomeScreenViewModel(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift index 95b3629ea..d5cb71c3d 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRecoveryKeyConfirmationBanner.swift @@ -121,6 +121,7 @@ struct HomeScreenRecoveryKeyConfirmationBanner_Previews: PreviewProvider, Testab let userSession = UserSessionMock(.init(clientProxy: clientProxy)) return HomeScreenViewModel(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), diff --git a/ElementX/Sources/Services/UserSession/UserSessionDetails.swift b/ElementX/Sources/Services/UserSession/UserSessionDetails.swift new file mode 100644 index 000000000..a8979933c --- /dev/null +++ b/ElementX/Sources/Services/UserSession/UserSessionDetails.swift @@ -0,0 +1,17 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +/// What a flow can know about a signed in account, without access to its session. +struct UserSessionDetails { + let userID: String +} + +extension UserSessionDetails { + init(userSession: UserSessionProtocol) { + userID = userSession.clientProxy.userID + } +} diff --git a/ElementX/Sources/Services/UserSession/UserSessionManager.swift b/ElementX/Sources/Services/UserSession/UserSessionManager.swift new file mode 100644 index 000000000..bb5548e1d --- /dev/null +++ b/ElementX/Sources/Services/UserSession/UserSessionManager.swift @@ -0,0 +1,107 @@ +// +// Copyright 2026 Element Creations 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 OrderedCollections + +/// Owns the live session of every account signed in on this device, most recently active first. +/// +/// Only the `AppCoordinator` calls the lifecycle methods, so the active account always changes +/// through its state machine. Flows only receive the `UserSessionDetails` of its sessions. +final class UserSessionManager: UserSessionManagerProtocol { + private let userSessionStore: UserSessionStoreProtocol + private let appSettings: AppSettings + + private let sessionsSubject = CurrentValueSubject<[UserSessionProtocol], Never>([]) + + /// Every signed in account, most recently selected first, with its session once it's live. + /// Assigning `nil` removes an account rather than clearing its session. + private var accounts: OrderedDictionary = [:] { + didSet { sessionsSubject.send(accounts.values.compactMap { $0 }) } + } + + var userIDs: [String] { + Array(accounts.keys) + } + + var sessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never> { + sessionsSubject.asCurrentValuePublisher() + } + + var activeSession: UserSessionProtocol? { + accounts.values.first ?? nil + } + + init(userSessionStore: UserSessionStoreProtocol, appSettings: AppSettings) { + self.userSessionStore = userSessionStore + self.appSettings = appSettings + + let userIDs = userSessionStore.userIDs + .map { (userID: $0, lastSelectedDate: appSettings.userSettings(for: $0).account.lastSelectedDate) } + .sorted { lhs, rhs in + switch (lhs.lastSelectedDate, rhs.lastSelectedDate) { + case let (lhsDate?, rhsDate?) where lhsDate != rhsDate: lhsDate > rhsDate + case (.some, .none): true + case (.none, .some): false + default: lhs.userID < rhs.userID + } + } + .map(\.userID) + accounts = OrderedDictionary(uniqueKeysWithValues: userIDs.map { ($0, UserSessionProtocol?.none) }) + } + + func session(for userID: String) -> UserSessionProtocol? { + accounts[userID] ?? nil + } + + // MARK: - Lifecycle + + func restoreActiveSession() async -> Result { + guard !accounts.isEmpty else { + return .failure(.noAccounts) + } + + while let userID = accounts.keys.first { + switch await userSessionStore.restoreUserSession(userID: userID) { + case .success(let userSession): + return .success(userSession) + case .failure(let error): + MXLog.error("Failed restoring \(userID), falling back to the next account: \(error)") + accounts.removeValue(forKey: userID) + } + } + + return .failure(.failedRestoringSessions) + } + + func add(_ userSession: UserSessionProtocol) { + let userID = userSession.clientProxy.userID + + if accounts.keys.contains(userID) { + accounts[userID] = userSession + } else { + appSettings.userSettings(for: userID).account.lastSelectedDate = .now + accounts.updateValue(userSession, forKey: userID, insertingAt: 0) + } + } + + func remove(userID: String) { + guard let userSession = accounts[userID] ?? nil else { + MXLog.error("Tried to remove \(userID), which doesn't have a live session.") + return + } + + userSessionStore.logout(userSession: userSession) + accounts.removeValue(forKey: userID) + } + + func reset() { + userSessionStore.reset() + accounts.removeAll() + } +} diff --git a/ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift b/ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift new file mode 100644 index 000000000..1627f095c --- /dev/null +++ b/ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift @@ -0,0 +1,42 @@ +// +// Copyright 2026 Element Creations 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 + +enum UserSessionManagerError: Error { + /// There are no signed in accounts to restore. + case noAccounts + /// None of the signed in accounts could be restored, so they have all been removed. + case failedRestoringSessions +} + +// sourcery: AutoMockable +protocol UserSessionManagerProtocol: AnyObject { + /// Every signed in account, most recently active first. Known before any session is live, so use it to count accounts. + var userIDs: [String] { get } + /// The live sessions, in the same order as `userIDs`. + var sessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never> { get } + /// The session of the account whose UI is on screen. + var activeSession: UserSessionProtocol? { get } + + func session(for userID: String) -> UserSessionProtocol? + + // MARK: - Lifecycle + + /// Restores the most recently active account, falling back to the next one when it fails (the store deletes failed accounts). + /// + /// The session isn't registered: `add` it once it's ready to be used, e.g. after any migrations. + func restoreActiveSession() async -> Result + /// Registers a session that's ready to be used, replacing any previous session for its account. + /// A new account becomes the active one, a known account keeps its place. + func add(_ userSession: UserSessionProtocol) + /// Deletes an account's credentials and data, and forgets its session. + func remove(userID: String) + /// Deletes every account's credentials and data. + func reset() +} diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index bc4413d93..b59f223c5 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -52,11 +52,9 @@ class UserSessionStore: UserSessionStoreProtocol { keychainController.removeAllRestorationTokens() } - func restoreUserSession() async -> Result { - let availableCredentials = keychainController.restorationTokens() - - guard let credentials = availableCredentials.first else { - return .failure(.missingCredentials) + func restoreUserSession(userID: String) async -> Result { + guard let credentials = keychainController.restorationTokens().first(where: { $0.userID == userID }) else { + return .failure(.unknownAccount) } let userSettings = appSettings.userSettings(for: credentials.userID) diff --git a/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift b/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift index 1b5d06a70..d2474ee46 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStoreProtocol.swift @@ -10,7 +10,7 @@ import Foundation import MatrixRustSDK enum UserSessionStoreError: Error { - case missingCredentials + case unknownAccount case failedRestoringLogin case failedSettingUpSession case failedSettingUpClientProxy(Error) @@ -30,8 +30,8 @@ protocol UserSessionStoreProtocol { /// Returns the delegate that should handle any changes to a `Client`'s `Session`. var clientSessionDelegate: ClientSessionDelegate { get } - /// Restores an existing user session. - func restoreUserSession() async -> Result + /// Restores the session of the given account. If this fails, the account's credentials and data are deleted. + func restoreUserSession(userID: String) async -> Result /// Creates a user session for a new client from the SDK along with the passphrase used for the data stores. func userSession(for client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: Data) async -> Result diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 4cb577993..516f25620 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -640,6 +640,7 @@ class MockScreen: Identifiable { appLockService: AppLockService(keychainController: KeychainControllerMock(), appSettings: appSettings), flowParameters: CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: timelineControllerFactory, @@ -692,6 +693,7 @@ class MockScreen: Identifiable { userDiscoveryService: userDiscoveryService, navigationStackCoordinator: navigationStackCoordinator, flowParameters: CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: TimelineControllerFactoryMock(.init()), @@ -792,6 +794,7 @@ class MockScreen: Identifiable { let flowCoordinator = LinkNewDeviceFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator, appLockService: appLockService, flowParameters: CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: TimelineControllerFactoryMock(.init()), @@ -848,6 +851,7 @@ class MockScreen: Identifiable { let flowCoordinator = ChatsTabFlowCoordinator(navigationSplitCoordinator: navigationSplitCoordinator, flowParameters: CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: TimelineControllerFactoryMock(.init(timelineController: timelineController)), diff --git a/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift b/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift index 7b04a580a..c9346f2ca 100644 --- a/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift +++ b/UnitTests/Sources/ChatsTabFlowCoordinatorTests.swift @@ -38,6 +38,7 @@ struct ChatsTabFlowCoordinatorTests { let userSession = UserSessionMock(.init(clientProxy: clientProxy)) let flowParameters = CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: timelineControllerFactory, diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index e696a7649..dfbfd6019 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -460,6 +460,19 @@ final class HomeScreenViewModelTests { try await deferred.fulfill() } + @Test + func multiAccountAnnouncementIsMarkedSeenWithSeveralAccounts() async throws { + userSettings.app.multiAccountEnabled = true + userSettings.app.hasSeenMultiAccountAnnouncement = false + setupViewModel(otherAccountUserIDs: ["@other:client.com"]) + + let deferred = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement } + context.send(viewAction: .screenAppeared) + try await deferred.fulfill() + + #expect(userSettings.app.hasSeenMultiAccountAnnouncement) + } + // MARK: - Helpers enum InviteType { case rooms, spaces } @@ -551,7 +564,7 @@ final class HomeScreenViewModelTests { return (roomListSubject, stateSubject) } - private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil, invites: InviteType? = nil, roomSummaryProvider: RoomSummaryProviderMock? = nil) { + private func setupViewModel(otherAccountUserIDs: [String] = [], securityStatePublisher: CurrentValuePublisher? = nil, invites: InviteType? = nil, roomSummaryProvider: RoomSummaryProviderMock? = nil) { cancellables.removeAll() var rooms: [RoomSummary] = .mockRooms @@ -595,7 +608,10 @@ final class HomeScreenViewModelTests { notificationManager = NotificationManagerMock() + let sessionDetails = [UserSessionDetails(userSession: userSession)] + otherAccountUserIDs.map { UserSessionDetails(userID: $0) } + viewModel = HomeScreenViewModel(userSession: userSession, + availableSessionsPublisher: .init(sessionDetails), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), analyticsService: AnalyticsServiceMock(.init()), bugReportService: BugReportServiceMock(.init()), diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index 177295440..17e1f1546 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -522,7 +522,9 @@ final class RoomFlowCoordinatorTests { "1" } - let flowParameters = CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings)), + let userSession = UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings)) + let flowParameters = CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: timelineControllerFactory, diff --git a/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift b/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift index a69c063e5..32f964901 100644 --- a/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomMembersFlowCoordinatorTests.swift @@ -54,6 +54,7 @@ struct RoomMembersFlowCoordinatorTests { let userSession = UserSessionMock(.init(clientProxy: clientProxy)) let flowParameters = CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: TimelineControllerFactoryMock(.init()), diff --git a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift index 783fbc708..ccdf9a6d1 100644 --- a/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift +++ b/UnitTests/Sources/UserSessionFlowCoordinatorTests.swift @@ -51,6 +51,7 @@ struct UserSessionFlowCoordinatorTests { let userSession = UserSessionMock(.init(clientProxy: clientProxy)) let flowParameters = CommonFlowParameters(userSession: userSession, + availableSessionsPublisher: .init([.init(userSession: userSession)]), bugReportService: BugReportServiceMock(.init()), elementCallService: ElementCallServiceMock(.init()), timelineControllerFactory: TimelineControllerFactoryMock(.init()), diff --git a/UnitTests/Sources/UserSessionManagerTests.swift b/UnitTests/Sources/UserSessionManagerTests.swift new file mode 100644 index 000000000..d1142467a --- /dev/null +++ b/UnitTests/Sources/UserSessionManagerTests.swift @@ -0,0 +1,119 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Testing + +@MainActor +struct UserSessionManagerTests { + let userSessionStore = UserSessionStoreMock() + let appSettings = AppSettings.volatile() + + @Test + func ordersAccountsByWhenTheyWereLastSelected() { + appSettings.userSettings(for: "@alice:matrix.org").account.lastSelectedDate = .now.addingTimeInterval(-60) + appSettings.userSettings(for: "@bob:matrix.org").account.lastSelectedDate = .now + + // Accounts that were never selected (e.g. after upgrading from a single account) come last. + let manager = makeManager(userIDs: ["@dave:matrix.org", "@alice:matrix.org", "@carol:matrix.org", "@bob:matrix.org"]) + + #expect(manager.userIDs == ["@bob:matrix.org", "@alice:matrix.org", "@carol:matrix.org", "@dave:matrix.org"]) + } + + @Test + func restoreFallsBackToTheNextAccount() async { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + // Alice already has a live session, e.g. when restoring again after clearing the cache. + manager.add(makeUserSession(userID: "@alice:matrix.org")) + let bob = makeUserSession(userID: "@bob:matrix.org") + userSessionStore.restoreUserSessionUserIDClosure = { userID in + userID == "@bob:matrix.org" ? .success(bob) : .failure(.failedRestoringLogin) + } + + guard case .success(let userSession) = await manager.restoreActiveSession() else { + Issue.record("Restoring should fall back to Bob.") + return + } + + #expect(userSession.clientProxy.userID == "@bob:matrix.org") + #expect(manager.userIDs == ["@bob:matrix.org"]) + #expect(manager.session(for: "@alice:matrix.org") == nil) + // Nothing can use the session until it's added, e.g. while migrations run. + #expect(manager.activeSession == nil) + } + + @Test + func restoreFailsWhenNoAccountCanBeRestored() async { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + userSessionStore.restoreUserSessionUserIDReturnValue = .failure(.failedRestoringLogin) + + guard case .failure(.failedRestoringSessions) = await manager.restoreActiveSession() else { + Issue.record("Restoring should fail once every account has failed.") + return + } + + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org", "@bob:matrix.org"]) + #expect(manager.userIDs.isEmpty) + } + + @Test + func addRegistersTheSession() { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + + // A known account keeps its place, e.g. after clearing the cache. + let bob = makeUserSession(userID: "@bob:matrix.org") + manager.add(bob) + #expect(manager.userIDs == ["@alice:matrix.org", "@bob:matrix.org"]) + #expect((manager.session(for: "@bob:matrix.org") as? UserSessionMock) === bob) + #expect(manager.activeSession == nil) + + // A new account becomes the active one. + let carol = makeUserSession(userID: "@carol:matrix.org") + manager.add(carol) + #expect(manager.userIDs == ["@carol:matrix.org", "@alice:matrix.org", "@bob:matrix.org"]) + #expect(appSettings.userSettings(for: "@carol:matrix.org").account.lastSelectedDate != nil) + #expect((manager.activeSession as? UserSessionMock) === carol) + #expect(manager.sessionsPublisher.value.map(\.clientProxy.userID) == ["@carol:matrix.org", "@bob:matrix.org"]) + } + + @Test + func addReplacesTheSessionOfAKnownAccount() { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + manager.add(makeUserSession(userID: "@alice:matrix.org")) + + // E.g. restoring again after clearing the cache, or signing in again after a soft logout. + let newAlice = makeUserSession(userID: "@alice:matrix.org") + manager.add(newAlice) + + #expect(manager.userIDs == ["@alice:matrix.org", "@bob:matrix.org"]) + #expect((manager.activeSession as? UserSessionMock) === newAlice) + } + + @Test + func removeDeletesTheAccount() { + let manager = makeManager(userIDs: ["@alice:matrix.org"]) + let alice = makeUserSession(userID: "@alice:matrix.org") + manager.add(alice) + + manager.remove(userID: "@alice:matrix.org") + + #expect((userSessionStore.logoutUserSessionReceivedUserSession as? UserSessionMock) === alice) + #expect(manager.userIDs.isEmpty) + #expect(manager.activeSession == nil) + } + + // MARK: - Helpers + + private func makeManager(userIDs: [String]) -> UserSessionManager { + userSessionStore.userIDs = userIDs + return UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings) + } + + private func makeUserSession(userID: String) -> UserSessionMock { + UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: userID)))) + } +} diff --git a/UnitTests/Sources/UserSessionStoreTests.swift b/UnitTests/Sources/UserSessionStoreTests.swift index 493dbf2d2..e75e131a3 100644 --- a/UnitTests/Sources/UserSessionStoreTests.swift +++ b/UnitTests/Sources/UserSessionStoreTests.swift @@ -46,8 +46,8 @@ struct UserSessionStoreTests { // MARK: - Restoration @Test - func restoreWithoutCredentials() async { - guard case .failure(.missingCredentials) = await store.restoreUserSession() else { + func restoreUnknownAccount() async { + guard case .failure(.unknownAccount) = await store.restoreUserSession(userID: "@alice:matrix.org") else { Issue.record("Restoration should fail when there are no credentials.") return } @@ -60,7 +60,7 @@ struct UserSessionStoreTests { keychainController.restorationTokensReturnValue = [credentials] // When restoring the session. - guard case .failure(.failedRestoringLogin) = await store.restoreUserSession() else { + guard case .failure(.failedRestoringLogin) = await store.restoreUserSession(userID: credentials.userID) else { Issue.record("Restoration should fail when the non-transient user data is missing.") return } @@ -79,7 +79,7 @@ struct UserSessionStoreTests { clientFactory.makeAppClientCredentialsClientSessionDelegateAppSettingsAppHooksThrowableError = TestError.generic // When restoring the session. - guard case .failure(.failedRestoringLogin) = await store.restoreUserSession() else { + guard case .failure(.failedRestoringLogin) = await store.restoreUserSession(userID: credentials.userID) else { Issue.record("Restoration should fail when the client can't be created.") return } @@ -90,18 +90,19 @@ struct UserSessionStoreTests { @Test func restoreSucceeds() async throws { - // Given valid session data and a client factory that returns a client. + // Given valid session data for Alice, stored after another account. let sessionDirectories = try makeValidSessionDirectories() defer { try? sessionDirectories.delete() } - keychainController.restorationTokensReturnValue = [makeCredentials(sessionDirectories: sessionDirectories)] + keychainController.restorationTokensReturnValue = [makeCredentials(sessionDirectories: .init(), userID: "@bob:matrix.org"), + makeCredentials(sessionDirectories: sessionDirectories)] - // When restoring the session. - guard case .success(let userSession) = await store.restoreUserSession() else { + // When restoring Alice's session. + guard case .success(let userSession) = await store.restoreUserSession(userID: "@alice:matrix.org") else { Issue.record("Restoration should succeed.") return } - // Then a user session should be built for the restored client. + // Then a user session should be built for Alice's client. #expect(userSession.clientProxy.userID == "@alice:matrix.org") }