Files
element-x-ios/ElementX/Sources/Application/AppCoordinator.swift
Mauro 268d6c8ead Notify every signed-in account and turn notifications on and off per account (#6337)
* Store the notifications switch per account

Adds AccountSettings.enableNotifications and moves the app-wide value into the signed in accounts before they're restored: the first account takes it out of the app settings and the others get the same switch, so someone who turned notifications off keeps them off.

* Add a notification service to every user session

Each account's NotificationService sets its pusher when its notifications are on and deletes it when they're off (appending with multiple accounts), keeps the account's unread count and decides which of its delivered notifications to clear. It asks the NotificationManager, the only owner of the notification center, through actions: remove the notifications matching a rule, request authorization and the unread count. The UserSessionManager connects every live session's service to the manager, and flows and screens use the session's service instead of the manager, which leaves the flow parameters.

* Route notifications by account

Taps only open rooms of the active account for now, a tap made while restoring only opens once its account is the active one, replies are sent from the account they were made for, and signing out only clears that account's notifications unless it was the last one. The notification extension hides pushes for an account that signed out while other accounts are still signed in.

* Turn notifications on and off for each account

The notification settings switch is now saved per account, and each account's NotificationService sets or deletes its pusher when it changes, asking for permission when it's turned on.

* Apply review suggestions to multi-account notifications

Migration
- Move the notifications switch with a single per-account migration, like 26.10.1 and 26.10.2, and drop its dedicated test.

Notification manager and service
- Follow each account's badge count publisher directly, removing the badge count action and the stored counts.
- Only reset the badge and unregister from APNs when the last account signs out, as each account already clears its own notifications.
- Set up the service's subscriptions in a single setupSubscriptions().
- Rename switchChanged(to:) to enableNotificationsChanged(to:) and pusherConfiguration(identifiers:) to pusherConfiguration(for:).
- Build the service without hopping to the main actor, it already runs there.

Notification service extension
- Check the account's switch in NotificationHandler using the session's settings, and log an error when a push arrives for an account that's turned off.
- Always show pushes that match no signed-in account and log an error, so a broken setup stays visible.

Other
- Store a pending inline reply in a PendingInlineReplyDetails struct.
- Assign the chats tab badge with weakAssign.
- Make PusherConfiguration Equatable so the pusher test checks it in one expectation.
- Clarify the comment about notifications without a receiver.

* Print instead of logging before the app hooks run in the NSE

A push that matches no signed-in account goes back to printing, as MXLog shouldn't be used before the app hooks have run. The push is still shown so a broken setup stays visible.
2026-10-15 13:10:26 +02:00

1522 lines
68 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import AnalyticsEvents
import BackgroundTasks
import Combine
import Intents
import MatrixRustSDK
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
private let userSessionManager: UserSessionManagerProtocol
// periphery:ignore - retaining purpose
private let targetConfiguration: Target.ConfigurationResult
private let appMediator: AppMediator
private let appSettings: AppSettings
private let analyticsService: AnalyticsServiceProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appDelegate: AppDelegate
private let appHooks: AppHooks
private let bugReportService: BugReportServiceProtocol
private let elementCallService: ElementCallServiceProtocol
/// Common background task to continue long-running tasks in the background.
private var backgroundTask: UIBackgroundTaskIdentifier?
private var backgroundRefreshSyncObserver: AnyCancellable?
private var userSessionMigrationsOldVersion: Version?
/// The accounts migrated since launch, so restoring one again (e.g. after clearing the cache) never migrates it twice.
private var migratedUserIDs: Set<String> = []
/// Restores the signed in accounts. Cancelled when signing out or clearing the cache.
private var restorationTask: Task<Void, Never>?
private var userSession: UserSessionProtocol? {
userSessionManager.activeSession
}
private var authenticationFlowCoordinator: AuthenticationFlowCoordinator?
private let appLockFlowCoordinator: AppLockFlowCoordinator
// periphery:ignore - used to avoid deallocation
private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator?
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var softLogoutCoordinator: SoftLogoutScreenCoordinator?
private var appDelegateObserver: AnyCancellable?
/// Becomes `true` once the app is no longer waiting on a session restore, whatever the outcome.
private let isSessionRestoredSubject = CurrentValueSubject<Bool, Never>(false)
private var clientProxyObserver: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
let windowManager: SecureWindowManagerProtocol
let notificationManager: NotificationManagerProtocol
private let appRouteURLParser: AppRouteURLParser
private var storedAppRoute: AppRoute?
/// The account of a notification tapped while restoring, its route only opens once that account is the active one.
@Consumable private var storedAppRouteReceiverID: String?
/// A reply made from a notification before its account was live.
private var storedInlineReply: PendingInlineReplyDetails?
@Consumable private var storedRoomsToAwait: Set<String>?
init(appDelegate: AppDelegate) {
let appHooks = AppHooks()
appHooks.setUp()
// Override colours before we start building any UI components.
appHooks.compoundHook.override(colors: Color.compound, uiColors: UIColor.compound)
windowManager = WindowManager(appDelegate: appDelegate)
let networkMonitor = NetworkMonitor()
appMediator = AppMediator(windowManager: windowManager, networkMonitor: networkMonitor)
guard let userDefaults = TrackedUserDefaults(suiteName: AppSettings.suiteName) else {
fatalError("Catastrophic error retrieving user defaults for \(AppSettings.suiteName)")
}
let appSettings = appHooks.appSettingsHook.configure(AppSettings(store: userDefaults))
self.appSettings = appSettings
targetConfiguration = Target.mainApp.configure(logLevel: appSettings.logLevel,
traceLogPacks: appSettings.traceLogPacks,
sentryURL: appSettings.bugReportSentryRustURL,
rageshakeURL: appSettings.bugReportRageshakeURL,
appHooks: appHooks)
let appName = InfoPlistReader.main.bundleDisplayName
let appVersion = InfoPlistReader.main.bundleShortVersionString
let appBuild = InfoPlistReader.main.bundleVersion
MXLog.info("\(appName) \(appVersion) (\(appBuild))")
if ProcessInfo.processInfo.environment["RESET_APP_SETTINGS"].map(Bool.init) == true {
appSettings.resetAllSettings()
}
self.appDelegate = appDelegate
self.appHooks = appHooks
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
let posthogAnalyticsClient = PostHogAnalyticsClient()
posthogAnalyticsClient.updateSuperProperties(AnalyticsEvent.SuperProperties(appPlatform: .EXI, cryptoSDK: .Rust, cryptoSDKVersion: sdkGitSha()))
analyticsService = AnalyticsService(client: posthogAnalyticsClient, appSettings: appSettings)
userIndicatorController = UserIndicatorController()
elementCallService = ElementCallService(appSettings: appSettings)
navigationRootCoordinator = NavigationRootCoordinator()
stateMachine = AppCoordinatorStateMachine()
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
let keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
let userSessionStore = UserSessionStore(keychainController: keychainController,
appSettings: appSettings,
analyticsService: analyticsService,
appHooks: appHooks,
networkMonitor: networkMonitor)
notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(), appSettings: appSettings)
userSessionManager = UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings, notificationManager: notificationManager)
let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings)
let appLockNavigationCoordinator = NavigationRootCoordinator()
appLockFlowCoordinator = AppLockFlowCoordinator(appLockService: appLockService,
navigationCoordinator: appLockNavigationCoordinator,
appSettings: appSettings)
bugReportService = BugReportService(rageshakeURLPublisher: appSettings.bugReportRageshakeURL.publisher,
applicationID: appSettings.bugReportApplicationID,
sdkGitSHA: sdkGitSha(),
appHooks: appHooks)
Self.setupSentry(bugReportService: bugReportService, appSettings: appSettings, analytics: analyticsService)
analyticsService.startIfEnabled()
windowManager.delegate = self
configureNotificationManager()
guard let currentVersion = Version(InfoPlistReader(bundle: .main).bundleShortVersionString) else {
fatalError("The app's version number **must** use semver for migration purposes.")
}
if let previousVersion = appSettings.lastVersionLaunched.flatMap(Version.init) {
performMigrationsIfNecessary(from: previousVersion, to: currentVersion)
} else {
// The app has been deleted since the previous run. Reset everything.
wipeUserData(includingSettings: true)
}
appSettings.lastVersionLaunched = currentVersion.description
setupStateMachine()
observeApplicationState()
observeUserSessionChanges()
observeAppLockChanges()
registerBackgroundAppRefresh()
registerSearchBackfill()
appSettings.analyticsConsentStatePublisher
.dropFirst() // Sentry is configured during init; only reconfigure when consent state actually changes
.sink { [bugReportService, analyticsService, appSettings] _ in
Self.setupSentry(bugReportService: bugReportService, appSettings: appSettings, analytics: analyticsService)
}
.store(in: &cancellables)
elementCallService.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
switch action {
case .startCall(let roomID, let isVoiceCall):
self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall), windowType: nil)
case .receivedIncomingCallRequest:
// When reporting a VoIP call through the CXProvider's `reportNewIncomingVoIPPushPayload`
// the UIApplication states don't change and services are neither started nor ran on
// a background task. Handle both manually here.
Task {
await self?.resumeClientServices()
self?.scheduleDelayedPauseServices()
}
default:
break
}
}
.store(in: &cancellables)
windowManager.secondaryWindowsEnabled = !appLockService.isEnabled
appLockService.isEnabledPublisher.sink { [weak windowManager] appLockEnabled in
windowManager?.secondaryWindowsEnabled = !appLockEnabled
}
.store(in: &cancellables)
}
func start() {
guard stateMachine.state == .initial else {
MXLog.info("Received a start request when already started")
return
}
guard !userSessionManager.userIDs.isEmpty else {
stateMachine.processEvent(.startWithAuthentication)
return
}
stateMachine.processEvent(.startWithExistingSession)
}
func toPresentable() -> AnyView {
AnyView(navigationRootCoordinator.toPresentable()
.environment(\.analyticsService, analyticsService)
.onReceive(appSettings.appAppearancePublisher) { [weak self] appAppearance in
guard let self else { return }
windowManager.windows.forEach { window in
// Unfortunately .preferredColorScheme doesn't propagate properly throughout the app when changed
window.overrideUserInterfaceStyle = appAppearance.interfaceStyle
}
})
}
func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool {
guard let confirmationParameters = url.confirmationParameters else {
return false
}
navigationRootCoordinator.alertInfo = .init(id: .init(),
title: L10n.dialogConfirmLinkTitle,
message: L10n.dialogConfirmLinkMessage(confirmationParameters.displayString,
confirmationParameters.internalURL.absoluteString),
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionContinue) { openURLAction(confirmationParameters.internalURL) })
return true
}
func handleAppRoute(_ appRoute: AppRoute, windowType: SecondaryWindowType?) {
// Read before resuming the services, by which time a push tap or deep link will have made the app active.
let animated = appMediator.appState == .active
Task {
await asyncHandleAppRoute(appRoute, windowType: windowType, animated: animated)
}
}
private func asyncHandleAppRoute(_ appRoute: AppRoute, windowType: SecondaryWindowType?, animated: Bool) async {
MXLog.info("Handling app route: \(appRoute)")
await resumeClientServices()
if let windowType {
windowManager.handleRoute(appRoute, windowType: windowType)
return
}
var handled = false
switch appRoute {
case .accountProvisioningLink:
if let authenticationFlowCoordinator {
authenticationFlowCoordinator.handleAppRoute(appRoute, animated: animated)
handled = true
}
default:
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: animated)
handled = true
}
}
if !handled {
storedAppRoute = appRoute
}
}
func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: SecondaryWindowType?) -> Bool {
// Parse into an AppRoute to redirect these in a type safe way.
if let route = appRouteURLParser.route(from: url) {
switch route {
case .accountProvisioningLink:
handleAppRoute(route,
windowType: windowType)
case .oAuthCallback(let url):
if stateMachine.state == .softLogout {
softLogoutCoordinator?.handleOAuthCallbackURL(url)
} else {
authenticationFlowCoordinator?.handleOAuthCallbackURL(url)
}
case .userProfile(let userID):
if isExternalURL {
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.roomMemberDetails(userID: userID),
windowType: windowType)
}
case .room(let roomID, let via):
if isExternalURL {
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childRoom(roomID: roomID, via: via),
windowType: windowType)
}
case .roomAlias(let alias):
if isExternalURL {
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childRoomAlias(alias),
windowType: windowType)
}
case .event(let eventID, let roomID, let via):
if isExternalURL {
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via),
windowType: windowType)
}
case .eventOnRoomAlias(let eventID, let alias):
if isExternalURL {
handleAppRoute(route,
windowType: windowType)
} else {
handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias),
windowType: windowType)
}
case .share(let payload):
guard isExternalURL else {
MXLog.error("Received unexpected internal share route")
break
}
do {
try handleAppRoute(.share(payload.withDefaultTemporaryDirectory()),
windowType: windowType)
} catch {
MXLog.error("Failed moving payload out of the app group container: \(error)")
}
default:
break
}
return true
}
return false
}
func handleUserActivity(_ userActivity: NSUserActivity) {
guard let intent = userActivity.interaction?.intent as? INStartCallIntent,
let contact = intent.contacts?.first,
let roomIdentifier = contact.personHandle?.value else {
MXLog.error("Failed retrieving information from userActivity: \(userActivity)")
return
}
MXLog.info("Starting call in room: \(roomIdentifier)")
handleAppRoute(AppRoute.call(roomID: roomIdentifier, isVoiceCall: intent.callCapability == .audioCall), windowType: nil)
}
// 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
stateMachine.processEvent(.createdUserSession)
}
// MARK: - WindowManagerDelegate
func windowManagerDidConfigureWindows(_ windowManager: SecureWindowManagerProtocol) {
windowManager.alternateWindow.rootViewController = UIHostingController(rootView: appLockFlowCoordinator.toPresentable())
userIndicatorController.window = windowManager.overlayWindow
}
// MARK: - NotificationManagerDelegate
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
func unregisterForRemoteNotifications() {
UIApplication.shared.unregisterForRemoteNotifications()
}
func shouldDisplayInAppNotification(content: UNNotificationContent) -> Bool {
guard let roomID = content.roomID else {
return true
}
guard let userSessionFlowCoordinator else {
// there is not a user session yet
return false
}
// Only the active account's rooms can be on screen.
if let receiverID = content.receiverID, receiverID != userSession?.clientProxy.userID {
return true
}
return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID)
}
func notificationTapped(content: UNNotificationContent) async {
MXLog.info("Tapped Notification")
guard let roomID = content.roomID,
let receiverID = content.receiverID else {
return
}
if let userSession {
guard receiverID == userSession.clientProxy.userID else {
// TODO: Switch to the receiver's account and open the room (element-hq/element-meta#3328).
MXLog.info("Ignoring a notification tap for \(receiverID), it isn't the active account.")
return
}
} else {
storedAppRouteReceiverID = receiverID
}
let eventID = appSettings.focusEventOnNotificationTap ? content.eventID : nil
if content.categoryIdentifier == NotificationConstants.Category.invite {
if let userSession {
userSession.clientProxy.roomsToAwait.insert(roomID)
} else {
storedRoomsToAwait = [roomID]
}
handleAppRoute(.room(roomID: roomID, via: []), windowType: nil)
} else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID {
handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: eventID), windowType: nil)
} else if let eventID {
// Only track main timeline event deeplinking
analyticsService.signpost.startTransaction(.notificationToMessage)
handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), windowType: nil)
} else {
handleAppRoute(.room(roomID: roomID, via: []), windowType: nil)
}
}
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
MXLog.info("Handle notification reply")
guard let roomID = content.roomID,
let receiverID = content.receiverID else {
return
}
guard let userSession = userSessionManager.session(for: receiverID) else {
// Store the data so it can be sent once the receiver's session is live.
storedInlineReply = PendingInlineReplyDetails(userID: receiverID, roomID: roomID, message: replyText)
return
}
await processInlineReply(replyText, roomID: roomID, userSession: userSession)
}
// MARK: - Private
/// Perform any required migrations for the app to function correctly.
private func performMigrationsIfNecessary(from oldVersion: Version, to newVersion: Version) {
guard oldVersion != newVersion else { return }
// Be tidy and clean up after ourselves every now and then (because Apple is lazy)
clearTemporaryDirectories()
MXLog.info("The app was upgraded from \(oldVersion) to \(newVersion)")
if oldVersion < Version(1, 6, 7) {
Tracing.deleteLogFiles(in: Tracing.legacyLogsDirectory)
MXLog.info("Migrating to v1.6.7, log files have been wiped")
}
if oldVersion < Version(25, 7, 4) {
Tracing.migrateLogFiles()
MXLog.info("Migrating to version 25.07.4, log files have been moved.")
}
if oldVersion < Version(26, 10, 2) {
MXLog.info("Migrating to version 26.10.2, moving notification sounds.")
let soundsDirectory = URL.libraryDirectory.appending(component: "Sounds", directoryHint: .isDirectory)
// Sounds are now referenced directly, the duplicate file can go.
try? FileManager.default.removeItem(at: soundsDirectory.appending(component: "currentAlert.caf"))
let oldCustomTonesLocation = soundsDirectory.appending(component: "AvailableSounds", directoryHint: .isDirectory)
if FileManager.default.fileExists(atPath: oldCustomTonesLocation.path(percentEncoded: false)) {
do {
try FileManager.default.moveItem(at: oldCustomTonesLocation, to: URL.customTonesDirectory)
} catch {
MXLog.error("Failed moving custom notification sounds: \(error)")
}
}
}
// Store the old version to run additional migrations on the user session once it has been set up.
userSessionMigrationsOldVersion = oldVersion
}
// swiftlint:disable:next cyclomatic_complexity
private func performUserSessionMigrations(_ userSession: UserSessionProtocol) async {
guard let oldVersion = userSessionMigrationsOldVersion,
migratedUserIDs.insert(userSession.clientProxy.userID).inserted else {
return
}
MXLog.info("Migrating user session from \(oldVersion)")
// Vacuuming can outlast a background task, getting the app killed before the migrations below run.
if appMediator.appState != .background {
MXLog.info("Performing client store optimizations.")
await userSession.clientProxy.optimizeStores()
MXLog.info("Finished optimizing client stores.")
}
if oldVersion < Version(1, 6, 0) {
MXLog.info("Migrating to v1.6.0, marking identity confirmation onboarding as ran.")
userSession.userSettings.account.hasRunIdentityConfirmationOnboarding = true
userSession.userSettings.app.hasRunNotificationPermissionsOnboarding = true
}
if oldVersion < Version(25, 6, 0) {
MXLog.info("Migrating to version 25.06.0, migrating timeline media settings to account data.")
performSettingsToAccountDataMigration(userSession: userSession)
}
if oldVersion < Version(25, 9, 2) {
MXLog.info("Migrating to version 25.09.2, triggering sync to ensure m.space state is up to date.")
await userSession.clientProxy.expireSyncSessions()
}
if oldVersion < Version(25, 10, 0) {
MXLog.info("Migrating to version 25.10.0, showing new sound banner to existing user.")
appSettings.hasSeenNewSoundBanner = false
}
if oldVersion < Version(26, 07, 3) {
MXLog.info("Migrating to version 26.07.3, marking the app as previously signed in.")
appSettings.hasSignedInBefore = true
}
if oldVersion < Version(26, 10, 1) {
MXLog.info("Migrating to version 26.10.1, moving initial 'session specific' settings.")
userSession.userSettings.account.migrateAppSettingsValue(\.hasRunIdentityConfirmationOnboardingKey)
userSession.userSettings.account.migrateAppSettingsValue(\.searchBreadcrumbsKey)
}
if oldVersion < Version(26, 10, 2) {
MXLog.info("Migrating to version 26.10.2, moving visible account settings.")
userSession.userSettings.account.migrateAppSettingsValue(\.showAllRoomListActivityKey)
userSession.userSettings.account.migrateAppSettingsValue(\.selectedNotificationToneKey)
userSession.userSettings.account.migrateAppSettingsValue(\.sharePresenceKey)
// Make sure system notification sounds are available in the new directory.
if let selectedNotificationTone = userSession.userSettings.account.selectedNotificationTone {
do {
MXLog.info("Copying the selected system notification sound.")
try NotificationToneManager.copySystemToneIfNeeded(selectedNotificationTone)
} catch {
MXLog.error("Failed migrating the selected notification sound: \(error)")
}
}
}
if oldVersion < Version(26, 10, 3) {
MXLog.info("Migrating to version 26.10.3, moving the notifications switch into the account.")
userSession.userSettings.account.migrateAppSettingsValue(\.enableNotificationsKey)
}
}
/// This could be removed once the adoption of 25.06.x is widespread.
private func performSettingsToAccountDataMigration(userSession: UserSessionProtocol) {
guard let userDefaults = UserDefaults(suiteName: InfoPlistReader.main.appGroupIdentifier) else {
return
}
let hideInviteAvatars = userDefaults.value(forKey: "hideInviteAvatars") as? Bool
let timelineMediaVisibility = userDefaults
.data(forKey: "timelineMediaVisibility")
.flatMap {
try? JSONDecoder().decode(TimelineMediaVisibility.self, from: $0)
}
let hideTimelineMedia = userDefaults.value(forKey: "hideTimelineMedia") as? Bool
guard hideInviteAvatars != nil || timelineMediaVisibility != nil || hideTimelineMedia != nil else {
// No migration needed, no local settings found.
return
}
Task {
switch await userSession.clientProxy.fetchMediaPreviewConfiguration() {
case let .success(config):
guard config == nil else {
// Found a server configuration, no need to migrate.
userDefaults.removeObject(forKey: "hideInviteAvatars")
userDefaults.removeObject(forKey: "timelineMediaVisibility")
userDefaults.removeObject(forKey: "hideTimelineMedia")
return
}
if let hideInviteAvatars, case .success = await userSession.clientProxy.setHideInviteAvatars(hideInviteAvatars) {
userDefaults.removeObject(forKey: "hideInviteAvatars")
}
if let timelineMediaVisibility, case .success = await userSession.clientProxy.setTimelineMediaVisibility(timelineMediaVisibility) {
userDefaults.removeObject(forKey: "timelineMediaVisibility")
} else if let hideTimelineMedia, case .success = await userSession.clientProxy.setTimelineMediaVisibility(hideTimelineMedia ? .never : .always) {
userDefaults.removeObject(forKey: "hideTimelineMedia")
}
case let .failure(error):
MXLog.error("Could not perform migration, failed to fetch media preview config: \(error)")
return
}
}
}
/// Clears the keychain, app support directory etc ready for a fresh use.
/// - Parameter includingSettings: Whether to additionally wipe the user's app settings too.
private func wipeUserData(includingSettings: Bool = false) {
if includingSettings {
appSettings.resetAllSettings()
appLockFlowCoordinator.appLockService.disable()
}
userSessionManager.reset()
}
/// Manually cleans up any files in the app group's `tmp` directory.
///
/// **Note:** If there is a single file we consider it to be an active share payload and ignore it.
private func clearTemporaryDirectories() {
// First get rid of everything in the App's temporary directory
do {
let fileURLs = try FileManager.default.contentsOfDirectory(at: URL.temporaryDirectory, includingPropertiesForKeys: nil, options: [])
fileURLs.forEach { url in
do {
try FileManager.default.removeItem(at: url)
} catch {
MXLog.warning("Failed to remove file from temporary directory: \(error)")
}
}
} catch {
MXLog.warning("Failed to enumerate temporary directory: \(error)")
}
// Manual clean to handle the potential case where the app crashes before moving a shared file.
do {
let fileURLs = try FileManager.default.contentsOfDirectory(at: URL.appGroupTemporaryDirectory, includingPropertiesForKeys: nil, options: [])
guard fileURLs.count > 1 else {
return // If there is only a single item in here, there's likely a pending share payload that is yet to be processed.
}
for url in fileURLs {
do {
try FileManager.default.removeItem(at: url)
} catch {
MXLog.warning("Failed to remove file from app group temporary directory: \(error)")
}
}
} catch {
MXLog.warning("Failed to enumerate app group temporary directory: \(error)")
}
}
private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in
guard let self else { return }
if context.toState != .restoringSession {
isSessionRestoredSubject.send(true)
}
switch (context.fromState, context.event, context.toState) {
case (.initial, .startWithAuthentication, .signedOut):
startAuthentication()
case (.signedOut, .createdUserSession, .signedIn):
setupUserSession(isNewLogin: true)
restoreUserSessions()
case (.initial, .startWithExistingSession, .restoringSession):
restoreUserSessions()
case (.restoringSession, .failedRestoringSession, .signedOut):
showLoginErrorToast()
presentSplashScreen()
case (.restoringSession, .createdUserSession, .signedIn):
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,
// such as the SDK sending an authError due to token invalidation.
break
case (_, .signOut(let isSoft, _), .signingOut):
logout(isSoft: isSoft)
case (.signingOut(_, let disableAppLock), .completedSigningOut, .signedOut):
presentSplashScreen(isSoftLogout: false, disableAppLock: disableAppLock)
case (.signingOut(_, let disableAppLock), .showSoftLogout, .softLogout):
presentSplashScreen(isSoftLogout: true, disableAppLock: disableAppLock)
case (.signedIn, .clearCache, .initial):
clearCache()
default:
fatalError("Unknown transition: \(context)")
}
}
stateMachine.addErrorHandler { context in
if context.fromState == context.toState {
MXLog.error("Failed transition from equal states: \(context.fromState)")
} else {
fatalError("Failed transition with context: \(context)")
}
}
}
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] userSession in
self?.showActiveSessionIfRestoring()
self?.sendStoredInlineReply(from: userSession)
}
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)
}
/// Sends a reply made from a notification before its account was live.
private func sendStoredInlineReply(from userSession: UserSessionProtocol) {
guard let storedInlineReply, storedInlineReply.userID == userSession.clientProxy.userID else { return }
self.storedInlineReply = nil
Task {
await processInlineReply(storedInlineReply.message, roomID: storedInlineReply.roomID, userSession: userSession)
}
}
private func startAuthentication() {
let encryptionKeyProvider = EncryptionKeyProvider()
let classicAppManager = ClassicAppManager()
let authenticationService = AuthenticationService(userSessionManager: userSessionManager,
encryptionKeyProvider: encryptionKeyProvider,
classicAppManager: classicAppManager,
appSettings: appSettings,
appHooks: appHooks)
Task { await authenticationService.setupClassicAppAccountState() }
let coordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
bugReportService: bugReportService,
navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator,
appSettings: appSettings,
appHooks: appHooks,
userIndicatorController: userIndicatorController)
coordinator.delegate = self
authenticationFlowCoordinator = coordinator
coordinator.start()
if storedAppRoute?.isAuthenticationRoute == true,
let storedAppRoute = storedAppRoute.take() {
coordinator.handleAppRoute(storedAppRoute, animated: false)
}
}
private func runPostSessionSetupTasks() async {
guard let userSession, let userSessionFlowCoordinator else {
fatalError("User session not setup")
}
if let storedAppRouteReceiverID, storedAppRouteReceiverID != userSession.clientProxy.userID {
// TODO: Switch to the receiver's account and open the room (element-hq/element-meta#3328).
MXLog.info("Dropping a notification tap for \(storedAppRouteReceiverID), it isn't the active account.")
storedAppRoute = nil
storedRoomsToAwait = nil
}
if let storedRoomsToAwait {
userSession.clientProxy.roomsToAwait = storedRoomsToAwait
}
if storedAppRoute?.isAuthenticationRoute == false,
let storedAppRoute = storedAppRoute.take() {
userSessionFlowCoordinator.handleAppRoute(storedAppRoute, animated: false)
}
}
private func startAuthenticationSoftLogout() {
guard let userSession else {
fatalError("User session not setup")
}
Task { [self] in
let credentials = SoftLogoutScreenCredentials(userID: userSession.clientProxy.userID,
homeserverName: userSession.clientProxy.homeserver,
userDisplayName: userSession.clientProxy.userProfilePublisher.value.displayName ?? "",
deviceID: userSession.clientProxy.deviceID)
let authenticationService = AuthenticationService(userSessionManager: userSessionManager,
encryptionKeyProvider: EncryptionKeyProvider(),
classicAppManager: ClassicAppManager(),
appSettings: appSettings,
appHooks: appHooks)
_ = await authenticationService.configure(for: userSession.clientProxy.homeserver, flow: .login)
let parameters = SoftLogoutScreenCoordinatorParameters(authenticationService: authenticationService,
credentials: credentials,
keyBackupNeeded: false,
appMediator: appMediator,
appSettings: appSettings,
appHooks: appHooks,
userIndicatorController: userIndicatorController)
let coordinator = SoftLogoutScreenCoordinator(parameters: parameters)
softLogoutCoordinator = coordinator
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .signedIn(let session):
self.migratedUserIDs.insert(session.clientProxy.userID)
self.userSessionManager.add(session)
self.configureActiveSession()
self.softLogoutCoordinator = nil
stateMachine.processEvent(.createdUserSession)
case .clearAllData:
self.softLogoutCoordinator = nil
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
}
}
.store(in: &cancellables)
navigationRootCoordinator.setRootCoordinator(coordinator)
}
}
private func setupUserSession(isNewLogin: Bool) {
guard let userSession else {
fatalError("User session not setup")
}
if let serverName = userSession.clientProxy.userIDServerName {
analyticsService.signpost.addGlobalTag(.homeserver, value: serverName)
}
if !isNewLogin {
analyticsService.signpost.startTransaction(.cachedRoomList)
}
let flowParameters = CommonFlowParameters(userSession: userSession,
availableSessionsPublisher: userSessionManager.sessionsPublisher.map { $0.map(UserSessionDetails.init) },
bugReportService: bugReportService,
elementCallService: elementCallService,
timelineControllerFactory: TimelineControllerFactory(userSettings: userSession.userSettings),
emojiProvider: EmojiProvider(userSettings: userSession.userSettings),
linkMetadataProvider: LinkMetadataProvider(),
appMediator: appMediator,
appHooks: appHooks,
analytics: analyticsService,
userIndicatorController: userIndicatorController,
stateMachineFactory: StateMachineFactory())
let userSessionFlowCoordinator = UserSessionFlowCoordinator(isNewLogin: isNewLogin,
navigationRootCoordinator: navigationRootCoordinator,
appLockService: appLockFlowCoordinator.appLockService,
flowParameters: flowParameters)
userSessionFlowCoordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .logout:
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
case .clearCache:
stateMachine.processEvent(.clearCache)
case .forceLogout:
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true))
}
}
.store(in: &cancellables)
userSessionFlowCoordinator.start()
self.userSessionFlowCoordinator = userSessionFlowCoordinator
Task {
await runPostSessionSetupTasks()
}
}
private func logout(isSoft: Bool) {
guard let userSession else {
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()
Task { await pauseClientServices(isBackgroundTask: false) }
userSessionFlowCoordinator?.stop()
// Leaves any ongoing call and releases the native call stack, which a soft logout would
// otherwise leave running against a session that can no longer reach the server.
elementCallService.setUserSession(nil)
guard !isSoft else {
stateMachine.processEvent(.showSoftLogout)
hideLoadingIndicator()
return
}
Task {
// Delete the pusher while the session can still reach the server.
await userSession.notificationService.tearDown()
// First log out from the server
await userSession.clientProxy.logout()
// Regardless of the result, clear user data
userSessionManager.remove(userID: userSession.clientProxy.userID)
tearDownUserSession()
// Signed in accounts that aren't live yet still count.
if userSessionManager.userIDs.isEmpty {
notificationManager.reset()
}
userSession.userSettings.account.reset()
appHooks.remoteSettingsHook.reset(appSettings)
// Reset analytics
analyticsService.optOut()
analyticsService.resetConsentState()
stateMachine.processEvent(.completedSigningOut)
hideLoadingIndicator()
}
}
private func tearDownUserSession() {
userIndicatorController.retractAllIndicators()
userSessionFlowCoordinator = nil
}
private func presentSplashScreen(isSoftLogout: Bool = false, disableAppLock: Bool = false) {
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
if isSoftLogout {
startAuthenticationSoftLogout()
} else {
startAuthentication()
}
if disableAppLock {
Task {
// Ensure the navigation stack has settled.
try? await Task.sleep(for: .milliseconds(500))
appLockFlowCoordinator.appLockService.disable()
windowManager.switchToMain()
}
}
}
/// Points the app-wide services at the active account's session.
private func configureActiveSession() {
guard let userSession else {
fatalError("User session not setup")
}
// TODO: Calls only follow the active account, ElementCallService must change to hold every account's session.
elementCallService.setUserSession(userSession)
Task {
await resumeClientServices()
}
}
private func configureNotificationManager() {
notificationManager.delegate = self
notificationManager.start()
appDelegateObserver = appDelegate.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
switch callback {
case .registeredNotifications(let deviceToken):
Task { await self?.notificationManager.register(with: deviceToken) }
case .failedToRegisteredNotifications(let error):
self?.notificationManager.registrationFailed(with: error)
}
}
}
private func observeUserSessionChanges() {
userSessionManager.callbacksPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
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() {
appLockFlowCoordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .lockApp:
windowManager.switchToAlternate()
case .unlockApp:
windowManager.switchToMain()
case .forceLogout:
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true))
}
}
.store(in: &cancellables)
}
private func clearCache() {
guard let userSession else {
fatalError("User session not setup")
}
restorationTask?.cancel()
showLoadingIndicator()
navigationRootCoordinator.setRootCoordinator(PlaceholderScreenCoordinator(hideBrandChrome: appSettings.hideBrandChrome))
Task { await pauseClientServices(isBackgroundTask: false) }
userSessionFlowCoordinator?.stop()
// Allow for everything to deallocate properly
Task {
await userSessionManager.clearCaches(userID: userSession.clientProxy.userID)
stateMachine.processEvent(.startWithExistingSession)
hideLoadingIndicator()
}
}
private static func setupSentry(bugReportService: BugReportServiceProtocol, appSettings: AppSettings, analytics: AnalyticsServiceProtocol) {
guard let bugReportSentryURL = appSettings.bugReportSentryURL else { return }
let options: Options = .init()
#if DEBUG
options.enabled = false
#else
options.enabled = appSettings.analyticsConsentState == .optedIn
#endif
options.dsn = bugReportSentryURL.absoluteString
// Matches android, at least for now.
switch AppSettings.appBuildType {
case .debug:
options.environment = "DEBUG"
case .nightly:
options.environment = "NIGHTLY"
case .release:
options.environment = "RELEASE"
}
// Sentry swizzling shows up quite often as the heaviest stack trace when profiling
// We don't need any of the features it powers (see docs)
options.enableSwizzling = false
// WatchdogTermination is currently the top issue but we've had zero complaints
// so it might very well just all be false positives
options.enableWatchdogTerminationTracking = false
// MetricKit is now the preferred way to track app hangs
options.enableMetricKit = true
options.enableAppHangTracking = false
// Most of the network requests are made Rust side, this is useless
options.enableNetworkBreadcrumbs = false
// Doesn't seem to work at all well with SwiftUI
options.enableAutoBreadcrumbTracking = false
// Release health, we want the crash free rates it powers
options.enableAutoSessionTracking = true
// Off by default but spelled out so a Sentry default flip doesn't silently enable them
options.sessionReplay.sessionSampleRate = 0
options.sessionReplay.onErrorSampleRate = 0
options.attachScreenshot = false
options.attachViewHierarchy = false
options.sendDefaultPii = false
options.enableMetricKit = false
// Experimental. Stitches stack traces of asynchronous code together
options.swiftAsyncStacktraces = true
// Uniform sample rate: 1.0 captures 100% of transactions
// In Production you will probably want a smaller number such as 0.5 for 50%
options.sampleRate = 1.0
options.tracesSampleRate = 1.0
options.configureProfiling = { $0.sessionSampleRate = 1.0 }
// This callback is only executed once during the entire run of the program to avoid
// multiple callbacks if there are multiple crash events to send (see method documentation)
options.onLastRunStatusDetermined = { status, event in
guard case .didCrash = status, let event else { return }
MXLog.error("Sentry detected a crash in the previous run: \(event.eventId.sentryIdString)")
bugReportService.lastCrashEventIDSubject.send(event.eventId.sentryIdString)
}
// Mirror every crash into our own logs (which ship with rageshakes) before it's sent to Sentry.
// Device values are read here on the main thread; beforeSend runs on a background queue.
let deviceModel = UIDevice.current.model
let systemVersion = UIDevice.current.systemVersion
options.beforeSend = { @Sendable event in
if let crashLog = event.crashLog(deviceModel: deviceModel, systemVersion: systemVersion) {
MXLog.error("Sentry crash event \(event.eventId.sentryIdString):\n\(crashLog)")
}
return event
}
// Any ongoing transactions will no longer be valid after calling SentrySDK.start so lets
// remove them and start over, otherwise the app will crash if finishTransaction is used.
analytics.signpost.resetTransactions()
SentrySDK.start(options: options) // Swift
enableSentryLogging(enabled: options.enabled) // Rust
MXLog.info("Sentry configured (enabled: \(options.enabled))")
}
private func processInlineReply(_ replyText: String, roomID: String, userSession: UserSessionProtocol) async {
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Tried to reply in an unjoined room: \(roomID)")
return
}
switch await roomProxy.timeline.sendMessage(replyText,
html: nil,
inReplyToEventID: nil,
intentionalMentions: .empty) {
case .success:
break
default:
await notificationManager.showLocalNotification(with: "⚠️ " + L10n.commonError,
subtitle: L10n.errorSomeMessagesHaveNotBeenSent)
}
}
// MARK: Toasts and loading indicators
private static let loadingIndicatorIdentifier = "\(AppCoordinator.self)-Loading"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
private func showLoginErrorToast() {
userIndicatorController.submitIndicator(UserIndicator(title: "Failed logging in"))
}
// MARK: - Application State
private func pauseClientServices(isBackgroundTask: Bool) async {
if isBackgroundTask, UIApplication.shared.applicationState == .active {
// Attempt to pause the background task services cleanly, only if the app not already running
return
}
await userSessionManager.pauseServices()
clientProxyObserver = nil
}
private func resumeClientServices() async {
guard let userSession else { return }
analyticsService.signpost.startTransaction(.upToDateRoomList)
await userSessionManager.resumeServices()
guard clientProxyObserver == nil else {
return
}
clientProxyObserver = userSession.clientProxy
.loadingStatePublisher
.dropFirst()
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
let toastIdentifier = "StaleDataIndicator"
guard let self else { return }
switch state {
case .loading:
if self.userSession?.clientProxy.homeserverReachabilityPublisher.value == .reachable,
self.appMediator.networkMonitor.reachabilityPublisher.value == .reachable {
self.userIndicatorController.submitIndicator(.init(id: toastIdentifier, type: .toast(progress: .indeterminate), title: L10n.commonSyncing, persistent: true))
}
case .notLoading:
self.analyticsService.signpost.finishTransaction(.upToDateRoomList)
self.userIndicatorController.retractIndicatorWithId(toastIdentifier)
}
}
}
private func observeApplicationState() {
NotificationCenter.default.addObserver(self,
selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(applicationDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(applicationWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(applicationWillTerminate),
name: UIApplication.willTerminateNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(didChangeContentSizeCategory),
name: UIContentSizeCategory.didChangeNotification,
object: nil)
}
@objc
private func didChangeContentSizeCategory() {
AttributedStringBuilder.invalidateCaches()
}
@objc
private func applicationWillTerminate() {
MXLog.info("Application will terminate")
Task { await pauseClientServices(isBackgroundTask: false) }
}
@objc
private func applicationDidEnterBackground() {
MXLog.info("Application did enter background")
scheduleDelayedPauseServices()
scheduleBackgroundAppRefresh()
scheduleSearchBackfill()
}
@objc
private func applicationWillResignActive() {
MXLog.info("Application will resign active")
}
private func scheduleDelayedPauseServices() {
guard backgroundTask == nil else {
return
}
backgroundTask = appMediator.beginBackgroundTask {
MXLog.info("Background task is about to expire.")
// We're intentionally strongly retaining self here to an EXC_BAD_ACCESS
// `backgroundTask` will be eventually released in `endActiveBackgroundTask`
// https://sentry.tools.element.io/organizations/element/issues/4477794/events/9cfd04e4d045440f87498809cf718de5/
Task { @MainActor in
await self.pauseClientServices(isBackgroundTask: true)
self.endActiveBackgroundTask()
}
}
}
@objc
private func applicationWillEnterForeground() {
MXLog.info("Application will enter foreground")
endActiveBackgroundTask()
Task {
await resumeClientServices()
}
}
@objc
private func applicationDidBecomeActive() {
MXLog.info("Application did become active")
}
private func endActiveBackgroundTask() {
guard let backgroundTask else {
return
}
MXLog.info("Ending background task.")
appMediator.endBackgroundTask(backgroundTask)
self.backgroundTask = nil
}
}
// MARK: Background app refresh
private extension AppCoordinator {
func registerBackgroundAppRefresh() {
let result = BGTaskScheduler.shared.register(forTaskWithIdentifier: appSettings.backgroundAppRefreshTaskIdentifier, using: .main) { [weak self] task in
guard let task = task as? BGAppRefreshTask else {
MXLog.error("Invalid background app refresh configuration")
return
}
Task {
await self?.handleBackgroundAppRefresh(task)
}
}
MXLog.info("Register background app refresh with result: \(result)")
}
func scheduleBackgroundAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: appSettings.backgroundAppRefreshTaskIdentifier)
// We have other background tasks that keep the app alive
request.earliestBeginDate = Date(timeIntervalSinceNow: 30)
do {
try BGTaskScheduler.shared.submit(request)
MXLog.info("Successfully scheduled background app refresh task")
} catch {
MXLog.error("Failed scheduling background app refresh with error :\(error)")
}
}
func handleBackgroundAppRefresh(_ task: BGAppRefreshTask) async {
MXLog.info("Started background app refresh")
// This is important for the app to keep refreshing in the background
scheduleBackgroundAppRefresh()
// We have a lot of crashes stemming here which we previously believed were caused by pauseServices not being
// async on the client proxy side. We have now realised that will likely not fix anything but we also noticed
// this does not crash on the main thread, even though the whole AppCoordinator is on the Main actor. As such,
// we hop onto the main actor explicitly before pausing.
// https://sentry.tools.element.io/organizations/element/issues/4477794/
// BGTask isn't annotated but is documented as thread-safe, with
// setTaskCompleted(success:) being callable from any thread.
nonisolated(unsafe) let task = task
task.expirationHandler = { @Sendable [weak self] in
MXLog.info("Background app refresh task is about to expire.")
Task { @MainActor in
await self?.pauseClientServices(isBackgroundTask: true)
MXLog.info("Marking Background app refresh task as complete.")
task.setTaskCompleted(success: true)
}
}
await waitForSessionRestore()
guard let userSession else {
MXLog.info("Background app refresh: no user session, giving up.")
task.setTaskCompleted(success: false)
return
}
// 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 userSessionManager.configurePresence(.offline, sendImmediately: false)
await resumeClientServices()
// Be a good citizen, run for a max of 10 SS responses or 10 seconds
// An SS request will time out after 30 seconds if no new data is available
backgroundRefreshSyncObserver = userSession.clientProxy
.actionsPublisher
.filter(\.isSyncUpdate)
.collect(.byTimeOrCount(DispatchQueue.main, .seconds(10), 10))
.sink { [weak self] _ in
guard let self else { return }
MXLog.info("Background app refresh finished")
backgroundRefreshSyncObserver?.cancel()
// Make sure we stop the sync loop, otherwise the ongoing request is immediately
// handled the next time the app refreshes, which can trigger timeout failures.
Task {
await self.pauseClientServices(isBackgroundTask: true)
MXLog.info("Marking Background app refresh task as complete.")
task.setTaskCompleted(success: true)
}
}
}
/// When iOS relaunches a terminated app for a background task, the session needs restoring before the task can run.
private func waitForSessionRestore() async {
// A background launch doesn't necessarily connect a scene, which is what normally calls `start()`.
if stateMachine.state == .initial, !userSessionManager.userIDs.isEmpty {
start()
}
for await isSessionRestored in isSessionRestoredSubject.timeout(.seconds(5), scheduler: DispatchQueue.main).values where isSessionRestored {
return
}
}
}
// MARK: Search backfill
private extension AppCoordinator {
func registerSearchBackfill() {
let result = BGTaskScheduler.shared.register(forTaskWithIdentifier: appSettings.searchBackfillTaskIdentifier, using: .main) { [weak self] task in
guard let task = task as? BGProcessingTask else {
MXLog.error("Invalid search backfill configuration")
return
}
Task {
await self?.handleSearchBackfill(task)
}
}
MXLog.info("Register search backfill with result: \(result)")
}
func scheduleSearchBackfill() {
guard let userSession, userSession.userSettings.app.globalSearchEnabled else {
return
}
let request = BGProcessingTaskRequest(identifier: appSettings.searchBackfillTaskIdentifier)
request.requiresExternalPower = true
request.requiresNetworkConnectivity = true
do {
try BGTaskScheduler.shared.submit(request)
MXLog.info("Successfully scheduled search backfill task")
} catch {
MXLog.error("Failed scheduling search backfill with error: \(error)")
}
}
func handleSearchBackfill(_ task: BGProcessingTask) async {
MXLog.info("Started search backfill task")
await waitForSessionRestore()
task.expirationHandler = { @Sendable [weak self] in
MXLog.info("Search backfill task is about to expire.")
Task { @MainActor in self?.userSessionManager.stopSearchBackfill() }
}
userSessionManager.startSearchBackfill(strategy: .background)
// 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))
}
MXLog.info("Marking search backfill task as complete.")
task.setTaskCompleted(success: true)
}
}
private struct PendingInlineReplyDetails {
let userID: String
let roomID: String
let message: String
}