Beam/ElementX/Sources/Application/AppCoordinator.swift
Stefan Ceriu 4d34a1ed56 Bump the RustSDK to v25.03.05
- adopt the new timeline configuration which now exposes read receipts tracking. For now it should be set to `false` for all timelines except the live one, which still gets configured properly on the rust side through `init_timeline`. Eventually we will converge on this configuration API for all of them.
- also adopt the new rust side cache clearing method and remove our own
2025-03-05 14:24:44 +02:00

1061 lines
45 KiB
Swift

//
// Copyright 2022-2024 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
class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDelegate, NotificationManagerDelegate, SecureWindowManagerDelegate {
private let stateMachine: AppCoordinatorStateMachine
private let navigationRootCoordinator: NavigationRootCoordinator
private let userSessionStore: UserSessionStoreProtocol
private let appMediator: AppMediator
private let appSettings: AppSettings
private let appDelegate: AppDelegate
private let appHooks: AppHooks
private let elementCallService: ElementCallServiceProtocol
/// Common background task to continue long-running tasks in the background.
private var backgroundTask: UIBackgroundTaskIdentifier?
private var userSession: UserSessionProtocol? {
didSet {
userSessionObserver?.cancel()
if userSession != nil {
configureElementCallService()
configureNotificationManager()
observeUserSessionChanges()
startSync()
}
}
}
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?
private var userSessionObserver: AnyCancellable?
private var clientProxyObserver: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
let windowManager: SecureWindowManagerProtocol
let notificationManager: NotificationManagerProtocol
private let appRouteURLParser: AppRouteURLParser
@Consumable private var storedAppRoute: AppRoute?
@Consumable private var storedInlineReply: (roomID: String, message: String)?
@Consumable private var storedRoomsToAwait: Set<String>?
init(appDelegate: AppDelegate) {
let appHooks = AppHooks()
appHooks.configure()
windowManager = WindowManager(appDelegate: appDelegate)
let networkMonitor = NetworkMonitor()
appMediator = AppMediator(windowManager: windowManager, networkMonitor: networkMonitor)
let appSettings = appHooks.appSettingsHook.configure(AppSettings())
MXLog.configure(currentTarget: "elementx", filePrefix: nil, logLevel: appSettings.logLevel)
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.appSettings = appSettings
self.appHooks = appHooks
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
elementCallService = ElementCallService()
navigationRootCoordinator = NavigationRootCoordinator()
stateMachine = AppCoordinatorStateMachine()
navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
let keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
userSessionStore = UserSessionStore(keychainController: keychainController, appSettings: appSettings, appHooks: appHooks, networkMonitor: networkMonitor)
let appLockService = AppLockService(keychainController: keychainController, appSettings: appSettings)
let appLockNavigationCoordinator = NavigationRootCoordinator()
appLockFlowCoordinator = AppLockFlowCoordinator(appLockService: appLockService,
navigationCoordinator: appLockNavigationCoordinator)
notificationManager = NotificationManager(notificationCenter: UNUserNotificationCenter.current(),
appSettings: appSettings)
Self.setupServiceLocator(appSettings: appSettings, appHooks: appHooks)
Self.setupSentry(appSettings: appSettings)
ServiceLocator.shared.analytics.signpost.start()
ServiceLocator.shared.analytics.startIfEnabled()
windowManager.delegate = self
notificationManager.delegate = self
notificationManager.start()
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()
observeNetworkState()
observeAppLockChanges()
registerBackgroundAppRefresh()
appSettings.$analyticsConsentState
.dropFirst() // Called above before configuring the ServiceLocator
.sink { _ in
Self.setupSentry(appSettings: appSettings)
}
.store(in: &cancellables)
elementCallService.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
switch action {
case .startCall(let roomID):
self?.handleAppRoute(.call(roomID: roomID))
case .receivedIncomingCallRequest:
// When reporting a VoIP call through the CXProvider's `reportNewIncomingVoIPPushPayload`
// the UIApplication states don't change and syncing is neither started nor ran on
// a background task. Handle both manually here.
self?.startSync()
self?.scheduleDelayedSyncStop()
default:
break
}
}
.store(in: &cancellables)
}
func start() {
guard stateMachine.state == .initial else {
MXLog.error("Received a start request when already started")
return
}
guard userSessionStore.hasSessions else {
stateMachine.processEvent(.startWithAuthentication)
return
}
stateMachine.processEvent(.startWithExistingSession)
}
func stop() {
hideLoadingIndicator()
}
func toPresentable() -> AnyView {
AnyView(
navigationRootCoordinator.toPresentable()
.environment(\.analyticsService, ServiceLocator.shared.analytics)
.onReceive(appSettings.$appAppearance) { [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 handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool {
// Parse into an AppRoute to redirect these in a type safe way.
if let route = appRouteURLParser.route(from: url) {
switch route {
case .genericCallLink(let url):
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
} else {
presentCallScreen(genericCallLink: url)
}
case .userProfile(let userID):
if isExternalURL {
handleAppRoute(route)
} else {
handleAppRoute(.roomMemberDetails(userID: userID))
}
case .room(let roomID, let via):
if isExternalURL {
handleAppRoute(route)
} else {
handleAppRoute(.childRoom(roomID: roomID, via: via))
}
case .roomAlias(let alias):
if isExternalURL {
handleAppRoute(route)
} else {
handleAppRoute(.childRoomAlias(alias))
}
case .event(let eventID, let roomID, let via):
if isExternalURL {
handleAppRoute(route)
} else {
handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via))
}
case .eventOnRoomAlias(let eventID, let alias):
if isExternalURL {
handleAppRoute(route)
} else {
handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias))
}
case .share:
guard isExternalURL else {
MXLog.error("Received unexpected internal share route")
break
}
handleAppRoute(route)
default:
break
}
return true
}
return false
}
func handleUserActivity(_ userActivity: NSUserActivity) {
// `INStartVideoCallIntent` is to be replaced with `INStartCallIntent`
// but calls from Recents still send it ¯\_()_/¯
guard let intent = userActivity.interaction?.intent as? INStartVideoCallIntent,
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))
}
// MARK: - AuthenticationFlowCoordinatorDelegate
func authenticationFlowCoordinator(didLoginWithSession userSession: UserSessionProtocol) {
self.userSession = userSession
authenticationFlowCoordinator = nil
stateMachine.processEvent(.createdUserSession)
}
// MARK: - WindowManagerDelegate
func windowManagerDidConfigureWindows(_ windowManager: SecureWindowManagerProtocol) {
windowManager.alternateWindow.rootViewController = UIHostingController(rootView: appLockFlowCoordinator.toPresentable())
ServiceLocator.shared.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
}
return !userSessionFlowCoordinator.isDisplayingRoomScreen(withRoomID: roomID)
}
func notificationTapped(content: UNNotificationContent) async {
MXLog.info("[AppCoordinator] tappedNotification")
guard let roomID = content.roomID,
content.receiverID != nil else {
return
}
if content.categoryIdentifier == NotificationConstants.Category.invite {
if let userSession {
userSession.clientProxy.roomsToAwait.insert(roomID)
} else {
storedRoomsToAwait = [roomID]
}
}
handleAppRoute(.room(roomID: roomID, via: []))
}
func handleInlineReply(_ service: NotificationManagerProtocol, content: UNNotificationContent, replyText: String) async {
MXLog.info("[AppCoordinator] handle notification reply")
guard let roomID = content.userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String else {
return
}
if userSession == nil {
// Store the data so it can be used after the session is established
storedInlineReply = (roomID, replyText)
return
}
await processInlineReply(roomID: roomID, replyText: replyText)
}
// MARK: - Private
private static func setupServiceLocator(appSettings: AppSettings, appHooks: AppHooks) {
ServiceLocator.shared.register(userIndicatorController: UserIndicatorController())
ServiceLocator.shared.register(appSettings: appSettings)
ServiceLocator.shared.register(bugReportService: BugReportService(withBaseURL: appSettings.bugReportServiceBaseURL,
applicationId: appSettings.bugReportApplicationId,
sdkGitSHA: sdkGitSha(),
maxUploadSize: appSettings.bugReportMaxUploadSize,
appHooks: appHooks))
let posthogAnalyticsClient = PostHogAnalyticsClient()
posthogAnalyticsClient.updateSuperProperties(AnalyticsEvent.SuperProperties(appPlatform: .EXI, cryptoSDK: .Rust, cryptoSDKVersion: sdkGitSha()))
ServiceLocator.shared.register(analytics: AnalyticsService(client: posthogAnalyticsClient,
appSettings: appSettings))
}
/// Perform any required migrations for the app to function correctly.
private func performMigrationsIfNecessary(from oldVersion: Version, to newVersion: Version) {
guard oldVersion != newVersion else { return }
MXLog.info("The app was upgraded from \(oldVersion) to \(newVersion)")
if oldVersion < Version(1, 6, 0) {
MXLog.info("Migrating to v1.6.0, marking identity confirmation onboarding as ran.")
if !userSessionStore.userIDs.isEmpty {
appSettings.hasRunIdentityConfirmationOnboarding = true
appSettings.hasRunNotificationPermissionsOnboarding = true
}
}
if oldVersion < Version(1, 6, 7) {
Tracing.deleteLogFiles()
MXLog.info("Migrating to v1.6.7, log files have been wiped")
}
}
/// 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()
}
userSessionStore.reset()
}
private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in
guard let self else { return }
switch (context.fromState, context.event, context.toState) {
case (.initial, .startWithAuthentication, .signedOut):
startAuthentication()
case (.signedOut, .createdUserSession, .signedIn):
setupUserSession(isNewLogin: true)
case (.initial, .startWithExistingSession, .restoringSession):
restoreUserSession()
case (.restoringSession, .failedRestoringSession, .signedOut):
showLoginErrorToast()
presentSplashScreen()
case (.restoringSession, .createdUserSession, .signedIn):
setupUserSession(isNewLogin: false)
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 restoreUserSession() {
Task {
switch await userSessionStore.restoreUserSession() {
case .success(let userSession):
self.userSession = userSession
stateMachine.processEvent(.createdUserSession)
case .failure:
MXLog.error("Failed to restore an existing session.")
stateMachine.processEvent(.failedRestoringSession)
}
}
}
private func startAuthentication() {
let encryptionKeyProvider = EncryptionKeyProvider()
let authenticationService = AuthenticationService(userSessionStore: userSessionStore,
encryptionKeyProvider: encryptionKeyProvider,
appSettings: appSettings,
appHooks: appHooks)
let qrCodeLoginService = QRCodeLoginService(encryptionKeyProvider: encryptionKeyProvider,
userSessionStore: userSessionStore,
appSettings: appSettings,
appHooks: appHooks)
authenticationFlowCoordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
qrCodeLoginService: qrCodeLoginService,
bugReportService: ServiceLocator.shared.bugReportService,
navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
authenticationFlowCoordinator?.delegate = self
authenticationFlowCoordinator?.start()
}
private func runPostSessionSetupTasks() async {
guard let userSession, let userSessionFlowCoordinator else {
fatalError("User session not setup")
}
if let storedRoomsToAwait {
userSession.clientProxy.roomsToAwait = storedRoomsToAwait
}
if let storedAppRoute {
userSessionFlowCoordinator.handleAppRoute(storedAppRoute, animated: false)
}
if let storedInlineReply {
await processInlineReply(roomID: storedInlineReply.roomID, replyText: storedInlineReply.message)
}
}
private func startAuthenticationSoftLogout() {
guard let userSession else {
fatalError("User session not setup")
}
Task {
let credentials = SoftLogoutScreenCredentials(userID: userSession.clientProxy.userID,
homeserverName: userSession.clientProxy.homeserver,
userDisplayName: userSession.clientProxy.userDisplayNamePublisher.value ?? "",
deviceID: userSession.clientProxy.deviceID)
let authenticationService = AuthenticationService(userSessionStore: userSessionStore,
encryptionKeyProvider: EncryptionKeyProvider(),
appSettings: appSettings,
appHooks: appHooks)
_ = await authenticationService.configure(for: userSession.clientProxy.homeserver, flow: .login)
let parameters = SoftLogoutScreenCoordinatorParameters(authenticationService: authenticationService,
credentials: credentials,
keyBackupNeeded: false,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
let coordinator = SoftLogoutScreenCoordinator(parameters: parameters)
self.softLogoutCoordinator = coordinator
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .signedIn(let session):
self.userSession = session
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")
}
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
navigationRootCoordinator: navigationRootCoordinator,
appLockService: appLockFlowCoordinator.appLockService,
bugReportService: ServiceLocator.shared.bugReportService,
elementCallService: elementCallService,
timelineControllerFactory: TimelineControllerFactory(),
appMediator: appMediator,
appSettings: appSettings,
appHooks: appHooks,
analytics: ServiceLocator.shared.analytics,
notificationManager: notificationManager,
isNewLogin: isNewLogin)
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")
}
showLoadingIndicator()
stopSync(isBackgroundTask: false)
userSessionFlowCoordinator?.stop()
guard !isSoft else {
stateMachine.processEvent(.showSoftLogout)
hideLoadingIndicator()
return
}
// The user will log out, clear any existing notifications and unregister from receving new ones
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
UNUserNotificationCenter.current().setBadgeCount(0)
unregisterForRemoteNotifications()
Task {
// First log out from the server
await userSession.clientProxy.logout()
// Regardless of the result, clear user data
userSessionStore.logout(userSession: userSession)
tearDownUserSession()
AppSettings.resetSessionSpecificSettings()
// Reset analytics
ServiceLocator.shared.analytics.optOut()
ServiceLocator.shared.analytics.resetConsentState()
stateMachine.processEvent(.completedSigningOut)
hideLoadingIndicator()
}
}
private func tearDownUserSession() {
ServiceLocator.shared.userIndicatorController.retractAllIndicators()
userSession = nil
userSessionFlowCoordinator = nil
notificationManager.setUserSession(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()
}
}
}
private func configureElementCallService() {
guard let userSession else {
fatalError("User session not setup")
}
elementCallService.setClientProxy(userSession.clientProxy)
}
private func presentCallScreen(genericCallLink url: URL) {
let configuration = ElementCallConfiguration(genericCallLink: url)
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService,
configuration: configuration,
allowPictureInPicture: false,
appHooks: appHooks))
callScreenCoordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .pictureInPictureIsAvailable:
break
case .pictureInPictureStarted, .pictureInPictureStopped:
// Don't allow PiP when signed out - the user could login at which point we'd
// need to hand over the call from here to the user session flow coordinator.
MXLog.error("Picture in Picture not supported before login.")
case .dismiss:
navigationRootCoordinator.setOverlayCoordinator(nil)
}
}
.store(in: &cancellables)
navigationRootCoordinator.setOverlayCoordinator(callScreenCoordinator, animated: false)
}
private func configureNotificationManager() {
notificationManager.setUserSession(userSession)
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() {
guard let userSession else {
fatalError("User session not setup")
}
userSessionObserver = userSession.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .didReceiveAuthError(let isSoftLogout):
stateMachine.processEvent(.signOut(isSoft: isSoftLogout, disableAppLock: false))
}
}
}
private func observeNetworkState() {
let reachabilityNotificationIdentifier = "io.element.elementx.reachability.notification"
appMediator.networkMonitor
.reachabilityPublisher
.sink { reachability in
MXLog.info("Reachability changed to \(reachability)")
if reachability == .reachable {
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(reachabilityNotificationIdentifier)
} else {
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: reachabilityNotificationIdentifier,
title: L10n.commonOffline,
persistent: true))
}
}
.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 handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
} else {
storedAppRoute = appRoute
}
}
private func clearCache() {
guard let userSession else {
fatalError("User session not setup")
}
showLoadingIndicator()
navigationRootCoordinator.setRootCoordinator(PlaceholderScreenCoordinator())
stopSync(isBackgroundTask: false)
userSessionFlowCoordinator?.stop()
tearDownUserSession()
// Allow for everything to deallocate properly
Task {
try? await Task.sleep(for: .seconds(2))
await userSession.clientProxy.clearCaches()
stateMachine.processEvent(.startWithExistingSession)
hideLoadingIndicator()
}
}
private static func setupSentry(appSettings: AppSettings) {
let options: Options = .init()
#if DEBUG
options.enabled = false
#else
options.enabled = appSettings.analyticsConsentState == .optedIn
#endif
options.dsn = appSettings.bugReportSentryURL.absoluteString
if AppSettings.isDevelopmentBuild {
options.environment = "development"
}
// 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
// Disabled as it seems to report a lot of false positives
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
// 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%
if AppSettings.isDevelopmentBuild {
options.sampleRate = 1.0
options.tracesSampleRate = 1.0
options.profilesSampleRate = 1.0
} else {
options.sampleRate = 0.5
options.tracesSampleRate = 0.5
options.profilesSampleRate = 0.5
}
// 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.onCrashedLastRun = { event in
MXLog.error("Sentry detected a crash in the previous run: \(event.eventId.sentryIdString)")
ServiceLocator.shared.bugReportService.lastCrashEventID = event.eventId.sentryIdString
}
SentrySDK.start(options: options)
MXLog.info("SentrySDK started")
}
private func teardownSentry() {
SentrySDK.close()
MXLog.info("SentrySDK stopped")
}
private func processInlineReply(roomID: String, replyText: String) async {
guard let userSession else {
fatalError("User session not setup")
}
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() {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func hideLoadingIndicator() {
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
private func showLoginErrorToast() {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: "Failed logging in"))
}
// MARK: - Application State
private func stopSync(isBackgroundTask: Bool, completion: (() -> Void)? = nil) {
if isBackgroundTask, UIApplication.shared.applicationState == .active {
// Attempt to stop the background task sync loop cleanly, only if the app not already running
return
}
userSession?.clientProxy.stopSync(completion: completion)
clientProxyObserver = nil
}
private func startSync() {
guard let userSession else { return }
let serverName = String(userSession.clientProxy.userIDServerName ?? "Unknown")
ServiceLocator.shared.analytics.signpost.beginFirstSync(serverName: serverName)
userSession.clientProxy.startSync()
guard clientProxyObserver == nil else {
return
}
clientProxyObserver = userSession.clientProxy
.loadingStatePublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
let toastIdentifier = "StaleDataIndicator"
switch state {
case .loading:
if self?.appMediator.networkMonitor.reachabilityPublisher.value == .reachable {
ServiceLocator.shared.userIndicatorController.submitIndicator(.init(id: toastIdentifier, type: .toast(progress: .indeterminate), title: L10n.commonSyncing, persistent: true))
}
case .notLoading:
ServiceLocator.shared.analytics.signpost.endFirstSync()
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(toastIdentifier)
}
}
}
private func observeApplicationState() {
NotificationCenter.default.addObserver(self,
selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification,
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() {
stopSync(isBackgroundTask: false)
}
@objc
private func applicationWillResignActive() {
MXLog.info("Application will resign active")
scheduleDelayedSyncStop()
scheduleBackgroundAppRefresh()
}
private func scheduleDelayedSyncStop() {
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/
self.stopSync(isBackgroundTask: true) {
self.endActiveBackgroundTask()
}
}
}
@objc
private func applicationDidBecomeActive() {
MXLog.info("Application did become active")
endActiveBackgroundTask()
startSync()
}
private func endActiveBackgroundTask() {
guard let backgroundTask else {
return
}
MXLog.info("Ending background task.")
appMediator.endBackgroundTask(backgroundTask)
self.backgroundTask = nil
}
// MARK: Background app refresh
private 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
}
self?.handleBackgroundAppRefresh(task)
}
MXLog.info("Register background app refresh with result: \(result)")
}
private 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)")
}
}
private var backgroundRefreshSyncObserver: AnyCancellable?
private func handleBackgroundAppRefresh(_ task: BGAppRefreshTask) {
MXLog.info("Started background app refresh")
// This is important for the app to keep refreshing in the background
scheduleBackgroundAppRefresh()
task.expirationHandler = { [weak self] in
MXLog.info("Background app refresh task is about to expire.")
self?.stopSync(isBackgroundTask: true) {
MXLog.info("Marking Background app refresh task as complete.")
task.setTaskCompleted(success: true)
}
}
guard let userSession else {
return
}
startSync()
// 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.
stopSync(isBackgroundTask: true) {
MXLog.info("Marking Background app refresh task as complete.")
task.setTaskCompleted(success: true)
}
}
}
}