diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 631c5a41f..17fe92d66 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -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 = [] + /// Restores the signed in accounts. Cancelled when signing out or clearing the cache. + private var restorationTask: Task? 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(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)) } diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 4dfc98c23..a5bd1799b 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -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 } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 1c620e909..57482b9f0 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -14823,11 +14823,21 @@ nonisolated class UserSessionManagerMock: UserSessionManagerProtocol, @unchecked } nonisolated(unsafe) var underlyingSessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never>! nonisolated(unsafe) var activeSession: UserSessionProtocol? + var callbacksPublisher: AnyPublisher { + get { return underlyingCallbacksPublisher } + set(value) { underlyingCallbacksPublisher = value } + } + nonisolated(unsafe) var underlyingCallbacksPublisher: AnyPublisher! var clientSessionDelegate: ClientSessionDelegate { get { return underlyingClientSessionDelegate } set(value) { underlyingClientSessionDelegate = value } } nonisolated(unsafe) var underlyingClientSessionDelegate: ClientSessionDelegate! + var isSearchBackfillRunning: Bool { + get { return underlyingIsSearchBackfillRunning } + set(value) { underlyingIsSearchBackfillRunning = value } + } + nonisolated(unsafe) var underlyingIsSearchBackfillRunning: Bool! //MARK: - session @@ -14871,33 +14881,53 @@ nonisolated class UserSessionManagerMock: UserSessionManagerProtocol, @unchecked return sessionForReturnValue } } - //MARK: - restoreActiveSession + //MARK: - restoreSessions - private let restoreActiveSessionCallsCountLock = NSLock() - private nonisolated(unsafe) var restoreActiveSessionUnderlyingCallsCount = 0 - var restoreActiveSessionCallsCount: Int { - get { restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount } } - set { restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount = newValue } } + private let restoreSessionsPrepareDidAddCallsCountLock = NSLock() + private nonisolated(unsafe) var restoreSessionsPrepareDidAddUnderlyingCallsCount = 0 + var restoreSessionsPrepareDidAddCallsCount: Int { + get { restoreSessionsPrepareDidAddCallsCountLock.withLock { restoreSessionsPrepareDidAddUnderlyingCallsCount } } + set { restoreSessionsPrepareDidAddCallsCountLock.withLock { restoreSessionsPrepareDidAddUnderlyingCallsCount = newValue } } } - var restoreActiveSessionCalled: Bool { - return restoreActiveSessionCallsCount > 0 + var restoreSessionsPrepareDidAddCalled: Bool { + return restoreSessionsPrepareDidAddCallsCount > 0 } + nonisolated(unsafe) var restoreSessionsPrepareDidAddClosure: ((@MainActor (UserSessionProtocol) async -> Void, @MainActor (UserSessionProtocol) -> Void) async -> Void)? - private let restoreActiveSessionReturnValueLock = NSLock() - private nonisolated(unsafe) var restoreActiveSessionUnderlyingReturnValue: Result! - var restoreActiveSessionReturnValue: Result! { - get { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue } } - set { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue = newValue } } + @concurrent func restoreSessions(prepare: @MainActor (UserSessionProtocol) async -> Void, didAdd: @MainActor (UserSessionProtocol) -> Void) async { + restoreSessionsPrepareDidAddCallsCountLock.withLock { restoreSessionsPrepareDidAddUnderlyingCallsCount += 1 } + await restoreSessionsPrepareDidAddClosure?(prepare, didAdd) } - nonisolated(unsafe) var restoreActiveSessionClosure: (() async -> Result)? + //MARK: - clearCaches - @concurrent func restoreActiveSession() async -> Result { - restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount += 1 } - if let restoreActiveSessionClosure = restoreActiveSessionClosure { - return await restoreActiveSessionClosure() - } else { - return restoreActiveSessionReturnValue - } + private let clearCachesUserIDCallsCountLock = NSLock() + private nonisolated(unsafe) var clearCachesUserIDUnderlyingCallsCount = 0 + var clearCachesUserIDCallsCount: Int { + get { clearCachesUserIDCallsCountLock.withLock { clearCachesUserIDUnderlyingCallsCount } } + set { clearCachesUserIDCallsCountLock.withLock { clearCachesUserIDUnderlyingCallsCount = newValue } } + } + var clearCachesUserIDCalled: Bool { + return clearCachesUserIDCallsCount > 0 + } + private let clearCachesUserIDReceivedUserIDLock = NSLock() + private nonisolated(unsafe) var clearCachesUserIDUnderlyingReceivedUserID: String? + var clearCachesUserIDReceivedUserID: String? { + get { clearCachesUserIDReceivedUserIDLock.withLock { clearCachesUserIDUnderlyingReceivedUserID } } + set { clearCachesUserIDReceivedUserIDLock.withLock { clearCachesUserIDUnderlyingReceivedUserID = newValue } } + } + private let clearCachesUserIDReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var clearCachesUserIDUnderlyingReceivedInvocations: [String] = [] + var clearCachesUserIDReceivedInvocations: [String] { + get { clearCachesUserIDReceivedInvocationsLock.withLock { clearCachesUserIDUnderlyingReceivedInvocations } } + set { clearCachesUserIDReceivedInvocationsLock.withLock { clearCachesUserIDUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var clearCachesUserIDClosure: ((String) async -> Void)? + + @concurrent func clearCaches(userID: String) async { + clearCachesUserIDCallsCountLock.withLock { clearCachesUserIDUnderlyingCallsCount += 1 } + clearCachesUserIDReceivedUserID = userID + clearCachesUserIDReceivedInvocationsLock.withLock { clearCachesUserIDUnderlyingReceivedInvocations.append(userID) } + await clearCachesUserIDClosure?(userID) } //MARK: - userSession @@ -15020,6 +15050,119 @@ nonisolated class UserSessionManagerMock: UserSessionManagerProtocol, @unchecked resetCallsCountLock.withLock { resetUnderlyingCallsCount += 1 } resetClosure?() } + //MARK: - resumeServices + + private let resumeServicesCallsCountLock = NSLock() + private nonisolated(unsafe) var resumeServicesUnderlyingCallsCount = 0 + var resumeServicesCallsCount: Int { + get { resumeServicesCallsCountLock.withLock { resumeServicesUnderlyingCallsCount } } + set { resumeServicesCallsCountLock.withLock { resumeServicesUnderlyingCallsCount = newValue } } + } + var resumeServicesCalled: Bool { + return resumeServicesCallsCount > 0 + } + nonisolated(unsafe) var resumeServicesClosure: (() async -> Void)? + + @concurrent func resumeServices() async { + resumeServicesCallsCountLock.withLock { resumeServicesUnderlyingCallsCount += 1 } + await resumeServicesClosure?() + } + //MARK: - pauseServices + + private let pauseServicesCallsCountLock = NSLock() + private nonisolated(unsafe) var pauseServicesUnderlyingCallsCount = 0 + var pauseServicesCallsCount: Int { + get { pauseServicesCallsCountLock.withLock { pauseServicesUnderlyingCallsCount } } + set { pauseServicesCallsCountLock.withLock { pauseServicesUnderlyingCallsCount = newValue } } + } + var pauseServicesCalled: Bool { + return pauseServicesCallsCount > 0 + } + nonisolated(unsafe) var pauseServicesClosure: (() async -> Void)? + + @concurrent func pauseServices() async { + pauseServicesCallsCountLock.withLock { pauseServicesUnderlyingCallsCount += 1 } + await pauseServicesClosure?() + } + //MARK: - configurePresence + + private let configurePresenceSendImmediatelyCallsCountLock = NSLock() + private nonisolated(unsafe) var configurePresenceSendImmediatelyUnderlyingCallsCount = 0 + var configurePresenceSendImmediatelyCallsCount: Int { + get { configurePresenceSendImmediatelyCallsCountLock.withLock { configurePresenceSendImmediatelyUnderlyingCallsCount } } + set { configurePresenceSendImmediatelyCallsCountLock.withLock { configurePresenceSendImmediatelyUnderlyingCallsCount = newValue } } + } + var configurePresenceSendImmediatelyCalled: Bool { + return configurePresenceSendImmediatelyCallsCount > 0 + } + private let configurePresenceSendImmediatelyReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var configurePresenceSendImmediatelyUnderlyingReceivedArguments: (presence: ClientProxyPresence, sendImmediately: Bool)? + var configurePresenceSendImmediatelyReceivedArguments: (presence: ClientProxyPresence, sendImmediately: Bool)? { + get { configurePresenceSendImmediatelyReceivedArgumentsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedArguments } } + set { configurePresenceSendImmediatelyReceivedArgumentsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedArguments = newValue } } + } + private let configurePresenceSendImmediatelyReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var configurePresenceSendImmediatelyUnderlyingReceivedInvocations: [(presence: ClientProxyPresence, sendImmediately: Bool)] = [] + var configurePresenceSendImmediatelyReceivedInvocations: [(presence: ClientProxyPresence, sendImmediately: Bool)] { + get { configurePresenceSendImmediatelyReceivedInvocationsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedInvocations } } + set { configurePresenceSendImmediatelyReceivedInvocationsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var configurePresenceSendImmediatelyClosure: ((ClientProxyPresence, Bool) async -> Void)? + + @concurrent func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async { + configurePresenceSendImmediatelyCallsCountLock.withLock { configurePresenceSendImmediatelyUnderlyingCallsCount += 1 } + configurePresenceSendImmediatelyReceivedArguments = (presence: presence, sendImmediately: sendImmediately) + configurePresenceSendImmediatelyReceivedInvocationsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedInvocations.append((presence: presence, sendImmediately: sendImmediately)) } + await configurePresenceSendImmediatelyClosure?(presence, sendImmediately) + } + //MARK: - startSearchBackfill + + private let startSearchBackfillStrategyCallsCountLock = NSLock() + private nonisolated(unsafe) var startSearchBackfillStrategyUnderlyingCallsCount = 0 + var startSearchBackfillStrategyCallsCount: Int { + get { startSearchBackfillStrategyCallsCountLock.withLock { startSearchBackfillStrategyUnderlyingCallsCount } } + set { startSearchBackfillStrategyCallsCountLock.withLock { startSearchBackfillStrategyUnderlyingCallsCount = newValue } } + } + var startSearchBackfillStrategyCalled: Bool { + return startSearchBackfillStrategyCallsCount > 0 + } + private let startSearchBackfillStrategyReceivedStrategyLock = NSLock() + private nonisolated(unsafe) var startSearchBackfillStrategyUnderlyingReceivedStrategy: SearchBackfillStrategy? + var startSearchBackfillStrategyReceivedStrategy: SearchBackfillStrategy? { + get { startSearchBackfillStrategyReceivedStrategyLock.withLock { startSearchBackfillStrategyUnderlyingReceivedStrategy } } + set { startSearchBackfillStrategyReceivedStrategyLock.withLock { startSearchBackfillStrategyUnderlyingReceivedStrategy = newValue } } + } + private let startSearchBackfillStrategyReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var startSearchBackfillStrategyUnderlyingReceivedInvocations: [SearchBackfillStrategy] = [] + var startSearchBackfillStrategyReceivedInvocations: [SearchBackfillStrategy] { + get { startSearchBackfillStrategyReceivedInvocationsLock.withLock { startSearchBackfillStrategyUnderlyingReceivedInvocations } } + set { startSearchBackfillStrategyReceivedInvocationsLock.withLock { startSearchBackfillStrategyUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var startSearchBackfillStrategyClosure: ((SearchBackfillStrategy) -> Void)? + + func startSearchBackfill(strategy: SearchBackfillStrategy) { + startSearchBackfillStrategyCallsCountLock.withLock { startSearchBackfillStrategyUnderlyingCallsCount += 1 } + startSearchBackfillStrategyReceivedStrategy = strategy + startSearchBackfillStrategyReceivedInvocationsLock.withLock { startSearchBackfillStrategyUnderlyingReceivedInvocations.append(strategy) } + startSearchBackfillStrategyClosure?(strategy) + } + //MARK: - stopSearchBackfill + + private let stopSearchBackfillCallsCountLock = NSLock() + private nonisolated(unsafe) var stopSearchBackfillUnderlyingCallsCount = 0 + var stopSearchBackfillCallsCount: Int { + get { stopSearchBackfillCallsCountLock.withLock { stopSearchBackfillUnderlyingCallsCount } } + set { stopSearchBackfillCallsCountLock.withLock { stopSearchBackfillUnderlyingCallsCount = newValue } } + } + var stopSearchBackfillCalled: Bool { + return stopSearchBackfillCallsCount > 0 + } + nonisolated(unsafe) var stopSearchBackfillClosure: (() -> Void)? + + func stopSearchBackfill() { + stopSearchBackfillCallsCountLock.withLock { stopSearchBackfillUnderlyingCallsCount += 1 } + stopSearchBackfillClosure?() + } } nonisolated class UserSessionMock: UserSessionProtocol, @unchecked Sendable { var clientProxy: ClientProxyProtocol { diff --git a/ElementX/Sources/Mocks/UserSessionMock.swift b/ElementX/Sources/Mocks/UserSessionMock.swift index c18b50cae..93602cb2f 100644 --- a/ElementX/Sources/Mocks/UserSessionMock.swift +++ b/ElementX/Sources/Mocks/UserSessionMock.swift @@ -22,6 +22,7 @@ struct UserSessionMockConfiguration { clientProxy = configuration.clientProxy userSettings = configuration.userSettings + callbacks = .init() mediaProvider = MediaProviderMock(.init()) voiceMessageMediaManager = VoiceMessageMediaManagerMock() diff --git a/ElementX/Sources/Services/UserSession/UserSessionManager.swift b/ElementX/Sources/Services/UserSession/UserSessionManager.swift index 26436c9ac..f129cc898 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionManager.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionManager.swift @@ -19,6 +19,10 @@ final class UserSessionManager: UserSessionManagerProtocol { private let appSettings: AppSettings private let sessionsSubject = CurrentValueSubject<[UserSessionProtocol], Never>([]) + /// Whether the services are running, so that the sessions restored meanwhile are resumed too. + private var areServicesRunning = false + /// Accounts whose caches were cleared. Their old session stays live until restoring replaces it. + private var clearedUserIDs: Set = [] /// 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. @@ -38,10 +42,32 @@ final class UserSessionManager: UserSessionManagerProtocol { accounts.values.first ?? nil } + var callbacksPublisher: AnyPublisher { + sessionsSubject + .map { userSessions in + Publishers.MergeMany(userSessions.map { userSession in + let userID = userSession.clientProxy.userID + return userSession.callbacks.map { callback in + switch callback { + case .didReceiveAuthError(let isSoftLogout): + UserSessionManagerCallback.didReceiveAuthError(userID: userID, isSoftLogout: isSoftLogout) + } + } + }) + } + .switchToLatest() + .eraseToAnyPublisher() + } + var clientSessionDelegate: ClientSessionDelegate { userSessionStore.clientSessionDelegate } + /// Whether an account has a session that's live and up to date (its caches weren't cleared). + private var hasLiveSession: Bool { + accounts.contains { userID, userSession in userSession != nil && !clearedUserIDs.contains(userID) } + } + init(userSessionStore: UserSessionStoreProtocol, appSettings: AppSettings) { self.userSessionStore = userSessionStore self.appSettings = appSettings @@ -66,22 +92,36 @@ final class UserSessionManager: UserSessionManagerProtocol { // MARK: - Lifecycle - func restoreActiveSession() async -> Result { - 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) + func restoreSessions(prepare: @MainActor (UserSessionProtocol) async -> Void, didAdd: @MainActor (UserSessionProtocol) -> Void) async { + for userID in accounts.keys where session(for: userID) == nil || clearedUserIDs.contains(userID) { + // Every live session syncs, so without multi-account only one account is ever made live. + guard appSettings.multiAccountEnabled || !hasLiveSession else { return } + // Signing out or clearing the cache cancels this, after which nothing may go live. + guard !Task.isCancelled else { return } + guard let userSession = await restoreUserSession(userID: userID) else { continue } + guard !Task.isCancelled else { return } + + await prepare(userSession) + guard !Task.isCancelled else { return } + + add(userSession) + didAdd(userSession) + + if areServicesRunning { + await userSession.clientProxy.resumeServices() } + + // Lets the work queued for this session (e.g. its first sync) start before the next account's restore. + await Task.yield() } + } + + func clearCaches(userID: String) async { + guard let userSession = session(for: userID) else { return } - return .failure(.failedRestoringSessions) + // Marked before clearing, so a restore that starts meanwhile replaces the session. + clearedUserIDs.insert(userID) + await userSession.clientProxy.clearCaches() } func userSession(for client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: Data) async -> Result { @@ -90,6 +130,7 @@ final class UserSessionManager: UserSessionManagerProtocol { func add(_ userSession: UserSessionProtocol) { let userID = userSession.clientProxy.userID + clearedUserIDs.remove(userID) if accounts.keys.contains(userID) { accounts[userID] = userSession @@ -106,11 +147,62 @@ final class UserSessionManager: UserSessionManagerProtocol { } userSessionStore.logout(userSession: userSession) + clearedUserIDs.remove(userID) accounts.removeValue(forKey: userID) } func reset() { userSessionStore.reset() + clearedUserIDs.removeAll() accounts.removeAll() } + + // MARK: - Services + + var isSearchBackfillRunning: Bool { + sessionsSubject.value.contains { $0.clientProxy.isSearchBackfillRunning } + } + + func resumeServices() async { + areServicesRunning = true + for userSession in sessionsSubject.value { + await userSession.clientProxy.resumeServices() + } + } + + func pauseServices() async { + areServicesRunning = false + for userSession in sessionsSubject.value { + await userSession.clientProxy.pauseServices() + } + } + + func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async { + for userSession in sessionsSubject.value { + _ = await userSession.clientProxy.configurePresence(presence, sendImmediately: sendImmediately) + } + } + + func startSearchBackfill(strategy: SearchBackfillStrategy) { + sessionsSubject.value.forEach { $0.clientProxy.startSearchBackfill(strategy: strategy) } + } + + func stopSearchBackfill() { + sessionsSubject.value.forEach { $0.clientProxy.stopSearchBackfill() } + } + + // MARK: - Private + + /// Restores an account's session, removing the account when that fails (the store deletes its data). + private func restoreUserSession(userID: String) async -> UserSessionProtocol? { + switch await userSessionStore.restoreUserSession(userID: userID) { + case .success(let userSession): + return userSession + case .failure(let error): + MXLog.error("Failed restoring \(userID): \(error)") + clearedUserIDs.remove(userID) + accounts.removeValue(forKey: userID) + return nil + } + } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift b/ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift index e53c73682..b59b1353a 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionManagerProtocol.swift @@ -9,11 +9,8 @@ import Combine import Foundation import MatrixRustSDK -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 +enum UserSessionManagerCallback: Equatable { + case didReceiveAuthError(userID: String, isSoftLogout: Bool) } // sourcery: AutoMockable @@ -24,6 +21,8 @@ protocol UserSessionManagerProtocol: AnyObject { var sessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never> { get } /// The session of the account whose UI is on screen. var activeSession: UserSessionProtocol? { get } + /// The auth errors of every live session, with the account they belong to. + var callbacksPublisher: AnyPublisher { get } /// The delegate that stores changes to a client's session, such as refreshed tokens. var clientSessionDelegate: ClientSessionDelegate { get } @@ -31,10 +30,12 @@ protocol UserSessionManagerProtocol: AnyObject { // 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 + /// Restores every account that isn't live, or whose caches were cleared, most recently selected first and one after the other. + /// Each session is `prepare`d (e.g. migrated), added, passed to `didAdd`, then resumed if the services are running. + /// An account that fails is removed (the store deletes its data). Without `multiAccountEnabled` only one account is ever live. + func restoreSessions(prepare: @MainActor (UserSessionProtocol) async -> Void, didAdd: @MainActor (UserSessionProtocol) -> Void) async + /// Clears an account's caches. Its session stays live until `restoreSessions` replaces it. + func clearCaches(userID: String) async /// Creates the session of an account that has just signed in, and stores its credentials. /// /// The session isn't registered: `add` it once it's ready to be used. @@ -46,4 +47,17 @@ protocol UserSessionManagerProtocol: AnyObject { func remove(userID: String) /// Deletes every account's credentials and data. func reset() + + // MARK: - Services + + /// Whether any live session is still running a search backfill. + var isSearchBackfillRunning: Bool { get } + + /// Resumes every live session's services, and those of the sessions restored while they're running. + func resumeServices() async + /// Pauses every live session's services. + func pauseServices() async + func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async + func startSearchBackfill(strategy: SearchBackfillStrategy) + func stopSearchBackfill() } diff --git a/UnitTests/Sources/UserSessionManagerTests.swift b/UnitTests/Sources/UserSessionManagerTests.swift index d1142467a..a372e69b4 100644 --- a/UnitTests/Sources/UserSessionManagerTests.swift +++ b/UnitTests/Sources/UserSessionManagerTests.swift @@ -25,39 +25,166 @@ struct UserSessionManagerTests { } @Test - func restoreFallsBackToTheNextAccount() async { + func restoringFallsBackToTheNextAccount() async { let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) - // Alice already has a live session, e.g. when restoring again after clearing the cache. - manager.add(makeUserSession(userID: "@alice:matrix.org")) let bob = makeUserSession(userID: "@bob:matrix.org") userSessionStore.restoreUserSessionUserIDClosure = { userID in userID == "@bob:matrix.org" ? .success(bob) : .failure(.failedRestoringLogin) } + let recorder = RestorationRecorder() - guard case .success(let userSession) = await manager.restoreActiveSession() else { - Issue.record("Restoring should fall back to Bob.") - return - } + await manager.restoreSessions { userSession in + // Migrations run before the session can be used. + #expect(manager.session(for: userSession.clientProxy.userID) == nil) + recorder.preparedUserIDs.append(userSession.clientProxy.userID) + } didAdd: { recorder.addedUserIDs.append($0.clientProxy.userID) } - #expect(userSession.clientProxy.userID == "@bob:matrix.org") + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org", "@bob:matrix.org"]) + #expect(recorder.preparedUserIDs == ["@bob:matrix.org"]) + #expect(recorder.addedUserIDs == ["@bob:matrix.org"]) + // The store deletes an account that fails to restore. #expect(manager.userIDs == ["@bob:matrix.org"]) - #expect(manager.session(for: "@alice:matrix.org") == nil) - // Nothing can use the session until it's added, e.g. while migrations run. - #expect(manager.activeSession == nil) + #expect((manager.activeSession as? UserSessionMock) === bob) } @Test - func restoreFailsWhenNoAccountCanBeRestored() async { + func restoringRemovesEveryAccountThatFails() async { let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) userSessionStore.restoreUserSessionUserIDReturnValue = .failure(.failedRestoringLogin) + let recorder = RestorationRecorder() - guard case .failure(.failedRestoringSessions) = await manager.restoreActiveSession() else { - Issue.record("Restoring should fail once every account has failed.") - return - } + await manager.restoreSessions { recorder.preparedUserIDs.append($0.clientProxy.userID) } didAdd: { recorder.addedUserIDs.append($0.clientProxy.userID) } #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org", "@bob:matrix.org"]) #expect(manager.userIDs.isEmpty) + #expect(manager.activeSession == nil) + #expect(recorder.preparedUserIDs.isEmpty) + #expect(recorder.addedUserIDs.isEmpty) + } + + @Test + func restoringMakesOnlyOneAccountLiveWithoutMultiAccount() async { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + let sessions = ["@alice:matrix.org": makeUserSession(userID: "@alice:matrix.org"), "@bob:matrix.org": makeUserSession(userID: "@bob:matrix.org")] + userSessionStore.restoreUserSessionUserIDClosure = { userID in sessions[userID].map { .success($0) } ?? .failure(.failedRestoringLogin) } + + await manager.restoreSessions { _ in } didAdd: { _ in } + + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org"]) + #expect(manager.userIDs == ["@alice:matrix.org", "@bob:matrix.org"]) + #expect(manager.sessionsPublisher.value.map(\.clientProxy.userID) == ["@alice:matrix.org"]) + } + + @Test + func restoringMakesEveryAccountLiveWithMultiAccount() async { + appSettings.multiAccountEnabled = true + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + let aliceClientProxy = ClientProxyMock(.init(userID: "@alice:matrix.org")) + let bobClientProxy = ClientProxyMock(.init(userID: "@bob:matrix.org")) + let sessions = ["@alice:matrix.org": UserSessionMock(.init(clientProxy: aliceClientProxy)), "@bob:matrix.org": UserSessionMock(.init(clientProxy: bobClientProxy))] + userSessionStore.restoreUserSessionUserIDClosure = { userID in sessions[userID].map { .success($0) } ?? .failure(.failedRestoringLogin) } + await manager.resumeServices() + let recorder = RestorationRecorder() + + await manager.restoreSessions { _ in } didAdd: { recorder.addedUserIDs.append($0.clientProxy.userID) } + + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org", "@bob:matrix.org"]) + #expect(recorder.addedUserIDs == ["@alice:matrix.org", "@bob:matrix.org"]) + #expect(aliceClientProxy.resumeServicesCalled) + #expect(bobClientProxy.resumeServicesCalled) + #expect(manager.activeSession?.clientProxy.userID == "@alice:matrix.org") + } + + @Test + func restoringSkipsLiveAccounts() async { + appSettings.multiAccountEnabled = true + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + manager.add(makeUserSession(userID: "@alice:matrix.org")) + userSessionStore.restoreUserSessionUserIDReturnValue = .success(makeUserSession(userID: "@bob:matrix.org")) + + await manager.restoreSessions { _ in } didAdd: { _ in } + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@bob:matrix.org"]) + + // Once every account is live, there's nothing left to restore. + await manager.restoreSessions { _ in } didAdd: { _ in } + #expect(userSessionStore.restoreUserSessionUserIDCallsCount == 1) + } + + @Test + func restoringDoesntResumeSessionsWhilePaused() async { + let manager = makeManager(userIDs: ["@alice:matrix.org"]) + let aliceClientProxy = ClientProxyMock(.init(userID: "@alice:matrix.org")) + userSessionStore.restoreUserSessionUserIDReturnValue = .success(UserSessionMock(.init(clientProxy: aliceClientProxy))) + + // E.g. a background launch, which pauses the sessions when its task completes. + await manager.restoreSessions { _ in } didAdd: { _ in } + + #expect(manager.activeSession != nil) + #expect(!aliceClientProxy.resumeServicesCalled) + } + + @Test + func restoringStopsOnceCancelled() async { + appSettings.multiAccountEnabled = true + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + userSessionStore.restoreUserSessionUserIDReturnValue = .success(makeUserSession(userID: "@alice:matrix.org")) + + // E.g. signing out while an account is being migrated. + await Task { + await manager.restoreSessions { _ in withUnsafeCurrentTask { $0?.cancel() } } didAdd: { _ in } + }.value + + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org"]) + #expect(manager.activeSession == nil) + #expect(manager.userIDs == ["@alice:matrix.org", "@bob:matrix.org"]) + } + + @Test + func clearingCachesReplacesTheSessionOnTheNextRestore() async { + appSettings.multiAccountEnabled = true + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + let aliceClientProxy = ClientProxyMock(.init(userID: "@alice:matrix.org")) + aliceClientProxy.clearCachesReturnValue = .success(()) + let oldAlice = UserSessionMock(.init(clientProxy: aliceClientProxy)) + let bob = makeUserSession(userID: "@bob:matrix.org") + manager.add(oldAlice) + manager.add(bob) + + await manager.clearCaches(userID: "@alice:matrix.org") + #expect(aliceClientProxy.clearCachesCalled) + + let newAlice = makeUserSession(userID: "@alice:matrix.org") + userSessionStore.restoreUserSessionUserIDReturnValue = .success(newAlice) + await manager.restoreSessions { _ in + // The cleared session stays live until the new one replaces it. + #expect((manager.activeSession as? UserSessionMock) === oldAlice) + } didAdd: { _ in } + + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org"]) + #expect((manager.activeSession as? UserSessionMock) === newAlice) + #expect((manager.session(for: "@bob:matrix.org") as? UserSessionMock) === bob) + + await manager.restoreSessions { _ in } didAdd: { _ in } + #expect(userSessionStore.restoreUserSessionUserIDCallsCount == 1) + } + + @Test + func restoringAfterClearingCachesFallsBackToALiveAccount() async { + appSettings.multiAccountEnabled = true + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + let aliceClientProxy = ClientProxyMock(.init(userID: "@alice:matrix.org")) + aliceClientProxy.clearCachesReturnValue = .success(()) + manager.add(UserSessionMock(.init(clientProxy: aliceClientProxy))) + let bob = makeUserSession(userID: "@bob:matrix.org") + manager.add(bob) + userSessionStore.restoreUserSessionUserIDReturnValue = .failure(.failedRestoringLogin) + + await manager.clearCaches(userID: "@alice:matrix.org") + await manager.restoreSessions { _ in } didAdd: { _ in } + + #expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org"]) + #expect(manager.userIDs == ["@bob:matrix.org"]) + #expect((manager.activeSession as? UserSessionMock) === bob) } @Test @@ -106,6 +233,41 @@ struct UserSessionManagerTests { #expect(manager.activeSession == nil) } + @Test + func resumingAndPausingReachEverySession() async { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + let aliceClientProxy = ClientProxyMock(.init(userID: "@alice:matrix.org")) + let bobClientProxy = ClientProxyMock(.init(userID: "@bob:matrix.org")) + manager.add(UserSessionMock(.init(clientProxy: aliceClientProxy))) + manager.add(UserSessionMock(.init(clientProxy: bobClientProxy))) + + await manager.resumeServices() + #expect(aliceClientProxy.resumeServicesCallsCount == 1) + #expect(bobClientProxy.resumeServicesCallsCount == 1) + + await manager.pauseServices() + #expect(aliceClientProxy.pauseServicesCallsCount == 1) + #expect(bobClientProxy.pauseServicesCallsCount == 1) + } + + @Test + func reportsTheAuthErrorsOfTheLiveSessions() async throws { + let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"]) + manager.add(makeUserSession(userID: "@alice:matrix.org")) + let bob = makeUserSession(userID: "@bob:matrix.org") + manager.add(bob) + + let deferred = deferFulfillment(manager.callbacksPublisher) { $0 == .didReceiveAuthError(userID: "@bob:matrix.org", isSoftLogout: true) } + bob.callbacks.send(.didReceiveAuthError(isSoftLogout: true)) + try await deferred.fulfill() + + // A replaced session (e.g. after clearing the cache) mustn't report anything anymore. + let deferredFailure = deferFailure(manager.callbacksPublisher, timeout: .seconds(1)) { _ in true } + manager.add(makeUserSession(userID: "@bob:matrix.org")) + bob.callbacks.send(.didReceiveAuthError(isSoftLogout: false)) + try await deferredFailure.fulfill() + } + // MARK: - Helpers private func makeManager(userIDs: [String]) -> UserSessionManager { @@ -117,3 +279,8 @@ struct UserSessionManagerTests { UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: userID)))) } } + +private final class RestorationRecorder { + var preparedUserIDs: [String] = [] + var addedUserIDs: [String] = [] +}