Skip to content

Commit

Permalink
Encryption Flow Coordinators. (#3471)
Browse files Browse the repository at this point in the history
* 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
pixlwave authored Nov 4, 2024
1 parent 85e1de2 commit 8e26718
Show file tree
Hide file tree
Showing 51 changed files with 822 additions and 226 deletions.
24 changes: 24 additions & 0 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions ElementX/Sources/FlowCoordinators/EncryptionResetFlowCoordinator.swift
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()
}
}
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)
}
}
}
Loading

0 comments on commit 8e26718

Please sign in to comment.