Key backup returns (#1951)

* Converge on UserSessionFlowCoordinator logout confirmation

* Add logout confirmation screen strings to untranslated.

* Fix chat backup learn more URl fragment.

* Implement logout flows that check recovery and key backup for the last session

* Move logout confirmation screen strings to localazy

* Change encrypted timeline item copy to "Waiting for decryption key"

* Use different encrypted history banner based on key backup states

* Introduce a SettingsFlowCoordinator and implement navigation directly to the secure backup screen from the logout flows.

* Fix **mocked** secure backup controller flows

* Simplify encrypted history banner logic

* Address PR comments
This commit is contained in:
Stefan Ceriu
2023-10-24 18:38:41 +03:00
committed by GitHub
parent 6e5680fc36
commit 37f1a79a11
34 changed files with 806 additions and 111 deletions
@@ -106,9 +106,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
MXLog.error("[RoomFlowCoordinator] Failed to get member: RoomProxy is nil")
}
}
case .invites:
break
case .genericCallLink, .oidcCallback:
case .invites, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
break
}
}
@@ -347,7 +345,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: userSession.voiceMessageMediaManager)
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
secureBackupController: userSession.clientProxy.secureBackupController)
self.timelineController = timelineController
analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace)
@@ -0,0 +1,135 @@
//
// Copyright 2023 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 Foundation
enum SettingsFlowCoordinatorAction {
case presentedSettings
case dismissedSettings
case runLogoutFlow
case clearCache
}
struct SettingsFlowCoordinatorParameters {
let userSession: UserSessionProtocol
let appLockService: AppLockServiceProtocol
let bugReportService: BugReportServiceProtocol
let notificationSettings: NotificationSettingsProxyProtocol
let secureBackupController: SecureBackupControllerProtocol
let appSettings: AppSettings
let navigationSplitCoordinator: NavigationSplitCoordinator
}
class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private let parameters: SettingsFlowCoordinatorParameters
private var navigationStackCoordinator: NavigationStackCoordinator!
private var userIndicatorController: UserIndicatorControllerProtocol!
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SettingsFlowCoordinatorAction, Never> = .init()
var actions: AnyPublisher<SettingsFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SettingsFlowCoordinatorParameters) {
self.parameters = parameters
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch appRoute {
case .settings:
presentSettingsScreen(animated: animated)
case .chatBackupSettings:
if navigationStackCoordinator == nil {
presentSettingsScreen(animated: animated)
}
// The navigation stack doesn't like it if the root and the push happen
// on the same loop run
DispatchQueue.main.async {
self.presentSecureBackupScreen(animated: animated)
}
default:
break
}
}
func clearRoute(animated: Bool) { }
// MARK: - Private
private func presentSettingsScreen(animated: Bool) {
navigationStackCoordinator = NavigationStackCoordinator()
userIndicatorController = UserIndicatorController(rootCoordinator: navigationStackCoordinator)
let parameters = SettingsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
userIndicatorController: userIndicatorController,
userSession: parameters.userSession,
appLockService: parameters.appLockService,
bugReportService: parameters.bugReportService,
notificationSettings: parameters.userSession.clientProxy.notificationSettings,
secureBackupController: parameters.userSession.clientProxy.secureBackupController,
appSettings: parameters.appSettings)
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: parameters)
settingsScreenCoordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
self.parameters.navigationSplitCoordinator.setSheetCoordinator(nil)
case .logout:
self.parameters.navigationSplitCoordinator.setSheetCoordinator(nil)
// The settings sheet needs to be dismissed before the alert can be shown
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.actionsSubject.send(.runLogoutFlow)
}
case .clearCache:
actionsSubject.send(.clearCache)
case .secureBackup:
presentSecureBackupScreen(animated: true)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator, animated: animated)
self.parameters.navigationSplitCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
guard let self else { return }
navigationStackCoordinator = nil
userIndicatorController = nil
actionsSubject.send(.dismissedSettings)
}
actionsSubject.send(.presentedSettings)
}
private func presentSecureBackupScreen(animated: Bool) {
let coordinator = SecureBackupScreenCoordinator(parameters: .init(appSettings: parameters.appSettings,
secureBackupController: parameters.userSession.clientProxy.secureBackupController,
navigationStackCoordinator: navigationStackCoordinator,
userIndicatorController: userIndicatorController))
navigationStackCoordinator.push(coordinator, animated: animated)
}
}
@@ -18,7 +18,7 @@ import Combine
import SwiftUI
enum UserSessionFlowCoordinatorAction {
case signOut
case logout
case clearCache
}
@@ -34,6 +34,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let stateMachine: UserSessionFlowCoordinatorStateMachine
private let roomFlowCoordinator: RoomFlowCoordinator
private let settingsFlowCoordinator: SettingsFlowCoordinator
private var cancellables = Set<AnyCancellable>()
private var migrationCancellable: AnyCancellable?
@@ -76,11 +77,20 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
appSettings: appSettings,
analytics: analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
settingsFlowCoordinator = SettingsFlowCoordinator(parameters: .init(userSession: userSession,
appLockService: appLockService,
bugReportService: bugReportService,
notificationSettings: userSession.clientProxy.notificationSettings,
secureBackupController: userSession.clientProxy.secureBackupController,
appSettings: appSettings,
navigationSplitCoordinator: navigationSplitCoordinator))
setupStateMachine()
roomFlowCoordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentedRoom(let roomID):
analytics.signpost.beginRoomFlow(roomID)
@@ -93,6 +103,22 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
.store(in: &cancellables)
settingsFlowCoordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentedSettings:
stateMachine.processEvent(.showSettingsScreen)
case .dismissedSettings:
stateMachine.processEvent(.dismissedSettingsScreen)
case .runLogoutFlow:
runLogoutFlow()
case .clearCache:
actionsSubject.send(.clearCache)
}
}
.store(in: &cancellables)
}
func start() {
@@ -131,6 +157,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
case .oidcCallback:
break
case .settings, .chatBackupSettings:
settingsFlowCoordinator.handleAppRoute(appRoute, animated: animated)
}
}
}
@@ -193,7 +221,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
break
case (.roomList, .showSettingsScreen, .settingsScreen):
presentSettingsScreen(animated: animated)
break
case (.settingsScreen, .dismissedSettingsScreen, .roomList):
break
@@ -211,7 +239,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
presentInvitesList(animated: animated)
case (.invitesScreen, .showInvitesScreen, .invitesScreen):
break
case (.invitesScreen, .closedInvitesScreen, .roomList):
case (.invitesScreen, .dismissedInvitesScreen, .roomList):
break
case (.roomList, .showLogoutConfirmationScreen, .logoutConfirmationScreen):
presentSecureBackupConfirmationScreen()
case (.logoutConfirmationScreen, .dismissedLogoutConfirmationScreen, .roomList):
break
default:
@@ -288,15 +321,15 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
roomFlowCoordinator.handleAppRoute(.roomList, animated: true)
}
case .presentSettingsScreen:
stateMachine.processEvent(.showSettingsScreen)
settingsFlowCoordinator.handleAppRoute(.settings, animated: true)
case .presentFeedbackScreen:
stateMachine.processEvent(.feedbackScreen)
case .presentSessionVerificationScreen:
stateMachine.processEvent(.showSessionVerificationScreen)
case .presentStartChatScreen:
stateMachine.processEvent(.showStartChatScreen)
case .signOut:
actionsSubject.send(.signOut)
case .logout:
runLogoutFlow()
case .presentInvitesScreen:
stateMachine.processEvent(.showInvitesScreen)
}
@@ -321,44 +354,44 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
// MARK: Settings
private func presentSettingsScreen(animated: Bool) {
let settingsNavigationStackCoordinator = NavigationStackCoordinator()
private func runLogoutFlow() {
let secureBackupController = userSession.clientProxy.secureBackupController
let userIndicatorController = UserIndicatorController(rootCoordinator: settingsNavigationStackCoordinator)
let parameters = SettingsScreenCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator,
userIndicatorController: userIndicatorController,
userSession: userSession,
appLockService: appLockService,
bugReportService: bugReportService,
notificationSettings: userSession.clientProxy.notificationSettings,
secureBackupController: userSession.clientProxy.secureBackupController,
appSettings: appSettings)
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: parameters)
settingsScreenCoordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
navigationSplitCoordinator.setSheetCoordinator(nil)
case .logout:
navigationSplitCoordinator.setSheetCoordinator(nil)
actionsSubject.send(.signOut)
case .clearCache:
actionsSubject.send(.clearCache)
}
}
.store(in: &cancellables)
settingsNavigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator, animated: animated)
navigationSplitCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
self?.stateMachine.processEvent(.dismissedSettingsScreen)
guard secureBackupController.isLastSession, appSettings.chatBackupEnabled else {
ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.screenSignoutRecoveryDisabledTitle,
message: L10n.screenSignoutRecoveryDisabledSubtitle,
primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in
self?.actionsSubject.send(.logout)
})
return
}
guard secureBackupController.recoveryKeyState.value == .enabled else {
ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.screenSignoutRecoveryDisabledTitle,
message: L10n.screenSignoutRecoveryDisabledSubtitle,
primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in
self?.actionsSubject.send(.logout)
}, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in
self?.settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
})
return
}
guard secureBackupController.keyBackupState.value == .enabled else {
ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.screenSignoutKeyBackupDisabledTitle,
message: L10n.screenSignoutKeyBackupDisabledSubtitle,
primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in
self?.actionsSubject.send(.logout)
}, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in
self?.settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
})
return
}
presentSecureBackupConfirmationScreen()
}
// MARK: Session verification
@@ -457,7 +490,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables)
sidebarNavigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
self?.stateMachine.processEvent(.closedInvitesScreen)
self?.stateMachine.processEvent(.dismissedInvitesScreen)
}
}
@@ -479,4 +512,28 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
navigationSplitCoordinator.setSheetCoordinator(callScreenCoordinator, animated: true)
}
// MARK: Secure backup confirmation
private func presentSecureBackupConfirmationScreen() {
let coordinator = SecureBackupLogoutConfirmationScreenCoordinator(parameters: .init(secureBackupController: userSession.clientProxy.secureBackupController,
networkMonitor: ServiceLocator.shared.networkMonitor))
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
navigationSplitCoordinator.setSheetCoordinator(nil)
case .settings:
settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
case .logout:
actionsSubject.send(.logout)
}
}
.store(in: &cancellables)
navigationSplitCoordinator.setSheetCoordinator(coordinator, animated: true)
}
}
@@ -46,6 +46,9 @@ class UserSessionFlowCoordinatorStateMachine {
/// Showing invites list screen
case invitesScreen(selectedRoomID: String?)
// Showing the logout flows
case logoutConfirmationScreen(selectedRoomID: String?)
}
struct EventUserInfo {
@@ -100,7 +103,12 @@ class UserSessionFlowCoordinatorStateMachine {
/// Request presentation of the invites screen
case showInvitesScreen
/// The invites screen has been dismissed
case closedInvitesScreen
case dismissedInvitesScreen
/// Logout has been requested and this is the last sesion
case showLogoutConfirmationScreen
/// Logout has been cancelled
case dismissedLogoutConfirmationScreen
}
private let stateMachine: StateMachine<State, Event>
@@ -157,12 +165,17 @@ class UserSessionFlowCoordinatorStateMachine {
case (.showInvitesScreen, .invitesScreen(let selectedRoomID)):
return .invitesScreen(selectedRoomID: selectedRoomID)
case (.closedInvitesScreen, .invitesScreen(let selectedRoomID)):
case (.dismissedInvitesScreen, .invitesScreen(let selectedRoomID)):
return .roomList(selectedRoomID: selectedRoomID)
case (.presentWelcomeScreen, .roomList):
return .welcomeScreen
case (.showLogoutConfirmationScreen, .roomList(let selectedRoomID)):
return .logoutConfirmationScreen(selectedRoomID: selectedRoomID)
case (.dismissedLogoutConfirmationScreen, .logoutConfirmationScreen(let selectedRoomID)):
return .roomList(selectedRoomID: selectedRoomID)
default:
return nil
}