mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Introduced a UserSessionFlowCoordinator and split up the AppCoordinator state machine (#212)
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
struct ServiceLocator {
|
||||
fileprivate static var serviceLocator: ServiceLocator?
|
||||
static var shared: ServiceLocator {
|
||||
guard let serviceLocator = serviceLocator else {
|
||||
fatalError("The service locator should be setup at this point")
|
||||
}
|
||||
|
||||
return serviceLocator
|
||||
}
|
||||
|
||||
let userIndicatorPresenter: UserIndicatorTypePresenter
|
||||
}
|
||||
|
||||
class AppCoordinator: Coordinator {
|
||||
private let window: UIWindow
|
||||
|
||||
private let stateMachine: AppCoordinatorStateMachine
|
||||
|
||||
private let mainNavigationController: UINavigationController
|
||||
private let splashViewController: UIViewController
|
||||
|
||||
private let navigationRouter: NavigationRouter
|
||||
|
||||
private let userSessionStore: UserSessionStoreProtocol
|
||||
|
||||
private var userSession: UserSessionProtocol! {
|
||||
didSet {
|
||||
deobserveUserSessionChanges()
|
||||
if let userSession = userSession, !userSession.isSoftLogout {
|
||||
observeUserSessionChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
|
||||
|
||||
private let bugReportService: BugReportServiceProtocol
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||
|
||||
private var loadingIndicator: UserIndicator?
|
||||
private var statusIndicator: UserIndicator?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
init() {
|
||||
stateMachine = AppCoordinatorStateMachine()
|
||||
|
||||
bugReportService = BugReportService(withBaseURL: BuildSettings.bugReportServiceBaseURL, sentryURL: BuildSettings.bugReportSentryURL)
|
||||
|
||||
splashViewController = SplashViewController()
|
||||
|
||||
mainNavigationController = ElementNavigationController(rootViewController: splashViewController)
|
||||
mainNavigationController.navigationBar.prefersLargeTitles = true
|
||||
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = mainNavigationController
|
||||
window.tintColor = .element.accent
|
||||
|
||||
navigationRouter = NavigationRouter(navigationController: mainNavigationController)
|
||||
|
||||
ServiceLocator.serviceLocator = ServiceLocator(userIndicatorPresenter: UserIndicatorTypePresenter(presentingViewController: mainNavigationController))
|
||||
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
|
||||
fatalError("Should have a valid bundle identifier at this point")
|
||||
}
|
||||
|
||||
backgroundTaskService = UIKitBackgroundTaskService(withApplication: UIApplication.shared)
|
||||
|
||||
userSessionStore = UserSessionStore(bundleIdentifier: bundleIdentifier,
|
||||
backgroundTaskService: backgroundTaskService)
|
||||
|
||||
setupStateMachine()
|
||||
|
||||
setupLogging()
|
||||
|
||||
// Benchmark.trackingEnabled = true
|
||||
}
|
||||
|
||||
func start() {
|
||||
window.makeKeyAndVisible()
|
||||
stateMachine.processEvent(userSessionStore.hasSessions ? .startWithExistingSession : .startWithAuthentication)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupLogging() {
|
||||
let loggerConfiguration = MXLogConfiguration()
|
||||
|
||||
#if DEBUG
|
||||
// This exposes the full Rust side tracing subscriber filter for more flexibility.
|
||||
// We can filter by level, crate and even file. See more details here:
|
||||
// https://docs.rs/tracing-subscriber/0.2.7/tracing_subscriber/filter/struct.EnvFilter.html#examples
|
||||
setupTracing(configuration: "warn,hyper=warn,sled=warn,matrix_sdk_sled=warn")
|
||||
|
||||
loggerConfiguration.logLevel = .debug
|
||||
#else
|
||||
setupTracing(configuration: "info,hyper=warn,sled=warn,matrix_sdk_sled=warn")
|
||||
loggerConfiguration.logLevel = .info
|
||||
#endif
|
||||
|
||||
// Avoid redirecting NSLogs to files if we are attached to a debugger.
|
||||
if isatty(STDERR_FILENO) == 0 {
|
||||
loggerConfiguration.redirectLogsToFiles = true
|
||||
}
|
||||
|
||||
MXLog.configure(loggerConfiguration)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func setupStateMachine() {
|
||||
stateMachine.addTransitionHandler { [weak self] context in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch (context.fromState, context.event, context.toState) {
|
||||
case (.initial, .startWithAuthentication, .signedOut):
|
||||
self.startAuthentication()
|
||||
case (.signedOut, .succeededSigningIn, .signedIn):
|
||||
self.setupUserSession()
|
||||
case (.initial, .startWithExistingSession, .restoringSession):
|
||||
self.showLoadingIndicator()
|
||||
self.restoreUserSession()
|
||||
case (.restoringSession, .failedRestoringSession, .signedOut):
|
||||
self.hideLoadingIndicator()
|
||||
self.showLoginErrorToast()
|
||||
case (.restoringSession, .succeededRestoringSession, .signedIn):
|
||||
self.hideLoadingIndicator()
|
||||
self.setupUserSession()
|
||||
case (_, .signOut, .signingOut):
|
||||
self.showLoadingIndicator()
|
||||
self.tearDownUserSession()
|
||||
case (.signingOut, .completedSigningOut, .signedOut):
|
||||
self.presentSplashScreen()
|
||||
self.hideLoadingIndicator()
|
||||
case (_, .remoteSignOut(let isSoft), .remoteSigningOut):
|
||||
self.showLoadingIndicator()
|
||||
self.tearDownUserSession(isSoftLogout: isSoft)
|
||||
case (.remoteSigningOut(let isSoft), .completedSigningOut, .signedOut):
|
||||
self.presentSplashScreen(isSoftLogout: isSoft)
|
||||
self.hideLoadingIndicator()
|
||||
default:
|
||||
fatalError("Unknown transition: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Failed transition with context: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreUserSession() {
|
||||
Task {
|
||||
switch await userSessionStore.restoreUserSession() {
|
||||
case .success(let userSession):
|
||||
self.userSession = userSession
|
||||
if userSession.isSoftLogout {
|
||||
stateMachine.processEvent(.remoteSignOut(isSoft: true))
|
||||
} else {
|
||||
stateMachine.processEvent(.succeededRestoringSession)
|
||||
}
|
||||
case .failure:
|
||||
MXLog.error("Failed to restore an existing session.")
|
||||
stateMachine.processEvent(.failedRestoringSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startAuthentication() {
|
||||
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
|
||||
let coordinator = AuthenticationCoordinator(authenticationService: authenticationService,
|
||||
navigationRouter: navigationRouter)
|
||||
coordinator.delegate = self
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
}
|
||||
|
||||
private func startAuthenticationSoftLogout() {
|
||||
Task {
|
||||
var displayName = ""
|
||||
if case .success(let name) = await userSession.clientProxy.loadUserDisplayName() {
|
||||
displayName = name
|
||||
}
|
||||
|
||||
let credentials = SoftLogoutCredentials(userId: userSession.userID,
|
||||
homeserverName: userSession.homeserver,
|
||||
userDisplayName: displayName,
|
||||
deviceId: userSession.deviceId)
|
||||
|
||||
let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore)
|
||||
_ = await authenticationService.configure(for: userSession.homeserver)
|
||||
|
||||
let parameters = SoftLogoutCoordinatorParameters(authenticationService: authenticationService,
|
||||
credentials: credentials,
|
||||
keyBackupNeeded: false)
|
||||
let coordinator = SoftLogoutCoordinator(parameters: parameters)
|
||||
coordinator.callback = { result in
|
||||
switch result {
|
||||
case .signedIn(let session):
|
||||
self.userSession = session
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.stateMachine.processEvent(.succeededSigningIn)
|
||||
case .clearAllData:
|
||||
// clear user data
|
||||
self.userSessionStore.logout(userSession: self.userSession)
|
||||
self.userSession = nil
|
||||
self.remove(childCoordinator: coordinator)
|
||||
self.startAuthentication()
|
||||
}
|
||||
}
|
||||
|
||||
add(childCoordinator: coordinator)
|
||||
coordinator.start()
|
||||
|
||||
navigationRouter.setRootModule(coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUserSession() {
|
||||
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
|
||||
navigationRouter: navigationRouter,
|
||||
bugReportService: bugReportService)
|
||||
|
||||
userSessionFlowCoordinator.callback = { [weak self] action in
|
||||
switch action {
|
||||
case .signOut:
|
||||
self?.stateMachine.processEvent(.signOut)
|
||||
}
|
||||
}
|
||||
|
||||
userSessionFlowCoordinator.start()
|
||||
|
||||
self.userSessionFlowCoordinator = userSessionFlowCoordinator
|
||||
}
|
||||
|
||||
private func tearDownUserSession(isSoftLogout: Bool = false) {
|
||||
userSession.clientProxy.stopSync()
|
||||
userSessionFlowCoordinator?.stop()
|
||||
|
||||
deobserveUserSessionChanges()
|
||||
|
||||
if !isSoftLogout {
|
||||
Task {
|
||||
// first log out from the server
|
||||
_ = await userSession.clientProxy.logout()
|
||||
|
||||
// regardless of the result, clear user data
|
||||
userSessionStore.logout(userSession: userSession)
|
||||
userSession = nil
|
||||
}
|
||||
}
|
||||
|
||||
// complete logging out
|
||||
stateMachine.processEvent(.completedSigningOut)
|
||||
}
|
||||
|
||||
private func presentSplashScreen(isSoftLogout: Bool = false) {
|
||||
if let presentedCoordinator = childCoordinators.first {
|
||||
remove(childCoordinator: presentedCoordinator)
|
||||
}
|
||||
|
||||
mainNavigationController.setViewControllers([splashViewController], animated: false)
|
||||
|
||||
if isSoftLogout {
|
||||
startAuthenticationSoftLogout()
|
||||
} else {
|
||||
startAuthentication()
|
||||
}
|
||||
}
|
||||
|
||||
private func observeUserSessionChanges() {
|
||||
userSession.callbacks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] callback in
|
||||
guard let self = self else { return }
|
||||
switch callback {
|
||||
case .didReceiveAuthError(let isSoftLogout):
|
||||
self.stateMachine.processEvent(.remoteSignOut(isSoft: isSoftLogout))
|
||||
case .updateRestoreTokenNeeded:
|
||||
if let userSession = self.userSession {
|
||||
_ = self.userSessionStore.refreshRestoreToken(for: userSession)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func deobserveUserSessionChanges() {
|
||||
cancellables.forEach { $0.cancel() }
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
// MARK: Toasts and loading indicators
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
loadingIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true))
|
||||
}
|
||||
|
||||
private func hideLoadingIndicator() {
|
||||
loadingIndicator = nil
|
||||
}
|
||||
|
||||
private func showLoginErrorToast() {
|
||||
statusIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.error(label: "Failed logging in"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AuthenticationCoordinatorDelegate
|
||||
|
||||
extension AppCoordinator: AuthenticationCoordinatorDelegate {
|
||||
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
|
||||
self.userSession = userSession
|
||||
remove(childCoordinator: authenticationCoordinator)
|
||||
stateMachine.processEvent(.succeededSigningIn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftState
|
||||
|
||||
class AppCoordinatorStateMachine {
|
||||
/// States the AppCoordinator can find itself in
|
||||
enum State: StateType {
|
||||
/// The initial state, used before the AppCoordinator starts
|
||||
case initial
|
||||
/// Showing the login screen
|
||||
case signedOut
|
||||
/// Opening an existing session.
|
||||
case restoringSession
|
||||
|
||||
/// User session started
|
||||
case signedIn
|
||||
|
||||
/// Processing a sign out request
|
||||
case signingOut
|
||||
|
||||
/// Processing a remote sign out
|
||||
case remoteSigningOut(isSoft: Bool)
|
||||
}
|
||||
|
||||
/// Events that can be triggered on the AppCoordinator state machine
|
||||
enum Event: EventType {
|
||||
/// Start the `AppCoordinator` by showing authentication.
|
||||
case startWithAuthentication
|
||||
/// Signing in succeeded
|
||||
case succeededSigningIn
|
||||
|
||||
/// Start the `AppCoordinator` by restoring an existing account.
|
||||
case startWithExistingSession
|
||||
/// Restoring session succeeded.
|
||||
case succeededRestoringSession
|
||||
/// Restoring session failed.
|
||||
case failedRestoringSession
|
||||
|
||||
/// Request sign out
|
||||
case signOut
|
||||
/// Remote sign out.
|
||||
case remoteSignOut(isSoft: Bool)
|
||||
/// Signing out completed
|
||||
case completedSigningOut
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
|
||||
init() {
|
||||
stateMachine = StateMachine(state: .initial) { machine in
|
||||
machine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut])
|
||||
machine.addRoutes(event: .succeededSigningIn, transitions: [.signedOut => .signedIn])
|
||||
|
||||
machine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession])
|
||||
machine.addRoutes(event: .succeededRestoringSession, transitions: [.restoringSession => .signedIn])
|
||||
machine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut])
|
||||
|
||||
machine.addRoutes(event: .signOut, transitions: [.any => .signingOut])
|
||||
machine.addRoutes(event: .completedSigningOut, transitions: [.signingOut => .signedOut])
|
||||
|
||||
// Transitions with associated values need to be handled through `addRouteMapping`
|
||||
machine.addRouteMapping { event, fromState, _ in
|
||||
switch (event, fromState) {
|
||||
case (.remoteSignOut(let isSoft), _):
|
||||
return .remoteSigningOut(isSoft: isSoft)
|
||||
case (.completedSigningOut, .remoteSigningOut):
|
||||
return .signedOut
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to move the state machine to another state through an event
|
||||
/// It will either invoke the `transitionHandler` or the `errorHandler` depending on its current state
|
||||
func processEvent(_ event: Event) {
|
||||
stateMachine.tryEvent(event)
|
||||
}
|
||||
|
||||
/// Registers a callback for processing state machine transitions
|
||||
func addTransitionHandler(_ handler: @escaping StateMachine<State, Event>.Handler) {
|
||||
stateMachine.addAnyHandler(.any => .any, handler: handler)
|
||||
}
|
||||
|
||||
/// Registers a callback for processing state machine errors
|
||||
func addErrorHandler(_ handler: @escaping StateMachine<State, Event>.Handler) {
|
||||
stateMachine.addErrorHandler(handler: handler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
private lazy var appCoordinator: Coordinator = Tests.isRunningUITests ? UITestsAppCoordinator() : AppCoordinator()
|
||||
|
||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
// use `en` as fallback language
|
||||
Bundle.elementFallbackLanguage = "en"
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
if Tests.isRunningUnitTests {
|
||||
return true
|
||||
}
|
||||
|
||||
appCoordinator.start()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class BuildSettings {
|
||||
// MARK: - Servers
|
||||
|
||||
static let defaultHomeserverAddress = "matrix.org"
|
||||
static let slidingSyncProxyBaseURL = URL(staticString: "https://slidingsync.lab.element.dev")
|
||||
|
||||
// MARK: - Bug report
|
||||
|
||||
static let bugReportServiceBaseURL = URL(staticString: "https://riot.im/bugreports")
|
||||
static let bugReportSentryURL = URL(staticString: "https://f39ac49e97714316965b777d9f3d6cd8@sentry.tools.element.io/44")
|
||||
// Use the name allocated by the bug report server
|
||||
static let bugReportApplicationId = "riot-ios"
|
||||
static let bugReportUISIId = "element-auto-uisi"
|
||||
|
||||
static let bugReportGHLabels = ["Element-X"]
|
||||
|
||||
// MARK: - Analytics
|
||||
|
||||
#if DEBUG
|
||||
/// The configuration to use for analytics during development. Set `isEnabled` to false to disable analytics in debug builds.
|
||||
/// **Note:** Analytics are disabled by default for forks. If you are maintaining a fork, set custom configurations.
|
||||
static let analyticsConfiguration = AnalyticsConfiguration(isEnabled: ElementInfoPlist.cfBundleIdentifier.starts(with: "io.element.elementx"),
|
||||
host: "https://posthog.element.dev",
|
||||
apiKey: "phc_VtA1L35nw3aeAtHIx1ayrGdzGkss7k1xINeXcoIQzXN",
|
||||
termsURL: URL(staticString: "https://element.io/cookie-policy"))
|
||||
#else
|
||||
/// The configuration to use for analytics. Set `isEnabled` to false to disable analytics.
|
||||
/// **Note:** Analytics are disabled by default for forks. If you are maintaining a fork, set custom configurations.
|
||||
static let analyticsConfiguration = AnalyticsConfiguration(isEnabled: ElementInfoPlist.cfBundleIdentifier.starts(with: "io.element.elementx"),
|
||||
host: "https://posthog.hss.element.io",
|
||||
apiKey: "phc_Jzsm6DTm6V2705zeU5dcNvQDlonOR68XvX2sh1sEOHO",
|
||||
termsURL: URL(staticString: "https://element.io/cookie-policy"))
|
||||
#endif
|
||||
|
||||
// MARK: - Settings screen
|
||||
|
||||
static let settingsCrashButtonVisible = true
|
||||
static let settingsShowTimelineStyle = true
|
||||
|
||||
// MARK: - Room screen
|
||||
|
||||
static let defaultRoomTimelineStyle: TimelineStyle = .bubbles
|
||||
|
||||
// MARK: - Other
|
||||
|
||||
static var permalinkBaseURL = URL(staticString: "https://matrix.to")
|
||||
}
|
||||
Reference in New Issue
Block a user