mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
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:
@@ -15,6 +15,7 @@ import Sentry
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Version
|
import Version
|
||||||
|
|
||||||
|
// swiftlint:disable:next type_body_length
|
||||||
class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDelegate, NotificationManagerDelegate, SecureWindowManagerDelegate {
|
class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDelegate, NotificationManagerDelegate, SecureWindowManagerDelegate {
|
||||||
private let stateMachine: AppCoordinatorStateMachine
|
private let stateMachine: AppCoordinatorStateMachine
|
||||||
private let navigationRootCoordinator: NavigationRootCoordinator
|
private let navigationRootCoordinator: NavigationRootCoordinator
|
||||||
@@ -37,6 +38,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
private var backgroundRefreshSyncObserver: AnyCancellable?
|
private var backgroundRefreshSyncObserver: AnyCancellable?
|
||||||
|
|
||||||
private var userSessionMigrationsOldVersion: Version?
|
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? {
|
private var userSession: UserSessionProtocol? {
|
||||||
userSessionManager.activeSession
|
userSessionManager.activeSession
|
||||||
}
|
}
|
||||||
@@ -48,7 +53,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
|
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
|
||||||
private var softLogoutCoordinator: SoftLogoutScreenCoordinator?
|
private var softLogoutCoordinator: SoftLogoutScreenCoordinator?
|
||||||
private var appDelegateObserver: AnyCancellable?
|
private var appDelegateObserver: AnyCancellable?
|
||||||
private var userSessionObserver: AnyCancellable?
|
|
||||||
/// Becomes `true` once the app is no longer waiting on a session restore, whatever the outcome.
|
/// Becomes `true` once the app is no longer waiting on a session restore, whatever the outcome.
|
||||||
private let isSessionRestoredSubject = CurrentValueSubject<Bool, Never>(false)
|
private let isSessionRestoredSubject = CurrentValueSubject<Bool, Never>(false)
|
||||||
private var clientProxyObserver: AnyCancellable?
|
private var clientProxyObserver: AnyCancellable?
|
||||||
@@ -161,6 +165,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
setupStateMachine()
|
setupStateMachine()
|
||||||
|
|
||||||
observeApplicationState()
|
observeApplicationState()
|
||||||
|
observeUserSessionChanges()
|
||||||
observeAppLockChanges()
|
observeAppLockChanges()
|
||||||
|
|
||||||
registerBackgroundAppRefresh()
|
registerBackgroundAppRefresh()
|
||||||
@@ -369,6 +374,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
// MARK: - AuthenticationFlowCoordinatorDelegate
|
// MARK: - AuthenticationFlowCoordinatorDelegate
|
||||||
|
|
||||||
func authenticationFlowCoordinator(didLoginWithSession userSession: UserSessionProtocol) {
|
func authenticationFlowCoordinator(didLoginWithSession userSession: UserSessionProtocol) {
|
||||||
|
// A new login has nothing to migrate.
|
||||||
|
migratedUserIDs.insert(userSession.clientProxy.userID)
|
||||||
userSessionManager.add(userSession)
|
userSessionManager.add(userSession)
|
||||||
configureActiveSession()
|
configureActiveSession()
|
||||||
authenticationFlowCoordinator = nil
|
authenticationFlowCoordinator = nil
|
||||||
@@ -490,7 +497,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
|
|
||||||
// swiftlint:disable:next cyclomatic_complexity
|
// swiftlint:disable:next cyclomatic_complexity
|
||||||
private func performUserSessionMigrations(_ userSession: UserSessionProtocol) async {
|
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)")
|
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.
|
/// This could be removed once the adoption of 25.06.x is widespread.
|
||||||
@@ -661,8 +669,9 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
startAuthentication()
|
startAuthentication()
|
||||||
case (.signedOut, .createdUserSession, .signedIn):
|
case (.signedOut, .createdUserSession, .signedIn):
|
||||||
setupUserSession(isNewLogin: true)
|
setupUserSession(isNewLogin: true)
|
||||||
|
restoreUserSessions()
|
||||||
case (.initial, .startWithExistingSession, .restoringSession):
|
case (.initial, .startWithExistingSession, .restoringSession):
|
||||||
restoreUserSession()
|
restoreUserSessions()
|
||||||
case (.restoringSession, .failedRestoringSession, .signedOut):
|
case (.restoringSession, .failedRestoringSession, .signedOut):
|
||||||
showLoginErrorToast()
|
showLoginErrorToast()
|
||||||
presentSplashScreen()
|
presentSplashScreen()
|
||||||
@@ -670,6 +679,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
setupUserSession(isNewLogin: false)
|
setupUserSession(isNewLogin: false)
|
||||||
case (.softLogout, .createdUserSession, .signedIn):
|
case (.softLogout, .createdUserSession, .signedIn):
|
||||||
setupUserSession(isNewLogin: false)
|
setupUserSession(isNewLogin: false)
|
||||||
|
restoreUserSessions()
|
||||||
|
|
||||||
case (.signingOut, .signOut, .signingOut):
|
case (.signingOut, .signOut, .signingOut):
|
||||||
// We can ignore signOut when already in the process of signing out,
|
// We can ignore signOut when already in the process of signing out,
|
||||||
@@ -697,21 +707,37 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restoreUserSession() {
|
private func restoreUserSessions() {
|
||||||
Task {
|
let previousRestorationTask = restorationTask
|
||||||
switch await userSessionManager.restoreActiveSession() {
|
restorationTask = Task {
|
||||||
case .success(let userSession):
|
// A cancelled restoration may still be building a client, which mustn't share a store with a new one.
|
||||||
await self.performUserSessionMigrations(userSession)
|
await previousRestorationTask?.value
|
||||||
userSessionManager.add(userSession)
|
|
||||||
configureActiveSession()
|
await userSessionManager.restoreSessions { [weak self] userSession in
|
||||||
stateMachine.processEvent(.createdUserSession)
|
await self?.performUserSessionMigrations(userSession)
|
||||||
case .failure:
|
} 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.")
|
MXLog.error("Failed to restore an existing session.")
|
||||||
stateMachine.processEvent(.failedRestoringSession)
|
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() {
|
private func startAuthentication() {
|
||||||
let encryptionKeyProvider = EncryptionKeyProvider()
|
let encryptionKeyProvider = EncryptionKeyProvider()
|
||||||
let classicAppManager = ClassicAppManager()
|
let classicAppManager = ClassicAppManager()
|
||||||
@@ -792,6 +818,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case .signedIn(let session):
|
case .signedIn(let session):
|
||||||
|
self.migratedUserIDs.insert(session.clientProxy.userID)
|
||||||
self.userSessionManager.add(session)
|
self.userSessionManager.add(session)
|
||||||
self.configureActiveSession()
|
self.configureActiveSession()
|
||||||
self.softLogoutCoordinator = nil
|
self.softLogoutCoordinator = nil
|
||||||
@@ -868,6 +895,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
fatalError("User session not setup")
|
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()
|
windowManager.closeAllSecondaryWindows()
|
||||||
|
|
||||||
showLoadingIndicator()
|
showLoadingIndicator()
|
||||||
@@ -915,8 +944,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
private func tearDownUserSession() {
|
private func tearDownUserSession() {
|
||||||
userIndicatorController.retractAllIndicators()
|
userIndicatorController.retractAllIndicators()
|
||||||
|
|
||||||
userSessionObserver?.cancel()
|
|
||||||
|
|
||||||
userSessionFlowCoordinator = nil
|
userSessionFlowCoordinator = nil
|
||||||
|
|
||||||
notificationManager.setUserSession(nil)
|
notificationManager.setUserSession(nil)
|
||||||
@@ -947,21 +974,16 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
fatalError("User session not setup")
|
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 {
|
Task {
|
||||||
await resumeClientServices()
|
await resumeClientServices()
|
||||||
await appHooks.configure(with: userSession)
|
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) {
|
private func configureNotificationManager(for userSession: UserSessionProtocol) {
|
||||||
notificationManager.setUserSession(userSession)
|
notificationManager.setUserSession(userSession)
|
||||||
|
|
||||||
@@ -977,16 +999,22 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observeUserSessionChanges(_ userSession: UserSessionProtocol) {
|
private func observeUserSessionChanges() {
|
||||||
userSessionObserver = userSession.callbacks
|
userSessionManager.callbacksPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] callback in
|
.sink { [weak self] callback in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch callback {
|
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))
|
stateMachine.processEvent(.signOut(isSoft: isSoftLogout, disableAppLock: false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func observeAppLockChanges() {
|
private func observeAppLockChanges() {
|
||||||
@@ -1009,6 +1037,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
fatalError("User session not setup")
|
fatalError("User session not setup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restorationTask?.cancel()
|
||||||
showLoadingIndicator()
|
showLoadingIndicator()
|
||||||
|
|
||||||
navigationRootCoordinator.setRootCoordinator(PlaceholderScreenCoordinator(hideBrandChrome: appSettings.hideBrandChrome))
|
navigationRootCoordinator.setRootCoordinator(PlaceholderScreenCoordinator(hideBrandChrome: appSettings.hideBrandChrome))
|
||||||
@@ -1018,7 +1047,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
|
|
||||||
// Allow for everything to deallocate properly
|
// Allow for everything to deallocate properly
|
||||||
Task {
|
Task {
|
||||||
await userSession.clientProxy.clearCaches()
|
await userSessionManager.clearCaches(userID: userSession.clientProxy.userID)
|
||||||
stateMachine.processEvent(.startWithExistingSession)
|
stateMachine.processEvent(.startWithExistingSession)
|
||||||
hideLoadingIndicator()
|
hideLoadingIndicator()
|
||||||
}
|
}
|
||||||
@@ -1163,7 +1192,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await userSession?.clientProxy.pauseServices()
|
await userSessionManager.pauseServices()
|
||||||
clientProxyObserver = nil
|
clientProxyObserver = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1201,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
|
|
||||||
analyticsService.signpost.startTransaction(.upToDateRoomList)
|
analyticsService.signpost.startTransaction(.upToDateRoomList)
|
||||||
|
|
||||||
await userSession.clientProxy.resumeServices()
|
await userSessionManager.resumeServices()
|
||||||
|
|
||||||
guard clientProxyObserver == nil else {
|
guard clientProxyObserver == nil else {
|
||||||
return
|
return
|
||||||
@@ -1364,7 +1393,7 @@ private extension AppCoordinator {
|
|||||||
|
|
||||||
// Configure the background-refresh sync to carry set_presence=offline so it doesn't mark the
|
// 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.
|
// 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()
|
await resumeClientServices()
|
||||||
|
|
||||||
@@ -1442,20 +1471,15 @@ private extension AppCoordinator {
|
|||||||
|
|
||||||
await waitForSessionRestore()
|
await waitForSessionRestore()
|
||||||
|
|
||||||
guard let clientProxy = userSession?.clientProxy else {
|
|
||||||
task.setTaskCompleted(success: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
task.expirationHandler = { @Sendable [weak self] in
|
task.expirationHandler = { @Sendable [weak self] in
|
||||||
MXLog.info("Search backfill task is about to expire.")
|
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.
|
// TaskHandle can't be awaited, so poll until every sweep finishes or is stopped on expiry.
|
||||||
while clientProxy.isSearchBackfillRunning {
|
while userSessionManager.isSearchBackfillRunning {
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -428,6 +428,10 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
@UserPreference(defaultValue: false)
|
@UserPreference(defaultValue: false)
|
||||||
var multiAccountEnabled: Bool
|
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) {
|
init(store: UserDefaultsProtocol) {
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14823,11 +14823,21 @@ nonisolated class UserSessionManagerMock: UserSessionManagerProtocol, @unchecked
|
|||||||
}
|
}
|
||||||
nonisolated(unsafe) var underlyingSessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never>!
|
nonisolated(unsafe) var underlyingSessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never>!
|
||||||
nonisolated(unsafe) var activeSession: UserSessionProtocol?
|
nonisolated(unsafe) var activeSession: UserSessionProtocol?
|
||||||
|
var callbacksPublisher: AnyPublisher<UserSessionManagerCallback, Never> {
|
||||||
|
get { return underlyingCallbacksPublisher }
|
||||||
|
set(value) { underlyingCallbacksPublisher = value }
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var underlyingCallbacksPublisher: AnyPublisher<UserSessionManagerCallback, Never>!
|
||||||
var clientSessionDelegate: ClientSessionDelegate {
|
var clientSessionDelegate: ClientSessionDelegate {
|
||||||
get { return underlyingClientSessionDelegate }
|
get { return underlyingClientSessionDelegate }
|
||||||
set(value) { underlyingClientSessionDelegate = value }
|
set(value) { underlyingClientSessionDelegate = value }
|
||||||
}
|
}
|
||||||
nonisolated(unsafe) var underlyingClientSessionDelegate: ClientSessionDelegate!
|
nonisolated(unsafe) var underlyingClientSessionDelegate: ClientSessionDelegate!
|
||||||
|
var isSearchBackfillRunning: Bool {
|
||||||
|
get { return underlyingIsSearchBackfillRunning }
|
||||||
|
set(value) { underlyingIsSearchBackfillRunning = value }
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var underlyingIsSearchBackfillRunning: Bool!
|
||||||
|
|
||||||
//MARK: - session
|
//MARK: - session
|
||||||
|
|
||||||
@@ -14871,33 +14881,53 @@ nonisolated class UserSessionManagerMock: UserSessionManagerProtocol, @unchecked
|
|||||||
return sessionForReturnValue
|
return sessionForReturnValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//MARK: - restoreActiveSession
|
//MARK: - restoreSessions
|
||||||
|
|
||||||
private let restoreActiveSessionCallsCountLock = NSLock()
|
private let restoreSessionsPrepareDidAddCallsCountLock = NSLock()
|
||||||
private nonisolated(unsafe) var restoreActiveSessionUnderlyingCallsCount = 0
|
private nonisolated(unsafe) var restoreSessionsPrepareDidAddUnderlyingCallsCount = 0
|
||||||
var restoreActiveSessionCallsCount: Int {
|
var restoreSessionsPrepareDidAddCallsCount: Int {
|
||||||
get { restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount } }
|
get { restoreSessionsPrepareDidAddCallsCountLock.withLock { restoreSessionsPrepareDidAddUnderlyingCallsCount } }
|
||||||
set { restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount = newValue } }
|
set { restoreSessionsPrepareDidAddCallsCountLock.withLock { restoreSessionsPrepareDidAddUnderlyingCallsCount = newValue } }
|
||||||
}
|
}
|
||||||
var restoreActiveSessionCalled: Bool {
|
var restoreSessionsPrepareDidAddCalled: Bool {
|
||||||
return restoreActiveSessionCallsCount > 0
|
return restoreSessionsPrepareDidAddCallsCount > 0
|
||||||
}
|
}
|
||||||
|
nonisolated(unsafe) var restoreSessionsPrepareDidAddClosure: ((@MainActor (UserSessionProtocol) async -> Void, @MainActor (UserSessionProtocol) -> Void) async -> Void)?
|
||||||
|
|
||||||
private let restoreActiveSessionReturnValueLock = NSLock()
|
@concurrent func restoreSessions(prepare: @MainActor (UserSessionProtocol) async -> Void, didAdd: @MainActor (UserSessionProtocol) -> Void) async {
|
||||||
private nonisolated(unsafe) var restoreActiveSessionUnderlyingReturnValue: Result<UserSessionProtocol, UserSessionManagerError>!
|
restoreSessionsPrepareDidAddCallsCountLock.withLock { restoreSessionsPrepareDidAddUnderlyingCallsCount += 1 }
|
||||||
var restoreActiveSessionReturnValue: Result<UserSessionProtocol, UserSessionManagerError>! {
|
await restoreSessionsPrepareDidAddClosure?(prepare, didAdd)
|
||||||
get { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue } }
|
|
||||||
set { restoreActiveSessionReturnValueLock.withLock { restoreActiveSessionUnderlyingReturnValue = newValue } }
|
|
||||||
}
|
}
|
||||||
nonisolated(unsafe) var restoreActiveSessionClosure: (() async -> Result<UserSessionProtocol, UserSessionManagerError>)?
|
//MARK: - clearCaches
|
||||||
|
|
||||||
@concurrent func restoreActiveSession() async -> Result<UserSessionProtocol, UserSessionManagerError> {
|
private let clearCachesUserIDCallsCountLock = NSLock()
|
||||||
restoreActiveSessionCallsCountLock.withLock { restoreActiveSessionUnderlyingCallsCount += 1 }
|
private nonisolated(unsafe) var clearCachesUserIDUnderlyingCallsCount = 0
|
||||||
if let restoreActiveSessionClosure = restoreActiveSessionClosure {
|
var clearCachesUserIDCallsCount: Int {
|
||||||
return await restoreActiveSessionClosure()
|
get { clearCachesUserIDCallsCountLock.withLock { clearCachesUserIDUnderlyingCallsCount } }
|
||||||
} else {
|
set { clearCachesUserIDCallsCountLock.withLock { clearCachesUserIDUnderlyingCallsCount = newValue } }
|
||||||
return restoreActiveSessionReturnValue
|
}
|
||||||
}
|
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
|
//MARK: - userSession
|
||||||
|
|
||||||
@@ -15020,6 +15050,119 @@ nonisolated class UserSessionManagerMock: UserSessionManagerProtocol, @unchecked
|
|||||||
resetCallsCountLock.withLock { resetUnderlyingCallsCount += 1 }
|
resetCallsCountLock.withLock { resetUnderlyingCallsCount += 1 }
|
||||||
resetClosure?()
|
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 {
|
nonisolated class UserSessionMock: UserSessionProtocol, @unchecked Sendable {
|
||||||
var clientProxy: ClientProxyProtocol {
|
var clientProxy: ClientProxyProtocol {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct UserSessionMockConfiguration {
|
|||||||
|
|
||||||
clientProxy = configuration.clientProxy
|
clientProxy = configuration.clientProxy
|
||||||
userSettings = configuration.userSettings
|
userSettings = configuration.userSettings
|
||||||
|
callbacks = .init()
|
||||||
|
|
||||||
mediaProvider = MediaProviderMock(.init())
|
mediaProvider = MediaProviderMock(.init())
|
||||||
voiceMessageMediaManager = VoiceMessageMediaManagerMock()
|
voiceMessageMediaManager = VoiceMessageMediaManagerMock()
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ final class UserSessionManager: UserSessionManagerProtocol {
|
|||||||
private let appSettings: AppSettings
|
private let appSettings: AppSettings
|
||||||
|
|
||||||
private let sessionsSubject = CurrentValueSubject<[UserSessionProtocol], Never>([])
|
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<String> = []
|
||||||
|
|
||||||
/// Every signed in account, most recently selected first, with its session once it's live.
|
/// 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.
|
/// Assigning `nil` removes an account rather than clearing its session.
|
||||||
@@ -38,10 +42,32 @@ final class UserSessionManager: UserSessionManagerProtocol {
|
|||||||
accounts.values.first ?? nil
|
accounts.values.first ?? nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var callbacksPublisher: AnyPublisher<UserSessionManagerCallback, Never> {
|
||||||
|
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 {
|
var clientSessionDelegate: ClientSessionDelegate {
|
||||||
userSessionStore.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) {
|
init(userSessionStore: UserSessionStoreProtocol, appSettings: AppSettings) {
|
||||||
self.userSessionStore = userSessionStore
|
self.userSessionStore = userSessionStore
|
||||||
self.appSettings = appSettings
|
self.appSettings = appSettings
|
||||||
@@ -66,22 +92,36 @@ final class UserSessionManager: UserSessionManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func restoreActiveSession() async -> Result<UserSessionProtocol, UserSessionManagerError> {
|
func restoreSessions(prepare: @MainActor (UserSessionProtocol) async -> Void, didAdd: @MainActor (UserSessionProtocol) -> Void) async {
|
||||||
guard !accounts.isEmpty else {
|
for userID in accounts.keys where session(for: userID) == nil || clearedUserIDs.contains(userID) {
|
||||||
return .failure(.noAccounts)
|
// 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.
|
||||||
while let userID = accounts.keys.first {
|
guard !Task.isCancelled else { return }
|
||||||
switch await userSessionStore.restoreUserSession(userID: userID) {
|
guard let userSession = await restoreUserSession(userID: userID) else { continue }
|
||||||
case .success(let userSession):
|
guard !Task.isCancelled else { return }
|
||||||
return .success(userSession)
|
|
||||||
case .failure(let error):
|
await prepare(userSession)
|
||||||
MXLog.error("Failed restoring \(userID), falling back to the next account: \(error)")
|
guard !Task.isCancelled else { return }
|
||||||
accounts.removeValue(forKey: userID)
|
|
||||||
|
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<UserSessionProtocol, UserSessionStoreError> {
|
func userSession(for client: ClientProtocol, sessionDirectories: SessionDirectories, passphrase: Data) async -> Result<UserSessionProtocol, UserSessionStoreError> {
|
||||||
@@ -90,6 +130,7 @@ final class UserSessionManager: UserSessionManagerProtocol {
|
|||||||
|
|
||||||
func add(_ userSession: UserSessionProtocol) {
|
func add(_ userSession: UserSessionProtocol) {
|
||||||
let userID = userSession.clientProxy.userID
|
let userID = userSession.clientProxy.userID
|
||||||
|
clearedUserIDs.remove(userID)
|
||||||
|
|
||||||
if accounts.keys.contains(userID) {
|
if accounts.keys.contains(userID) {
|
||||||
accounts[userID] = userSession
|
accounts[userID] = userSession
|
||||||
@@ -106,11 +147,62 @@ final class UserSessionManager: UserSessionManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userSessionStore.logout(userSession: userSession)
|
userSessionStore.logout(userSession: userSession)
|
||||||
|
clearedUserIDs.remove(userID)
|
||||||
accounts.removeValue(forKey: userID)
|
accounts.removeValue(forKey: userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
userSessionStore.reset()
|
userSessionStore.reset()
|
||||||
|
clearedUserIDs.removeAll()
|
||||||
accounts.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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
|
||||||
enum UserSessionManagerError: Error {
|
enum UserSessionManagerCallback: Equatable {
|
||||||
/// There are no signed in accounts to restore.
|
case didReceiveAuthError(userID: String, isSoftLogout: Bool)
|
||||||
case noAccounts
|
|
||||||
/// None of the signed in accounts could be restored, so they have all been removed.
|
|
||||||
case failedRestoringSessions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
@@ -24,6 +21,8 @@ protocol UserSessionManagerProtocol: AnyObject {
|
|||||||
var sessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never> { get }
|
var sessionsPublisher: CurrentValuePublisher<[UserSessionProtocol], Never> { get }
|
||||||
/// The session of the account whose UI is on screen.
|
/// The session of the account whose UI is on screen.
|
||||||
var activeSession: UserSessionProtocol? { get }
|
var activeSession: UserSessionProtocol? { get }
|
||||||
|
/// The auth errors of every live session, with the account they belong to.
|
||||||
|
var callbacksPublisher: AnyPublisher<UserSessionManagerCallback, Never> { get }
|
||||||
/// The delegate that stores changes to a client's session, such as refreshed tokens.
|
/// The delegate that stores changes to a client's session, such as refreshed tokens.
|
||||||
var clientSessionDelegate: ClientSessionDelegate { get }
|
var clientSessionDelegate: ClientSessionDelegate { get }
|
||||||
|
|
||||||
@@ -31,10 +30,12 @@ protocol UserSessionManagerProtocol: AnyObject {
|
|||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
/// Restores the most recently active account, falling back to the next one when it fails (the store deletes failed accounts).
|
/// 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.
|
||||||
/// The session isn't registered: `add` it once it's ready to be used, e.g. after any migrations.
|
/// An account that fails is removed (the store deletes its data). Without `multiAccountEnabled` only one account is ever live.
|
||||||
func restoreActiveSession() async -> Result<UserSessionProtocol, UserSessionManagerError>
|
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.
|
/// 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.
|
/// 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)
|
func remove(userID: String)
|
||||||
/// Deletes every account's credentials and data.
|
/// Deletes every account's credentials and data.
|
||||||
func reset()
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,39 +25,166 @@ struct UserSessionManagerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func restoreFallsBackToTheNextAccount() async {
|
func restoringFallsBackToTheNextAccount() async {
|
||||||
let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"])
|
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")
|
let bob = makeUserSession(userID: "@bob:matrix.org")
|
||||||
userSessionStore.restoreUserSessionUserIDClosure = { userID in
|
userSessionStore.restoreUserSessionUserIDClosure = { userID in
|
||||||
userID == "@bob:matrix.org" ? .success(bob) : .failure(.failedRestoringLogin)
|
userID == "@bob:matrix.org" ? .success(bob) : .failure(.failedRestoringLogin)
|
||||||
}
|
}
|
||||||
|
let recorder = RestorationRecorder()
|
||||||
|
|
||||||
guard case .success(let userSession) = await manager.restoreActiveSession() else {
|
await manager.restoreSessions { userSession in
|
||||||
Issue.record("Restoring should fall back to Bob.")
|
// Migrations run before the session can be used.
|
||||||
return
|
#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.userIDs == ["@bob:matrix.org"])
|
||||||
#expect(manager.session(for: "@alice:matrix.org") == nil)
|
#expect((manager.activeSession as? UserSessionMock) === bob)
|
||||||
// Nothing can use the session until it's added, e.g. while migrations run.
|
|
||||||
#expect(manager.activeSession == nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func restoreFailsWhenNoAccountCanBeRestored() async {
|
func restoringRemovesEveryAccountThatFails() async {
|
||||||
let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"])
|
let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"])
|
||||||
userSessionStore.restoreUserSessionUserIDReturnValue = .failure(.failedRestoringLogin)
|
userSessionStore.restoreUserSessionUserIDReturnValue = .failure(.failedRestoringLogin)
|
||||||
|
let recorder = RestorationRecorder()
|
||||||
|
|
||||||
guard case .failure(.failedRestoringSessions) = await manager.restoreActiveSession() else {
|
await manager.restoreSessions { recorder.preparedUserIDs.append($0.clientProxy.userID) } didAdd: { recorder.addedUserIDs.append($0.clientProxy.userID) }
|
||||||
Issue.record("Restoring should fail once every account has failed.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org", "@bob:matrix.org"])
|
#expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org", "@bob:matrix.org"])
|
||||||
#expect(manager.userIDs.isEmpty)
|
#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
|
@Test
|
||||||
@@ -106,6 +233,41 @@ struct UserSessionManagerTests {
|
|||||||
#expect(manager.activeSession == nil)
|
#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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func makeManager(userIDs: [String]) -> UserSessionManager {
|
private func makeManager(userIDs: [String]) -> UserSessionManager {
|
||||||
@@ -117,3 +279,8 @@ struct UserSessionManagerTests {
|
|||||||
UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: userID))))
|
UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: userID))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class RestorationRecorder {
|
||||||
|
var preparedUserIDs: [String] = []
|
||||||
|
var addedUserIDs: [String] = []
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user