Skip to content

Commit 956e05d

Browse files
[PM-19577] Add flight recorder log creation (#1505)
1 parent 50f432a commit 956e05d

23 files changed

Lines changed: 892 additions & 24 deletions

BitwardenShared/Core/Platform/Extensions/FileManager+Extensions.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,13 @@ extension FileManager {
3030
)
3131
.appendingPathComponent("Exports", isDirectory: true)
3232
}
33+
34+
/// Returns a URL for the directory containing flight recorder logs.
35+
///
36+
/// - Returns: A URL for a directory to store flight recorder logs.
37+
///
38+
func flightRecorderLogURL() throws -> URL {
39+
containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.groupIdentifier)!
40+
.appendingPathComponent("FlightRecorderLogs", isDirectory: true)
41+
}
3342
}

BitwardenShared/Core/Platform/Extensions/URL.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,15 @@ extension URL {
8080
return components.url
8181
}
8282
}
83+
84+
/// Sets whether the file should be excluded from backups.
85+
///
86+
/// - Parameter value: `true` if the file should be excluded from backups, or `false` otherwise.
87+
///
88+
func setIsExcludedFromBackup(_ value: Bool) throws {
89+
var url = self
90+
var values = URLResourceValues()
91+
values.isExcludedFromBackup = value
92+
try url.setResourceValues(values)
93+
}
8394
}

BitwardenShared/Core/Platform/Extensions/URLTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,28 @@ class URLTests: BitwardenTestCase {
6161

6262
try XCTAssertFalse(XCTUnwrap(URL(string: "https://example.com")).isApp)
6363
}
64+
65+
/// `setIsExcludedFromBackup(_:)` sets whether the file is excluded from backups.
66+
func test_setIsExcludedFromBackup() throws {
67+
let fileName = UUID().uuidString
68+
let url = try XCTUnwrap(URL(fileURLWithPath: NSTemporaryDirectory())).appendingPathComponent(fileName)
69+
try Data().write(to: url)
70+
71+
try XCTAssertFalse(url.isExcludedFromBackups())
72+
73+
try url.setIsExcludedFromBackup(true)
74+
try XCTAssertTrue(url.isExcludedFromBackups())
75+
76+
try url.setIsExcludedFromBackup(false)
77+
try XCTAssertFalse(url.isExcludedFromBackups())
78+
79+
try FileManager.default.removeItem(at: url)
80+
}
81+
}
82+
83+
private extension URL {
84+
func isExcludedFromBackups() throws -> Bool {
85+
let values = try resourceValues(forKeys: [.isExcludedFromBackupKey])
86+
return values.isExcludedFromBackup ?? false
87+
}
6488
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Foundation
2+
3+
// MARK: - FlightRecorderData
4+
5+
/// A data model containing the persisted data necessary for the flight recorder. This stores the
6+
/// metadata for the active and any archived logs.
7+
///
8+
struct FlightRecorderData: Codable, Equatable {
9+
// MARK: Properties
10+
11+
/// The current log, if the flight recorder is active.
12+
var activeLog: LogMetadata? {
13+
didSet {
14+
guard let oldValue else { return }
15+
archivedLogs.append(oldValue)
16+
}
17+
}
18+
19+
/// A list of previously recorded and inactive logs, which remain available on device until they
20+
/// are deleted by the user or expire and are deleted by the app.
21+
var archivedLogs: [LogMetadata] = []
22+
23+
// MARK: Computed Properties
24+
25+
/// The full list of logs containing the active and any archived logs.
26+
var allLogs: [LogMetadata] {
27+
([activeLog] + archivedLogs).compactMap { $0 }
28+
}
29+
}
30+
31+
extension FlightRecorderData {
32+
/// A data model containing the metadata for a flight recorder log.
33+
///
34+
struct LogMetadata: Codable, Equatable, Identifiable {
35+
// MARK: Properties
36+
37+
/// The duration for how long the flight recorder was enabled for the log.
38+
let duration: FlightRecorderLoggingDuration
39+
40+
/// The file name of the file on disk.
41+
let fileName: String
42+
43+
/// The date the logging was started.
44+
let startDate: Date
45+
46+
// MARK: Computed Properties
47+
48+
var id: String {
49+
fileName
50+
}
51+
52+
// MARK: Initialization
53+
54+
/// Initialize a `LogMetadata`.
55+
///
56+
/// - Parameters:
57+
/// - duration: The duration for how long the flight recorder was enabled for the log.
58+
/// - startDate: The date the logging was started.
59+
///
60+
init(duration: FlightRecorderLoggingDuration, startDate: Date) {
61+
self.duration = duration
62+
self.startDate = startDate
63+
64+
let dateFormatter = DateFormatter()
65+
dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss"
66+
fileName = "flight_recorder_\(dateFormatter.string(from: startDate)).txt"
67+
}
68+
}
69+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import XCTest
2+
3+
@testable import BitwardenShared
4+
5+
class FlightRecorderDataTests: BitwardenTestCase {
6+
// MARK: Tests
7+
8+
/// `allLogs` returns a list of all logs when there are no logs.
9+
func test_allLogs_empty() {
10+
let subject = FlightRecorderData()
11+
XCTAssertEqual(subject.allLogs, [])
12+
}
13+
14+
/// `allLogs` returns a list of all logs when there's active and archived logs.
15+
func test_allLogs_activeAndArchivedLogs() {
16+
let activeLog = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
17+
let archivedLogs = [
18+
FlightRecorderData.LogMetadata(duration: .oneHour, startDate: .now),
19+
FlightRecorderData.LogMetadata(duration: .oneWeek, startDate: .now),
20+
]
21+
let subject = FlightRecorderData(activeLog: activeLog, archivedLogs: archivedLogs)
22+
XCTAssertEqual(subject.allLogs, [activeLog] + archivedLogs)
23+
}
24+
25+
/// `allLogs` returns a list of all logs when there's an active log.
26+
func test_allLogs_activeLog() {
27+
let log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
28+
let subject = FlightRecorderData(activeLog: log)
29+
XCTAssertEqual(subject.allLogs, [log])
30+
}
31+
32+
/// `allLogs` returns a list of all logs when there are archived logs.
33+
func test_allLogs_archivedLogs() {
34+
let logs = [
35+
FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now),
36+
FlightRecorderData.LogMetadata(duration: .oneHour, startDate: .now),
37+
FlightRecorderData.LogMetadata(duration: .oneWeek, startDate: .now),
38+
]
39+
let subject = FlightRecorderData(archivedLogs: logs)
40+
XCTAssertEqual(subject.allLogs, logs)
41+
}
42+
43+
/// `activeLog` sets the active log.
44+
func test_setActiveLog() {
45+
var subject = FlightRecorderData()
46+
let log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
47+
subject.activeLog = log
48+
49+
XCTAssertEqual(subject, FlightRecorderData(activeLog: log))
50+
}
51+
52+
/// `activeLog` sets the active log, archiving an existing log if there's already one active.
53+
func test_setActiveLog_existingLog() {
54+
let log1 = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
55+
var subject = FlightRecorderData(activeLog: log1)
56+
57+
let log2 = FlightRecorderData.LogMetadata(duration: .oneWeek, startDate: .now)
58+
subject.activeLog = log2
59+
60+
XCTAssertEqual(subject, FlightRecorderData(activeLog: log2, archivedLogs: [log1]))
61+
}
62+
63+
// MARK: FlightRecorderData.LogMetadata Tests
64+
65+
/// `id` returns the log's file name as a unique identifier.
66+
func test_logMetadata_id() {
67+
let log1 = FlightRecorderData.LogMetadata(duration: .oneHour, startDate: .now)
68+
XCTAssertEqual(log1.id, log1.fileName)
69+
70+
let log2 = FlightRecorderData.LogMetadata(duration: .oneWeek, startDate: .now)
71+
XCTAssertEqual(log2.id, log2.fileName)
72+
}
73+
74+
/// `init(duration:startDate:)` creates a file name for the log based on the start date.
75+
func test_logMetadata_init_fileName() {
76+
let log1 = FlightRecorderData.LogMetadata(
77+
duration: .oneHour,
78+
startDate: Date(year: 2025, month: 4, day: 11, hour: 10, minute: 30, second: 20)
79+
)
80+
XCTAssertEqual(log1.fileName, "flight_recorder_2025-04-11-10-30-20.txt")
81+
82+
let log2 = FlightRecorderData.LogMetadata(
83+
duration: .oneWeek,
84+
startDate: Date(year: 2025, month: 1, day: 2, hour: 3, minute: 4, second: 5)
85+
)
86+
XCTAssertEqual(log2.fileName, "flight_recorder_2025-01-02-03-04-05.txt")
87+
}
88+
}

BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDuration.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22

33
/// An enum that represents how long to enable the flight recorder.
44
///
5-
enum FlightRecorderLoggingDuration: CaseIterable, Menuable {
5+
enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable {
66
/// The flight recorder is enabled for one hour.
77
case oneHour
88

@@ -23,6 +23,16 @@ enum FlightRecorderLoggingDuration: CaseIterable, Menuable {
2323
case .oneWeek: Localizations.oneWeek
2424
}
2525
}
26+
27+
/// A short string representation of the duration (e.g. 1h, 8h, 1w).
28+
var shortDescription: String {
29+
switch self {
30+
case .oneHour: "1h"
31+
case .eightHours: "8h"
32+
case .twentyFourHours: "24h"
33+
case .oneWeek: "1w"
34+
}
35+
}
2636
}
2737

2838
// MARK: - Calendar + FlightRecorderLoggingDuration

BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDurationTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,12 @@ class FlightRecorderLoggingDurationTests: BitwardenTestCase {
2929
XCTAssertEqual(FlightRecorderLoggingDuration.twentyFourHours.localizedName, Localizations.xHours(24))
3030
XCTAssertEqual(FlightRecorderLoggingDuration.oneWeek.localizedName, Localizations.oneWeek)
3131
}
32+
33+
/// `shortDescription` returns a short string representation of the logging duration.
34+
func test_shortDescription() {
35+
XCTAssertEqual(FlightRecorderLoggingDuration.oneHour.shortDescription, "1h")
36+
XCTAssertEqual(FlightRecorderLoggingDuration.eightHours.shortDescription, "8h")
37+
XCTAssertEqual(FlightRecorderLoggingDuration.twentyFourHours.shortDescription, "24h")
38+
XCTAssertEqual(FlightRecorderLoggingDuration.oneWeek.shortDescription, "1w")
39+
}
3240
}

0 commit comments

Comments
 (0)