Onboarding flow coordinator and FTUE changes (#2578)

Fixes #2595, fixes #2594, fixes #2593, fixes #2592, fixes #2591
This commit is contained in:
Stefan Ceriu
2024-03-21 14:01:23 +02:00
committed by GitHub
parent 9e6b6ba60a
commit a62c96f1c5
176 changed files with 1886 additions and 912 deletions
@@ -24,7 +24,6 @@ protocol AuthenticationFlowCoordinatorDelegate: AnyObject {
class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
private let authenticationService: AuthenticationServiceProxyProtocol
private let appLockService: AppLockServiceProtocol
private let bugReportService: BugReportServiceProtocol
private let navigationRootCoordinator: NavigationRootCoordinator
private let navigationStackCoordinator: NavigationStackCoordinator
@@ -36,23 +35,18 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
private var oidcPresenter: OIDCAuthenticationPresenter?
// periphery: ignore - used to store the coordinator to avoid deallocation
private var appLockFlowCoordinator: AppLockSetupFlowCoordinator?
// periphery:ignore - retaining purpose
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
weak var delegate: AuthenticationFlowCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol,
appLockService: AppLockServiceProtocol,
bugReportService: BugReportServiceProtocol,
navigationRootCoordinator: NavigationRootCoordinator,
appSettings: AppSettings,
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol) {
self.authenticationService = authenticationService
self.appLockService = appLockService
self.bugReportService = bugReportService
self.navigationRootCoordinator = navigationRootCoordinator
self.appSettings = appSettings
@@ -108,7 +102,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
private func showReportProblemScreen() {
bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(navigationStackCoordinator),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
userIndicatorController: userIndicatorController,
bugReportService: bugReportService,
userID: nil,
deviceID: nil))
@@ -265,58 +259,10 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
private func userHasSignedIn(userSession: UserSessionProtocol) {
appSettings.lastLoginDate = .now
if appSettings.appLockIsMandatory, !appLockService.isEnabled {
showAppLockSetupFlow(userSession: userSession)
} else if analytics.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(userSession: userSession)
} else {
delegate?.authenticationFlowCoordinator(didLoginWithSession: userSession)
}
delegate?.authenticationFlowCoordinator(didLoginWithSession: userSession)
}
private func showAppLockSetupFlow(userSession: UserSessionProtocol) {
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .onboarding,
appLockService: appLockService,
navigationStackCoordinator: navigationStackCoordinator)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .complete:
appLockFlowCoordinator = nil
if analytics.shouldShowAnalyticsPrompt {
showAnalyticsPrompt(userSession: userSession)
} else {
delegate?.authenticationFlowCoordinator(didLoginWithSession: userSession)
}
case .forceLogout:
fatalError("The PIN creation flow should not fail.")
}
}
.store(in: &cancellables)
appLockFlowCoordinator = coordinator
coordinator.start()
}
private func showAnalyticsPrompt(userSession: UserSessionProtocol) {
let coordinator = AnalyticsPromptScreenCoordinator(analytics: analytics, termsURL: appSettings.analyticsConfiguration.termsURL)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .done:
delegate?.authenticationFlowCoordinator(didLoginWithSession: userSession)
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator)
}
private static let loadingIndicatorIdentifier = "authenticationFlowCoordinatorLoading"
private static let loadingIndicatorIdentifier = "\(AuthenticationFlowCoordinator.self)-Loading"
private func startLoading() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
@@ -0,0 +1,343 @@
//
// Copyright 2024 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
import SwiftState
class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
private let userSession: UserSessionProtocol
private let appLockService: AppLockServiceProtocol
private let analyticsService: AnalyticsService
private let appSettings: AppSettings
private let notificationManager: NotificationManagerProtocol
private let rootNavigationStackCoordinator: NavigationStackCoordinator
private let userIndicatorController: UserIndicatorControllerProtocol
private let isNewLogin: Bool
private var navigationStackCoordinator: NavigationStackCoordinator!
enum State: StateType {
case initial
case identityConfirmation
case identityConfirmed
case appLockSetup
case analyticsPrompt
case notificationPermissions
case finished
}
enum Event: EventType {
case next
}
private let stateMachine: StateMachine<State, Event>
private var cancellables = Set<AnyCancellable>()
// periphery: ignore - used to store the coordinator to avoid deallocation
private var appLockFlowCoordinator: AppLockSetupFlowCoordinator?
init(userSession: UserSessionProtocol,
appLockService: AppLockServiceProtocol,
analyticsService: AnalyticsService,
appSettings: AppSettings,
notificationManager: NotificationManagerProtocol,
navigationStackCoordinator: NavigationStackCoordinator,
userIndicatorController: UserIndicatorControllerProtocol,
isNewLogin: Bool) {
self.userSession = userSession
self.appLockService = appLockService
self.analyticsService = analyticsService
self.appSettings = appSettings
self.notificationManager = notificationManager
self.userIndicatorController = userIndicatorController
self.isNewLogin = isNewLogin
rootNavigationStackCoordinator = navigationStackCoordinator
self.navigationStackCoordinator = NavigationStackCoordinator()
stateMachine = .init(state: .initial)
}
var shouldStart: Bool {
guard stateMachine.state == .initial, !ProcessInfo.isRunningIntegrationTests else {
return false
}
return isNewLogin || requiresVerification || requiresAppLockSetup || requiresAnalyticsSetup || requiresNotificationsSetup
}
func start() {
guard shouldStart else {
fatalError("This flow coordinator shouldn't have been started")
}
configureStateMachine()
stateMachine.tryEvent(.next)
rootNavigationStackCoordinator.setFullScreenCoverCoordinator(navigationStackCoordinator, animated: !isNewLogin)
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
fatalError()
}
func clearRoute(animated: Bool) {
fatalError()
}
// MARK: - Private
private var requiresVerification: Bool {
// We want to make sure onboarding finishes but also every time the user becomes unverified (e.g. account reset)
!appSettings.hasRunIdentityConfirmationOnboarding || userSession.sessionSecurityStatePublisher.value.verificationState == .unverified
}
private var requiresAppLockSetup: Bool {
appSettings.appLockIsMandatory && !appLockService.isEnabled
}
private var requiresAnalyticsSetup: Bool {
analyticsService.shouldShowAnalyticsPrompt
}
private var requiresNotificationsSetup: Bool {
!appSettings.hasRunNotificationPermissionsOnboarding
}
private func configureStateMachine() {
stateMachine.addRouteMapping { [weak self] _, fromState, _ in
guard let self else {
return nil
}
switch (fromState, requiresVerification, requiresAppLockSetup, requiresAnalyticsSetup, requiresNotificationsSetup) {
case (.initial, true, _, _, _):
return .identityConfirmation
case (.initial, false, true, _, _):
return .appLockSetup
case (.initial, false, false, true, _):
return .analyticsPrompt
case (.initial, false, false, false, true):
return .notificationPermissions
case (.identityConfirmation, _, _, _, _):
return .identityConfirmed
case (.identityConfirmed, _, true, _, _):
return .appLockSetup
case (.identityConfirmed, _, false, true, _):
return .analyticsPrompt
case (.identityConfirmed, _, false, false, true):
return .notificationPermissions
case (.identityConfirmed, _, false, false, false):
return .finished
case (.appLockSetup, _, _, true, _):
return .analyticsPrompt
case (.appLockSetup, _, _, false, true):
return .notificationPermissions
case (.appLockSetup, _, _, false, false):
return .finished
case (.analyticsPrompt, _, _, _, true):
return .notificationPermissions
case (.analyticsPrompt, _, _, _, false):
return .finished
case (.notificationPermissions, _, _, _, _):
return .finished
default:
return nil
}
}
stateMachine.addAnyHandler(.any => .any) { [weak self] context in
guard let self else { return }
switch (context.fromState, context.event, context.toState) {
case (_, _, .identityConfirmation):
presentIdentityConfirmationScreen()
case (_, _, .identityConfirmed):
presentIdentityConfirmedScreen()
case (_, _, .appLockSetup):
presentAppLockSetupFlow()
case (_, _, .analyticsPrompt):
presentAnalyticsPromptScreen()
case (_, _, .notificationPermissions):
presentNotificationPermissionsScreen()
case (_, _, .finished):
rootNavigationStackCoordinator.setFullScreenCoverCoordinator(nil)
default:
fatalError("Unknown transition: \(context)")
}
}
stateMachine.addErrorHandler { context in
fatalError("Unexpected transition: \(context)")
}
}
private func presentIdentityConfirmationScreen() {
let parameters = IdentityConfirmationScreenCoordinatorParameters(userSession: userSession,
appSettings: appSettings,
userIndicatorController: userIndicatorController)
let coordinator = IdentityConfirmationScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .otherDevice:
Task {
await self.presentSessionVerificationScreen()
}
case .recoveryKey:
presentRecoveryKeyScreen()
}
}
.store(in: &cancellables)
presentCoordinator(coordinator)
}
private func presentSessionVerificationScreen() async {
guard case let .success(sessionVerificationController) = await userSession.clientProxy.sessionVerificationControllerProxy() else {
fatalError("The sessionVerificationController should aways be valid at this point")
}
let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController)
let coordinator = SessionVerificationScreenCoordinator(parameters: parameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .done:
appSettings.hasRunIdentityConfirmationOnboarding = true
stateMachine.tryEvent(.next)
}
}
.store(in: &cancellables)
presentCoordinator(coordinator)
}
private func presentRecoveryKeyScreen() {
let parameters = SecureBackupRecoveryKeyScreenCoordinatorParameters(secureBackupController: userSession.clientProxy.secureBackupController,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
isModallyPresented: false)
let coordinator = SecureBackupRecoveryKeyScreenCoordinator(parameters: parameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .recoveryFixed:
appSettings.hasRunIdentityConfirmationOnboarding = true
stateMachine.tryEvent(.next)
default:
fatalError("Other flows shouldn't be possible")
}
}
.store(in: &cancellables)
presentCoordinator(coordinator)
}
private func presentIdentityConfirmedScreen() {
let coordinator = IdentityConfirmedScreenCoordinator(parameters: .init())
coordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .done:
stateMachine.tryEvent(.next)
}
}
.store(in: &cancellables)
presentCoordinator(coordinator)
}
private func presentAppLockSetupFlow() {
let coordinator = AppLockSetupFlowCoordinator(presentingFlow: .onboarding,
appLockService: appLockService,
navigationStackCoordinator: navigationStackCoordinator)
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .complete:
appLockFlowCoordinator = nil
stateMachine.tryEvent(.next)
case .forceLogout:
fatalError("The PIN creation flow should not fail.")
}
}
.store(in: &cancellables)
appLockFlowCoordinator = coordinator
coordinator.start()
}
private func presentAnalyticsPromptScreen() {
let coordinator = AnalyticsPromptScreenCoordinator(analytics: analyticsService, termsURL: appSettings.analyticsConfiguration.termsURL)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .done:
stateMachine.tryEvent(.next)
}
}
.store(in: &cancellables)
presentCoordinator(coordinator)
}
private func presentNotificationPermissionsScreen() {
let coordinator = NotificationPermissionsScreenCoordinator(parameters: .init(notificationManager: notificationManager))
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .done:
appSettings.hasRunNotificationPermissionsOnboarding = true
stateMachine.tryEvent(.next)
}
}
.store(in: &cancellables)
presentCoordinator(coordinator)
}
private func presentCoordinator(_ coordinator: CoordinatorProtocol) {
if navigationStackCoordinator.rootCoordinator == nil {
navigationStackCoordinator.setRootCoordinator(coordinator)
} else {
navigationStackCoordinator.push(coordinator)
}
}
}
@@ -434,8 +434,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
secureBackupController: userSession.clientProxy.secureBackupController)
timelineItemFactory: timelineItemFactory)
self.timelineController = timelineController
analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace)
@@ -918,8 +917,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
let roomTimelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
secureBackupController: userSession.clientProxy.secureBackupController)
timelineItemFactory: timelineItemFactory)
let parameters = RoomPollsHistoryScreenCoordinatorParameters(roomProxy: roomProxy,
pollInteractionHandler: PollInteractionHandler(analyticsService: analytics, roomProxy: roomProxy),
@@ -130,10 +130,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
presentLegalInformationScreen()
case .blockedUsers:
presentBlockedUsersScreen()
case .sessionVerification:
Task {
await self.presentSessionVerificationScreen()
}
case .accountSessions:
presentAccountSessionsListURL()
case .notifications:
@@ -212,35 +208,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
userIndicatorController: parameters.userIndicatorController))
navigationStackCoordinator.push(coordinator)
}
private func presentSessionVerificationScreen() async {
guard case let .success(sessionVerificationController) = await parameters.userSession.clientProxy.sessionVerificationControllerProxy() else {
fatalError("The sessionVerificationController should aways be valid at this point")
}
let verificationParameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController,
recoveryState: parameters.userSession.sessionSecurityStatePublisher.value.recoveryState)
let coordinator = SessionVerificationScreenCoordinator(parameters: verificationParameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .recoveryKey:
navigationStackCoordinator.setSheetCoordinator(nil)
handleAppRoute(.chatBackupSettings, animated: true)
case .done:
navigationStackCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setSheetCoordinator(coordinator) { [weak self] in
self?.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
private func presentNotificationSettings() {
let notificationParameters = NotificationSettingsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
userSession: parameters.userSession,
@@ -31,7 +31,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let windowManager: WindowManagerProtocol
private let bugReportService: BugReportServiceProtocol
private let appSettings: AppSettings
private let actionsSubject: PassthroughSubject<UserSessionFlowCoordinatorAction, Never> = .init()
private let stateMachine: UserSessionFlowCoordinatorStateMachine
@@ -39,6 +38,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let settingsFlowCoordinator: SettingsFlowCoordinator
private let onboardingFlowCoordinator: OnboardingFlowCoordinator
// periphery:ignore - retaining purpose
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
@@ -52,7 +53,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let selectedRoomSubject = CurrentValueSubject<String?, Never>(nil)
var actions: AnyPublisher<UserSessionFlowCoordinatorAction, Never> {
private let actionsSubject: PassthroughSubject<UserSessionFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<UserSessionFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
@@ -63,7 +65,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
bugReportService: BugReportServiceProtocol,
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
appSettings: AppSettings,
analytics: AnalyticsService) {
analytics: AnalyticsService,
notificationManager: NotificationManagerProtocol,
isNewLogin: Bool) {
stateMachine = UserSessionFlowCoordinatorStateMachine()
self.userSession = userSession
self.navigationRootCoordinator = navigationRootCoordinator
@@ -98,8 +102,29 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
navigationSplitCoordinator: navigationSplitCoordinator,
userIndicatorController: ServiceLocator.shared.userIndicatorController))
onboardingFlowCoordinator = OnboardingFlowCoordinator(userSession: userSession,
appLockService: appLockService,
analyticsService: analytics,
appSettings: appSettings,
notificationManager: notificationManager,
navigationStackCoordinator: detailNavigationStackCoordinator,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
isNewLogin: isNewLogin)
setupStateMachine()
userSession.sessionSecurityStatePublisher
.map(\.verificationState)
.filter { $0 != .unknown }
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
attemptStartingOnboarding()
}
.store(in: &cancellables)
roomFlowCoordinator.actions.sink { [weak self] action in
guard let self else { return }
@@ -208,11 +233,18 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
func clearRoute(animated: Bool) {
fatalError("not necessary as of right now")
roomFlowCoordinator.clearRoute(animated: animated)
}
// MARK: - Private
func attemptStartingOnboarding() {
if onboardingFlowCoordinator.shouldStart {
clearRoute(animated: false)
onboardingFlowCoordinator.start()
}
}
private func clearPresentedSheets(animated: Bool, completion: @escaping () -> Void) {
if navigationSplitCoordinator.sheetCoordinator == nil {
completion()
@@ -234,6 +266,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
switch (context.fromState, context.event, context.toState) {
case (.initial, .start, .roomList):
presentHomeScreen()
attemptStartingOnboarding()
case(.roomList, .selectRoom, .roomList):
break
@@ -244,13 +277,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
break
case (.invitesScreen, .deselectRoom, .invitesScreen):
break
case (.roomList, .showSessionVerificationScreen, .sessionVerificationScreen):
Task {
await self.presentSessionVerification(animated: animated)
}
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .roomList):
break
case (.roomList, .showSettingsScreen, .settingsScreen):
break
@@ -334,8 +360,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
settingsFlowCoordinator.handleAppRoute(.settings, animated: true)
case .presentFeedbackScreen:
stateMachine.processEvent(.feedbackScreen)
case .presentSessionVerificationScreen:
stateMachine.processEvent(.showSessionVerificationScreen)
case .presentSecureBackupSettings:
settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
case .presentStartChatScreen:
@@ -400,36 +424,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
presentSecureBackupLogoutConfirmationScreen()
}
// MARK: Session verification
private func presentSessionVerification(animated: Bool) async {
guard case let .success(sessionVerificationController) = await userSession.clientProxy.sessionVerificationControllerProxy() else {
fatalError("The sessionVerificationController should aways be valid at this point")
}
let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController,
recoveryState: userSession.sessionSecurityStatePublisher.value.recoveryState)
let coordinator = SessionVerificationScreenCoordinator(parameters: parameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .recoveryKey:
settingsFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
case .done:
navigationSplitCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
navigationSplitCoordinator.setSheetCoordinator(coordinator, animated: animated) { [weak self] in
self?.stateMachine.processEvent(.dismissedSessionVerificationScreen)
}
}
// MARK: Start Chat
private func presentStartChat(animated: Bool) {
@@ -26,9 +26,6 @@ class UserSessionFlowCoordinatorStateMachine {
/// Showing the home screen. The `selectedRoomID` represents the timeline shown on the detail panel (if any)
case roomList(selectedRoomID: String?)
/// Showing the session verification flows
case sessionVerificationScreen(selectedRoomID: String?)
/// Showing the session verification flows
case feedbackScreen(selectedRoomID: String?)
@@ -70,11 +67,6 @@ class UserSessionFlowCoordinatorStateMachine {
/// The feedback screen has been dismissed
case dismissedFeedbackScreen
/// Request the start of the session verification flow
case showSessionVerificationScreen
/// Session verification has finished
case dismissedSessionVerificationScreen
/// Request the start of the start chat flow
case showStartChatScreen
/// Start chat has been dismissed
@@ -126,11 +118,6 @@ class UserSessionFlowCoordinatorStateMachine {
case (.feedbackScreen(let selectedRoomID), .dismissedFeedbackScreen):
return .roomList(selectedRoomID: selectedRoomID)
case (.roomList(let selectedRoomID), .showSessionVerificationScreen):
return .sessionVerificationScreen(selectedRoomID: selectedRoomID)
case (.sessionVerificationScreen(let selectedRoomID), .dismissedSessionVerificationScreen):
return .roomList(selectedRoomID: selectedRoomID)
case (.roomList(let selectedRoomID), .showStartChatScreen):
return .startChatScreen(selectedRoomID: selectedRoomID)
case (.startChatScreen(let selectedRoomID), .dismissedStartChatScreen):