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