// // 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 = [] /// Restores the signed in accounts. Cancelled when signing out or clearing the cache. private var restorationTask: Task? 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(false) private var clientProxyObserver: AnyCancellable? private var cancellables = Set() 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? 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 }