Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AuthenticatorShared/UI/Platform/Application/AppModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
& HasConfigService
& HasErrorReporter
& HasExportItemsService
& HasFlightRecorder
& HasPasteboardService
& HasStateService

Expand Down Expand Up @@ -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):
Expand All @@ -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)
}
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand All @@ -28,6 +29,7 @@ class SettingsProcessorTests: BitwardenTestCase {
biometricsRepository = MockBiometricsRepository()
configService = MockConfigService()
coordinator = MockCoordinator()
flightRecorder = MockFlightRecorder()
pasteboardService = MockPasteboardService()
subject = SettingsProcessor(
coordinator: coordinator.asAnyCoordinator(),
Expand All @@ -37,6 +39,7 @@ class SettingsProcessorTests: BitwardenTestCase {
authenticatorItemRepository: authItemRepository,
biometricsRepository: biometricsRepository,
configService: configService,
flightRecorder: flightRecorder,
pasteboardService: pasteboardService,
),
state: SettingsState(),
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
& HasErrorAlertServices.ErrorAlertServices
& HasErrorReporter
& HasExportItemsService
& HasFlightRecorder
& HasImportItemsService
& HasPasteboardService
& HasStateService
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions GlobalTestHelpers-bwa/MockAppModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class MockAppModule:
AuthModule,
DebugMenuModule,
FileSelectionModule,
FlightRecorderModule,
ItemListModule,
TutorialModule,
TabModule {
Expand All @@ -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>()
Expand Down Expand Up @@ -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> {
Expand Down