[PM-26063] Add flight recorder to Authenticator's settings (#2147)

This commit is contained in:
Matt Czech 2025-11-17 14:45:38 -06:00 committed by GitHub
parent b8161ee72c
commit 26c158b2e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 184 additions and 1 deletions

View File

@ -56,3 +56,17 @@ extension DefaultAppModule: AppModule {
).asAnyCoordinator()
}
}
// MARK: - DefaultAppModule + FlightRecorderModule
extension DefaultAppModule: FlightRecorderModule {
public func makeFlightRecorderCoordinator(
stackNavigator: StackNavigator,
) -> AnyCoordinator<FlightRecorderRoute, Void> {
FlightRecorderCoordinator(
services: services,
stackNavigator: stackNavigator,
)
.asAnyCoordinator()
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -17,6 +17,7 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
& HasConfigService
& HasErrorReporter
& HasExportItemsService
& HasFlightRecorder
& HasPasteboardService
& HasStateService
@ -51,6 +52,15 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
override func perform(_ effect: SettingsEffect) async {
switch effect {
case let .flightRecorder(flightRecorderEffect):
switch flightRecorderEffect {
case let .toggleFlightRecorder(isOn):
if isOn {
coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
} else {
await services.flightRecorder.disableFlightRecorder()
}
}
case .loadData:
await loadData()
case let .sessionTimeoutValueChanged(timeoutValue):
@ -65,6 +75,8 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
minutes: timeoutValue.rawValue,
userId: services.appSettingsStore.localUserId,
)
case .streamFlightRecorderLog:
await streamFlightRecorderLog()
case let .toggleUnlockWithBiometrics(isOn):
await setBiometricAuth(isOn)
}
@ -88,6 +100,11 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
services.appSettingsStore.defaultSaveOption = option
case .exportItemsTapped:
coordinator.navigate(to: .exportItems)
case let .flightRecorder(flightRecorderAction):
switch flightRecorderAction {
case .viewLogsTapped:
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
}
case .helpCenterTapped:
state.url = ExternalLinksConstants.helpAndFeedback
case .importItemsTapped:
@ -189,6 +206,13 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
services.errorReporter.log(error: error)
}
}
/// Streams the flight recorder's active log metadata.
private func streamFlightRecorderLog() async {
for await activeLog in await services.flightRecorder.activeLogPublisher().values {
state.flightRecorderState.activeLog = activeLog
}
}
}
// MARK: - SelectLanguageDelegate

View File

@ -14,6 +14,7 @@ class SettingsProcessorTests: BitwardenTestCase {
var biometricsRepository: MockBiometricsRepository!
var configService: MockConfigService!
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
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

View File

@ -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

View File

@ -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 {

View File

@ -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)

View File

@ -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() {

View File

@ -69,6 +69,16 @@ class SettingsCoordinatorTests: BitwardenTestCase {
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<ExportItemsView>)
}
/// `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 {

View File

@ -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

View File

@ -9,6 +9,7 @@ class MockAppModule:
AuthModule,
DebugMenuModule,
FileSelectionModule,
FlightRecorderModule,
ItemListModule,
TutorialModule,
TabModule {
@ -18,6 +19,7 @@ class MockAppModule:
var debugMenuCoordinator = MockCoordinator<DebugMenuRoute, Void>()
var fileSelectionDelegate: FileSelectionDelegate?
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, FileSelectionEvent>()
var flightRecorderCoordinator = MockCoordinator<FlightRecorderRoute, Void>()
var itemListCoordinator = MockCoordinator<ItemListRoute, ItemListEvent>()
var tabCoordinator = MockCoordinator<TabRoute, Void>()
var tutorialCoordinator = MockCoordinator<TutorialRoute, TutorialEvent>()
@ -55,6 +57,12 @@ class MockAppModule:
return fileSelectionCoordinator.asAnyCoordinator()
}
func makeFlightRecorderCoordinator(
stackNavigator _: StackNavigator,
) -> AnyCoordinator<FlightRecorderRoute, Void> {
flightRecorderCoordinator.asAnyCoordinator()
}
func makeItemListCoordinator(
stackNavigator _: StackNavigator,
) -> AnyCoordinator<ItemListRoute, ItemListEvent> {