Skip to content

Commit 4d479a9

Browse files
[PM-19553] Add flight recorder banner to vault list (#1543)
1 parent 3102643 commit 4d479a9

File tree

28 files changed

+610
-5
lines changed

28 files changed

+610
-5
lines changed

BitwardenShared/Core/Platform/Models/Data/FlightRecorderData.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ struct FlightRecorderData: Codable, Equatable {
1212
/// The current log, if the flight recorder is active.
1313
var activeLog: LogMetadata? {
1414
didSet {
15-
guard let oldValue else { return }
15+
guard let oldValue, oldValue.id != activeLog?.id else { return }
1616
inactiveLogs.insert(oldValue, at: 0)
1717
}
1818
}
@@ -42,6 +42,9 @@ extension FlightRecorderData {
4242
struct LogMetadata: Codable, Equatable, Identifiable {
4343
// MARK: Properties
4444

45+
/// A list of user IDs that have seen and dismissed the flight recorder toast banner for this log.
46+
var bannerDismissedByUserIds: [String]
47+
4548
/// The duration for how long the flight recorder was enabled for the log.
4649
let duration: FlightRecorderLoggingDuration
4750

@@ -65,6 +68,22 @@ extension FlightRecorderData {
6568
) ?? endDate
6669
}
6770

71+
/// The formatted end date for the log.
72+
var formattedEndDate: String {
73+
let dateFormatter = DateFormatter()
74+
dateFormatter.dateStyle = .short
75+
dateFormatter.timeStyle = .none
76+
return dateFormatter.string(from: endDate)
77+
}
78+
79+
/// The formatted end time for the log.
80+
var formattedEndTime: String {
81+
let dateFormatter = DateFormatter()
82+
dateFormatter.dateStyle = .none
83+
dateFormatter.timeStyle = .short
84+
return dateFormatter.string(from: endDate)
85+
}
86+
6887
var id: String {
6988
fileName
7089
}
@@ -78,6 +97,7 @@ extension FlightRecorderData {
7897
/// - startDate: The date the logging was started.
7998
///
8099
init(duration: FlightRecorderLoggingDuration, startDate: Date) {
100+
bannerDismissedByUserIds = []
81101
self.duration = duration
82102
self.startDate = startDate
83103

BitwardenShared/Core/Platform/Models/Data/FlightRecorderDataTests.swift

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,29 @@ class FlightRecorderDataTests: BitwardenTestCase {
8989

9090
/// `activeLog` sets the active log, archiving an existing log if there's already one active.
9191
func test_setActiveLog_existingLog() {
92-
let log1 = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
93-
let log2 = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
92+
let log1 = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: Date(year: 2025, month: 1, day: 1))
93+
let log2 = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: Date(year: 2025, month: 1, day: 2))
9494
var subject = FlightRecorderData(activeLog: log2, inactiveLogs: [log1])
9595

96-
let log3 = FlightRecorderData.LogMetadata(duration: .oneWeek, startDate: .now)
96+
let log3 = FlightRecorderData.LogMetadata(duration: .oneWeek, startDate: Date(year: 2025, month: 1, day: 3))
9797
subject.activeLog = log3
9898

9999
XCTAssertEqual(subject, FlightRecorderData(activeLog: log3, inactiveLogs: [log2, log1]))
100100
}
101101

102+
/// Using `activeLog` to modify a property of the active log doesn't make the log inactive.
103+
func test_setActiveLog_modifyExistingLogProperty() {
104+
var subject = FlightRecorderData()
105+
var log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
106+
subject.activeLog = log
107+
108+
subject.activeLog?.bannerDismissedByUserIds.append("123")
109+
subject.activeLog?.bannerDismissedByUserIds.append("456")
110+
111+
log.bannerDismissedByUserIds.append(contentsOf: ["123", "456"])
112+
XCTAssertEqual(subject, FlightRecorderData(activeLog: log))
113+
}
114+
102115
// MARK: FlightRecorderData.LogMetadata Tests
103116

104117
/// `expirationDate` returns the date when the log will expire and be deleted.
@@ -119,6 +132,42 @@ class FlightRecorderDataTests: BitwardenTestCase {
119132
)
120133
}
121134

135+
/// `formattedEndDate` returns the log's formatted end date.
136+
func test_logMetadata_formattedEndDate() {
137+
XCTAssertEqual(
138+
FlightRecorderData.LogMetadata(
139+
duration: .oneHour,
140+
startDate: Date(year: 2025, month: 4, day: 3, hour: 10, minute: 30)
141+
).formattedEndDate,
142+
"4/3/25"
143+
)
144+
XCTAssertEqual(
145+
FlightRecorderData.LogMetadata(
146+
duration: .eightHours,
147+
startDate: Date(year: 2025, month: 4, day: 8, hour: 10, minute: 30)
148+
).formattedEndDate,
149+
"4/8/25"
150+
)
151+
}
152+
153+
/// `formattedEndDate` returns the log's formatted end time.
154+
func test_logMetadata_formattedEndTime() {
155+
XCTAssertEqual(
156+
FlightRecorderData.LogMetadata(
157+
duration: .oneHour,
158+
startDate: Date(year: 2025, month: 4, day: 8, hour: 10, minute: 30)
159+
).formattedEndTime,
160+
"11:30 AM"
161+
)
162+
XCTAssertEqual(
163+
FlightRecorderData.LogMetadata(
164+
duration: .eightHours,
165+
startDate: Date(year: 2025, month: 4, day: 8, hour: 10, minute: 30)
166+
).formattedEndTime,
167+
"6:30 PM"
168+
)
169+
}
170+
122171
/// `id` returns the log's file name as a unique identifier.
123172
func test_logMetadata_id() {
124173
let log1 = FlightRecorderData.LogMetadata(duration: .oneHour, startDate: .now)

BitwardenShared/Core/Platform/Services/FlightRecorder.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import OSLog
1111
/// local file.
1212
///
1313
protocol FlightRecorder: Sendable, BitwardenLogger {
14+
/// A publisher which publishes the active log of the flight recorder.
15+
///
16+
/// - Returns: A publisher for the active log of the flight recorder.
17+
///
18+
func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never>
19+
1420
/// Deletes all inactive flight recorder logs. This will not delete the currently active log.
1521
///
1622
func deleteInactiveLogs() async throws
@@ -51,6 +57,13 @@ protocol FlightRecorder: Sendable, BitwardenLogger {
5157
/// - line: The line number in the file that called the log method.
5258
///
5359
func log(_ message: String, file: String, line: UInt) async
60+
61+
/// Sets a flag indicating that the flight recorder banner for the active log was viewed and
62+
/// dismissed by the active user.
63+
///
64+
/// - Parameter userId: The ID of the user who dismissed the banner.
65+
///
66+
func setFlightRecorderBannerDismissed(userId: String) async
5467
}
5568

5669
extension FlightRecorder {
@@ -350,6 +363,11 @@ actor DefaultFlightRecorder {
350363
// MARK: - DefaultFlightRecorder + FlightRecorder
351364

352365
extension DefaultFlightRecorder: FlightRecorder {
366+
func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
367+
_ = await getFlightRecorderData() // Ensure data has already been loaded to the subject.
368+
return dataSubject.map { $0?.activeLog }.eraseToAnyPublisher()
369+
}
370+
353371
func deleteInactiveLogs() async throws {
354372
guard var data = await getFlightRecorderData() else {
355373
throw FlightRecorderError.dataUnavailable
@@ -435,4 +453,10 @@ extension DefaultFlightRecorder: FlightRecorder {
435453
))
436454
}
437455
}
456+
457+
func setFlightRecorderBannerDismissed(userId: String) async {
458+
guard var data = await getFlightRecorderData(), data.activeLog != nil else { return }
459+
data.activeLog?.bannerDismissedByUserIds.append(userId)
460+
await setFlightRecorderData(data)
461+
}
438462
}

BitwardenShared/Core/Platform/Services/FlightRecorderTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,40 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
7878

7979
// MARK: Tests
8080

81+
/// `activeLogPublisher()` publishes the active log of the flight recorder when there's an
82+
/// existing active log.
83+
func test_activeLogPublisher_existingActiveLog() async throws {
84+
stateService.flightRecorderData = FlightRecorderData(activeLog: activeLog)
85+
subject = makeSubject()
86+
87+
var initialPublisher = await subject.activeLogPublisher().values.makeAsyncIterator()
88+
let firstLog = await initialPublisher.next()
89+
XCTAssertEqual(firstLog, activeLog)
90+
91+
// Once the data is cached by the flight recorder, it isn't re-read from state service on
92+
// subsequent subscriptions. We can test this by modifying the flight recorder data in state
93+
// service and observing that the flight recorder is still enabled.
94+
stateService.flightRecorderData = nil
95+
96+
var secondPublisher = await subject.activeLogPublisher().values.makeAsyncIterator()
97+
let secondLog = await secondPublisher.next()
98+
XCTAssertEqual(secondLog, activeLog)
99+
}
100+
101+
/// `activeLogPublisher()` publishes the active log of the flight recorder when there's no
102+
/// existing flight recorder data.
103+
func test_activeLogPublisher_noFlightRecorderData() async throws {
104+
var publishedValues = [FlightRecorderData.LogMetadata?]()
105+
let publisher = await subject.activeLogPublisher().sink { publishedValues.append($0) }
106+
defer { publisher.cancel() }
107+
108+
try await subject.enableFlightRecorder(duration: .eightHours)
109+
await subject.disableFlightRecorder()
110+
111+
let inactiveLog = try XCTUnwrap(stateService.flightRecorderData?.inactiveLogs.first)
112+
XCTAssertEqual(publishedValues, [nil, inactiveLog, nil])
113+
}
114+
81115
/// `deleteInactiveLogs()` deletes all of the inactive logs and associated metadata.
82116
func test_deleteInactiveLogs() async throws {
83117
stateService.flightRecorderData = FlightRecorderData(
@@ -597,6 +631,27 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
597631
XCTAssertNil(stateService.flightRecorderData)
598632
}
599633

634+
/// `setFlightRecorderBannerDismissed(userId:)` sets that the flight recorder banner was
635+
/// dismissed by the user.
636+
func test_setFlightRecorderBannerDismissed() async {
637+
stateService.flightRecorderData = FlightRecorderData(activeLog: activeLog)
638+
await subject.setFlightRecorderBannerDismissed(userId: "123")
639+
640+
var activeLogWithDismissedBanner = activeLog
641+
activeLogWithDismissedBanner.bannerDismissedByUserIds.append("123")
642+
XCTAssertEqual(
643+
stateService.flightRecorderData,
644+
FlightRecorderData(activeLog: activeLogWithDismissedBanner)
645+
)
646+
}
647+
648+
/// `setFlightRecorderBannerDismissed(userId:)` doesn't modify the flight recorder data if
649+
/// there's no flight recorder data.
650+
func test_setFlightRecorderBannerDismissed_noFlightRecorderData() async {
651+
await subject.setFlightRecorderBannerDismissed(userId: "123")
652+
XCTAssertNil(stateService.flightRecorderData)
653+
}
654+
600655
// MARK: DefaultFlightRecorder Tests
601656

602657
/// `DefaultFlightRecorder` implements `BitwardenLogger.log()` which logs to the active log.

BitwardenShared/Core/Platform/Services/TestHelpers/MockFlightRecorder.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Combine
44

55
@MainActor
66
final class MockFlightRecorder: FlightRecorder {
7+
var activeLogSubject = CurrentValueSubject<FlightRecorderData.LogMetadata?, Never>(nil)
78
var deleteInactiveLogsCalled = false
89
var deleteInactiveLogsResult: Result<Void, Error> = .success(())
910
var deleteLogResult: Result<Void, Error> = .success(())
@@ -16,9 +17,14 @@ final class MockFlightRecorder: FlightRecorder {
1617
var fetchLogsResult: Result<[FlightRecorderLogMetadata], Error> = .success([])
1718
var isEnabledSubject = CurrentValueSubject<Bool, Never>(false)
1819
var logMessages = [String]()
20+
var setFlightRecorderBannerDismissedUserIds = [String]()
1921

2022
nonisolated init() {}
2123

24+
func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
25+
activeLogSubject.eraseToAnyPublisher()
26+
}
27+
2228
func deleteInactiveLogs() async throws {
2329
deleteInactiveLogsCalled = true
2430
try deleteInactiveLogsResult.get()
@@ -51,4 +57,8 @@ final class MockFlightRecorder: FlightRecorder {
5157
func log(_ message: String, file: String, line: UInt) async {
5258
logMessages.append(message)
5359
}
60+
61+
func setFlightRecorderBannerDismissed(userId: String) async {
62+
setFlightRecorderBannerDismissedUserIds.append(userId)
63+
}
5464
}
3.45 KB
Loading
6.84 KB
Loading

BitwardenShared/UI/Platform/Application/AppCoordinator.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,4 +507,8 @@ extension AppCoordinator: VaultCoordinatorDelegate {
507507
func presentLoginRequest(_ loginRequest: LoginRequest) {
508508
showLoginRequest(loginRequest)
509509
}
510+
511+
func switchToSettingsTab(route: SettingsRoute) {
512+
navigate(to: .tab(.settings(route)))
513+
}
510514
} // swiftlint:disable:this file_length

BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,11 @@ class AppCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_bo
509509
)
510510
XCTAssertEqual(module.authCoordinator.routes, [AuthRoute.landing])
511511
}
512+
513+
/// `switchToSettingsTab(route:)` switches to the settings tab and navigates to the settings route.
514+
@MainActor
515+
func test_switchToSettingsTab() {
516+
subject.switchToSettingsTab(route: .about)
517+
XCTAssertEqual(module.tabCoordinator.routes, [.settings(.about)])
518+
}
512519
} // swiftlint:disable:this file_length

BitwardenShared/UI/Platform/Application/Appearance/Styles/PrimaryButtonStyle.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ struct PrimaryButtonStyle: ButtonStyle {
4242
.multilineTextAlignment(.center)
4343
.styleGuide(size.fontStyle, includeLinePadding: false, includeLineSpacing: false)
4444
.padding(.vertical, size.verticalPadding)
45-
.padding(.horizontal, 20)
45+
.padding(.horizontal, size.horizontalPadding)
4646
.frame(maxWidth: shouldFillWidth ? .infinity : nil, minHeight: size.minimumHeight)
4747
.background(backgroundColor)
4848
.clipShape(Capsule())

0 commit comments

Comments
 (0)