Introduce a user session manager (#6295)

This commit is contained in:
Mauro
2026-10-09 14:07:39 +02:00
committed by GitHub
parent 65bab49f39
commit 60db364436
26 changed files with 594 additions and 66 deletions
+16
View File
@@ -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 = "<group>"; };
2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = "<group>"; };
276833816F5DEB1CE3B8BE1E /* ReportRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreen.swift; sourceTree = "<group>"; };
278B47B68F3079389DD34D35 /* UserSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionManagerTests.swift; sourceTree = "<group>"; };
27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = "<group>"; };
27A5FBB74981BF8EFFDAE90D /* HTMLFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFixtures.swift; sourceTree = "<group>"; };
27A6C38B29A60E187D6A4B0A /* RoomSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSDKMock.swift; sourceTree = "<group>"; };
@@ -2962,6 +2967,7 @@
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = "<group>"; };
C2B8AA3199BAAD066E36CB07 /* RoomListSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomListSDKMock.swift; sourceTree = "<group>"; };
C2E9B841EE4878283ECDB554 /* InviteUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreen.swift; sourceTree = "<group>"; };
C309DD8B55DA72E69CB652FC /* UserSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionManager.swift; sourceTree = "<group>"; };
C30F45308428A4D9FFDB2FB8 /* BannedRoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxy.swift; sourceTree = "<group>"; };
C3141EE5B2CBEC45270E01EB /* CreateRoomSpaceSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomSpaceSelectionSheet.swift; sourceTree = "<group>"; };
C3285BD95B564CA2A948E511 /* OnboardingFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlowCoordinator.swift; sourceTree = "<group>"; };
@@ -3223,6 +3229,7 @@
ED33988DA4FD4FC666800106 /* SessionVerificationScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModel.swift; sourceTree = "<group>"; };
ED482057AE39D5C6D9C5F3D8 /* message.caf */ = {isa = PBXFileReference; path = message.caf; sourceTree = "<group>"; };
ED49073BB1C1FC649DAC2CCD /* LocationRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationRoomTimelineView.swift; sourceTree = "<group>"; };
ED58B554B4D31EC752C0E6DB /* UserSessionManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionManagerProtocol.swift; sourceTree = "<group>"; };
ED60E4D2CD678E1EBF16F77A /* BlockedUsersScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreen.swift; sourceTree = "<group>"; };
EDDE826EAB1BAB80C1104980 /* SpaceFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceFlowCoordinator.swift; sourceTree = "<group>"; };
EDE997F400545F3265935B53 /* UserSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsTests.swift; sourceTree = "<group>"; };
@@ -3283,6 +3290,7 @@
F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderProtocol.swift; sourceTree = "<group>"; };
F5F7E6F5D5A54C68DC2EDD71 /* UserSessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStoreTests.swift; sourceTree = "<group>"; };
F64A8582F65567AC38C2976A /* PollFormScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFormScreenViewModel.swift; sourceTree = "<group>"; };
F6AEB10558A35EC5D86FF1F5 /* UserSessionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionDetails.swift; sourceTree = "<group>"; };
F70373EF23A73181D2568D79 /* SyncServiceBuilderSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncServiceBuilderSDKMock.swift; sourceTree = "<group>"; };
F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = "<group>"; };
F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackSpeed.swift; sourceTree = "<group>"; };
@@ -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 */,
@@ -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()
}
@@ -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
@@ -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)
@@ -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,
@@ -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<UserSessionProtocol, UserSessionManagerError>!
var restoreActiveSessionReturnValue: Result<UserSessionProtocol, UserSessionManagerError>! {
get { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue } }
set { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var restoreActiveSessionClosure: (() async -> Result<UserSessionProtocol, UserSessionManagerError>)?
@concurrent func restoreActiveSession() async -> Result<UserSessionProtocol, UserSessionManagerError> {
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<UserSessionProtocol, UserSessionStoreError>!
var restoreUserSessionReturnValue: Result<UserSessionProtocol, UserSessionStoreError>! {
get { restoreUserSessionReturnValueLock.withLock { restoreUserSessionUnderlyingReturnValue } }
set { restoreUserSessionReturnValueLock.withLock { restoreUserSessionUnderlyingReturnValue = newValue } }
private let restoreUserSessionUserIDReturnValueLock = NSLock()
private nonisolated(unsafe) var restoreUserSessionUserIDUnderlyingReturnValue: Result<UserSessionProtocol, UserSessionStoreError>!
var restoreUserSessionUserIDReturnValue: Result<UserSessionProtocol, UserSessionStoreError>! {
get { restoreUserSessionUserIDReturnValueLock.withLock { restoreUserSessionUserIDUnderlyingReturnValue } }
set { restoreUserSessionUserIDReturnValueLock.withLock { restoreUserSessionUserIDUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var restoreUserSessionClosure: (() async -> Result<UserSessionProtocol, UserSessionStoreError>)?
nonisolated(unsafe) var restoreUserSessionUserIDClosure: ((String) async -> Result<UserSessionProtocol, UserSessionStoreError>)?
@concurrent func restoreUserSession() async -> Result<UserSessionProtocol, UserSessionStoreError> {
restoreUserSessionCallsCountLock.withLock { restoreUserSessionUnderlyingCallsCount += 1 }
if let restoreUserSessionClosure = restoreUserSessionClosure {
return await restoreUserSessionClosure()
@concurrent func restoreUserSession(userID: String) async -> Result<UserSessionProtocol, UserSessionStoreError> {
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
@@ -11,6 +11,7 @@ import SwiftUI
struct HomeScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let availableSessionsPublisher: CurrentValuePublisher<[UserSessionDetails], Never>
let bugReportService: BugReportServiceProtocol
let selectedRoomPublisher: CurrentValuePublisher<String?, Never>
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,
@@ -15,6 +15,7 @@ typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, Hom
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
private let userSession: UserSessionProtocol
private let availableSessionsPublisher: CurrentValuePublisher<[UserSessionDetails], Never>
private let spaceFilterSubject: CurrentValueSubject<SpaceServiceFilter?, Never>
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<String?, Never>,
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,
@@ -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<String?, Never>(nil).asCurrentValuePublisher(),
analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
@@ -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<String?, Never>(nil).asCurrentValuePublisher(),
analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
@@ -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<String?, Never>(nil).asCurrentValuePublisher(),
analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
@@ -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<String?, Never>(nil).asCurrentValuePublisher(),
analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
@@ -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<String?, Never>(nil).asCurrentValuePublisher(),
analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
@@ -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
}
}
@@ -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<String, UserSessionProtocol?> = [:] {
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<UserSessionProtocol, UserSessionManagerError> {
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()
}
}
@@ -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<UserSessionProtocol, UserSessionManagerError>
/// 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()
}
@@ -52,11 +52,9 @@ class UserSessionStore: UserSessionStoreProtocol {
keychainController.removeAllRestorationTokens()
}
func restoreUserSession() async -> Result<UserSessionProtocol, UserSessionStoreError> {
let availableCredentials = keychainController.restorationTokens()
guard let credentials = availableCredentials.first else {
return .failure(.missingCredentials)
func restoreUserSession(userID: String) async -> Result<UserSessionProtocol, UserSessionStoreError> {
guard let credentials = keychainController.restorationTokens().first(where: { $0.userID == userID }) else {
return .failure(.unknownAccount)
}
let userSettings = appSettings.userSettings(for: credentials.userID)
@@ -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<UserSessionProtocol, UserSessionStoreError>
/// Restores the session of the given account. If this fails, the account's credentials and data are deleted.
func restoreUserSession(userID: String) async -> Result<UserSessionProtocol, UserSessionStoreError>
/// 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<UserSessionProtocol, UserSessionStoreError>
@@ -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)),
@@ -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,
@@ -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<SessionSecurityState, Never>? = nil, invites: InviteType? = nil, roomSummaryProvider: RoomSummaryProviderMock? = nil) {
private func setupViewModel(otherAccountUserIDs: [String] = [], securityStatePublisher: CurrentValuePublisher<SessionSecurityState, Never>? = 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<String?, Never>(nil).asCurrentValuePublisher(),
analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
@@ -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,
@@ -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()),
@@ -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()),
@@ -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))))
}
}
+10 -9
View File
@@ -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")
}