mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 00:42:29 -06:00
[PM-26063] Add flight recorder to Authenticator's settings (#2147)
This commit is contained in:
parent
b8161ee72c
commit
26c158b2e5
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user