Skip to content

Commit 26c158b

Browse files
[PM-26063] Add flight recorder to Authenticator's settings (#2147)
1 parent b8161ee commit 26c158b

File tree

12 files changed

+184
-1
lines changed

12 files changed

+184
-1
lines changed

AuthenticatorShared/UI/Platform/Application/AppModule.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,17 @@ extension DefaultAppModule: AppModule {
5656
).asAnyCoordinator()
5757
}
5858
}
59+
60+
// MARK: - DefaultAppModule + FlightRecorderModule
61+
62+
extension DefaultAppModule: FlightRecorderModule {
63+
public func makeFlightRecorderCoordinator(
64+
stackNavigator: StackNavigator,
65+
) -> AnyCoordinator<FlightRecorderRoute, Void> {
66+
FlightRecorderCoordinator(
67+
services: services,
68+
stackNavigator: stackNavigator,
69+
)
70+
.asAnyCoordinator()
71+
}
72+
}

AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ enum SettingsAction: Equatable {
1818
/// The export items button was tapped.
1919
case exportItemsTapped
2020

21+
/// An action for the Flight Recorder feature.
22+
case flightRecorder(FlightRecorderSettingsSectionAction)
23+
2124
/// The help center button was tapped.
2225
case helpCenterTapped
2326

AuthenticatorShared/UI/Platform/Settings/Settings/SettingsEffect.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@ import BitwardenKit
44

55
/// Effects that can be processed by an `SettingsProcessor`.
66
enum SettingsEffect: Equatable {
7+
/// An effect for the flight recorder feature.
8+
case flightRecorder(FlightRecorderSettingsSectionEffect)
9+
710
/// The view appeared so the initial data should be loaded.
811
case loadData
912

1013
/// The session timeout value was changed.
1114
case sessionTimeoutValueChanged(SessionTimeoutValue)
1215

16+
/// Stream the active flight recorder log.
17+
case streamFlightRecorderLog
18+
1319
/// Unlock with Biometrics was toggled.
1420
case toggleUnlockWithBiometrics(Bool)
1521
}

AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
1717
& HasConfigService
1818
& HasErrorReporter
1919
& HasExportItemsService
20+
& HasFlightRecorder
2021
& HasPasteboardService
2122
& HasStateService
2223

@@ -51,6 +52,15 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
5152

5253
override func perform(_ effect: SettingsEffect) async {
5354
switch effect {
55+
case let .flightRecorder(flightRecorderEffect):
56+
switch flightRecorderEffect {
57+
case let .toggleFlightRecorder(isOn):
58+
if isOn {
59+
coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
60+
} else {
61+
await services.flightRecorder.disableFlightRecorder()
62+
}
63+
}
5464
case .loadData:
5565
await loadData()
5666
case let .sessionTimeoutValueChanged(timeoutValue):
@@ -65,6 +75,8 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
6575
minutes: timeoutValue.rawValue,
6676
userId: services.appSettingsStore.localUserId,
6777
)
78+
case .streamFlightRecorderLog:
79+
await streamFlightRecorderLog()
6880
case let .toggleUnlockWithBiometrics(isOn):
6981
await setBiometricAuth(isOn)
7082
}
@@ -88,6 +100,11 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
88100
services.appSettingsStore.defaultSaveOption = option
89101
case .exportItemsTapped:
90102
coordinator.navigate(to: .exportItems)
103+
case let .flightRecorder(flightRecorderAction):
104+
switch flightRecorderAction {
105+
case .viewLogsTapped:
106+
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
107+
}
91108
case .helpCenterTapped:
92109
state.url = ExternalLinksConstants.helpAndFeedback
93110
case .importItemsTapped:
@@ -189,6 +206,13 @@ final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Set
189206
services.errorReporter.log(error: error)
190207
}
191208
}
209+
210+
/// Streams the flight recorder's active log metadata.
211+
private func streamFlightRecorderLog() async {
212+
for await activeLog in await services.flightRecorder.activeLogPublisher().values {
213+
state.flightRecorderState.activeLog = activeLog
214+
}
215+
}
192216
}
193217

194218
// MARK: - SelectLanguageDelegate

AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessorTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class SettingsProcessorTests: BitwardenTestCase {
1414
var biometricsRepository: MockBiometricsRepository!
1515
var configService: MockConfigService!
1616
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
17+
var flightRecorder: MockFlightRecorder!
1718
var pasteboardService: MockPasteboardService!
1819
var subject: SettingsProcessor!
1920

@@ -28,6 +29,7 @@ class SettingsProcessorTests: BitwardenTestCase {
2829
biometricsRepository = MockBiometricsRepository()
2930
configService = MockConfigService()
3031
coordinator = MockCoordinator()
32+
flightRecorder = MockFlightRecorder()
3133
pasteboardService = MockPasteboardService()
3234
subject = SettingsProcessor(
3335
coordinator: coordinator.asAnyCoordinator(),
@@ -37,6 +39,7 @@ class SettingsProcessorTests: BitwardenTestCase {
3739
authenticatorItemRepository: authItemRepository,
3840
biometricsRepository: biometricsRepository,
3941
configService: configService,
42+
flightRecorder: flightRecorder,
4043
pasteboardService: pasteboardService,
4144
),
4245
state: SettingsState(),
@@ -52,12 +55,38 @@ class SettingsProcessorTests: BitwardenTestCase {
5255
biometricsRepository = nil
5356
configService = nil
5457
coordinator = nil
58+
flightRecorder = nil
5559
pasteboardService = nil
5660
subject = nil
5761
}
5862

5963
// MARK: Tests
6064

65+
/// `perform(_:)` with `.flightRecorder(.toggleFlightRecorder(true))` navigates to the enable
66+
/// flight recorder screen when toggled on.
67+
@MainActor
68+
func test_perform_flightRecorder_toggleFlightRecorder_on() async {
69+
XCTAssertNil(subject.state.flightRecorderState.activeLog)
70+
71+
await subject.perform(.flightRecorder(.toggleFlightRecorder(true)))
72+
73+
XCTAssertEqual(coordinator.routes, [.flightRecorder(.enableFlightRecorder)])
74+
}
75+
76+
/// `perform(_:)` with `.flightRecorder(.toggleFlightRecorder(false))` disables the flight
77+
/// recorder when toggled off.
78+
@MainActor
79+
func test_perform_flightRecorder_toggleFlightRecorder_off() async throws {
80+
subject.state.flightRecorderState.activeLog = FlightRecorderData.LogMetadata(
81+
duration: .eightHours,
82+
startDate: .now,
83+
)
84+
85+
await subject.perform(.flightRecorder(.toggleFlightRecorder(false)))
86+
87+
XCTAssertTrue(flightRecorder.disableFlightRecorderCalled)
88+
}
89+
6190
/// Performing `.loadData` sets the 'defaultSaveOption' to the current value in 'AppSettingsStore'.
6291
@MainActor
6392
func test_perform_loadData_defaultSaveOption() async throws {
@@ -157,6 +186,26 @@ class SettingsProcessorTests: BitwardenTestCase {
157186
XCTAssertEqual(subject.state.sessionTimeoutValue, .fifteenMinutes)
158187
}
159188

189+
/// `perform(_:)` with `.streamFlightRecorderLog` subscribes to the active flight recorder log.
190+
@MainActor
191+
func test_perform_streamFlightRecorderLog() async throws {
192+
XCTAssertNil(subject.state.flightRecorderState.activeLog)
193+
194+
let task = Task {
195+
await subject.perform(.streamFlightRecorderLog)
196+
}
197+
defer { task.cancel() }
198+
199+
let log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
200+
flightRecorder.activeLogSubject.send(log)
201+
try await waitForAsync { self.subject.state.flightRecorderState.activeLog != nil }
202+
XCTAssertEqual(subject.state.flightRecorderState.activeLog, log)
203+
204+
flightRecorder.activeLogSubject.send(nil)
205+
try await waitForAsync { self.subject.state.flightRecorderState.activeLog == nil }
206+
XCTAssertNil(subject.state.flightRecorderState.activeLog)
207+
}
208+
160209
/// Performing `.toggleUnlockWithBiometrics` with a `false` value disables biometric unlock and resets the
161210
/// session timeout to `.never`
162211
@MainActor
@@ -216,6 +265,15 @@ class SettingsProcessorTests: BitwardenTestCase {
216265
XCTAssertEqual(coordinator.routes.last, .exportItems)
217266
}
218267

268+
/// `receive(_:)` with action `.flightRecorder(.viewLogsTapped)` navigates to the view flight
269+
/// recorder logs screen.
270+
@MainActor
271+
func test_receive_flightRecorder_viewFlightRecorderLogsTapped() {
272+
subject.receive(.flightRecorder(.viewLogsTapped))
273+
274+
XCTAssertEqual(coordinator.routes, [.flightRecorder(.flightRecorderLogs)])
275+
}
276+
219277
/// Receiving `.syncWithBitwardenAppTapped` adds the Password Manager settings URL to the state to
220278
/// navigate the user to the BWPM app's settings.
221279
@MainActor

AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ struct SettingsState: Equatable {
2222
/// The current default save option.
2323
var defaultSaveOption: DefaultSaveOption = .none
2424

25+
/// The state for the Flight Recorder feature.
26+
var flightRecorderState = FlightRecorderSettingsSectionState()
27+
2528
/// The current default save option.
2629
var sessionTimeoutValue: SessionTimeoutValue = .never
2730

AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView+ViewInspectorTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,34 @@ class SettingsViewTests: BitwardenTestCase {
7373
XCTAssertEqual(processor.dispatchedActions.last, .exportItemsTapped)
7474
}
7575

76+
/// The flight recorder toggle turns logging on and off.
77+
@MainActor
78+
func test_flightRecorder_toggle_tap() async throws {
79+
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
80+
81+
try toggle.tap()
82+
try await waitForAsync { !self.processor.effects.isEmpty }
83+
XCTAssertEqual(processor.effects, [.flightRecorder(.toggleFlightRecorder(true))])
84+
processor.effects.removeAll()
85+
86+
processor.state.flightRecorderState.activeLog = FlightRecorderData.LogMetadata(
87+
duration: .eightHours,
88+
startDate: .now,
89+
)
90+
try toggle.tap()
91+
try await waitForAsync { !self.processor.effects.isEmpty }
92+
XCTAssertEqual(processor.effects, [.flightRecorder(.toggleFlightRecorder(false))])
93+
}
94+
95+
/// Tapping the flight recorder view recorded logs button dispatches the
96+
/// `.viewFlightRecorderLogsTapped` action.
97+
@MainActor
98+
func test_flightRecorder_viewRecordedLogsButton_tap() throws {
99+
let button = try subject.inspect().find(button: Localizations.viewRecordedLogs)
100+
try button.tap()
101+
XCTAssertEqual(processor.dispatchedActions.last, .flightRecorder(.viewLogsTapped))
102+
}
103+
76104
/// Tapping the help center button dispatches the `.helpCenterTapped` action.
77105
@MainActor
78106
func test_helpCenterButton_tap() throws {

AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,24 @@ struct SettingsView: View {
4949
.task {
5050
await store.perform(.loadData)
5151
}
52+
.task {
53+
await store.perform(.streamFlightRecorderLog)
54+
}
5255
}
5356

5457
// MARK: Private views
5558

5659
/// The about section containing privacy policy and version information.
5760
@ViewBuilder private var aboutSection: some View {
58-
SectionView(Localizations.about) {
61+
SectionView(Localizations.about, contentSpacing: 8) {
62+
FlightRecorderSettingsSectionView(
63+
store: store.child(
64+
state: \.flightRecorderState,
65+
mapAction: { .flightRecorder($0) },
66+
mapEffect: { .flightRecorder($0) },
67+
),
68+
)
69+
5970
ContentBlock(dividerLeadingPadding: 16) {
6071
externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped)
6172

AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
1111

1212
/// The module types required by this coordinator for creating child coordinators.
1313
typealias Module = FileSelectionModule
14+
& FlightRecorderModule
1415
& TutorialModule
1516

1617
typealias Services = HasAppInfoService
@@ -23,6 +24,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
2324
& HasErrorAlertServices.ErrorAlertServices
2425
& HasErrorReporter
2526
& HasExportItemsService
27+
& HasFlightRecorder
2628
& HasImportItemsService
2729
& HasPasteboardService
2830
& HasStateService
@@ -81,6 +83,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
8183
stackNavigator?.dismiss()
8284
case .exportItems:
8385
showExportItems()
86+
case let .flightRecorder(flightRecorderRoute):
87+
showFlightRecorder(route: flightRecorderRoute)
8488
case .importItems:
8589
showImportItems()
8690
case let .importItemsFileSelection(route):
@@ -132,6 +136,17 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
132136
stackNavigator?.present(navController)
133137
}
134138

139+
/// Shows a flight recorder view.
140+
///
141+
/// - Parameter route: A `FlightRecorderRoute` to navigate to.
142+
///
143+
private func showFlightRecorder(route: FlightRecorderRoute) {
144+
guard let stackNavigator else { return }
145+
let coordinator = module.makeFlightRecorderCoordinator(stackNavigator: stackNavigator)
146+
coordinator.start()
147+
coordinator.navigate(to: route)
148+
}
149+
135150
/// Presents an activity controller for importing items.
136151
///
137152
private func showImportItems() {

AuthenticatorShared/UI/Platform/Settings/SettingsCoordinatorTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ class SettingsCoordinatorTests: BitwardenTestCase {
6969
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<ExportItemsView>)
7070
}
7171

72+
/// `navigate(to:)` with `.flightRecorder` starts flight recorder coordinator and navigates to
73+
/// the enable flight recorder view.
74+
@MainActor
75+
func test_navigateTo_flightRecorder() throws {
76+
subject.navigate(to: .flightRecorder(.enableFlightRecorder))
77+
78+
XCTAssertTrue(module.flightRecorderCoordinator.isStarted)
79+
XCTAssertEqual(module.flightRecorderCoordinator.routes.last, .enableFlightRecorder)
80+
}
81+
7282
/// `navigate(to:)` with `.selectLanguage()` presents the select language view.
7383
@MainActor
7484
func test_navigateTo_selectLanguage() throws {

0 commit comments

Comments
 (0)