/* Copyright 2020-2024 New Vector Ltd. Copyright (c) 2021 BWI GmbH SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ import Combine import Foundation import Intents import MatrixSDK import CommonKit import UIKit /// The AppCoordinator is responsible of screen navigation and data injection at root application level. It decides /// if authentication or home screen should be shown and inject data needed for these flows, it changes the navigation /// stack on deep link, displays global warning. /// This class should avoid to contain too many data management code not related to screen navigation logic. For example /// `MXSession` or push notification management should be handled in dedicated classes and report only navigation /// changes to the AppCoordinator. final class AppCoordinator: NSObject, AppCoordinatorType { // MARK: - Constants // MARK: - Properties private let customSchemeURLParser: CustomSchemeURLParser // MARK: Private private let rootRouter: RootRouterType // swiftlint:disable weak_delegate fileprivate let legacyAppDelegate: LegacyAppDelegate = AppDelegate.theDelegate() // swiftlint:enable weak_delegate private lazy var appNavigator: AppNavigatorProtocol = { return AppNavigator(appCoordinator: self) }() fileprivate weak var splitViewCoordinator: SplitViewCoordinatorType? fileprivate weak var sideMenuCoordinator: SideMenuCoordinatorType? private let userSessionsService: UserSessionsService /// Main user Matrix session private var mainMatrixSession: MXSession? { return self.userSessionsService.mainUserSession?.matrixSession } private var currentSpaceId: String? private var cancellables: Set = .init() private var pushRulesUpdater: PushRulesUpdater? // MARK: Public var childCoordinators: [Coordinator] = [] // MARK: - Setup init(router: RootRouterType, window: UIWindow) { self.rootRouter = router self.customSchemeURLParser = CustomSchemeURLParser() self.userSessionsService = UserSessionsService.shared super.init() setupFlexDebuggerOnWindow(window) update(with: ThemeService.shared().theme) } // MARK: - Public methods func start() { setupLogger() setupTheme() excludeAllItemsFromBackup() setupPushRulesSessionEvents() // Setup navigation router store _ = NavigationRouterStore.shared // Setup user location services _ = UserLocationServiceProvider.shared if BWIBuildSettings.shared.enableSideMenu { self.addSideMenu() } NotificationCenter.default.addObserver(forName: NSNotification.Name.appDelegateNetworkStatusDidChange, object: nil, queue: OperationQueue.main) { [weak self] notification in guard let self = self else { return } if AppDelegate.theDelegate().isOffline { self.splitViewCoordinator?.showAppStateIndicator(with: VectorL10n.networkOfflineTitle, icon: UIImage(systemName: "wifi.slash")) } else { self.splitViewCoordinator?.hideAppStateIndicator() } } // NOTE: When split view is shown there can be no Matrix sessions ready. Keep this behavior or use a loading screen before showing the split view. self.showSplitView() MXLog.debug("[AppCoordinator] Showed split view") NotificationCenter.default.addObserver(self, selector: #selector(self.themeDidChange), name: Notification.Name.themeServiceDidChangeTheme, object: nil) } func open(url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { // NOTE: As said in the Apple documentation be careful on security issues with Custom Scheme URL: // https://developer.apple.com/documentation/xcode/allowing_apps_and_websites_to_link_to_your_content/defining_a_custom_url_scheme_for_your_app do { let deepLinkOption = try self.customSchemeURLParser.parse(url: url, options: options) return self.handleDeepLinkOption(deepLinkOption) } catch { MXLog.debug("[AppCoordinator] Custom scheme URL parsing failed with error: \(error)") return false } } // MARK: - Theme management @objc private func themeDidChange() { update(with: ThemeService.shared().theme) } private func update(with theme: Theme) { for window in UIApplication.shared.windows { window.overrideUserInterfaceStyle = ThemeService.shared().theme.userInterfaceStyle } } // MARK: - Private methods private func setupLogger() { UILog.configure(logger: MatrixSDKLogger.self) } private func setupTheme() { ThemeService.shared().themeId = RiotSettings.shared.userInterfaceTheme // Set theme id from current theme.identifier, themeId can be nil. if let themeId = ThemeIdentifier(rawValue: ThemeService.shared().theme.identifier) { ThemePublisher.configure(themeId: themeId) } else { MXLog.error("[AppCoordinator] No theme id found to update ThemePublisher") } // Always republish theme change events, and again always getting the identifier from the theme. let themeIdPublisher = NotificationCenter.default.publisher(for: Notification.Name.themeServiceDidChangeTheme) .compactMap({ _ in ThemeIdentifier(rawValue: ThemeService.shared().theme.identifier) }) .eraseToAnyPublisher() ThemePublisher.shared.republish(themeIdPublisher: themeIdPublisher) } private func excludeAllItemsFromBackup() { let manager = FileManager.default // Individual files and directories created by the application or SDK are excluded case-by-case, // but sometimes the lifecycle of a file is not directly controlled by the app (e.g. plists for // UserDefaults). For that reason the app will always exclude all top-level directories as well // as individual files. manager.excludeAllUserDirectoriesFromBackup() manager.excludeAllAppGroupDirectoriesFromBackup() } private func showAuthentication() { // TODO: Implement } private func showLoading() { // TODO: Implement } private func showPinCode() { // TODO: Implement } private func showSplitView() { let coordinatorParameters = SplitViewCoordinatorParameters(router: self.rootRouter, userSessionsService: self.userSessionsService, appNavigator: self.appNavigator) let splitViewCoordinator = SplitViewCoordinator(parameters: coordinatorParameters) splitViewCoordinator.delegate = self splitViewCoordinator.start() self.add(childCoordinator: splitViewCoordinator) self.splitViewCoordinator = splitViewCoordinator } private func addSideMenu() { let appInfo = AppInfo.current let coordinatorParameters = SideMenuCoordinatorParameters(appNavigator: self.appNavigator, userSessionsService: self.userSessionsService, appInfo: appInfo) let coordinator = SideMenuCoordinator(parameters: coordinatorParameters) coordinator.delegate = self coordinator.start() self.add(childCoordinator: coordinator) self.sideMenuCoordinator = coordinator } private func checkAppVersion() { // TODO: Implement } private func handleDeepLinkOption(_ deepLinkOption: DeepLinkOption) -> Bool { let canOpenLink: Bool switch deepLinkOption { case .connect(let loginToken, let transactionID): canOpenLink = AuthenticationService.shared.continueSSOLogin(with: loginToken, and: transactionID) } return canOpenLink } private func setupFlexDebuggerOnWindow(_ window: UIWindow) { #if DEBUG let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showFlexDebugger)) tapGestureRecognizer.numberOfTouchesRequired = 2 tapGestureRecognizer.numberOfTapsRequired = 2 window.addGestureRecognizer(tapGestureRecognizer) #endif } @objc private func showFlexDebugger() { } fileprivate func navigate(to destination: AppNavigatorDestination) { switch destination { case .homeSpace: MXLog.verbose("Switch to home space") self.navigateToSpace(with: nil) Analytics.shared.activeSpace = nil case .space(let spaceId): MXLog.verbose("Switch to space with id: \(spaceId)") self.navigateToSpace(with: spaceId) Analytics.shared.activeSpace = userSessionsService.mainUserSession?.matrixSession.spaceService.getSpace(withId: spaceId) } } private func navigateToSpace(with spaceId: String?) { guard spaceId != self.currentSpaceId else { MXLog.verbose("Space with id: \(String(describing: spaceId)) is already selected") return } self.currentSpaceId = spaceId // Reload split view with selected space id self.splitViewCoordinator?.start(with: spaceId) } private func setupPushRulesSessionEvents() { let sessionReady = NotificationCenter.default.publisher(for: .mxSessionStateDidChange) .compactMap { $0.object as? MXSession } .filter { $0.state == .running } .removeDuplicates { session1, session2 in session1 == session2 } sessionReady .sink { [weak self] session in self?.setupPushRulesUpdater(session: session) } .store(in: &cancellables) let sessionClosed = NotificationCenter.default.publisher(for: .mxSessionStateDidChange) .compactMap { $0.object as? MXSession } .filter { $0.state == .closed } sessionClosed .sink { [weak self] _ in self?.pushRulesUpdater = nil } .store(in: &cancellables) } private func setupPushRulesUpdater(session: MXSession) { pushRulesUpdater = .init(notificationSettingsService: MXNotificationSettingsService(session: session)) let applicationDidBecomeActive = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification).eraseOutput() let needsCheckPublisher = applicationDidBecomeActive.merge(with: Just(())).eraseToAnyPublisher() needsCheckPublisher .sink { _ in Task { @MainActor [weak self] in await self?.pushRulesUpdater?.syncRulesIfNeeded() } } .store(in: &cancellables) } } // MARK: - LegacyAppDelegateDelegate extension AppCoordinator: LegacyAppDelegateDelegate { func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, wantsToPopToHomeViewControllerAnimated animated: Bool, completion: (() -> Void)!) { MXLog.debug("[AppCoordinator] wantsToPopToHomeViewControllerAnimated") self.splitViewCoordinator?.popToHome(animated: animated, completion: completion) } func legacyAppDelegateRestoreEmptyDetailsViewController(_ legacyAppDelegate: LegacyAppDelegate!) { self.splitViewCoordinator?.resetDetails(animated: false) } func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didAddMatrixSession session: MXSession!) { } func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didRemoveMatrixSession session: MXSession?) { guard let session = session else { return } // Handle user session removal on clear cache. On clear cache the account has his session closed but the account is not removed. self.userSessionsService.removeUserSession(relatedToMatrixSession: session) } func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didAdd account: MXKAccount!) { self.userSessionsService.addUserSession(fromAccount: account) } func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didRemove account: MXKAccount!) { self.userSessionsService.removeUserSession(relatedToAccount: account) // bwi #5896 need logout info in allchatcoordnator self.splitViewCoordinator?.onLogout() } func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didNavigateToSpaceWithId spaceId: String!) { self.sideMenuCoordinator?.select(spaceWithId: spaceId) } // bwi: required for alerts func legacyAppDelegateDidUnlockPin(_ legacyAppDelegate: LegacyAppDelegate!) { self.splitViewCoordinator?.pinUnlocked() } } // MARK: - SplitViewCoordinatorDelegate extension AppCoordinator: SplitViewCoordinatorDelegate { func splitViewCoordinatorDidCompleteAuthentication(_ coordinator: SplitViewCoordinatorType) { self.legacyAppDelegate.authenticationDidComplete() } } // MARK: - SideMenuCoordinatorDelegate extension AppCoordinator: SideMenuCoordinatorDelegate { func sideMenuCoordinator(_ coordinator: SideMenuCoordinatorType, didTapMenuItem menuItem: SideMenuItem, fromSourceView sourceView: UIView) { } } // MARK: - AppNavigator // swiftlint:disable private_over_fileprivate fileprivate class AppNavigator: AppNavigatorProtocol { // swiftlint:enable private_over_fileprivate // MARK: - Properties private unowned let appCoordinator: AppCoordinator lazy var sideMenu: SideMenuPresentable = { guard let sideMenuCoordinator = appCoordinator.sideMenuCoordinator else { fatalError("sideMenuCoordinator is not initialized") } return SideMenuPresenter(sideMenuCoordinator: sideMenuCoordinator) }() // MARK: - Setup init(appCoordinator: AppCoordinator) { self.appCoordinator = appCoordinator } // MARK: - Public func navigate(to destination: AppNavigatorDestination) { self.appCoordinator.navigate(to: destination) } }