generated from element-hq/.github
-
Notifications
You must be signed in to change notification settings - Fork 100
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Encryption Flow Coordinators. (#3471)
* Manage the secure backup screens with flow coordinators. * Add UI tests for the EncryptionSettingsFlowCoordinator. * Realise that the settings flow can't reset anymore and remove the sub-flow 🤦♂️ * Add UI tests for the EncryptionResetFlowCoordinator.
- Loading branch information
Showing
51 changed files
with
822 additions
and
226 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
158 changes: 158 additions & 0 deletions
158
ElementX/Sources/FlowCoordinators/EncryptionResetFlowCoordinator.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
// | ||
// Copyright 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 SwiftState | ||
|
||
enum EncryptionResetFlowCoordinatorAction: Equatable { | ||
/// The flow is complete. | ||
case resetComplete | ||
/// The flow was cancelled. | ||
case cancel | ||
} | ||
|
||
struct EncryptionResetFlowCoordinatorParameters { | ||
let userSession: UserSessionProtocol | ||
let userIndicatorController: UserIndicatorControllerProtocol | ||
let navigationStackCoordinator: NavigationStackCoordinator | ||
let windowManger: WindowManagerProtocol | ||
} | ||
|
||
class EncryptionResetFlowCoordinator: FlowCoordinatorProtocol { | ||
private let userSession: UserSessionProtocol | ||
private let userIndicatorController: UserIndicatorControllerProtocol | ||
|
||
private let navigationStackCoordinator: NavigationStackCoordinator | ||
private let windowManager: WindowManagerProtocol | ||
|
||
enum State: StateType { | ||
/// The state machine hasn't started. | ||
case initial | ||
/// The root screen for this flow. | ||
case encryptionResetScreen | ||
/// Confirming the user's password to continue. | ||
case confirmingPassword | ||
} | ||
|
||
enum Event: EventType { | ||
/// The flow is being started. | ||
case start | ||
|
||
/// The user needs to confirm their password to reset. | ||
case confirmPassword | ||
/// The user confirmed their password. | ||
case finishedConfirmingPassword | ||
} | ||
|
||
private let stateMachine: StateMachine<State, Event> | ||
private var cancellables: Set<AnyCancellable> = [] | ||
|
||
private let actionsSubject: PassthroughSubject<EncryptionResetFlowCoordinatorAction, Never> = .init() | ||
var actionsPublisher: AnyPublisher<EncryptionResetFlowCoordinatorAction, Never> { | ||
actionsSubject.eraseToAnyPublisher() | ||
} | ||
|
||
init(parameters: EncryptionResetFlowCoordinatorParameters) { | ||
userSession = parameters.userSession | ||
userIndicatorController = parameters.userIndicatorController | ||
navigationStackCoordinator = parameters.navigationStackCoordinator | ||
windowManager = parameters.windowManger | ||
|
||
stateMachine = .init(state: .initial) | ||
configureStateMachine() | ||
} | ||
|
||
func start() { | ||
stateMachine.tryEvent(.start) | ||
} | ||
|
||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { | ||
// There aren't any routes to this screen, so always clear the stack. | ||
clearRoute(animated: animated) | ||
} | ||
|
||
func clearRoute(animated: Bool) { | ||
// As we push screens on top of an existing stack, popping to root wouldn't be safe. | ||
switch stateMachine.state { | ||
case .initial: | ||
break | ||
case .encryptionResetScreen: | ||
navigationStackCoordinator.pop(animated: animated) | ||
case .confirmingPassword: | ||
navigationStackCoordinator.pop(animated: animated) // Password screen. | ||
navigationStackCoordinator.pop(animated: animated) // EncryptionReset screen. | ||
} | ||
} | ||
|
||
// MARK: - Private | ||
|
||
private func configureStateMachine() { | ||
stateMachine.addRoutes(event: .start, transitions: [.initial => .encryptionResetScreen]) { [weak self] _ in | ||
self?.presentEncryptionResetScreen() | ||
} | ||
|
||
stateMachine.addRoutes(event: .confirmPassword, transitions: [.encryptionResetScreen => .confirmingPassword]) { [weak self] context in | ||
guard let passwordPublisher = context.userInfo as? PassthroughSubject<String, Never> else { fatalError("Expected a publisher in the userInfo.") } | ||
self?.presentPasswordScreen(passwordPublisher: passwordPublisher) | ||
} | ||
stateMachine.addRoutes(event: .finishedConfirmingPassword, transitions: [.confirmingPassword => .encryptionResetScreen]) | ||
|
||
stateMachine.addErrorHandler { context in | ||
fatalError("Unexpected transition: \(context)") | ||
} | ||
} | ||
|
||
private func presentEncryptionResetScreen() { | ||
let coordinator = EncryptionResetScreenCoordinator(parameters: .init(clientProxy: userSession.clientProxy, | ||
userIndicatorController: userIndicatorController)) | ||
|
||
coordinator.actionsPublisher.sink { [weak self] action in | ||
guard let self else { return } | ||
|
||
switch action { | ||
case .requestOIDCAuthorisation(let url): | ||
presentOIDCAuthorization(for: url) | ||
case .requestPassword(let passwordPublisher): | ||
stateMachine.tryEvent(.confirmPassword, userInfo: passwordPublisher) | ||
case .cancel: | ||
actionsSubject.send(.cancel) | ||
case .resetFinished: | ||
actionsSubject.send(.resetComplete) | ||
} | ||
} | ||
.store(in: &cancellables) | ||
|
||
navigationStackCoordinator.setRootCoordinator(coordinator) | ||
} | ||
|
||
private func presentPasswordScreen(passwordPublisher: PassthroughSubject<String, Never>) { | ||
let coordinator = EncryptionResetPasswordScreenCoordinator(parameters: .init(passwordPublisher: passwordPublisher)) | ||
|
||
coordinator.actionsPublisher.sink { [weak self] action in | ||
guard let self else { return } | ||
|
||
switch action { | ||
case .passwordEntered: | ||
navigationStackCoordinator.pop() | ||
} | ||
} | ||
.store(in: &cancellables) | ||
|
||
navigationStackCoordinator.push(coordinator) { [stateMachine] in | ||
stateMachine.tryEvent(.finishedConfirmingPassword) | ||
} | ||
} | ||
|
||
private var accountSettingsPresenter: OIDCAccountSettingsPresenter? | ||
private func presentOIDCAuthorization(for url: URL) { | ||
// Note to anyone in the future if you come back here to make this open in Safari instead of a WAS. | ||
// As of iOS 16, there is an issue on the simulator with accessing the cookie but it works on a device. 🤷♂️ | ||
accountSettingsPresenter = OIDCAccountSettingsPresenter(accountURL: url, presentationAnchor: windowManager.mainWindow) | ||
accountSettingsPresenter?.start() | ||
} | ||
} |
198 changes: 198 additions & 0 deletions
198
ElementX/Sources/FlowCoordinators/EncryptionSettingsFlowCoordinator.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
// | ||
// Copyright 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 SwiftState | ||
|
||
enum EncryptionSettingsFlowCoordinatorAction: Equatable { | ||
/// The flow is complete. | ||
case complete | ||
} | ||
|
||
struct EncryptionSettingsFlowCoordinatorParameters { | ||
let userSession: UserSessionProtocol | ||
let appSettings: AppSettings | ||
let userIndicatorController: UserIndicatorControllerProtocol | ||
let navigationStackCoordinator: NavigationStackCoordinator | ||
} | ||
|
||
class EncryptionSettingsFlowCoordinator: FlowCoordinatorProtocol { | ||
private let userSession: UserSessionProtocol | ||
private let appSettings: AppSettings | ||
private let userIndicatorController: UserIndicatorControllerProtocol | ||
private let navigationStackCoordinator: NavigationStackCoordinator | ||
|
||
// periphery:ignore - retaining purpose | ||
private var encryptionResetFlowCoordinator: EncryptionResetFlowCoordinator? | ||
|
||
enum State: StateType { | ||
/// The state machine hasn't started. | ||
case initial | ||
/// The root screen for this flow. | ||
case secureBackupScreen | ||
/// The user is managing their recovery key. | ||
case recoveryKeyScreen | ||
/// The user is disabling key backups. | ||
case keyBackupScreen | ||
} | ||
|
||
enum Event: EventType { | ||
/// The flow is being started. | ||
case start | ||
|
||
/// The user would like to manage their recovery key. | ||
case manageRecoveryKey | ||
/// The user finished managing their recovery key. | ||
case finishedManagingRecoveryKey | ||
|
||
/// The user doesn't want to use key backup any more. | ||
case disableKeyBackup | ||
/// The key backup screen was dismissed. | ||
case finishedDisablingKeyBackup | ||
} | ||
|
||
private let stateMachine: StateMachine<State, Event> | ||
private var cancellables: Set<AnyCancellable> = [] | ||
|
||
private let actionsSubject: PassthroughSubject<EncryptionSettingsFlowCoordinatorAction, Never> = .init() | ||
var actionsPublisher: AnyPublisher<EncryptionSettingsFlowCoordinatorAction, Never> { | ||
actionsSubject.eraseToAnyPublisher() | ||
} | ||
|
||
init(parameters: EncryptionSettingsFlowCoordinatorParameters) { | ||
userSession = parameters.userSession | ||
appSettings = parameters.appSettings | ||
userIndicatorController = parameters.userIndicatorController | ||
navigationStackCoordinator = parameters.navigationStackCoordinator | ||
|
||
stateMachine = .init(state: .initial) | ||
configureStateMachine() | ||
} | ||
|
||
func start() { | ||
stateMachine.tryEvent(.start) | ||
} | ||
|
||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) { | ||
switch appRoute { | ||
case .roomList, .room, .roomAlias, .childRoom, .childRoomAlias, | ||
.roomDetails, .roomMemberDetails, .userProfile, | ||
.event, .eventOnRoomAlias, .childEvent, .childEventOnRoomAlias, | ||
.call, .genericCallLink, .settings: | ||
// These routes aren't in this flow so clear the entire stack. | ||
clearRoute(animated: animated) | ||
case .chatBackupSettings: | ||
popToRootScreen(animated: animated) | ||
} | ||
} | ||
|
||
func clearRoute(animated: Bool) { | ||
let fromState = stateMachine.state | ||
popToRootScreen(animated: animated) | ||
guard fromState != .initial else { return } | ||
navigationStackCoordinator.pop(animated: animated) // SecureBackup screen. | ||
} | ||
|
||
func popToRootScreen(animated: Bool) { | ||
// As we push screens on top of an existing stack, a literal pop to root wouldn't be safe. | ||
switch stateMachine.state { | ||
case .initial, .secureBackupScreen: | ||
break | ||
case .recoveryKeyScreen: | ||
navigationStackCoordinator.setSheetCoordinator(nil, animated: animated) // RecoveryKey screen. | ||
case .keyBackupScreen: | ||
navigationStackCoordinator.setSheetCoordinator(nil, animated: animated) // KeyBackup screen. | ||
} | ||
} | ||
|
||
// MARK: - Private | ||
|
||
private func configureStateMachine() { | ||
stateMachine.addRoutes(event: .start, transitions: [.initial => .secureBackupScreen]) { [weak self] _ in | ||
self?.presentSecureBackupScreen() | ||
} | ||
|
||
stateMachine.addRoutes(event: .manageRecoveryKey, transitions: [.secureBackupScreen => .recoveryKeyScreen]) { [weak self] _ in | ||
self?.presentRecoveryKeyScreen() | ||
} | ||
stateMachine.addRoutes(event: .finishedManagingRecoveryKey, transitions: [.recoveryKeyScreen => .secureBackupScreen]) | ||
|
||
stateMachine.addRoutes(event: .disableKeyBackup, transitions: [.secureBackupScreen => .keyBackupScreen]) { [weak self] _ in | ||
self?.presentKeyBackupScreen() | ||
} | ||
stateMachine.addRoutes(event: .finishedDisablingKeyBackup, transitions: [.keyBackupScreen => .secureBackupScreen]) | ||
|
||
stateMachine.addErrorHandler { context in | ||
fatalError("Unexpected transition: \(context)") | ||
} | ||
} | ||
|
||
private func presentSecureBackupScreen(animated: Bool = true) { | ||
let coordinator = SecureBackupScreenCoordinator(parameters: .init(appSettings: appSettings, | ||
clientProxy: userSession.clientProxy, | ||
userIndicatorController: userIndicatorController)) | ||
coordinator.actions.sink { [weak self] action in | ||
guard let self else { return } | ||
|
||
switch action { | ||
case .manageRecoveryKey: | ||
stateMachine.tryEvent(.manageRecoveryKey) | ||
case .disableKeyBackup: | ||
stateMachine.tryEvent(.disableKeyBackup) | ||
} | ||
} | ||
.store(in: &cancellables) | ||
|
||
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in | ||
self?.actionsSubject.send(.complete) | ||
} | ||
} | ||
|
||
private func presentRecoveryKeyScreen() { | ||
let sheetNavigationStackCoordinator = NavigationStackCoordinator() | ||
let coordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController, | ||
userIndicatorController: userIndicatorController, | ||
isModallyPresented: true)) | ||
|
||
coordinator.actions.sink { [weak self] action in | ||
guard let self else { return } | ||
switch action { | ||
case .complete: | ||
navigationStackCoordinator.setSheetCoordinator(nil) | ||
} | ||
} | ||
.store(in: &cancellables) | ||
|
||
sheetNavigationStackCoordinator.setRootCoordinator(coordinator, animated: true) | ||
|
||
navigationStackCoordinator.setSheetCoordinator(sheetNavigationStackCoordinator) { [stateMachine] in | ||
stateMachine.tryEvent(.finishedManagingRecoveryKey) | ||
} | ||
} | ||
|
||
private func presentKeyBackupScreen() { | ||
let sheetNavigationStackCoordinator = NavigationStackCoordinator() | ||
|
||
let coordinator = SecureBackupKeyBackupScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController, | ||
userIndicatorController: userIndicatorController)) | ||
|
||
coordinator.actions.sink { [weak self] action in | ||
switch action { | ||
case .done: | ||
self?.navigationStackCoordinator.setSheetCoordinator(nil) | ||
} | ||
} | ||
.store(in: &cancellables) | ||
|
||
sheetNavigationStackCoordinator.setRootCoordinator(coordinator, animated: true) | ||
|
||
navigationStackCoordinator.setSheetCoordinator(sheetNavigationStackCoordinator) { [stateMachine] in | ||
stateMachine.tryEvent(.finishedDisablingKeyBackup) | ||
} | ||
} | ||
} |
Oops, something went wrong.