mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// periphery: ignore - used to store the coordinator to avoid deallocation
|
||||
private var appLockFlowCoordinator: AppLockSetupFlowCoordinator?
|
||||
// periphery: ignore - used to store the coordinator to avoid deallocation
|
||||
private var encryptionResetFlowCoordinator: EncryptionResetFlowCoordinator?
|
||||
|
||||
private let actionsSubject: PassthroughSubject<OnboardingFlowCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<OnboardingFlowCoordinatorAction, Never> {
|
||||
@@ -251,7 +253,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
|
||||
appSettings.hasRunIdentityConfirmationOnboarding = true
|
||||
stateMachine.tryEvent(.nextSkippingIdentityConfimed)
|
||||
case .reset:
|
||||
presentEncryptionResetScreen()
|
||||
startEncryptionResetFlow()
|
||||
case .logout:
|
||||
actionsSubject.send(.logout)
|
||||
}
|
||||
@@ -295,12 +297,8 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .recoveryFixed:
|
||||
case .complete:
|
||||
break // Moving to next state is Handled by the global session verification listener
|
||||
case .resetEncryption:
|
||||
presentEncryptionResetScreen()
|
||||
default:
|
||||
MXLog.error("Unexpected recovery action: \(action)")
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -308,31 +306,31 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
|
||||
presentCoordinator(coordinator)
|
||||
}
|
||||
|
||||
private func presentEncryptionResetScreen() {
|
||||
private func startEncryptionResetFlow() {
|
||||
let resetNavigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let coordinator = EncryptionResetScreenCoordinator(parameters: .init(clientProxy: userSession.clientProxy,
|
||||
navigationStackCoordinator: resetNavigationStackCoordinator,
|
||||
userIndicatorController: userIndicatorController))
|
||||
let coordinator = EncryptionResetFlowCoordinator(parameters: .init(userSession: userSession,
|
||||
userIndicatorController: userIndicatorController,
|
||||
navigationStackCoordinator: resetNavigationStackCoordinator,
|
||||
windowManger: windowManager))
|
||||
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .cancel:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .requestOIDCAuthorisation(let url):
|
||||
presentOIDCAuthorisationScreen(url: url)
|
||||
case .resetFinished:
|
||||
case .resetComplete:
|
||||
// Moving to next state is handled by the global session verification listener
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .cancel:
|
||||
navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
resetNavigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
encryptionResetFlowCoordinator = coordinator
|
||||
coordinator.start()
|
||||
|
||||
navigationStackCoordinator.setSheetCoordinator(resetNavigationStackCoordinator)
|
||||
navigationStackCoordinator.setSheetCoordinator(resetNavigationStackCoordinator) { [weak self] in
|
||||
self?.encryptionResetFlowCoordinator = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func presentIdentityConfirmedScreen() {
|
||||
@@ -411,12 +409,4 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
|
||||
navigationStackCoordinator.push(coordinator, dismissalCallback: dismissalCallback)
|
||||
}
|
||||
}
|
||||
|
||||
private var accountSettingsPresenter: OIDCAccountSettingsPresenter?
|
||||
private func presentOIDCAuthorisationScreen(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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,10 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
// periphery:ignore - retaining purpose
|
||||
private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator?
|
||||
|
||||
// periphery:ignore - retaining purpose
|
||||
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
|
||||
// periphery:ignore - retaining purpose
|
||||
private var encryptionSettingsFlowCoordinator: EncryptionSettingsFlowCoordinator?
|
||||
|
||||
private let actionsSubject: PassthroughSubject<SettingsFlowCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<SettingsFlowCoordinatorAction, Never> {
|
||||
@@ -68,7 +69,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
// The navigation stack doesn't like it if the root and the push happen
|
||||
// on the same loop run
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
self.presentSecureBackupScreen(animated: animated)
|
||||
self.startEncryptionSettingsFlow(animated: animated)
|
||||
}
|
||||
default:
|
||||
break
|
||||
@@ -102,7 +103,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
self.actionsSubject.send(.runLogoutFlow)
|
||||
}
|
||||
case .secureBackup:
|
||||
presentSecureBackupScreen(animated: true)
|
||||
startEncryptionSettingsFlow(animated: true)
|
||||
case .userDetails:
|
||||
presentUserDetailsEditScreen()
|
||||
case let .manageAccount(url):
|
||||
@@ -145,21 +146,22 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
|
||||
actionsSubject.send(.presentedSettings)
|
||||
}
|
||||
|
||||
private func presentSecureBackupScreen(animated: Bool) {
|
||||
let coordinator = SecureBackupScreenCoordinator(parameters: .init(appSettings: parameters.appSettings,
|
||||
clientProxy: parameters.userSession.clientProxy,
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
userIndicatorController: parameters.userIndicatorController))
|
||||
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
private func startEncryptionSettingsFlow(animated: Bool) {
|
||||
let coordinator = EncryptionSettingsFlowCoordinator(parameters: .init(userSession: parameters.userSession,
|
||||
appSettings: parameters.appSettings,
|
||||
userIndicatorController: parameters.userIndicatorController,
|
||||
navigationStackCoordinator: navigationStackCoordinator))
|
||||
coordinator.actionsPublisher.sink { [weak self] action in
|
||||
switch action {
|
||||
case .requestOIDCAuthorisation(let url):
|
||||
self?.presentAccountManagementURL(url)
|
||||
case .complete:
|
||||
// The flow coordinator tidies up the stack, no need to do anything.
|
||||
self?.encryptionSettingsFlowCoordinator = nil
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.push(coordinator, animated: animated)
|
||||
encryptionSettingsFlowCoordinator = coordinator
|
||||
coordinator.start()
|
||||
}
|
||||
|
||||
private func presentUserDetailsEditScreen() {
|
||||
|
||||
Reference in New Issue
Block a user