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
@@ -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)),