mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
268d6c8ead
* 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.
1522 lines
68 KiB
Swift
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
|
|
}
|