[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() ).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. /// The export items button was tapped.
case exportItemsTapped case exportItemsTapped
/// An action for the Flight Recorder feature.
case flightRecorder(FlightRecorderSettingsSectionAction)
/// The help center button was tapped. /// The help center button was tapped.
case helpCenterTapped case helpCenterTapped

View File

@ -4,12 +4,18 @@ import BitwardenKit
/// Effects that can be processed by an `SettingsProcessor`. /// Effects that can be processed by an `SettingsProcessor`.
enum SettingsEffect: Equatable { enum SettingsEffect: Equatable {
/// An effect for the flight recorder feature.
case flightRecorder(FlightRecorderSettingsSectionEffect)
/// The view appeared so the initial data should be loaded. /// The view appeared so the initial data should be loaded.
case loadData case loadData
/// The session timeout value was changed. /// The session timeout value was changed.
case sessionTimeoutValueChanged(SessionTimeoutValue) case sessionTimeoutValueChanged(SessionTimeoutValue)
/// Stream the active flight recorder log.
case streamFlightRecorderLog
/// Unlock with Biometrics was toggled. /// Unlock with Biometrics was toggled.
case toggleUnlockWithBiometrics(Bool) case toggleUnlockWithBiometrics(Bool)
} }

View File

@ -17,6 +17,7 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
& HasConfigService & HasConfigService
& HasErrorReporter & HasErrorReporter
& HasExportItemsService & HasExportItemsService
& HasFlightRecorder
& HasPasteboardService & HasPasteboardService
& HasStateService & HasStateService
@ -51,6 +52,15 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
override func perform(_ effect: SettingsEffect) async { override func perform(_ effect: SettingsEffect) async {
switch effect { 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: case .loadData:
await loadData() await loadData()
case let .sessionTimeoutValueChanged(timeoutValue): case let .sessionTimeoutValueChanged(timeoutValue):
@ -65,6 +75,8 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
minutes: timeoutValue.rawValue, minutes: timeoutValue.rawValue,
userId: services.appSettingsStore.localUserId, userId: services.appSettingsStore.localUserId,
) )
case .streamFlightRecorderLog:
await streamFlightRecorderLog()
case let .toggleUnlockWithBiometrics(isOn): case let .toggleUnlockWithBiometrics(isOn):
await setBiometricAuth(isOn) await setBiometricAuth(isOn)
} }
@ -88,6 +100,11 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
services.appSettingsStore.defaultSaveOption = option services.appSettingsStore.defaultSaveOption = option
case .exportItemsTapped: case .exportItemsTapped:
coordinator.navigate(to: .exportItems) coordinator.navigate(to: .exportItems)
case let .flightRecorder(flightRecorderAction):
switch flightRecorderAction {
case .viewLogsTapped:
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
}
case .helpCenterTapped: case .helpCenterTapped:
state.url = ExternalLinksConstants.helpAndFeedback state.url = ExternalLinksConstants.helpAndFeedback
case .importItemsTapped: case .importItemsTapped:
@ -189,6 +206,13 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
services.errorReporter.log(error: error) 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 // MARK: - SelectLanguageDelegate

View File

@ -14,6 +14,7 @@ class SettingsProcessorTests: BitwardenTestCase {
var biometricsRepository: MockBiometricsRepository! var biometricsRepository: MockBiometricsRepository!
var configService: MockConfigService! var configService: MockConfigService!
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>! var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var flightRecorder: MockFlightRecorder!
var pasteboardService: MockPasteboardService! var pasteboardService: MockPasteboardService!
var subject: SettingsProcessor! var subject: SettingsProcessor!
@ -28,6 +29,7 @@ class SettingsProcessorTests: BitwardenTestCase {
biometricsRepository = MockBiometricsRepository() biometricsRepository = MockBiometricsRepository()
configService = MockConfigService() configService = MockConfigService()
coordinator = MockCoordinator() coordinator = MockCoordinator()
flightRecorder = MockFlightRecorder()
pasteboardService = MockPasteboardService() pasteboardService = MockPasteboardService()
subject = SettingsProcessor( subject = SettingsProcessor(
coordinator: coordinator.asAnyCoordinator(), coordinator: coordinator.asAnyCoordinator(),
@ -37,6 +39,7 @@ class SettingsProcessorTests: BitwardenTestCase {
authenticatorItemRepository: authItemRepository, authenticatorItemRepository: authItemRepository,
biometricsRepository: biometricsRepository, biometricsRepository: biometricsRepository,
configService: configService, configService: configService,
flightRecorder: flightRecorder,
pasteboardService: pasteboardService, pasteboardService: pasteboardService,
), ),
state: SettingsState(), state: SettingsState(),
@ -52,12 +55,38 @@ class SettingsProcessorTests: BitwardenTestCase {
biometricsRepository = nil biometricsRepository = nil
configService = nil configService = nil
coordinator = nil coordinator = nil
flightRecorder = nil
pasteboardService = nil pasteboardService = nil
subject = nil subject = nil
} }
// MARK: Tests // 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'. /// Performing `.loadData` sets the 'defaultSaveOption' to the current value in 'AppSettingsStore'.
@MainActor @MainActor
func test_perform_loadData_defaultSaveOption() async throws { func test_perform_loadData_defaultSaveOption() async throws {
@ -157,6 +186,26 @@ class SettingsProcessorTests: BitwardenTestCase {
XCTAssertEqual(subject.state.sessionTimeoutValue, .fifteenMinutes) 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 /// Performing `.toggleUnlockWithBiometrics` with a `false` value disables biometric unlock and resets the
/// session timeout to `.never` /// session timeout to `.never`
@MainActor @MainActor
@ -216,6 +265,15 @@ class SettingsProcessorTests: BitwardenTestCase {
XCTAssertEqual(coordinator.routes.last, .exportItems) 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 /// Receiving `.syncWithBitwardenAppTapped` adds the Password Manager settings URL to the state to
/// navigate the user to the BWPM app's settings. /// navigate the user to the BWPM app's settings.
@MainActor @MainActor

View File

@ -22,6 +22,9 @@ struct SettingsState: Equatable {
/// The current default save option. /// The current default save option.
var defaultSaveOption: DefaultSaveOption = .none var defaultSaveOption: DefaultSaveOption = .none
/// The state for the Flight Recorder feature.
var flightRecorderState = FlightRecorderSettingsSectionState()
/// The current default save option. /// The current default save option.
var sessionTimeoutValue: SessionTimeoutValue = .never var sessionTimeoutValue: SessionTimeoutValue = .never

View File

@ -73,6 +73,34 @@ class SettingsViewTests: BitwardenTestCase {
XCTAssertEqual(processor.dispatchedActions.last, .exportItemsTapped) 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. /// Tapping the help center button dispatches the `.helpCenterTapped` action.
@MainActor @MainActor
func test_helpCenterButton_tap() throws { func test_helpCenterButton_tap() throws {

View File

@ -49,13 +49,24 @@ struct SettingsView: View {
.task { .task {
await store.perform(.loadData) await store.perform(.loadData)
} }
.task {
await store.perform(.streamFlightRecorderLog)
}
} }
// MARK: Private views // MARK: Private views
/// The about section containing privacy policy and version information. /// The about section containing privacy policy and version information.
@ViewBuilder private var aboutSection: some View { @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) { ContentBlock(dividerLeadingPadding: 16) {
externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped) 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. /// The module types required by this coordinator for creating child coordinators.
typealias Module = FileSelectionModule typealias Module = FileSelectionModule
& FlightRecorderModule
& TutorialModule & TutorialModule
typealias Services = HasAppInfoService typealias Services = HasAppInfoService
@ -23,6 +24,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
& HasErrorAlertServices.ErrorAlertServices & HasErrorAlertServices.ErrorAlertServices
& HasErrorReporter & HasErrorReporter
& HasExportItemsService & HasExportItemsService
& HasFlightRecorder
& HasImportItemsService & HasImportItemsService
& HasPasteboardService & HasPasteboardService
& HasStateService & HasStateService
@ -81,6 +83,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
stackNavigator?.dismiss() stackNavigator?.dismiss()
case .exportItems: case .exportItems:
showExportItems() showExportItems()
case let .flightRecorder(flightRecorderRoute):
showFlightRecorder(route: flightRecorderRoute)
case .importItems: case .importItems:
showImportItems() showImportItems()
case let .importItemsFileSelection(route): case let .importItemsFileSelection(route):
@ -132,6 +136,17 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
stackNavigator?.present(navController) 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. /// Presents an activity controller for importing items.
/// ///
private func showImportItems() { private func showImportItems() {

View File

@ -69,6 +69,16 @@ class SettingsCoordinatorTests: BitwardenTestCase {
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<ExportItemsView>) 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. /// `navigate(to:)` with `.selectLanguage()` presents the select language view.
@MainActor @MainActor
func test_navigateTo_selectLanguage() throws { func test_navigateTo_selectLanguage() throws {

View File

@ -11,6 +11,9 @@ public enum SettingsRoute: Equatable, Hashable {
/// A route to the export items view. /// A route to the export items view.
case exportItems case exportItems
/// A route to a Flight Recorder view.
case flightRecorder(FlightRecorderRoute)
/// A route to the import items view. /// A route to the import items view.
case importItems case importItems

View File

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