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.
This commit is contained in:
Doug
2024-11-04 14:22:50 +00:00
committed by GitHub
parent 85e1de2c1c
commit 8e26718d0b
51 changed files with 822 additions and 226 deletions
@@ -8,17 +8,12 @@
import Combine
import SwiftUI
struct EncryptionResetPasswordScreenCoordinatorParameters { }
struct EncryptionResetPasswordScreenCoordinatorParameters {
let passwordPublisher: PassthroughSubject<String, Never>
}
enum EncryptionResetPasswordScreenCoordinatorAction: CustomStringConvertible {
case resetIdentity(String)
var description: String {
switch self {
case .resetIdentity:
"resetIdentity"
}
}
enum EncryptionResetPasswordScreenCoordinatorAction {
case passwordEntered
}
final class EncryptionResetPasswordScreenCoordinator: CoordinatorProtocol {
@@ -35,7 +30,7 @@ final class EncryptionResetPasswordScreenCoordinator: CoordinatorProtocol {
init(parameters: EncryptionResetPasswordScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = EncryptionResetPasswordScreenViewModel()
viewModel = EncryptionResetPasswordScreenViewModel(passwordPublisher: parameters.passwordPublisher)
}
func start() {
@@ -44,8 +39,8 @@ final class EncryptionResetPasswordScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .resetIdentity(let password):
self.actionsSubject.send(.resetIdentity(password))
case .passwordEntered:
self.actionsSubject.send(.passwordEntered)
}
}
.store(in: &cancellables)
@@ -7,15 +7,8 @@
import Foundation
enum EncryptionResetPasswordScreenViewModelAction: CustomStringConvertible {
case resetIdentity(String)
var description: String {
switch self {
case .resetIdentity:
"resetIdentity"
}
}
enum EncryptionResetPasswordScreenViewModelAction {
case passwordEntered
}
struct EncryptionResetPasswordScreenViewState: BindableState {
@@ -28,5 +21,5 @@ struct EncryptionResetPasswordScreenViewStateBindings {
}
enum EncryptionResetPasswordScreenViewAction {
case resetIdentity
case submit
}
@@ -11,12 +11,16 @@ import SwiftUI
typealias EncryptionResetPasswordScreenViewModelType = StateStoreViewModel<EncryptionResetPasswordScreenViewState, EncryptionResetPasswordScreenViewAction>
class EncryptionResetPasswordScreenViewModel: EncryptionResetPasswordScreenViewModelType, EncryptionResetPasswordScreenViewModelProtocol {
private let passwordPublisher: PassthroughSubject<String, Never>
private let actionsSubject: PassthroughSubject<EncryptionResetPasswordScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<EncryptionResetPasswordScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init() {
init(passwordPublisher: PassthroughSubject<String, Never>) {
self.passwordPublisher = passwordPublisher
super.init(initialViewState: .init(bindings: .init(password: "")))
}
@@ -26,8 +30,9 @@ class EncryptionResetPasswordScreenViewModel: EncryptionResetPasswordScreenViewM
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .resetIdentity:
actionsSubject.send(.resetIdentity(state.bindings.password))
case .submit:
passwordPublisher.send(state.bindings.password)
actionsSubject.send(.passwordEntered)
}
}
}
@@ -5,6 +5,7 @@
// Please see LICENSE in the repository root for full details.
//
import Combine
import Compound
import SwiftUI
@@ -32,9 +33,10 @@ struct EncryptionResetPasswordScreen: View {
.padding(16)
} bottomContent: {
Button(L10n.actionResetIdentity, role: .destructive) {
context.send(viewAction: .resetIdentity)
context.send(viewAction: .submit)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.encryptionResetPasswordScreen.submit)
}
.background()
.backgroundStyle(.compound.bgCanvasDefault)
@@ -58,8 +60,9 @@ struct EncryptionResetPasswordScreen: View {
.focused($textFieldFocus)
.submitLabel(.done)
.onSubmit {
context.send(viewAction: .resetIdentity)
context.send(viewAction: .submit)
}
.accessibilityIdentifier(A11yIdentifiers.encryptionResetPasswordScreen.passwordField)
}
}
}
@@ -67,7 +70,8 @@ struct EncryptionResetPasswordScreen: View {
// MARK: - Previews
struct EncryptionResetPasswordScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = EncryptionResetPasswordScreenViewModel()
static let passwordPublisher = PassthroughSubject<String, Never>()
static let viewModel = EncryptionResetPasswordScreenViewModel(passwordPublisher: passwordPublisher)
static var previews: some View {
NavigationStack {
EncryptionResetPasswordScreen(context: viewModel.context)
@@ -9,14 +9,14 @@ import Combine
import SwiftUI
enum EncryptionResetScreenCoordinatorAction {
case cancel
case requestOIDCAuthorisation(URL)
case requestPassword(passwordPublisher: PassthroughSubject<String, Never>)
case resetFinished
case cancel
}
struct EncryptionResetScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let navigationStackCoordinator: NavigationStackCoordinator
let userIndicatorController: UserIndicatorControllerProtocol
}
@@ -43,10 +43,10 @@ final class EncryptionResetScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .requestPassword:
presentPasswordScreen()
case .requestOIDCAuthorisation(let url):
self.actionsSubject.send(.requestOIDCAuthorisation(url))
case .requestPassword(let passwordPublisher):
self.actionsSubject.send(.requestPassword(passwordPublisher: passwordPublisher))
case .resetFinished:
self.actionsSubject.send(.resetFinished)
case .cancel:
@@ -63,23 +63,4 @@ final class EncryptionResetScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(EncryptionResetScreen(context: viewModel.context))
}
// MARK: - Private
private func presentPasswordScreen() {
let coordinator = EncryptionResetPasswordScreenCoordinator(parameters: .init())
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .resetIdentity(let password):
viewModel.continueResetFlowWith(password: password)
parameters.navigationStackCoordinator.pop()
}
}
.store(in: &cancellables)
parameters.navigationStackCoordinator.push(coordinator)
}
}
@@ -5,10 +5,11 @@
// Please see LICENSE in the repository root for full details.
//
import Combine
import Foundation
enum EncryptionResetScreenViewModelAction {
case requestPassword
case requestPassword(passwordPublisher: PassthroughSubject<String, Never>)
case requestOIDCAuthorisation(url: URL)
case resetFinished
case cancel
@@ -21,6 +21,7 @@ class EncryptionResetScreenViewModel: EncryptionResetScreenViewModelType, Encryp
}
private var identityResetHandle: IdentityResetHandle?
private var passwordCancellable: AnyCancellable?
init(clientProxy: ClientProxyProtocol, userIndicatorController: UserIndicatorControllerProtocol) {
self.clientProxy = clientProxy
@@ -46,12 +47,6 @@ class EncryptionResetScreenViewModel: EncryptionResetScreenViewModelType, Encryp
}
}
func continueResetFlowWith(password: String) {
Task {
await resetWith(password: password)
}
}
func stop() {
Task {
await identityResetHandle?.cancel()
@@ -80,7 +75,14 @@ class EncryptionResetScreenViewModel: EncryptionResetScreenViewModelType, Encryp
switch handle.authType() {
case .uiaa:
actionsSubject.send(.requestPassword)
let passwordPublisher = PassthroughSubject<String, Never>()
passwordCancellable = passwordPublisher.sink { [weak self] password in
guard let self else { return }
passwordCancellable = nil
Task { await self.resetWith(password: password) }
}
actionsSubject.send(.requestPassword(passwordPublisher: passwordPublisher))
case .oidc(let oidcInfo):
guard let url = URL(string: oidcInfo.approvalUrl) else {
fatalError("Invalid URL received through identity reset handle: \(oidcInfo.approvalUrl)")
@@ -12,6 +12,5 @@ protocol EncryptionResetScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<EncryptionResetScreenViewModelAction, Never> { get }
var context: EncryptionResetScreenViewModelType.Context { get }
func continueResetFlowWith(password: String)
func stop()
}
@@ -19,6 +19,7 @@ struct EncryptionResetScreen: View {
context.send(viewAction: .reset)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.encryptionResetScreen.continueReset)
}
.background()
.backgroundStyle(.compound.bgSubtleSecondary)
@@ -22,6 +22,7 @@ struct SecureBackupKeyBackupScreen: View {
Text(L10n.screenChatBackupKeyBackupActionDisable)
}
.buttonStyle(.compound(.primary))
.accessibilityIdentifier(A11yIdentifiers.secureBackupKeyBackupScreen.deleteKeyStorage)
}
.background()
.backgroundStyle(.compound.bgCanvasDefault)
@@ -15,23 +15,22 @@ struct SecureBackupRecoveryKeyScreenCoordinatorParameters {
}
enum SecureBackupRecoveryKeyScreenCoordinatorAction {
case cancel
case recoverySetUp
case recoveryChanged
case recoveryFixed
case resetEncryption
case complete
}
final class SecureBackupRecoveryKeyScreenCoordinator: CoordinatorProtocol {
private let parameters: SecureBackupRecoveryKeyScreenCoordinatorParameters
private var viewModel: SecureBackupRecoveryKeyScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<SecureBackupRecoveryKeyScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SecureBackupRecoveryKeyScreenCoordinatorAction, Never> = .init()
var actions: AnyPublisher<SecureBackupRecoveryKeyScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SecureBackupRecoveryKeyScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SecureBackupRecoveryKeyScreenViewModel(secureBackupController: parameters.secureBackupController,
userIndicatorController: parameters.userIndicatorController,
isModallyPresented: parameters.isModallyPresented)
@@ -44,20 +43,19 @@ final class SecureBackupRecoveryKeyScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .cancel:
self.actionsSubject.send(.cancel)
self.actionsSubject.send(.complete)
case .done(let mode):
switch mode {
case .setupRecovery:
self.actionsSubject.send(.recoverySetUp)
showSuccessIndicator(title: L10n.screenRecoveryKeySetupSuccess)
case .changeRecovery:
self.actionsSubject.send(.recoveryChanged)
showSuccessIndicator(title: L10n.screenRecoveryKeyChangeSuccess)
case .fixRecovery:
self.actionsSubject.send(.recoveryFixed)
showSuccessIndicator(title: L10n.screenRecoveryKeyConfirmSuccess)
case .unknown:
fatalError()
}
case .resetEncryption:
self.actionsSubject.send(.resetEncryption)
self.actionsSubject.send(.complete)
}
}
.store(in: &cancellables)
@@ -66,4 +64,14 @@ final class SecureBackupRecoveryKeyScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(SecureBackupRecoveryKeyScreen(context: viewModel.context))
}
// MARK: - Private
private func showSuccessIndicator(title: String) {
parameters.userIndicatorController.submitIndicator(.init(id: .init(),
type: .modal(progress: .none, interactiveDismissDisabled: false, allowsInteraction: false),
title: title,
iconName: "checkmark",
persistent: false))
}
}
@@ -10,7 +10,6 @@ import Foundation
enum SecureBackupRecoveryKeyScreenViewModelAction {
case done(mode: SecureBackupRecoveryKeyScreenViewMode)
case cancel
case resetEncryption
}
enum SecureBackupRecoveryKeyScreenViewMode {
@@ -82,7 +81,6 @@ enum SecureBackupRecoveryKeyScreenViewAction {
case copyKey
case keySaved
case confirmKey
case resetEncryption
case done
case cancel
}
@@ -78,13 +78,11 @@ class SecureBackupRecoveryKeyScreenViewModel: SecureBackupRecoveryKeyScreenViewM
state.bindings.alertInfo = .init(id: .init(),
title: L10n.screenRecoveryKeySetupConfirmationTitle,
message: L10n.screenRecoveryKeySetupConfirmationDescription,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionContinue, action: { [weak self] in
primaryButton: .init(title: L10n.actionContinue) { [weak self] in
guard let self else { return }
actionsSubject.send(.done(mode: context.viewState.mode))
}))
case .resetEncryption:
actionsSubject.send(.resetEncryption)
},
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
@@ -89,6 +89,7 @@ struct SecureBackupRecoveryKeyScreen: View {
}
.buttonStyle(.compound(.primary))
.disabled(context.confirmationRecoveryKey.isEmpty)
.accessibilityIdentifier(A11yIdentifiers.secureBackupRecoveryKeyScreen.confirm)
}
}
@@ -111,6 +112,7 @@ struct SecureBackupRecoveryKeyScreen: View {
}
.buttonStyle(.compound(.primary))
.disabled(context.viewState.recoveryKey == nil || context.viewState.doneButtonEnabled == false)
.accessibilityIdentifier(A11yIdentifiers.secureBackupRecoveryKeyScreen.done)
}
}
@@ -140,6 +142,7 @@ struct SecureBackupRecoveryKeyScreen: View {
}
.font(.compound.bodyLGSemibold)
.padding(.vertical, 11)
.accessibilityIdentifier(A11yIdentifiers.secureBackupRecoveryKeyScreen.generateRecoveryKey)
} else {
HStack(spacing: 8) {
ProgressView()
@@ -163,6 +166,7 @@ struct SecureBackupRecoveryKeyScreen: View {
}
.tint(.compound.iconSecondary)
.accessibilityLabel(L10n.actionCopy)
.accessibilityIdentifier(A11yIdentifiers.secureBackupRecoveryKeyScreen.copyRecoveryKey)
}
}
}
@@ -204,6 +208,7 @@ struct SecureBackupRecoveryKeyScreen: View {
.onSubmit {
context.send(viewAction: .confirmKey)
}
.accessibilityIdentifier(A11yIdentifiers.secureBackupRecoveryKeyScreen.recoveryKeyField)
if let subtitle = context.viewState.recoveryKeySubtitle {
Text(subtitle)
@@ -11,12 +11,12 @@ import SwiftUI
struct SecureBackupScreenCoordinatorParameters {
let appSettings: AppSettings
let clientProxy: ClientProxyProtocol
weak var navigationStackCoordinator: NavigationStackCoordinator?
let userIndicatorController: UserIndicatorControllerProtocol
}
enum SecureBackupScreenCoordinatorAction {
case requestOIDCAuthorisation(URL)
case manageRecoveryKey
case disableKeyBackup
}
final class SecureBackupScreenCoordinator: CoordinatorProtocol {
@@ -43,53 +43,10 @@ final class SecureBackupScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .recoveryKey:
let recoveryNavigationStackCoordinator = NavigationStackCoordinator()
let recoveryKeyCoordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: .init(secureBackupController: parameters.clientProxy.secureBackupController,
userIndicatorController: parameters.userIndicatorController,
isModallyPresented: true))
recoveryKeyCoordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
case .recoverySetUp:
showSuccessIndicator(title: L10n.screenRecoveryKeySetupSuccess)
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
case .recoveryChanged:
showSuccessIndicator(title: L10n.screenRecoveryKeyChangeSuccess)
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
case .recoveryFixed:
showSuccessIndicator(title: L10n.screenRecoveryKeyConfirmSuccess)
parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
case .resetEncryption:
showEncryptionReset(recoveryNavigationStackCoordinator: recoveryNavigationStackCoordinator)
}
}
.store(in: &cancellables)
recoveryNavigationStackCoordinator.setRootCoordinator(recoveryKeyCoordinator, animated: true)
parameters.navigationStackCoordinator?.setSheetCoordinator(recoveryNavigationStackCoordinator)
case .keyBackup:
let navigationStackCoordinator = NavigationStackCoordinator()
let keyBackupCoordinator = SecureBackupKeyBackupScreenCoordinator(parameters: .init(secureBackupController: parameters.clientProxy.secureBackupController,
userIndicatorController: parameters.userIndicatorController))
keyBackupCoordinator.actions.sink { [weak self] action in
switch action {
case .done:
self?.parameters.navigationStackCoordinator?.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(keyBackupCoordinator, animated: true)
parameters.navigationStackCoordinator?.setSheetCoordinator(navigationStackCoordinator)
case .manageRecoveryKey:
actionsSubject.send(.manageRecoveryKey)
case .disableKeyBackup:
actionsSubject.send(.disableKeyBackup)
}
}
.store(in: &cancellables)
@@ -98,41 +55,4 @@ final class SecureBackupScreenCoordinator: CoordinatorProtocol {
func toPresentable() -> AnyView {
AnyView(SecureBackupScreen(context: viewModel.context))
}
// MARK: - Private
private func showSuccessIndicator(title: String) {
parameters.userIndicatorController.submitIndicator(.init(id: .init(),
type: .modal(progress: .none, interactiveDismissDisabled: false, allowsInteraction: false),
title: title,
iconName: "checkmark",
persistent: false))
}
private func showEncryptionReset(recoveryNavigationStackCoordinator: NavigationStackCoordinator) {
let resetNavigationStackCoordinator = NavigationStackCoordinator()
let coordinator = EncryptionResetScreenCoordinator(parameters: .init(clientProxy: parameters.clientProxy,
navigationStackCoordinator: resetNavigationStackCoordinator,
userIndicatorController: parameters.userIndicatorController))
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
recoveryNavigationStackCoordinator.setSheetCoordinator(nil)
case .requestOIDCAuthorisation(let url):
actionsSubject.send(.requestOIDCAuthorisation(url))
case .resetFinished:
parameters.navigationStackCoordinator?.setSheetCoordinator(nil) // Dismiss the recovery screen
recoveryNavigationStackCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
resetNavigationStackCoordinator.setRootCoordinator(coordinator)
recoveryNavigationStackCoordinator.setSheetCoordinator(resetNavigationStackCoordinator)
}
}
@@ -8,8 +8,8 @@
import Foundation
enum SecureBackupScreenViewModelAction {
case recoveryKey
case keyBackup
case manageRecoveryKey
case disableKeyBackup
}
struct SecureBackupScreenViewState: BindableState {
@@ -48,7 +48,7 @@ class SecureBackupScreenViewModel: SecureBackupScreenViewModelType, SecureBackup
override func process(viewAction: SecureBackupScreenViewAction) {
switch viewAction {
case .recoveryKey:
actionsSubject.send(.recoveryKey)
actionsSubject.send(.manageRecoveryKey)
case .keyStorageToggled(let enable):
let keyBackupState = secureBackupController.keyBackupState.value
switch (keyBackupState, enable) {
@@ -57,7 +57,7 @@ class SecureBackupScreenViewModel: SecureBackupScreenViewModelType, SecureBackup
enableBackup()
case (.enabled, false):
state.bindings.keyStorageEnabled = keyBackupState.keyStorageToggleState // Reset the toggle in case the user cancels
actionsSubject.send(.keyBackup)
actionsSubject.send(.disableKeyBackup)
default:
break
}
@@ -53,7 +53,13 @@ struct SecureBackupScreen: View {
.accessibilityElement(children: .combine)
})
keyStorageToggle
ListRow(label: .plain(title: L10n.screenChatBackupKeyStorageToggleTitle,
description: context.viewState.keyStorageToggleDescription),
kind: .toggle($context.keyStorageEnabled))
.onChange(of: context.keyStorageEnabled) { _, newValue in
context.send(viewAction: .keyStorageToggled(newValue))
}
.accessibilityIdentifier(A11yIdentifiers.secureBackupScreen.keyStorage)
}
}
@@ -67,15 +73,6 @@ struct SecureBackupScreen: View {
return description
}
private var keyStorageToggle: some View {
ListRow(label: .plain(title: L10n.screenChatBackupKeyStorageToggleTitle,
description: context.viewState.keyStorageToggleDescription),
kind: .toggle($context.keyStorageEnabled))
.onChange(of: context.keyStorageEnabled) { _, newValue in
context.send(viewAction: .keyStorageToggled(newValue))
}
}
private var recoveryKeySection: some View {
Section {
switch context.viewState.recoveryState {
@@ -85,6 +82,7 @@ struct SecureBackupScreen: View {
icon: \.key,
iconAlignment: .top),
kind: .navigationLink { context.send(viewAction: .recoveryKey) })
.accessibilityIdentifier(A11yIdentifiers.secureBackupScreen.recoveryKey)
case .disabled:
ListRow(label: .default(title: L10n.screenChatBackupRecoveryActionSetup,
description: L10n.screenChatBackupRecoveryActionChangeDescription,
@@ -92,10 +90,12 @@ struct SecureBackupScreen: View {
iconAlignment: .top),
details: .icon(BadgeView(size: 10)),
kind: .navigationLink { context.send(viewAction: .recoveryKey) })
.accessibilityIdentifier(A11yIdentifiers.secureBackupScreen.recoveryKey)
case .incomplete:
ListRow(label: .plain(title: L10n.screenChatBackupRecoveryActionConfirm),
details: .icon(BadgeView(size: 10)),
kind: .navigationLink { context.send(viewAction: .recoveryKey) })
.accessibilityIdentifier(A11yIdentifiers.secureBackupScreen.recoveryKey)
default:
ListRow(label: .plain(title: L10n.commonLoading), details: .isWaiting(true), kind: .label)
}