mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00

- 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
1061 lines
45 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|