element-ios/Riot/Modules/Application/AppCoordinator.swift

379 lines
14 KiB
Swift
Executable File

/*
Copyright 2020-2024 New Vector Ltd.
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
#if DEBUG
import FLEX
#endif
/// 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<AnyCancellable> = .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 BuildSettings.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() {
#if DEBUG
FLEXManager.shared.showExplorer()
#endif
}
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)
}
func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didNavigateToSpaceWithId spaceId: String!) {
self.sideMenuCoordinator?.select(spaceWithId: spaceId)
}
}
// 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)
}
}