Keep every signed-in account live (#6299)

* Keep every signed-in account live

With the multi-account flag on, the accounts other than the active one are now
restored at launch, one after the other once the active account's UI is up.
They run their migrations, sync while the app is active, get a pusher (appended
so accounts on the same homeserver keep each other's) and count towards the app
badge, and background refresh and search backfill cover them too. Only the
active account's remote settings are ever applied.

Calls stay with the active account until the call service handles every account,
and an auth error on another account is only logged until accounts can be signed
out one at a time. With the flag off only the active account is live, as before.

* Sum the app badge across every signed in account

Each live account now reports its own unread count, and the app badge
shows their sum. Delivered notifications are only cleared for the
account whose rooms were read, so a room shared by two accounts keeps
the other account's notifications.

The room list observer for the badge and the notifications moves from
the UserSessionFlowCoordinator to the AppCoordinator's per-account
wiring. The flow keeps updating the chats tab badge.

* Let the user session manager manage every session

- The AppCoordinator tells the manager to pause, resume, go offline or
  run the search backfill, instead of looping over the sessions itself.
- The manager also restores the other accounts, preparing each with the
  AppCoordinator's migrations and wiring, and resumes them while the
  services are running. With a single account nothing changes.
- Wiring an account again (e.g. after clearing the cache) replaces its
  observers rather than adding to them.
- Add addAccountFlowEnabled, a developer-only switch for the upcoming
  add account flow. It isn't a user preference, so testers enabling
  multi-account can't add a second account yet.

* Apply review suggestions to keeping every account live

- Move the notification changes (pushers for every account, the summed
  app badge and per-account clearing) to their own PR, so notifications
  follow the active account again for now.
- Revert loading only the active account's remote settings: they load in
  the store again, as on develop, until the hooks are reworked.
- The user session manager reports the auth errors of every live session,
  so the AppCoordinator subscribes once instead of keeping per-account
  observers.
- Restoring a single account is private to the manager now.
- Rename otherSessionsRestoreTask to otherSessionsRestorationTask.

* Restore every signed in account through one path

The user session manager now restores every account in a single pass,
most recently selected first, instead of separate restores for the
active account and the others. The AppCoordinator shows the first
account that comes back and no longer reads the multi-account flag:
without it, the manager only ever makes one account live.

- Clearing the cache clears every live account, and the next pass
  replaces their sessions.
- Each account is migrated once per launch, and an account that just
  signed in is never migrated.
- A pass also runs after signing in, so the other accounts come live.
- Remove UserSessionManagerError, restoreActiveSession and
  restoreOtherSessions.

* Only clear the cache of the active account

Clearing the cache goes back to clearing the account shown in the UI.
Its session stays live until the next restore pass replaces it, and when
that restore fails another live account becomes the active one.

* Apply review suggestions to restoring every account

- Remove the search backfill guard: with no sessions the manager starts
  nothing and reports that nothing is running, so the task completes.
- Add a TODO to switch to the most recently used account when the
  active one signs out while others are signed in (element-meta#3325).
- Disable SwiftLint's type_body_length on the AppCoordinator again, it's
  just over the limit with develop's new migrations.
This commit is contained in:
Mauro
2026-10-13 19:06:05 +02:00
committed by GitHub
parent ae9e0cc176
commit 7752ae3f6a
7 changed files with 545 additions and 100 deletions
@@ -15,6 +15,7 @@ import Sentry
import SwiftUI
import Version
// swiftlint:disable:next type_body_length
class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDelegate, NotificationManagerDelegate, SecureWindowManagerDelegate {
private let stateMachine: AppCoordinatorStateMachine
private let navigationRootCoordinator: NavigationRootCoordinator
@@ -37,6 +38,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private var backgroundRefreshSyncObserver: AnyCancellable?
private var userSessionMigrationsOldVersion: Version?
/// The accounts migrated since launch, so restoring one again (e.g. after clearing the cache) never migrates it twice.
private var migratedUserIDs: Set<String> = []
/// Restores the signed in accounts. Cancelled when signing out or clearing the cache.
private var restorationTask: Task<Void, Never>?
private var userSession: UserSessionProtocol? {
userSessionManager.activeSession
}
@@ -48,7 +53,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var softLogoutCoordinator: SoftLogoutScreenCoordinator?
private var appDelegateObserver: AnyCancellable?
private var userSessionObserver: AnyCancellable?
/// Becomes `true` once the app is no longer waiting on a session restore, whatever the outcome.
private let isSessionRestoredSubject = CurrentValueSubject<Bool, Never>(false)
private var clientProxyObserver: AnyCancellable?
@@ -161,6 +165,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
setupStateMachine()
observeApplicationState()
observeUserSessionChanges()
observeAppLockChanges()
registerBackgroundAppRefresh()
@@ -369,6 +374,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
// MARK: - AuthenticationFlowCoordinatorDelegate
func authenticationFlowCoordinator(didLoginWithSession userSession: UserSessionProtocol) {
// A new login has nothing to migrate.
migratedUserIDs.insert(userSession.clientProxy.userID)
userSessionManager.add(userSession)
configureActiveSession()
authenticationFlowCoordinator = nil
@@ -490,7 +497,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
// swiftlint:disable:next cyclomatic_complexity
private func performUserSessionMigrations(_ userSession: UserSessionProtocol) async {
guard let oldVersion = userSessionMigrationsOldVersion else { return }
guard let oldVersion = userSessionMigrationsOldVersion,
migratedUserIDs.insert(userSession.clientProxy.userID).inserted else {
return
}
MXLog.info("Migrating user session from \(oldVersion)")
@@ -549,8 +559,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
}
}
userSessionMigrationsOldVersion = nil
}
/// This could be removed once the adoption of 25.06.x is widespread.
@@ -661,8 +669,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
startAuthentication()
case (.signedOut, .createdUserSession, .signedIn):
setupUserSession(isNewLogin: true)
restoreUserSessions()
case (.initial, .startWithExistingSession, .restoringSession):
restoreUserSession()
restoreUserSessions()
case (.restoringSession, .failedRestoringSession, .signedOut):
showLoginErrorToast()
presentSplashScreen()
@@ -670,6 +679,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
setupUserSession(isNewLogin: false)
case (.softLogout, .createdUserSession, .signedIn):
setupUserSession(isNewLogin: false)
restoreUserSessions()
case (.signingOut, .signOut, .signingOut):
// We can ignore signOut when already in the process of signing out,
@@ -697,21 +707,37 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
}
private func restoreUserSession() {
Task {
switch await userSessionManager.restoreActiveSession() {
case .success(let userSession):
await self.performUserSessionMigrations(userSession)
userSessionManager.add(userSession)
configureActiveSession()
stateMachine.processEvent(.createdUserSession)
case .failure:
private func restoreUserSessions() {
let previousRestorationTask = restorationTask
restorationTask = Task {
// A cancelled restoration may still be building a client, which mustn't share a store with a new one.
await previousRestorationTask?.value
await userSessionManager.restoreSessions { [weak self] userSession in
await self?.performUserSessionMigrations(userSession)
} didAdd: { [weak self] _ in
self?.showActiveSessionIfRestoring()
}
guard !Task.isCancelled else { return }
// An account that was already live becomes the active one when those before it fail (e.g. after clearing the cache).
showActiveSessionIfRestoring()
if stateMachine.state == .restoringSession {
MXLog.error("Failed to restore an existing session.")
stateMachine.processEvent(.failedRestoringSession)
}
}
}
/// Shows the active session as soon as it's live, while the app is waiting for one.
private func showActiveSessionIfRestoring() {
guard stateMachine.state == .restoringSession, userSession != nil else { return }
configureActiveSession()
stateMachine.processEvent(.createdUserSession)
}
private func startAuthentication() {
let encryptionKeyProvider = EncryptionKeyProvider()
let classicAppManager = ClassicAppManager()
@@ -792,6 +818,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
switch action {
case .signedIn(let session):
self.migratedUserIDs.insert(session.clientProxy.userID)
self.userSessionManager.add(session)
self.configureActiveSession()
self.softLogoutCoordinator = nil
@@ -868,6 +895,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
fatalError("User session not setup")
}
// TODO: With other accounts signed in, switch to the most recently used one instead (element-hq/element-meta#3325).
restorationTask?.cancel()
windowManager.closeAllSecondaryWindows()
showLoadingIndicator()
@@ -915,8 +944,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private func tearDownUserSession() {
userIndicatorController.retractAllIndicators()
userSessionObserver?.cancel()
userSessionFlowCoordinator = nil
notificationManager.setUserSession(nil)
@@ -947,21 +974,16 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
fatalError("User session not setup")
}
configureServices(for: userSession)
// TODO: Calls only follow the active account, ElementCallService must change to hold every account's session.
elementCallService.setUserSession(userSession)
// TODO: Notifications only follow the active account, the NotificationManager must change to handle every account.
configureNotificationManager(for: userSession)
Task {
await resumeClientServices()
await appHooks.configure(with: userSession)
}
}
/// 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)
@@ -977,16 +999,22 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
}
private func observeUserSessionChanges(_ userSession: UserSessionProtocol) {
userSessionObserver = userSession.callbacks
private func observeUserSessionChanges() {
userSessionManager.callbacksPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .didReceiveAuthError(let isSoftLogout):
case .didReceiveAuthError(let userID, let isSoftLogout):
// Only the active account can be signed out for now.
guard userID == userSessionManager.activeSession?.clientProxy.userID else {
MXLog.error("Ignoring an auth error for \(userID), it isn't the active account.")
return
}
stateMachine.processEvent(.signOut(isSoft: isSoftLogout, disableAppLock: false))
}
}
.store(in: &cancellables)
}
private func observeAppLockChanges() {
@@ -1009,6 +1037,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
fatalError("User session not setup")
}
restorationTask?.cancel()
showLoadingIndicator()
navigationRootCoordinator.setRootCoordinator(PlaceholderScreenCoordinator(hideBrandChrome: appSettings.hideBrandChrome))
@@ -1018,7 +1047,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
// Allow for everything to deallocate properly
Task {
await userSession.clientProxy.clearCaches()
await userSessionManager.clearCaches(userID: userSession.clientProxy.userID)
stateMachine.processEvent(.startWithExistingSession)
hideLoadingIndicator()
}
@@ -1163,7 +1192,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
return
}
await userSession?.clientProxy.pauseServices()
await userSessionManager.pauseServices()
clientProxyObserver = nil
}
@@ -1172,7 +1201,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
analyticsService.signpost.startTransaction(.upToDateRoomList)
await userSession.clientProxy.resumeServices()
await userSessionManager.resumeServices()
guard clientProxyObserver == nil else {
return
@@ -1364,7 +1393,7 @@ private extension AppCoordinator {
// Configure the background-refresh sync to carry set_presence=offline so it doesn't mark the
// user online or idle. Note: If already online/idle then setting offline shouldn't override that.
_ = await userSession.clientProxy.configurePresence(.offline, sendImmediately: false)
await userSessionManager.configurePresence(.offline, sendImmediately: false)
await resumeClientServices()
@@ -1442,20 +1471,15 @@ private extension AppCoordinator {
await waitForSessionRestore()
guard let clientProxy = userSession?.clientProxy else {
task.setTaskCompleted(success: false)
return
}
task.expirationHandler = { @Sendable [weak self] in
MXLog.info("Search backfill task is about to expire.")
Task { @MainActor in self?.userSession?.clientProxy.stopSearchBackfill() }
Task { @MainActor in self?.userSessionManager.stopSearchBackfill() }
}
clientProxy.startSearchBackfill(strategy: .background)
userSessionManager.startSearchBackfill(strategy: .background)
// TaskHandle can't be awaited, so poll until the sweep finishes or is stopped on expiry.
while clientProxy.isSearchBackfillRunning {
// TaskHandle can't be awaited, so poll until every sweep finishes or is stopped on expiry.
while userSessionManager.isSearchBackfillRunning {
try? await Task.sleep(for: .seconds(1))
}
@@ -428,6 +428,10 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: false)
var multiAccountEnabled: Bool
/// Lets the add account flow start once `multiAccountEnabled` shows its entry points. Not a user preference, so testers
/// can't add a second account while signing out of one isn't safe yet. Only set it to `true` locally, for development.
let addAccountFlowEnabled = false
init(store: UserDefaultsProtocol) {
self.store = store
}