mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
Introduce a user session manager (#6295)
This commit is contained in:
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user