From 26c158b2e572977cdbe4c1b64c2ea27411fd257f Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Mon, 17 Nov 2025 14:45:38 -0600 Subject: [PATCH] [PM-26063] Add flight recorder to Authenticator's settings (#2147) --- .../UI/Platform/Application/AppModule.swift | 14 +++++ .../Settings/Settings/SettingsAction.swift | 3 + .../Settings/Settings/SettingsEffect.swift | 6 ++ .../Settings/Settings/SettingsProcessor.swift | 24 ++++++++ .../Settings/SettingsProcessorTests.swift | 58 +++++++++++++++++++ .../Settings/Settings/SettingsState.swift | 3 + .../SettingsView+ViewInspectorTests.swift | 28 +++++++++ .../Settings/Settings/SettingsView.swift | 13 ++++- .../Settings/SettingsCoordinator.swift | 15 +++++ .../Settings/SettingsCoordinatorTests.swift | 10 ++++ .../UI/Platform/Settings/SettingsRoute.swift | 3 + GlobalTestHelpers-bwa/MockAppModule.swift | 8 +++ 12 files changed, 184 insertions(+), 1 deletion(-) diff --git a/AuthenticatorShared/UI/Platform/Application/AppModule.swift b/AuthenticatorShared/UI/Platform/Application/AppModule.swift index c56d5702e..6d000585a 100644 --- a/AuthenticatorShared/UI/Platform/Application/AppModule.swift +++ b/AuthenticatorShared/UI/Platform/Application/AppModule.swift @@ -56,3 +56,17 @@ extension DefaultAppModule: AppModule { ).asAnyCoordinator() } } + +// MARK: - DefaultAppModule + FlightRecorderModule + +extension DefaultAppModule: FlightRecorderModule { + public func makeFlightRecorderCoordinator( + stackNavigator: StackNavigator, + ) -> AnyCoordinator { + FlightRecorderCoordinator( + services: services, + stackNavigator: stackNavigator, + ) + .asAnyCoordinator() + } +} diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift index 7f72b41f4..1963b661b 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift @@ -18,6 +18,9 @@ enum SettingsAction: Equatable { /// The export items button was tapped. case exportItemsTapped + /// An action for the Flight Recorder feature. + case flightRecorder(FlightRecorderSettingsSectionAction) + /// The help center button was tapped. case helpCenterTapped diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift index a602ca62a..bc3ed6e49 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift @@ -4,12 +4,18 @@ import BitwardenKit /// Effects that can be processed by an `SettingsProcessor`. enum SettingsEffect: Equatable { + /// An effect for the flight recorder feature. + case flightRecorder(FlightRecorderSettingsSectionEffect) + /// The view appeared so the initial data should be loaded. case loadData /// The session timeout value was changed. case sessionTimeoutValueChanged(SessionTimeoutValue) + /// Stream the active flight recorder log. + case streamFlightRecorderLog + /// Unlock with Biometrics was toggled. case toggleUnlockWithBiometrics(Bool) } diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift index cd338993a..cb3aa6694 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift @@ -17,6 +17,7 @@ final class SettingsProcessor: StateProcessor! + var flightRecorder: MockFlightRecorder! var pasteboardService: MockPasteboardService! var subject: SettingsProcessor! @@ -28,6 +29,7 @@ class SettingsProcessorTests: BitwardenTestCase { biometricsRepository = MockBiometricsRepository() configService = MockConfigService() coordinator = MockCoordinator() + flightRecorder = MockFlightRecorder() pasteboardService = MockPasteboardService() subject = SettingsProcessor( coordinator: coordinator.asAnyCoordinator(), @@ -37,6 +39,7 @@ class SettingsProcessorTests: BitwardenTestCase { authenticatorItemRepository: authItemRepository, biometricsRepository: biometricsRepository, configService: configService, + flightRecorder: flightRecorder, pasteboardService: pasteboardService, ), state: SettingsState(), @@ -52,12 +55,38 @@ class SettingsProcessorTests: BitwardenTestCase { biometricsRepository = nil configService = nil coordinator = nil + flightRecorder = nil pasteboardService = nil subject = nil } // MARK: Tests + /// `perform(_:)` with `.flightRecorder(.toggleFlightRecorder(true))` navigates to the enable + /// flight recorder screen when toggled on. + @MainActor + func test_perform_flightRecorder_toggleFlightRecorder_on() async { + XCTAssertNil(subject.state.flightRecorderState.activeLog) + + await subject.perform(.flightRecorder(.toggleFlightRecorder(true))) + + XCTAssertEqual(coordinator.routes, [.flightRecorder(.enableFlightRecorder)]) + } + + /// `perform(_:)` with `.flightRecorder(.toggleFlightRecorder(false))` disables the flight + /// recorder when toggled off. + @MainActor + func test_perform_flightRecorder_toggleFlightRecorder_off() async throws { + subject.state.flightRecorderState.activeLog = FlightRecorderData.LogMetadata( + duration: .eightHours, + startDate: .now, + ) + + await subject.perform(.flightRecorder(.toggleFlightRecorder(false))) + + XCTAssertTrue(flightRecorder.disableFlightRecorderCalled) + } + /// Performing `.loadData` sets the 'defaultSaveOption' to the current value in 'AppSettingsStore'. @MainActor func test_perform_loadData_defaultSaveOption() async throws { @@ -157,6 +186,26 @@ class SettingsProcessorTests: BitwardenTestCase { XCTAssertEqual(subject.state.sessionTimeoutValue, .fifteenMinutes) } + /// `perform(_:)` with `.streamFlightRecorderLog` subscribes to the active flight recorder log. + @MainActor + func test_perform_streamFlightRecorderLog() async throws { + XCTAssertNil(subject.state.flightRecorderState.activeLog) + + let task = Task { + await subject.perform(.streamFlightRecorderLog) + } + defer { task.cancel() } + + let log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now) + flightRecorder.activeLogSubject.send(log) + try await waitForAsync { self.subject.state.flightRecorderState.activeLog != nil } + XCTAssertEqual(subject.state.flightRecorderState.activeLog, log) + + flightRecorder.activeLogSubject.send(nil) + try await waitForAsync { self.subject.state.flightRecorderState.activeLog == nil } + XCTAssertNil(subject.state.flightRecorderState.activeLog) + } + /// Performing `.toggleUnlockWithBiometrics` with a `false` value disables biometric unlock and resets the /// session timeout to `.never` @MainActor @@ -216,6 +265,15 @@ class SettingsProcessorTests: BitwardenTestCase { XCTAssertEqual(coordinator.routes.last, .exportItems) } + /// `receive(_:)` with action `.flightRecorder(.viewLogsTapped)` navigates to the view flight + /// recorder logs screen. + @MainActor + func test_receive_flightRecorder_viewFlightRecorderLogsTapped() { + subject.receive(.flightRecorder(.viewLogsTapped)) + + XCTAssertEqual(coordinator.routes, [.flightRecorder(.flightRecorderLogs)]) + } + /// Receiving `.syncWithBitwardenAppTapped` adds the Password Manager settings URL to the state to /// navigate the user to the BWPM app's settings. @MainActor diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift index a5c6f25ad..0e5783bc2 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift @@ -22,6 +22,9 @@ struct SettingsState: Equatable { /// The current default save option. var defaultSaveOption: DefaultSaveOption = .none + /// The state for the Flight Recorder feature. + var flightRecorderState = FlightRecorderSettingsSectionState() + /// The current default save option. var sessionTimeoutValue: SessionTimeoutValue = .never diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift index 0b1287f5c..a212718d9 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift @@ -73,6 +73,34 @@ class SettingsViewTests: BitwardenTestCase { XCTAssertEqual(processor.dispatchedActions.last, .exportItemsTapped) } + /// The flight recorder toggle turns logging on and off. + @MainActor + func test_flightRecorder_toggle_tap() async throws { + let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder) + + try toggle.tap() + try await waitForAsync { !self.processor.effects.isEmpty } + XCTAssertEqual(processor.effects, [.flightRecorder(.toggleFlightRecorder(true))]) + processor.effects.removeAll() + + processor.state.flightRecorderState.activeLog = FlightRecorderData.LogMetadata( + duration: .eightHours, + startDate: .now, + ) + try toggle.tap() + try await waitForAsync { !self.processor.effects.isEmpty } + XCTAssertEqual(processor.effects, [.flightRecorder(.toggleFlightRecorder(false))]) + } + + /// Tapping the flight recorder view recorded logs button dispatches the + /// `.viewFlightRecorderLogsTapped` action. + @MainActor + func test_flightRecorder_viewRecordedLogsButton_tap() throws { + let button = try subject.inspect().find(button: Localizations.viewRecordedLogs) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .flightRecorder(.viewLogsTapped)) + } + /// Tapping the help center button dispatches the `.helpCenterTapped` action. @MainActor func test_helpCenterButton_tap() throws { diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift index 75a32e3b5..80d9ea3f3 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift @@ -49,13 +49,24 @@ struct SettingsView: View { .task { await store.perform(.loadData) } + .task { + await store.perform(.streamFlightRecorderLog) + } } // MARK: Private views /// The about section containing privacy policy and version information. @ViewBuilder private var aboutSection: some View { - SectionView(Localizations.about) { + SectionView(Localizations.about, contentSpacing: 8) { + FlightRecorderSettingsSectionView( + store: store.child( + state: \.flightRecorderState, + mapAction: { .flightRecorder($0) }, + mapEffect: { .flightRecorder($0) }, + ), + ) + ContentBlock(dividerLeadingPadding: 16) { externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped) diff --git a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift index 50dbea99b..fae934b8a 100644 --- a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -11,6 +11,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { /// The module types required by this coordinator for creating child coordinators. typealias Module = FileSelectionModule + & FlightRecorderModule & TutorialModule typealias Services = HasAppInfoService @@ -23,6 +24,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { & HasErrorAlertServices.ErrorAlertServices & HasErrorReporter & HasExportItemsService + & HasFlightRecorder & HasImportItemsService & HasPasteboardService & HasStateService @@ -81,6 +83,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { stackNavigator?.dismiss() case .exportItems: showExportItems() + case let .flightRecorder(flightRecorderRoute): + showFlightRecorder(route: flightRecorderRoute) case .importItems: showImportItems() case let .importItemsFileSelection(route): @@ -132,6 +136,17 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { stackNavigator?.present(navController) } + /// Shows a flight recorder view. + /// + /// - Parameter route: A `FlightRecorderRoute` to navigate to. + /// + private func showFlightRecorder(route: FlightRecorderRoute) { + guard let stackNavigator else { return } + let coordinator = module.makeFlightRecorderCoordinator(stackNavigator: stackNavigator) + coordinator.start() + coordinator.navigate(to: route) + } + /// Presents an activity controller for importing items. /// private func showImportItems() { diff --git a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinatorTests.swift b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinatorTests.swift index dad10ba8b..9bdf54985 100644 --- a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinatorTests.swift +++ b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinatorTests.swift @@ -69,6 +69,16 @@ class SettingsCoordinatorTests: BitwardenTestCase { XCTAssertTrue(navigationController.viewControllers.first is UIHostingController) } + /// `navigate(to:)` with `.flightRecorder` starts flight recorder coordinator and navigates to + /// the enable flight recorder view. + @MainActor + func test_navigateTo_flightRecorder() throws { + subject.navigate(to: .flightRecorder(.enableFlightRecorder)) + + XCTAssertTrue(module.flightRecorderCoordinator.isStarted) + XCTAssertEqual(module.flightRecorderCoordinator.routes.last, .enableFlightRecorder) + } + /// `navigate(to:)` with `.selectLanguage()` presents the select language view. @MainActor func test_navigateTo_selectLanguage() throws { diff --git a/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift b/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift index 6a3cf3094..d7bb671e8 100644 --- a/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift +++ b/AuthenticatorShared/UI/Platform/Settings/SettingsRoute.swift @@ -11,6 +11,9 @@ public enum SettingsRoute: Equatable, Hashable { /// A route to the export items view. case exportItems + /// A route to a Flight Recorder view. + case flightRecorder(FlightRecorderRoute) + /// A route to the import items view. case importItems diff --git a/GlobalTestHelpers-bwa/MockAppModule.swift b/GlobalTestHelpers-bwa/MockAppModule.swift index 761d11562..8908e119f 100644 --- a/GlobalTestHelpers-bwa/MockAppModule.swift +++ b/GlobalTestHelpers-bwa/MockAppModule.swift @@ -9,6 +9,7 @@ class MockAppModule: AuthModule, DebugMenuModule, FileSelectionModule, + FlightRecorderModule, ItemListModule, TutorialModule, TabModule { @@ -18,6 +19,7 @@ class MockAppModule: var debugMenuCoordinator = MockCoordinator() var fileSelectionDelegate: FileSelectionDelegate? var fileSelectionCoordinator = MockCoordinator() + var flightRecorderCoordinator = MockCoordinator() var itemListCoordinator = MockCoordinator() var tabCoordinator = MockCoordinator() var tutorialCoordinator = MockCoordinator() @@ -55,6 +57,12 @@ class MockAppModule: return fileSelectionCoordinator.asAnyCoordinator() } + func makeFlightRecorderCoordinator( + stackNavigator _: StackNavigator, + ) -> AnyCoordinator { + flightRecorderCoordinator.asAnyCoordinator() + } + func makeItemListCoordinator( stackNavigator _: StackNavigator, ) -> AnyCoordinator {