[PM-26063] Move FlightRecorder into BitwardenKit (#2133)

This commit is contained in:
Matt Czech 2025-11-13 12:40:59 -06:00 committed by GitHub
parent 6dadf79bf8
commit e8d7c67d78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 744 additions and 418 deletions

View File

@ -7,6 +7,7 @@ typealias Services = HasConfigService
& HasEnvironmentService & HasEnvironmentService
& HasErrorReportBuilder & HasErrorReportBuilder
& HasErrorReporter & HasErrorReporter
& HasFlightRecorder
& HasTimeProvider & HasTimeProvider
/// A service container used for testing processors within `BitwardenKitTests`. /// A service container used for testing processors within `BitwardenKitTests`.
@ -16,6 +17,7 @@ class ServiceContainer: Services {
let environmentService: EnvironmentService let environmentService: EnvironmentService
let errorReportBuilder: any ErrorReportBuilder let errorReportBuilder: any ErrorReportBuilder
let errorReporter: ErrorReporter let errorReporter: ErrorReporter
let flightRecorder: FlightRecorder
let timeProvider: TimeProvider let timeProvider: TimeProvider
required init( required init(
@ -23,12 +25,14 @@ class ServiceContainer: Services {
environmentService: EnvironmentService, environmentService: EnvironmentService,
errorReportBuilder: ErrorReportBuilder, errorReportBuilder: ErrorReportBuilder,
errorReporter: ErrorReporter, errorReporter: ErrorReporter,
flightRecorder: FlightRecorder,
timeProvider: TimeProvider, timeProvider: TimeProvider,
) { ) {
self.configService = configService self.configService = configService
self.errorReportBuilder = errorReportBuilder self.errorReportBuilder = errorReportBuilder
self.environmentService = environmentService self.environmentService = environmentService
self.errorReporter = errorReporter self.errorReporter = errorReporter
self.flightRecorder = flightRecorder
self.timeProvider = timeProvider self.timeProvider = timeProvider
} }
} }
@ -39,6 +43,7 @@ extension ServiceContainer {
errorReportBuilder: ErrorReportBuilder = MockErrorReportBuilder(), errorReportBuilder: ErrorReportBuilder = MockErrorReportBuilder(),
environmentService: EnvironmentService = MockEnvironmentService(), environmentService: EnvironmentService = MockEnvironmentService(),
errorReporter: ErrorReporter = MockErrorReporter(), errorReporter: ErrorReporter = MockErrorReporter(),
flightRecorder: FlightRecorder = MockFlightRecorder(),
timeProvider: TimeProvider = MockTimeProvider(.currentTime), timeProvider: TimeProvider = MockTimeProvider(.currentTime),
) -> ServiceContainer { ) -> ServiceContainer {
self.init( self.init(
@ -46,6 +51,7 @@ extension ServiceContainer {
environmentService: environmentService, environmentService: environmentService,
errorReportBuilder: errorReportBuilder, errorReportBuilder: errorReportBuilder,
errorReporter: errorReporter, errorReporter: errorReporter,
flightRecorder: flightRecorder,
timeProvider: timeProvider, timeProvider: timeProvider,
) )
} }

View File

@ -0,0 +1,12 @@
import Foundation
extension FileManager {
/// Returns a URL for the directory containing flight recorder logs.
///
/// - Returns: A URL for a directory to store flight recorder logs, or `nil` if the container URL is unavailable.
///
func flightRecorderLogURL() throws -> URL? {
containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.groupIdentifier)?
.appendingPathComponent("FlightRecorderLogs", isDirectory: true)
}
}

View File

@ -0,0 +1,17 @@
import Foundation
public extension Task where Success == Never, Failure == Never {
/// Suspends the current task for at least the specified duration in seconds.
///
/// - Parameters:
/// - delay: The number of seconds to sleep.
/// - tolerance: The acceptable tolerance for the sleep duration in seconds. Defaults to 1 second.
///
static func sleep(forSeconds delay: Double, tolerance: Double = 1) async throws {
if #available(iOS 16.0, *) {
try await sleep(for: .seconds(delay), tolerance: .seconds(tolerance))
} else {
try await sleep(nanoseconds: UInt64(delay * Double(NSEC_PER_SEC)))
}
}
}

View File

@ -31,4 +31,40 @@ public extension URL {
guard absoluteString.hasPrefix(prefix) else { return absoluteString } guard absoluteString.hasPrefix(prefix) else { return absoluteString }
return String(absoluteString.dropFirst(prefix.count)) return String(absoluteString.dropFirst(prefix.count))
} }
// MARK: Methods
/// Creates a new `URL` appending the provided query items to the url.
///
/// On iOS 16+, this method uses the method with the same name in Foundation. On iOS 15, this method
/// uses `URLComponents` to add the query items to the new url.
///
/// - Parameter queryItems: A list of `URLQueryItem`s to add to this url.
/// - Returns: A new `URL` with the query items appended.
///
func appending(queryItems: [URLQueryItem]) -> URL? {
if #available(iOS 16, *) {
// Set this variable to a non-optional `URL` type so that we are calling the function in Foundation,
// rather than recursively calling this method.
let url: URL = appending(queryItems: queryItems)
return url
} else {
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
else { return nil }
components.queryItems = queryItems
return components.url
}
}
/// Sets whether the file should be excluded from backups.
///
/// - Parameter value: `true` if the file should be excluded from backups, or `false` otherwise.
///
func setIsExcludedFromBackup(_ value: Bool) throws {
var url = self
var values = URLResourceValues()
values.isExcludedFromBackup = value
try url.setResourceValues(values)
}
} }

View File

@ -1,4 +1,3 @@
import BitwardenKit
import Foundation import Foundation
// MARK: - FlightRecorderData // MARK: - FlightRecorderData
@ -6,11 +5,11 @@ import Foundation
/// A data model containing the persisted data necessary for the flight recorder. This stores the /// A data model containing the persisted data necessary for the flight recorder. This stores the
/// metadata for the active and any inactive logs. /// metadata for the active and any inactive logs.
/// ///
struct FlightRecorderData: Codable, Equatable { public struct FlightRecorderData: Codable, Equatable {
// MARK: Properties // MARK: Properties
/// The current log, if the flight recorder is active. /// The current log, if the flight recorder is active.
var activeLog: LogMetadata? { public var activeLog: LogMetadata? {
didSet { didSet {
guard let oldValue, oldValue.id != activeLog?.id else { return } guard let oldValue, oldValue.id != activeLog?.id else { return }
inactiveLogs.insert(oldValue, at: 0) inactiveLogs.insert(oldValue, at: 0)
@ -19,48 +18,62 @@ struct FlightRecorderData: Codable, Equatable {
/// A list of previously recorded and inactive logs, which remain available on device until they /// A list of previously recorded and inactive logs, which remain available on device until they
/// are deleted by the user or expire and are deleted by the app. /// are deleted by the user or expire and are deleted by the app.
var inactiveLogs: [LogMetadata] = [] public var inactiveLogs: [LogMetadata] = []
// MARK: Computed Properties // MARK: Computed Properties
/// The full list of logs containing the active and any inactive logs. /// The full list of logs containing the active and any inactive logs.
var allLogs: [LogMetadata] { public var allLogs: [LogMetadata] {
([activeLog] + inactiveLogs).compactMap(\.self) ([activeLog] + inactiveLogs).compactMap(\.self)
} }
/// The upcoming date in which either the active log needs to end logging or an inactive log /// The upcoming date in which either the active log needs to end logging or an inactive log
/// expires and needs to be removed. /// expires and needs to be removed.
var nextLogLifecycleDate: Date? { public var nextLogLifecycleDate: Date? {
let dates = [activeLog?.endDate].compactMap(\.self) + inactiveLogs.map(\.expirationDate) let dates = [activeLog?.endDate].compactMap(\.self) + inactiveLogs.map(\.expirationDate)
return dates.min() return dates.min()
} }
// MARK: Initialization
/// Initialize `FlightRecorderData`.
///
/// - Parameters:
/// - activeLog: The current log, if the flight recorder is active.
/// - inactiveLogs: A list of previously recorded and inactive logs, which remain available
/// on device until they are deleted by the user or expire and are deleted by the app.
///
public init(activeLog: LogMetadata? = nil, inactiveLogs: [LogMetadata] = []) {
self.activeLog = activeLog
self.inactiveLogs = inactiveLogs
}
} }
extension FlightRecorderData { public extension FlightRecorderData {
/// A data model containing the metadata for a flight recorder log. /// A data model containing the metadata for a flight recorder log.
/// ///
struct LogMetadata: Codable, Equatable, Identifiable { struct LogMetadata: Codable, Equatable, Identifiable {
// MARK: Properties // MARK: Properties
/// The duration for how long the flight recorder was enabled for the log. /// The duration for how long the flight recorder was enabled for the log.
let duration: FlightRecorderLoggingDuration public let duration: FlightRecorderLoggingDuration
/// The date when the logging will end. /// The date when the logging will end.
let endDate: Date public let endDate: Date
/// The file name of the file on disk. /// The file name of the file on disk.
let fileName: String public let fileName: String
/// Whether the flight recorder toast banner has been dismissed for this log. /// Whether the flight recorder toast banner has been dismissed for this log.
@DefaultFalse var isBannerDismissed = false @DefaultFalse public var isBannerDismissed = false
/// The date the logging was started. /// The date the logging was started.
let startDate: Date public let startDate: Date
// MARK: Computed Properties // MARK: Computed Properties
/// The date when the flight recorder log will expire and be deleted. /// The date when the flight recorder log will expire and be deleted.
var expirationDate: Date { public var expirationDate: Date {
Calendar.current.date( Calendar.current.date(
byAdding: .day, byAdding: .day,
value: Constants.flightRecorderLogExpirationDays, value: Constants.flightRecorderLogExpirationDays,
@ -69,7 +82,7 @@ extension FlightRecorderData {
} }
/// The formatted end date for the log. /// The formatted end date for the log.
var formattedEndDate: String { public var formattedEndDate: String {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .none dateFormatter.timeStyle = .none
@ -77,14 +90,14 @@ extension FlightRecorderData {
} }
/// The formatted end time for the log. /// The formatted end time for the log.
var formattedEndTime: String { public var formattedEndTime: String {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .short dateFormatter.timeStyle = .short
return dateFormatter.string(from: endDate) return dateFormatter.string(from: endDate)
} }
var id: String { public var id: String {
fileName fileName
} }
@ -96,7 +109,7 @@ extension FlightRecorderData {
/// - duration: The duration for how long the flight recorder was enabled for the log. /// - duration: The duration for how long the flight recorder was enabled for the log.
/// - startDate: The date the logging was started. /// - startDate: The date the logging was started.
/// ///
init(duration: FlightRecorderLoggingDuration, startDate: Date) { public init(duration: FlightRecorderLoggingDuration, startDate: Date) {
self.duration = duration self.duration = duration
self.startDate = startDate self.startDate = startDate

View File

@ -1,6 +1,6 @@
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class FlightRecorderDataTests: BitwardenTestCase { class FlightRecorderDataTests: BitwardenTestCase {
// MARK: Tests // MARK: Tests

View File

@ -1,8 +1,9 @@
import Foundation import Foundation
@testable import BitwardenShared @testable import BitwardenKit
extension FlightRecorderLogMetadata { public extension FlightRecorderLogMetadata {
// swiftlint:disable:next missing_docs
static func fixture( static func fixture(
duration: FlightRecorderLoggingDuration = .twentyFourHours, duration: FlightRecorderLoggingDuration = .twentyFourHours,
endDate: Date = Date(year: 2025, month: 4, day: 4), endDate: Date = Date(year: 2025, month: 4, day: 4),

View File

@ -1,4 +1,3 @@
import BitwardenKit
import BitwardenResources import BitwardenResources
import Foundation import Foundation
@ -6,7 +5,7 @@ import Foundation
/// A data model containing the metadata associated with a flight recorder log. /// A data model containing the metadata associated with a flight recorder log.
/// ///
struct FlightRecorderLogMetadata: Equatable, Identifiable { public struct FlightRecorderLogMetadata: Equatable, Identifiable {
// MARK: Properties // MARK: Properties
/// The duration for how long the flight recorder was enabled for the log. /// The duration for how long the flight recorder was enabled for the log.
@ -22,7 +21,7 @@ struct FlightRecorderLogMetadata: Equatable, Identifiable {
let fileSize: String let fileSize: String
/// A unique identifier for the log. /// A unique identifier for the log.
let id: String public let id: String
/// Whether this represents the active log. /// Whether this represents the active log.
let isActiveLog: Bool let isActiveLog: Bool

View File

@ -1,7 +1,7 @@
import BitwardenResources
import XCTest import XCTest
import BitwardenResources @testable import BitwardenKit
@testable import BitwardenShared
class FlightRecorderLogMetadataTests: BitwardenTestCase { class FlightRecorderLogMetadataTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties

View File

@ -1,10 +1,9 @@
import BitwardenKit
import BitwardenResources import BitwardenResources
import Foundation import Foundation
/// An enum that represents how long to enable the flight recorder. /// An enum that represents how long to enable the flight recorder.
/// ///
enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable { public enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable {
/// The flight recorder is enabled for one hour. /// The flight recorder is enabled for one hour.
case oneHour case oneHour
@ -17,7 +16,7 @@ enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable {
/// The flight recorder is enabled for one week. /// The flight recorder is enabled for one week.
case oneWeek case oneWeek
var localizedName: String { public var localizedName: String {
switch self { switch self {
case .oneHour: Localizations.xHours(1) case .oneHour: Localizations.xHours(1)
case .eightHours: Localizations.xHours(8) case .eightHours: Localizations.xHours(8)

View File

@ -1,7 +1,7 @@
import BitwardenResources
import XCTest import XCTest
import BitwardenResources @testable import BitwardenKit
@testable import BitwardenShared
class FlightRecorderLoggingDurationTests: BitwardenTestCase { class FlightRecorderLoggingDurationTests: BitwardenTestCase {
// MARK: Tests // MARK: Tests

View File

@ -4,7 +4,7 @@ import Networking
/// An `HTTPLogger` that logs HTTP requests and responses to the flight recorder. /// An `HTTPLogger` that logs HTTP requests and responses to the flight recorder.
/// ///
final class FlightRecorderHTTPLogger: HTTPLogger { public final class FlightRecorderHTTPLogger: HTTPLogger {
// MARK: Properties // MARK: Properties
/// The service used by the application for recording temporary debug logs. /// The service used by the application for recording temporary debug logs.
@ -16,19 +16,19 @@ final class FlightRecorderHTTPLogger: HTTPLogger {
/// ///
/// - Parameter flightRecorder: The service used by the application for recording temporary debug logs. /// - Parameter flightRecorder: The service used by the application for recording temporary debug logs.
/// ///
init(flightRecorder: FlightRecorder) { public init(flightRecorder: FlightRecorder) {
self.flightRecorder = flightRecorder self.flightRecorder = flightRecorder
} }
// MARK: HTTPLogger // MARK: HTTPLogger
func logRequest(_ httpRequest: HTTPRequest) async { public func logRequest(_ httpRequest: HTTPRequest) async {
await flightRecorder.log( await flightRecorder.log(
"Request \(httpRequest.requestID): \(httpRequest.method.rawValue) \(httpRequest.url)", "Request \(httpRequest.requestID): \(httpRequest.method.rawValue) \(httpRequest.url)",
) )
} }
func logResponse(_ httpResponse: HTTPResponse) async { public func logResponse(_ httpResponse: HTTPResponse) async {
await flightRecorder.log( await flightRecorder.log(
"Response \(httpResponse.requestID): \(httpResponse.url) \(httpResponse.statusCode)", "Response \(httpResponse.requestID): \(httpResponse.url) \(httpResponse.statusCode)",
) )

View File

@ -1,8 +1,9 @@
import BitwardenKitMocks
import Networking import Networking
import TestHelpers import TestHelpers
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class FlightRecorderHTTPLoggerTests: BitwardenTestCase { class FlightRecorderHTTPLoggerTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties

View File

@ -1,4 +1,3 @@
import BitwardenKit
@preconcurrency import Combine @preconcurrency import Combine
import Foundation import Foundation
import OSLog import OSLog
@ -10,7 +9,7 @@ import OSLog
/// A protocol for a service which can temporarily be enabled to collect logs for debugging to a /// A protocol for a service which can temporarily be enabled to collect logs for debugging to a
/// local file. /// local file.
/// ///
protocol FlightRecorder: Sendable, BitwardenLogger { public protocol FlightRecorder: Sendable, BitwardenLogger {
/// A publisher which publishes the active log of the flight recorder. /// A publisher which publishes the active log of the flight recorder.
/// ///
/// - Returns: A publisher for the active log of the flight recorder. /// - Returns: A publisher for the active log of the flight recorder.
@ -64,11 +63,25 @@ protocol FlightRecorder: Sendable, BitwardenLogger {
func setFlightRecorderBannerDismissed() async func setFlightRecorderBannerDismissed() async
} }
extension FlightRecorder { public extension FlightRecorder {
/// Appends a message to the active log, if logging is currently enabled.
///
/// - Parameters:
/// - message: The message to append to the active log.
/// - file: The file that called the log method.
/// - line: The line number in the file that called the log method.
///
func log(_ message: String, file: String = #file, line: UInt = #line) async { func log(_ message: String, file: String = #file, line: UInt = #line) async {
await log(message, file: file, line: line) await log(message, file: file, line: line)
} }
/// Appends a message to the active log, if logging is currently enabled.
///
/// - Parameters:
/// - message: The message to append to the active log.
/// - file: The file that called the log method.
/// - line: The line number in the file that called the log method.
///
nonisolated func log(_ message: String, file: String, line: UInt) { nonisolated func log(_ message: String, file: String, line: UInt) {
Task { Task {
await log(message, file: file, line: line) await log(message, file: file, line: line)
@ -81,6 +94,9 @@ extension FlightRecorder {
/// An enumeration of errors thrown by a `FlightRecorder`. /// An enumeration of errors thrown by a `FlightRecorder`.
/// ///
enum FlightRecorderError: Error { enum FlightRecorderError: Error {
/// The container URL for the app group is unavailable.
case containerURLUnavailable
/// The stored flight recorder data doesn't exist. /// The stored flight recorder data doesn't exist.
case dataUnavailable case dataUnavailable
@ -119,6 +135,7 @@ extension FlightRecorderError: CustomNSError {
case .logNotFound: 5 case .logNotFound: 5
case .removeExpiredLogError: 6 case .removeExpiredLogError: 6
case .writeMessageError: 7 case .writeMessageError: 7
case .containerURLUnavailable: 8
} }
} }
@ -146,7 +163,8 @@ extension FlightRecorderError: CustomNSError {
extension FlightRecorderError: Equatable { extension FlightRecorderError: Equatable {
static func == (lhs: FlightRecorderError, rhs: FlightRecorderError) -> Bool { static func == (lhs: FlightRecorderError, rhs: FlightRecorderError) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.dataUnavailable, .dataUnavailable), case (.containerURLUnavailable, .containerURLUnavailable),
(.dataUnavailable, .dataUnavailable),
(.deletionNotPermitted, .deletionNotPermitted), (.deletionNotPermitted, .deletionNotPermitted),
(.logNotFound, .logNotFound): (.logNotFound, .logNotFound):
true true
@ -165,7 +183,7 @@ extension FlightRecorderError: Equatable {
/// A default implementation of a `FlightRecorder`. /// A default implementation of a `FlightRecorder`.
/// ///
actor DefaultFlightRecorder { public actor DefaultFlightRecorder {
// MARK: Private Properties // MARK: Private Properties
/// A subject containing the flight recorder data. This serves as a cache of the data after it /// A subject containing the flight recorder data. This serves as a cache of the data after it
@ -190,7 +208,7 @@ actor DefaultFlightRecorder {
private let fileManager: FileManagerProtocol private let fileManager: FileManagerProtocol
/// The service used by the application to manage account state. /// The service used by the application to manage account state.
private let stateService: StateService private let stateService: FlightRecorderStateService
/// The service used to get the present time. /// The service used to get the present time.
private let timeProvider: TimeProvider private let timeProvider: TimeProvider
@ -213,12 +231,12 @@ actor DefaultFlightRecorder {
/// - stateService: The service used by the application to manage account state. /// - stateService: The service used by the application to manage account state.
/// - timeProvider: The service used to get the present time. /// - timeProvider: The service used to get the present time.
/// ///
init( public init(
appInfoService: AppInfoService, appInfoService: AppInfoService,
disableLogLifecycleTimerForTesting: Bool = false, disableLogLifecycleTimerForTesting: Bool = false,
errorReporter: ErrorReporter, errorReporter: ErrorReporter,
fileManager: FileManagerProtocol = FileManager.default, fileManager: FileManagerProtocol = FileManager.default,
stateService: StateService, stateService: FlightRecorderStateService,
timeProvider: TimeProvider, timeProvider: TimeProvider,
) { ) {
self.appInfoService = appInfoService self.appInfoService = appInfoService
@ -371,7 +389,10 @@ actor DefaultFlightRecorder {
/// - Returns: A URL for the log file. /// - Returns: A URL for the log file.
/// ///
private func fileURL(for log: FlightRecorderData.LogMetadata) throws -> URL { private func fileURL(for log: FlightRecorderData.LogMetadata) throws -> URL {
try FileManager.default.flightRecorderLogURL().appendingPathComponent(log.fileName) guard let baseURL = try FileManager.default.flightRecorderLogURL() else {
throw FlightRecorderError.containerURLUnavailable
}
return baseURL.appendingPathComponent(log.fileName)
} }
/// Gets the `FlightRecorderData`. If the data has already been loaded, it will be returned /// Gets the `FlightRecorderData`. If the data has already been loaded, it will be returned
@ -419,12 +440,12 @@ actor DefaultFlightRecorder {
// MARK: - DefaultFlightRecorder + FlightRecorder // MARK: - DefaultFlightRecorder + FlightRecorder
extension DefaultFlightRecorder: FlightRecorder { extension DefaultFlightRecorder: FlightRecorder {
func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> { public func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
_ = await getFlightRecorderData() // Ensure data has already been loaded to the subject. _ = await getFlightRecorderData() // Ensure data has already been loaded to the subject.
return dataSubject.map { $0?.activeLog }.eraseToAnyPublisher() return dataSubject.map { $0?.activeLog }.eraseToAnyPublisher()
} }
func deleteInactiveLogs() async throws { public func deleteInactiveLogs() async throws {
guard var data = await getFlightRecorderData() else { guard var data = await getFlightRecorderData() else {
throw FlightRecorderError.dataUnavailable throw FlightRecorderError.dataUnavailable
} }
@ -437,7 +458,7 @@ extension DefaultFlightRecorder: FlightRecorder {
await setFlightRecorderData(data) await setFlightRecorderData(data)
} }
func deleteLog(_ log: FlightRecorderLogMetadata) async throws { public func deleteLog(_ log: FlightRecorderLogMetadata) async throws {
guard var data = await getFlightRecorderData() else { guard var data = await getFlightRecorderData() else {
throw FlightRecorderError.dataUnavailable throw FlightRecorderError.dataUnavailable
} }
@ -453,13 +474,13 @@ extension DefaultFlightRecorder: FlightRecorder {
await setFlightRecorderData(data) await setFlightRecorderData(data)
} }
func disableFlightRecorder() async { public func disableFlightRecorder() async {
guard var data = await getFlightRecorderData() else { return } guard var data = await getFlightRecorderData() else { return }
data.activeLog = nil data.activeLog = nil
await setFlightRecorderData(data) await setFlightRecorderData(data)
} }
func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws { public func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws {
let log = FlightRecorderData.LogMetadata(duration: duration, startDate: timeProvider.presentTime) let log = FlightRecorderData.LogMetadata(duration: duration, startDate: timeProvider.presentTime)
try await createLogFile(for: log) try await createLogFile(for: log)
@ -468,7 +489,7 @@ extension DefaultFlightRecorder: FlightRecorder {
await setFlightRecorderData(data) await setFlightRecorderData(data)
} }
func fetchLogs() async throws -> [FlightRecorderLogMetadata] { public func fetchLogs() async throws -> [FlightRecorderLogMetadata] {
guard let data = await getFlightRecorderData() else { return [] } guard let data = await getFlightRecorderData() else { return [] }
return try data.allLogs.map { log in return try data.allLogs.map { log in
try FlightRecorderLogMetadata( try FlightRecorderLogMetadata(
@ -484,12 +505,12 @@ extension DefaultFlightRecorder: FlightRecorder {
} }
} }
func isEnabledPublisher() async -> AnyPublisher<Bool, Never> { public func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
_ = await getFlightRecorderData() // Ensure data has already been loaded to the subject. _ = await getFlightRecorderData() // Ensure data has already been loaded to the subject.
return dataSubject.map { $0?.activeLog != nil }.eraseToAnyPublisher() return dataSubject.map { $0?.activeLog != nil }.eraseToAnyPublisher()
} }
func log(_ message: String, file: String, line: UInt) async { public func log(_ message: String, file: String, line: UInt) async {
guard var data = await getFlightRecorderData(), let log = data.activeLog else { return } guard var data = await getFlightRecorderData(), let log = data.activeLog else { return }
Logger.flightRecorder.debug("\(message)") Logger.flightRecorder.debug("\(message)")
do { do {
@ -506,7 +527,7 @@ extension DefaultFlightRecorder: FlightRecorder {
} }
} }
func setFlightRecorderBannerDismissed() async { public func setFlightRecorderBannerDismissed() async {
guard var data = await getFlightRecorderData(), data.activeLog != nil else { return } guard var data = await getFlightRecorderData(), data.activeLog != nil else { return }
data.activeLog?.isBannerDismissed = true data.activeLog?.isBannerDismissed = true
await setFlightRecorderData(data) await setFlightRecorderData(data)

View File

@ -0,0 +1,17 @@
// MARK: - FlightRecorderStateService
/// A protocol for a service that provides state management functionality required by the flight recorder.
///
public protocol FlightRecorderStateService: ActiveAccountStateProvider {
/// Retrieves the persisted flight recorder data.
///
/// - Returns: The stored `FlightRecorderData` if available, otherwise `nil`.
///
func getFlightRecorderData() async -> FlightRecorderData?
/// Persists the flight recorder data to storage.
///
/// - Parameter data: The `FlightRecorderData` to persist, or `nil` to clear the stored data.
///
func setFlightRecorderData(_ data: FlightRecorderData?) async
}

View File

@ -4,7 +4,7 @@ import InlineSnapshotTesting
import TestHelpers import TestHelpers
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
// swiftlint:disable file_length // swiftlint:disable file_length
@ -15,7 +15,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
var errorReporter: MockErrorReporter! var errorReporter: MockErrorReporter!
var fileManager: MockFileManager! var fileManager: MockFileManager!
var logURL: URL! var logURL: URL!
var stateService: MockStateService! var stateService: MockFlightRecorderStateService!
var subject: FlightRecorder! var subject: FlightRecorder!
var timeProvider: MockTimeProvider! var timeProvider: MockTimeProvider!
@ -42,11 +42,10 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
appInfoService = MockAppInfoService() appInfoService = MockAppInfoService()
errorReporter = MockErrorReporter() errorReporter = MockErrorReporter()
fileManager = MockFileManager() fileManager = MockFileManager()
stateService = MockStateService() stateService = MockFlightRecorderStateService()
timeProvider = MockTimeProvider(.mockTime(Date(year: 2025, month: 1, day: 1))) timeProvider = MockTimeProvider(.mockTime(Date(year: 2025, month: 1, day: 1)))
logURL = try XCTUnwrap(FileManager.default.flightRecorderLogURL() logURL = try flightRecorderLogURL().appendingPathComponent("flight_recorder_2025-01-01-00-00-00.txt")
.appendingPathComponent("flight_recorder_2025-01-01-00-00-00.txt"))
subject = makeSubject() subject = makeSubject()
} }
@ -124,8 +123,8 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
try XCTAssertEqual( try XCTAssertEqual(
fileManager.removeItemURLs, fileManager.removeItemURLs,
[ [
FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName), flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName),
FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName), flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName),
], ],
) )
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog)) XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog))
@ -326,11 +325,10 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
activeLog: activeLog, activeLog: activeLog,
inactiveLogs: [inactiveLog1, inactiveLog2], inactiveLogs: [inactiveLog1, inactiveLog2],
) )
let flightRecorderLogURL = try FileManager.default.flightRecorderLogURL()
let logs = try await subject.fetchLogs() let logs = try await subject.fetchLogs()
XCTAssertEqual( try XCTAssertEqual(
logs, logs,
[ [
FlightRecorderLogMetadata.fixture( FlightRecorderLogMetadata.fixture(
@ -341,7 +339,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
id: activeLog.id, id: activeLog.id,
isActiveLog: true, isActiveLog: true,
startDate: Date(year: 2025, month: 1, day: 1), startDate: Date(year: 2025, month: 1, day: 1),
url: flightRecorderLogURL.appendingPathComponent(activeLog.fileName), url: flightRecorderLogURL().appendingPathComponent(activeLog.fileName),
), ),
FlightRecorderLogMetadata.fixture( FlightRecorderLogMetadata.fixture(
duration: .oneHour, duration: .oneHour,
@ -351,7 +349,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
id: inactiveLog1.id, id: inactiveLog1.id,
isActiveLog: false, isActiveLog: false,
startDate: Date(year: 2025, month: 1, day: 2), startDate: Date(year: 2025, month: 1, day: 2),
url: flightRecorderLogURL.appendingPathComponent(inactiveLog1.fileName), url: flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName),
), ),
FlightRecorderLogMetadata.fixture( FlightRecorderLogMetadata.fixture(
duration: .oneWeek, duration: .oneWeek,
@ -361,7 +359,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
id: inactiveLog2.id, id: inactiveLog2.id,
isActiveLog: false, isActiveLog: false,
startDate: Date(year: 2025, month: 1, day: 3), startDate: Date(year: 2025, month: 1, day: 3),
url: flightRecorderLogURL.appendingPathComponent(inactiveLog2.fileName), url: flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName),
), ),
], ],
) )
@ -483,7 +481,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
// Expired active log is removed. // Expired active log is removed.
try XCTAssertEqual( try XCTAssertEqual(
fileManager.removeItemURLs, fileManager.removeItemURLs,
[FileManager.default.flightRecorderLogURL().appendingPathComponent(activeLog.fileName)], [flightRecorderLogURL().appendingPathComponent(activeLog.fileName)],
) )
XCTAssertEqual( XCTAssertEqual(
stateService.flightRecorderData, stateService.flightRecorderData,
@ -515,7 +513,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
// Expired inactive log is removed. // Expired inactive log is removed.
try XCTAssertEqual( try XCTAssertEqual(
fileManager.removeItemURLs, fileManager.removeItemURLs,
[FileManager.default.flightRecorderLogURL().appendingPathComponent(expiredLog.fileName)], [flightRecorderLogURL().appendingPathComponent(expiredLog.fileName)],
) )
XCTAssertEqual( XCTAssertEqual(
stateService.flightRecorderData, stateService.flightRecorderData,
@ -538,7 +536,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertEqual(error, .removeExpiredLogError(BitwardenTestError.example)) XCTAssertEqual(error, .removeExpiredLogError(BitwardenTestError.example))
try XCTAssertEqual( try XCTAssertEqual(
fileManager.removeItemURLs, fileManager.removeItemURLs,
[FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName)], [flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName)],
) )
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData()) XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData())
} }
@ -664,4 +662,11 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertEqual(appendedMessage, "2025-01-01T00:00:00Z: Hello world!\n") XCTAssertEqual(appendedMessage, "2025-01-01T00:00:00Z: Hello world!\n")
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog)) XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog))
} }
// MARK: Private
/// Returns an unwrapped URL to the directory containing flight recorder logs.
private func flightRecorderLogURL() throws -> URL {
try XCTUnwrap(FileManager.default.flightRecorderLogURL())
}
} }

View File

@ -0,0 +1,64 @@
import Combine
@testable import BitwardenKit
@MainActor
public final class MockFlightRecorder: FlightRecorder {
public var activeLogSubject = CurrentValueSubject<FlightRecorderData.LogMetadata?, Never>(nil)
public var deleteInactiveLogsCalled = false
public var deleteInactiveLogsResult: Result<Void, Error> = .success(())
public var deleteLogResult: Result<Void, Error> = .success(())
public var deleteLogLogs = [FlightRecorderLogMetadata]()
public var disableFlightRecorderCalled = false
public var enableFlightRecorderCalled = false
public var enableFlightRecorderDuration: FlightRecorderLoggingDuration?
public var enableFlightRecorderResult: Result<Void, Error> = .success(())
public var fetchLogsCalled = false
public var fetchLogsResult: Result<[FlightRecorderLogMetadata], Error> = .success([])
public var isEnabledSubject = CurrentValueSubject<Bool, Never>(false)
public var logMessages = [String]()
public var setFlightRecorderBannerDismissedCalled = false
public nonisolated init() {}
public func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
activeLogSubject.eraseToAnyPublisher()
}
public func deleteInactiveLogs() async throws {
deleteInactiveLogsCalled = true
try deleteInactiveLogsResult.get()
}
public func deleteLog(_ log: FlightRecorderLogMetadata) async throws {
deleteLogLogs.append(log)
try deleteLogResult.get()
}
public func disableFlightRecorder() {
disableFlightRecorderCalled = true
}
public func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws {
enableFlightRecorderCalled = true
enableFlightRecorderDuration = duration
try enableFlightRecorderResult.get()
}
public func fetchLogs() async throws -> [FlightRecorderLogMetadata] {
fetchLogsCalled = true
return try fetchLogsResult.get()
}
public func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
isEnabledSubject.eraseToAnyPublisher()
}
public func log(_ message: String, file: String, line: UInt) async {
logMessages.append(message)
}
public func setFlightRecorderBannerDismissed() async {
setFlightRecorderBannerDismissedCalled = true
}
}

View File

@ -0,0 +1,21 @@
import BitwardenKit
import TestHelpers
public class MockFlightRecorderStateService: FlightRecorderStateService {
public var activeAccountIdResult = Result<String, Error>.failure(BitwardenTestError.example)
public var flightRecorderData: FlightRecorderData?
public init() {}
public func getActiveAccountId() async throws -> String {
try activeAccountIdResult.get()
}
public func getFlightRecorderData() async -> FlightRecorderData? {
flightRecorderData
}
public func setFlightRecorderData(_ data: FlightRecorderData?) async {
flightRecorderData = data
}
}

View File

@ -28,6 +28,13 @@ public protocol HasErrorReporter {
var errorReporter: ErrorReporter { get } var errorReporter: ErrorReporter { get }
} }
/// Protocol for an object that provides a `FlightRecorder`.
///
public protocol HasFlightRecorder {
/// The service used by the application for recording temporary debug logs.
var flightRecorder: FlightRecorder { get }
}
/// Protocol for an object that provides a `TimeProvider`. /// Protocol for an object that provides a `TimeProvider`.
/// ///
public protocol HasTimeProvider { public protocol HasTimeProvider {

View File

@ -17,6 +17,10 @@ public enum Constants {
/// The device type, iOS = 1. /// The device type, iOS = 1.
public static let deviceType: DeviceType = 1 public static let deviceType: DeviceType = 1
/// The number of days that a flight recorder log will remain on the device after the end date
/// before being automatically deleted.
static let flightRecorderLogExpirationDays = 30
/// The minimum number of minutes before attempting a server config sync again. /// The minimum number of minutes before attempting a server config sync again.
public static let minimumConfigSyncInterval: TimeInterval = 60 * 60 // 60 minutes public static let minimumConfigSyncInterval: TimeInterval = 60 * 60 // 60 minutes

View File

@ -4,7 +4,7 @@ import Foundation
/// A protocol for an object that is used to perform filesystem tasks. /// A protocol for an object that is used to perform filesystem tasks.
/// ///
protocol FileManagerProtocol: AnyObject { public protocol FileManagerProtocol: AnyObject {
/// Appends the given data to the file at the specified URL. /// Appends the given data to the file at the specified URL.
/// ///
/// - Parameters: /// - Parameters:
@ -55,22 +55,22 @@ protocol FileManagerProtocol: AnyObject {
// MARK: - FileManager + FileManagerProtocol // MARK: - FileManager + FileManagerProtocol
extension FileManager: FileManagerProtocol { extension FileManager: FileManagerProtocol {
func append(_ data: Data, to url: URL) throws { public func append(_ data: Data, to url: URL) throws {
let handle = try FileHandle(forWritingTo: url) let handle = try FileHandle(forWritingTo: url)
try handle.seekToEnd() try handle.seekToEnd()
try handle.write(contentsOf: data) try handle.write(contentsOf: data)
try handle.close() try handle.close()
} }
func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { public func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {
try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil) try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil)
} }
func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws { public func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws {
try url.setIsExcludedFromBackup(value) try url.setIsExcludedFromBackup(value)
} }
func write(_ data: Data, to url: URL) throws { public func write(_ data: Data, to url: URL) throws {
try data.write(to: url) try data.write(to: url)
} }
} }

View File

@ -1,6 +1,6 @@
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class FileManagerTests: BitwardenTestCase { class FileManagerTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties

View File

@ -0,0 +1,63 @@
import Foundation
@testable import BitwardenKit
public class MockFileManager: FileManagerProtocol {
public var appendDataData: Data?
public var appendDataResult: Result<Void, Error> = .success(())
public var appendDataURL: URL?
public var attributesOfItemPath: String?
public var attributesOfItemResult: Result<[FileAttributeKey: Any], Error> = .success([:])
public var createDirectoryURL: URL?
public var createDirectoryCreateIntermediates: Bool?
public var createDirectoryResult: Result<Void, Error> = .success(())
public var removeItemURLs = [URL]()
public var removeItemResult: Result<Void, Error> = .success(())
public var setIsExcludedFromBackupValue: Bool?
public var setIsExcludedFromBackupURL: URL?
public var setIsExcludedFromBackupResult: Result<Void, Error> = .success(())
public var writeDataData: Data?
public var writeDataURL: URL?
public var writeDataResult: Result<Void, Error> = .success(())
public init() {}
public func append(_ data: Data, to url: URL) throws {
appendDataData = data
appendDataURL = url
try appendDataResult.get()
}
public func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
attributesOfItemPath = path
return try attributesOfItemResult.get()
}
public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool) throws {
createDirectoryURL = url
createDirectoryCreateIntermediates = createIntermediates
try createDirectoryResult.get()
}
public func removeItem(at url: URL) throws {
removeItemURLs.append(url)
try removeItemResult.get()
}
public func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws {
setIsExcludedFromBackupValue = value
setIsExcludedFromBackupURL = url
try setIsExcludedFromBackupResult.get()
}
public func write(_ data: Data, to url: URL) throws {
writeDataData = data
writeDataURL = url
try writeDataResult.get()
}
}

View File

@ -0,0 +1,27 @@
import BitwardenResources
// MARK: - Alert + Settings
extension Alert {
// MARK: Methods
/// Confirm deleting a flight recorder log.
///
/// - Parameters:
/// - isBulkDeletion: Whether the user is attempting to delete all logs or just a single log.
/// - action: The action to perform if the user selects yes to confirm deletion.
/// - Returns: An alert to confirm deleting a flight recorder log.
///
static func confirmDeleteLog(isBulkDeletion: Bool, action: @MainActor @escaping () async -> Void) -> Alert {
Alert(
title: isBulkDeletion
? Localizations.doYouReallyWantToDeleteAllRecordedLogs
: Localizations.doYouReallyWantToDeleteThisLog,
message: nil,
alertActions: [
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
AlertAction(title: Localizations.cancel, style: .cancel),
],
)
}
}

View File

@ -0,0 +1,44 @@
import BitwardenResources
import XCTest
@testable import BitwardenKit
class AlertFlightRecorderTests: BitwardenTestCase {
// MARK: Tests
/// `confirmDeleteLog(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm deleting a log.
func test_confirmDeleteLog() async throws {
var actionCalled = false
let subject = Alert.confirmDeleteLog(isBulkDeletion: false) { actionCalled = true }
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.doYouReallyWantToDeleteThisLog)
XCTAssertNil(subject.message)
try await subject.tapAction(title: Localizations.cancel)
XCTAssertFalse(actionCalled)
try await subject.tapAction(title: Localizations.yes)
XCTAssertTrue(actionCalled)
}
/// `confirmDeleteLog(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm deleting all logs.
func test_confirmDeleteLog_bulkDeletion() async throws {
var actionCalled = false
let subject = Alert.confirmDeleteLog(isBulkDeletion: true) { actionCalled = true }
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.doYouReallyWantToDeleteAllRecordedLogs)
XCTAssertNil(subject.message)
try await subject.tapAction(title: Localizations.cancel)
XCTAssertFalse(actionCalled)
try await subject.tapAction(title: Localizations.yes)
XCTAssertTrue(actionCalled)
}
}

View File

@ -1,4 +1,3 @@
import BitwardenKit
import Foundation import Foundation
// MARK: - EnableFlightRecorderProcessor // MARK: - EnableFlightRecorderProcessor
@ -18,7 +17,7 @@ final class EnableFlightRecorderProcessor: StateProcessor<
// MARK: Private Properties // MARK: Private Properties
/// The `Coordinator` that handles navigation. /// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent> private let coordinator: AnyCoordinator<FlightRecorderRoute, Void>
/// The services used by this processor. /// The services used by this processor.
private let services: Services private let services: Services
@ -33,7 +32,7 @@ final class EnableFlightRecorderProcessor: StateProcessor<
/// - state: The initial state of the processor. /// - state: The initial state of the processor.
/// ///
init( init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>, coordinator: AnyCoordinator<FlightRecorderRoute, Void>,
services: Services, services: Services,
state: EnableFlightRecorderState, state: EnableFlightRecorderState,
) { ) {

View File

@ -2,12 +2,12 @@ import BitwardenKitMocks
import TestHelpers import TestHelpers
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class EnableFlightRecorderProcessorTests: BitwardenTestCase { class EnableFlightRecorderProcessorTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>! var coordinator: MockCoordinator<FlightRecorderRoute, Void>!
var errorReporter: MockErrorReporter! var errorReporter: MockErrorReporter!
var flightRecorder: MockFlightRecorder! var flightRecorder: MockFlightRecorder!
var subject: EnableFlightRecorderProcessor! var subject: EnableFlightRecorderProcessor!

View File

@ -1,11 +1,10 @@
// swiftlint:disable:this file_name // swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks import BitwardenKitMocks
import BitwardenResources import BitwardenResources
import SnapshotTesting import SnapshotTesting
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class EnableFlightRecorderViewTests: BitwardenTestCase { class EnableFlightRecorderViewTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties

View File

@ -1,11 +1,10 @@
// swiftlint:disable:this file_name // swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks import BitwardenKitMocks
import BitwardenResources import BitwardenResources
import ViewInspectorTestHelpers import ViewInspectorTestHelpers
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class EnableFlightRecorderViewTests: BitwardenTestCase { class EnableFlightRecorderViewTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties

View File

@ -0,0 +1,97 @@
import UIKit
// MARK: - FlightRecorderCoordinator
/// A coordinator that manages navigation for the flight recorder.
///
public final class FlightRecorderCoordinator: Coordinator, HasStackNavigator {
// MARK: Types
public typealias Services = HasErrorAlertServices.ErrorAlertServices
& HasErrorReporter
& HasFlightRecorder
& HasTimeProvider
/// The services used by this coordinator.
private let services: Services
// MARK: Properties
/// The stack navigator that is managed by this coordinator.
public private(set) weak var stackNavigator: StackNavigator?
// MARK: Initialization
/// Creates a new `FlightRecorderCoordinator`.
///
/// - Parameters:
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
public init(
services: Services,
stackNavigator: StackNavigator,
) {
self.services = services
self.stackNavigator = stackNavigator
}
// MARK: Methods
public func navigate(to route: FlightRecorderRoute, context: AnyObject?) {
switch route {
case .dismiss:
stackNavigator?.dismiss()
case .enableFlightRecorder:
showEnableFlightRecorder()
case .flightRecorderLogs:
showFlightRecorderLogs()
case let .shareURL(url):
showShareSheet([url])
case let .shareURLs(urls):
showShareSheet(urls)
}
}
public func start() {}
// MARK: Private
/// Shows the enable flight recorder screen.
///
private func showEnableFlightRecorder() {
let processor = EnableFlightRecorderProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: EnableFlightRecorderState(),
)
stackNavigator?.present(EnableFlightRecorderView(store: Store(processor: processor)))
}
/// Shows the flight recorder logs screen.
///
private func showFlightRecorderLogs() {
let processor = FlightRecorderLogsProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: FlightRecorderLogsState(),
)
let view = FlightRecorderLogsView(store: Store(processor: processor), timeProvider: services.timeProvider)
stackNavigator?.present(view)
}
/// Shows the share sheet to share one or more items.
///
/// - Parameter items: The items to share.
///
private func showShareSheet(_ items: [Any]) {
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
stackNavigator?.present(activityVC)
}
}
// MARK: - HasErrorAlertServices
extension FlightRecorderCoordinator: HasErrorAlertServices {
public var errorAlertServices: ErrorAlertServices { services }
}

View File

@ -0,0 +1,86 @@
import BitwardenKitMocks
import SwiftUI
import XCTest
@testable import BitwardenKit
class FlightRecorderCoordinatorTests: BitwardenTestCase {
// MARK: Properties
var stackNavigator: MockStackNavigator!
var subject: FlightRecorderCoordinator!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
stackNavigator = MockStackNavigator()
subject = FlightRecorderCoordinator(
services: ServiceContainer.withMocks(),
stackNavigator: stackNavigator,
)
}
override func tearDown() {
super.tearDown()
stackNavigator = nil
subject = nil
}
// MARK: Tests
/// `navigate(to:)` with `.dismiss` dismisses the top most view presented by the stack
/// navigator.
@MainActor
func test_navigate_dismiss() throws {
subject.navigate(to: .dismiss)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .dismissed)
}
/// `navigate(to:)` with `.enableFlightRecorder` presents the enable flight recorder view.
@MainActor
func test_navigateTo_enableFlightRecorder() throws {
subject.navigate(to: .enableFlightRecorder)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is EnableFlightRecorderView)
XCTAssertEqual(action.embedInNavigationController, true)
}
/// `navigate(to:)` with `.flightRecorderLogs` presents the flight recorder logs view.
@MainActor
func test_navigateTo_flightRecorderLogs() throws {
subject.navigate(to: .flightRecorderLogs)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is FlightRecorderLogsView)
XCTAssertEqual(action.embedInNavigationController, true)
}
/// `navigate(to:)` with `.shareURL(_:)` presents an activity view controller to share the URL.
@MainActor
func test_navigateTo_shareURL() throws {
subject.navigate(to: .shareURL(.example))
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is UIActivityViewController)
}
/// `navigate(to:)` with `.shareURL(_:)` presents an activity view controller to share the URLs.
@MainActor
func test_navigateTo_shareURLs() throws {
subject.navigate(to: .shareURLs([.example]))
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is UIActivityViewController)
}
}

View File

@ -1,5 +1,3 @@
import BitwardenKit
// MARK: - FlightRecorderLogsAction // MARK: - FlightRecorderLogsAction
/// Actions handled by the `FlightRecorderLogsProcessor`. /// Actions handled by the `FlightRecorderLogsProcessor`.

View File

@ -1,4 +1,3 @@
import BitwardenKit
import BitwardenResources import BitwardenResources
import Foundation import Foundation
@ -19,7 +18,7 @@ final class FlightRecorderLogsProcessor: StateProcessor<
// MARK: Private Properties // MARK: Private Properties
/// The `Coordinator` that handles navigation. /// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent> private let coordinator: AnyCoordinator<FlightRecorderRoute, Void>
/// The services used by this processor. /// The services used by this processor.
private let services: Services private let services: Services
@ -34,7 +33,7 @@ final class FlightRecorderLogsProcessor: StateProcessor<
/// - state: The initial state of the processor. /// - state: The initial state of the processor.
/// ///
init( init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>, coordinator: AnyCoordinator<FlightRecorderRoute, Void>,
services: Services, services: Services,
state: FlightRecorderLogsState, state: FlightRecorderLogsState,
) { ) {

View File

@ -1,15 +1,14 @@
import BitwardenKit
import BitwardenKitMocks import BitwardenKitMocks
import BitwardenResources import BitwardenResources
import TestHelpers import TestHelpers
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class FlightRecorderLogsProcessorTests: BitwardenTestCase { class FlightRecorderLogsProcessorTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>! var coordinator: MockCoordinator<FlightRecorderRoute, Void>!
var errorReporter: MockErrorReporter! var errorReporter: MockErrorReporter!
var flightRecorder: MockFlightRecorder! var flightRecorder: MockFlightRecorder!
var subject: FlightRecorderLogsProcessor! var subject: FlightRecorderLogsProcessor!

View File

@ -1,5 +1,3 @@
import BitwardenKit
// MARK: - FlightRecorderLogsState // MARK: - FlightRecorderLogsState
/// An object that defines the current state of the `FlightRecorderLogsView`. /// An object that defines the current state of the `FlightRecorderLogsView`.

View File

@ -1,11 +1,10 @@
// swiftlint:disable:this file_name // swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks import BitwardenKitMocks
import BitwardenResources import BitwardenResources
import SnapshotTesting import SnapshotTesting
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class FlightRecorderLogsViewTests: BitwardenTestCase { class FlightRecorderLogsViewTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties

View File

@ -1,10 +1,10 @@
// swiftlint:disable:this file_name // swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks import BitwardenKitMocks
import BitwardenResources import BitwardenResources
import TestHelpers
import XCTest import XCTest
@testable import BitwardenShared @testable import BitwardenKit
class FlightRecorderLogsViewTests: BitwardenTestCase { class FlightRecorderLogsViewTests: BitwardenTestCase {
// MARK: Properties // MARK: Properties

View File

@ -1,4 +1,3 @@
import BitwardenKit
import BitwardenResources import BitwardenResources
import SwiftUI import SwiftUI
@ -53,7 +52,7 @@ struct FlightRecorderLogsView: View {
logsList logsList
} else { } else {
IllustratedMessageView( IllustratedMessageView(
image: Asset.Images.Illustrations.secureDevices.swiftUIImage, image: SharedAsset.Illustrations.secureDevices.swiftUIImage,
style: .mediumImage, style: .mediumImage,
message: Localizations.noLogsRecorded, message: Localizations.noLogsRecorded,
) )

View File

@ -0,0 +1,16 @@
// MARK: - FlightRecorderModule
/// An object that builds coordinators for the flight recorder flow.
///
@MainActor
public protocol FlightRecorderModule {
/// Initializes a coordinator for navigating between `FlightRecorderRoute`s.
///
/// - Parameters:
/// - stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to `FlightRecorderRoute`s.
///
func makeFlightRecorderCoordinator(
stackNavigator: StackNavigator,
) -> AnyCoordinator<FlightRecorderRoute, Void>
}

View File

@ -0,0 +1,20 @@
import Foundation
/// A route to a specific screen within the flight recorder flow.
///
public enum FlightRecorderRoute: Equatable, Hashable {
/// A route that dismisses the current view.
case dismiss
/// A route to enable and configure flight recorder.
case enableFlightRecorder
/// A route to the flight recorder logs screen.
case flightRecorderLogs
/// A route to the share sheet to share a URL.
case shareURL(URL)
/// A route to the share sheet to share multiple URLs.
case shareURLs([URL])
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "secure-devices.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "secure-devices-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -30,13 +30,4 @@ extension FileManager {
) )
.appendingPathComponent("Exports", isDirectory: true) .appendingPathComponent("Exports", isDirectory: true)
} }
/// Returns a URL for the directory containing flight recorder logs.
///
/// - Returns: A URL for a directory to store flight recorder logs.
///
func flightRecorderLogURL() throws -> URL {
containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.groupIdentifier)!
.appendingPathComponent("FlightRecorderLogs", isDirectory: true)
}
} }

View File

@ -1,11 +0,0 @@
import Foundation
extension Task where Success == Never, Failure == Never {
static func sleep(forSeconds delay: Double, tolerance: Double = 1) async throws {
if #available(iOS 16.0, *) {
try await sleep(for: .seconds(delay), tolerance: .seconds(tolerance))
} else {
try await sleep(nanoseconds: UInt64(delay * Double(NSEC_PER_SEC)))
}
}
}

View File

@ -44,40 +44,4 @@ extension URL {
var isApp: Bool { var isApp: Bool {
absoluteString.starts(with: Constants.iOSAppProtocol) absoluteString.starts(with: Constants.iOSAppProtocol)
} }
// MARK: Methods
/// Creates a new `URL` appending the provided query items to the url.
///
/// On iOS 16+, this method uses the method with the same name in Foundation. On iOS 15, this method
/// uses `URLComponents` to add the query items to the new url.
///
/// - Parameter queryItems: A list of `URLQueryItem`s to add to this url.
/// - Returns: A new `URL` with the query items appended.
///
func appending(queryItems: [URLQueryItem]) -> URL? {
if #available(iOS 16, *) {
// Set this variable to a non-optional `URL` type so that we are calling the function in Foundation,
// rather than recursively calling this method.
let url: URL = appending(queryItems: queryItems)
return url
} else {
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
else { return nil }
components.queryItems = queryItems
return components.url
}
}
/// Sets whether the file should be excluded from backups.
///
/// - Parameter value: `true` if the file should be excluded from backups, or `false` otherwise.
///
func setIsExcludedFromBackup(_ value: Bool) throws {
var url = self
var values = URLResourceValues()
values.isExcludedFromBackup = value
try url.setResourceValues(values)
}
} }

View File

@ -98,7 +98,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let fido2UserInterfaceHelper: Fido2UserInterfaceHelper let fido2UserInterfaceHelper: Fido2UserInterfaceHelper
/// The service used by the application for recording temporary debug logs. /// The service used by the application for recording temporary debug logs.
let flightRecorder: FlightRecorder public let flightRecorder: FlightRecorder
/// The repository used by the application to manage generator data for the UI layer. /// The repository used by the application to manage generator data for the UI layer.
let generatorRepository: GeneratorRepository let generatorRepository: GeneratorRepository

View File

@ -2,8 +2,6 @@ import AuthenticatorBridgeKit
import BitwardenKit import BitwardenKit
import BitwardenSdk import BitwardenSdk
// swiftlint:disable file_length
/// The services provided by the `ServiceContainer`. /// The services provided by the `ServiceContainer`.
typealias Services = HasAPIService typealias Services = HasAPIService
& HasAccountAPIService & HasAccountAPIService
@ -209,13 +207,6 @@ protocol HasFileAPIService {
var fileAPIService: FileAPIService { get } var fileAPIService: FileAPIService { get }
} }
/// Protocol for an object that provides a `FlightRecorder`.
///
protocol HasFlightRecorder {
/// The service used by the application for recording temporary debug logs.
var flightRecorder: FlightRecorder { get }
}
/// Protocol for an object that provides a `GeneratorRepository`. /// Protocol for an object that provides a `GeneratorRepository`.
/// ///
protocol HasGeneratorRepository { protocol HasGeneratorRepository {

View File

@ -1437,7 +1437,7 @@ enum StateServiceError: LocalizedError {
/// A default implementation of `StateService`. /// A default implementation of `StateService`.
/// ///
actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigStateService { // swiftlint:disable:this type_body_length line_length actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigStateService, FlightRecorderStateService { // swiftlint:disable:this type_body_length line_length
// MARK: Properties // MARK: Properties
/// The language option currently selected for the app. /// The language option currently selected for the app.

View File

@ -1,64 +0,0 @@
import Combine
@testable import BitwardenShared
@MainActor
final class MockFlightRecorder: FlightRecorder {
var activeLogSubject = CurrentValueSubject<FlightRecorderData.LogMetadata?, Never>(nil)
var deleteInactiveLogsCalled = false
var deleteInactiveLogsResult: Result<Void, Error> = .success(())
var deleteLogResult: Result<Void, Error> = .success(())
var deleteLogLogs = [FlightRecorderLogMetadata]()
var disableFlightRecorderCalled = false
var enableFlightRecorderCalled = false
var enableFlightRecorderDuration: FlightRecorderLoggingDuration?
var enableFlightRecorderResult: Result<Void, Error> = .success(())
var fetchLogsCalled = false
var fetchLogsResult: Result<[FlightRecorderLogMetadata], Error> = .success([])
var isEnabledSubject = CurrentValueSubject<Bool, Never>(false)
var logMessages = [String]()
var setFlightRecorderBannerDismissedCalled = false
nonisolated init() {}
func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
activeLogSubject.eraseToAnyPublisher()
}
func deleteInactiveLogs() async throws {
deleteInactiveLogsCalled = true
try deleteInactiveLogsResult.get()
}
func deleteLog(_ log: FlightRecorderLogMetadata) async throws {
deleteLogLogs.append(log)
try deleteLogResult.get()
}
func disableFlightRecorder() {
disableFlightRecorderCalled = true
}
func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws {
enableFlightRecorderCalled = true
enableFlightRecorderDuration = duration
try enableFlightRecorderResult.get()
}
func fetchLogs() async throws -> [FlightRecorderLogMetadata] {
fetchLogsCalled = true
return try fetchLogsResult.get()
}
func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
isEnabledSubject.eraseToAnyPublisher()
}
func log(_ message: String, file: String, line: UInt) async {
logMessages.append(message)
}
func setFlightRecorderBannerDismissed() async {
setFlightRecorderBannerDismissedCalled = true
}
}

View File

@ -26,10 +26,6 @@ extension Constants {
/// The URL for the web vault if the user account doesn't have one specified. /// The URL for the web vault if the user account doesn't have one specified.
static let defaultWebVaultHost = "bitwarden.com" static let defaultWebVaultHost = "bitwarden.com"
/// The number of days that a flight recorder log will remain on the device after the end date
/// before being automatically deleted.
static let flightRecorderLogExpirationDays = 30
/// The length of a masked password. /// The length of a masked password.
static let hiddenPasswordLength = 8 static let hiddenPasswordLength = 8

View File

@ -1,61 +0,0 @@
import Foundation
@testable import BitwardenShared
class MockFileManager: FileManagerProtocol {
var appendDataData: Data?
var appendDataResult: Result<Void, Error> = .success(())
var appendDataURL: URL?
var attributesOfItemPath: String?
var attributesOfItemResult: Result<[FileAttributeKey: Any], Error> = .success([:])
var createDirectoryURL: URL?
var createDirectoryCreateIntermediates: Bool?
var createDirectoryResult: Result<Void, Error> = .success(())
var removeItemURLs = [URL]()
var removeItemResult: Result<Void, Error> = .success(())
var setIsExcludedFromBackupValue: Bool?
var setIsExcludedFromBackupURL: URL?
var setIsExcludedFromBackupResult: Result<Void, Error> = .success(())
var writeDataData: Data?
var writeDataURL: URL?
var writeDataResult: Result<Void, Error> = .success(())
func append(_ data: Data, to url: URL) throws {
appendDataData = data
appendDataURL = url
try appendDataResult.get()
}
func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
attributesOfItemPath = path
return try attributesOfItemResult.get()
}
func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool) throws {
createDirectoryURL = url
createDirectoryCreateIntermediates = createIntermediates
try createDirectoryResult.get()
}
func removeItem(at url: URL) throws {
removeItemURLs.append(url)
try removeItemResult.get()
}
func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws {
setIsExcludedFromBackupValue = value
setIsExcludedFromBackupURL = url
try setIsExcludedFromBackupResult.get()
}
func write(_ data: Data, to url: URL) throws {
writeDataData = data
writeDataURL = url
try writeDataResult.get()
}
}

View File

@ -62,3 +62,17 @@ extension DefaultAppModule: AppModule {
).asAnyCoordinator() ).asAnyCoordinator()
} }
} }
// MARK: - DefaultAppModule + FlightRecorderModule
extension DefaultAppModule: FlightRecorderModule {
public func makeFlightRecorderCoordinator(
stackNavigator: StackNavigator,
) -> AnyCoordinator<FlightRecorderRoute, Void> {
FlightRecorderCoordinator(
services: services,
stackNavigator: stackNavigator,
)
.asAnyCoordinator()
}
}

View File

@ -41,26 +41,6 @@ extension Alert {
) )
} }
/// Confirm deleting a flight recorder log.
///
/// - Parameters:
/// - isBulkDeletion: Whether the user is attempting to delete all logs or just a single log.
/// - action: The action to perform if the user selects yes to confirm deletion.
/// - Returns: An alert to confirm deleting a flight recorder log.
///
static func confirmDeleteLog(isBulkDeletion: Bool, action: @MainActor @escaping () async -> Void) -> Alert {
Alert(
title: isBulkDeletion
? Localizations.doYouReallyWantToDeleteAllRecordedLogs
: Localizations.doYouReallyWantToDeleteThisLog,
message: nil,
alertActions: [
AlertAction(title: Localizations.yes, style: .default) { _ in await action() },
AlertAction(title: Localizations.cancel, style: .cancel),
],
)
}
/// Confirm denying all the login requests. /// Confirm denying all the login requests.
/// ///
/// - Parameter action: The action to perform if the user selects yes. /// - Parameter action: The action to perform if the user selects yes.

View File

@ -31,42 +31,6 @@ class AlertSettingsTests: BitwardenTestCase {
XCTAssertNil(subject.message) XCTAssertNil(subject.message)
} }
/// `confirmDeleteLog(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm deleting a log.
func test_confirmDeleteLog() async throws {
var actionCalled = false
let subject = Alert.confirmDeleteLog(isBulkDeletion: false) { actionCalled = true }
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.doYouReallyWantToDeleteThisLog)
XCTAssertNil(subject.message)
try await subject.tapAction(title: Localizations.cancel)
XCTAssertFalse(actionCalled)
try await subject.tapAction(title: Localizations.yes)
XCTAssertTrue(actionCalled)
}
/// `confirmDeleteLog(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm deleting all logs.
func test_confirmDeleteLog_bulkDeletion() async throws {
var actionCalled = false
let subject = Alert.confirmDeleteLog(isBulkDeletion: true) { actionCalled = true }
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.preferredStyle, .alert)
XCTAssertEqual(subject.title, Localizations.doYouReallyWantToDeleteAllRecordedLogs)
XCTAssertNil(subject.message)
try await subject.tapAction(title: Localizations.cancel)
XCTAssertFalse(actionCalled)
try await subject.tapAction(title: Localizations.yes)
XCTAssertTrue(actionCalled)
}
/// `confirmDenyingAllRequests(action:)` constructs an `Alert` with the title, /// `confirmDenyingAllRequests(action:)` constructs an `Alert` with the title,
/// message, yes, and cancel buttons to confirm denying all login requests /// message, yes, and cancel buttons to confirm denying all login requests
func test_confirmDenyingAllRequests() { func test_confirmDenyingAllRequests() {

View File

@ -57,7 +57,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
await streamFlightRecorderLog() await streamFlightRecorderLog()
case let .toggleFlightRecorder(isOn): case let .toggleFlightRecorder(isOn):
if isOn { if isOn {
coordinator.navigate(to: .enableFlightRecorder) coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
} else { } else {
await services.flightRecorder.disableFlightRecorder() await services.flightRecorder.disableFlightRecorder()
} }
@ -92,7 +92,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
case .versionTapped: case .versionTapped:
handleVersionTapped() handleVersionTapped()
case .viewFlightRecorderLogsTapped: case .viewFlightRecorderLogsTapped:
coordinator.navigate(to: .flightRecorderLogs) coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
case .webVaultTapped: case .webVaultTapped:
coordinator.showAlert(.webVaultAlert { coordinator.showAlert(.webVaultAlert {
self.state.url = self.services.environmentService.webVaultURL self.state.url = self.services.environmentService.webVaultURL

View File

@ -122,7 +122,7 @@ class AboutProcessorTests: BitwardenTestCase {
await subject.perform(.toggleFlightRecorder(true)) await subject.perform(.toggleFlightRecorder(true))
XCTAssertEqual(coordinator.routes, [.enableFlightRecorder]) XCTAssertEqual(coordinator.routes, [.flightRecorder(.enableFlightRecorder)])
} }
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state. /// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
@ -232,7 +232,7 @@ class AboutProcessorTests: BitwardenTestCase {
func test_receive_viewFlightRecorderLogsTapped() { func test_receive_viewFlightRecorderLogsTapped() {
subject.receive(.viewFlightRecorderLogsTapped) subject.receive(.viewFlightRecorderLogsTapped)
XCTAssertEqual(coordinator.routes, [.flightRecorderLogs]) XCTAssertEqual(coordinator.routes, [.flightRecorder(.flightRecorderLogs)])
} }
/// `receive(_:)` with `.webVaultTapped` shows an alert for navigating to the web vault /// `receive(_:)` with `.webVaultTapped` shows an alert for navigating to the web vault

View File

@ -1,3 +1,4 @@
import BitwardenKit
import BitwardenResources import BitwardenResources
import XCTest import XCTest

View File

@ -55,6 +55,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
typealias Module = AddEditFolderModule typealias Module = AddEditFolderModule
& AuthModule & AuthModule
& ExportCXFModule & ExportCXFModule
& FlightRecorderModule
& ImportLoginsModule & ImportLoginsModule
& LoginRequestModule & LoginRequestModule
& NavigatorBuilderModule & NavigatorBuilderModule
@ -152,8 +153,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
showAccountSecurity() showAccountSecurity()
case let .addEditFolder(folder): case let .addEditFolder(folder):
showAddEditFolder(folder, delegate: context as? AddEditFolderDelegate) showAddEditFolder(folder, delegate: context as? AddEditFolderDelegate)
case .enableFlightRecorder:
showEnableFlightRecorder()
case .appearance: case .appearance:
showAppearance() showAppearance()
case .appExtension: case .appExtension:
@ -174,8 +173,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
showExportVaultToApp() showExportVaultToApp()
case .exportVaultToFile: case .exportVaultToFile:
showExportVaultToFile() showExportVaultToFile()
case .flightRecorderLogs: case let .flightRecorder(route):
showFlightRecorderLogs() showFlightRecorder(route: route)
case .folders: case .folders:
showFolders() showFolders()
case .importLogins: case .importLogins:
@ -194,8 +193,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
showSettings(presentationMode: presentationMode) showSettings(presentationMode: presentationMode)
case let .shareURL(url): case let .shareURL(url):
showShareSheet([url]) showShareSheet([url])
case let .shareURLs(urls):
showShareSheet(urls)
case .vault: case .vault:
showVault() showVault()
case .vaultUnlockSetup: case .vaultUnlockSetup:
@ -343,17 +340,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
stackNavigator?.present(DeleteAccountView(store: Store(processor: processor))) stackNavigator?.present(DeleteAccountView(store: Store(processor: processor)))
} }
/// Shows the enable flight recorder screen.
///
private func showEnableFlightRecorder() {
let processor = EnableFlightRecorderProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: EnableFlightRecorderState(),
)
stackNavigator?.present(EnableFlightRecorderView(store: Store(processor: processor)))
}
/// Shows the share sheet to share one or more items. /// Shows the share sheet to share one or more items.
/// ///
/// - Parameter items: The items to share. /// - Parameter items: The items to share.
@ -400,16 +386,15 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
stackNavigator?.present(navigationController) stackNavigator?.present(navigationController)
} }
/// Shows the flight recorder logs screen. /// Shows a flight recorder view.
/// ///
private func showFlightRecorderLogs() { /// - Parameter route: A `FlightRecorderRoute` to navigate to.
let processor = FlightRecorderLogsProcessor( ///
coordinator: asAnyCoordinator(), private func showFlightRecorder(route: FlightRecorderRoute) {
services: services, guard let stackNavigator else { return }
state: FlightRecorderLogsState(), let coordinator = module.makeFlightRecorderCoordinator(stackNavigator: stackNavigator)
) coordinator.start()
let view = FlightRecorderLogsView(store: Store(processor: processor), timeProvider: services.timeProvider) coordinator.navigate(to: route)
stackNavigator?.present(view)
} }
/// Shows the folders screen. /// Shows the folders screen.

View File

@ -175,17 +175,6 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty
XCTAssertEqual(action.type, .dismissed) XCTAssertEqual(action.type, .dismissed)
} }
/// `navigate(to:)` with `.enableFlightRecorder` presents the enable flight recorder view.
@MainActor
func test_navigateTo_enableFlightRecorder() throws {
subject.navigate(to: .enableFlightRecorder)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is EnableFlightRecorderView)
XCTAssertEqual(action.embedInNavigationController, true)
}
/// `navigate(to:)` with `.exportVault` presents the export vault to file view when /// `navigate(to:)` with `.exportVault` presents the export vault to file view when
/// Credential Exchange flag to export is disabled. /// Credential Exchange flag to export is disabled.
@MainActor @MainActor
@ -262,15 +251,14 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty
XCTAssertEqual(module.importLoginsCoordinator.routes.last, .importLogins(.settings)) XCTAssertEqual(module.importLoginsCoordinator.routes.last, .importLogins(.settings))
} }
/// `navigate(to:)` with `.flightRecorderLogs` presents the flight recorder logs view. /// `navigate(to:)` with `.flightRecorder` starts flight recorder coordinator and navigates to
/// the enable flight recorder view.
@MainActor @MainActor
func test_navigateTo_flightRecorderLogs() throws { func test_navigateTo_flightRecorder() throws {
subject.navigate(to: .flightRecorderLogs) subject.navigate(to: .flightRecorder(.enableFlightRecorder))
let action = try XCTUnwrap(stackNavigator.actions.last) XCTAssertTrue(module.flightRecorderCoordinator.isStarted)
XCTAssertEqual(action.type, .presented) XCTAssertEqual(module.flightRecorderCoordinator.routes.last, .enableFlightRecorder)
XCTAssertTrue(action.view is FlightRecorderLogsView)
XCTAssertEqual(action.embedInNavigationController, true)
} }
/// `navigate(to:)` with `.lockVault` navigates the user to the login view. /// `navigate(to:)` with `.lockVault` navigates the user to the login view.
@ -404,16 +392,6 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty
XCTAssertTrue(action.view is UIActivityViewController) XCTAssertTrue(action.view is UIActivityViewController)
} }
/// `navigate(to:)` with `.shareURL(_:)` presents an activity view controller to share the URLs.
@MainActor
func test_navigateTo_shareURLs() throws {
subject.navigate(to: .shareURLs([.example]))
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .presented)
XCTAssertTrue(action.view is UIActivityViewController)
}
/// `navigate(to:)` with `.vault` pushes the vault settings view onto the stack navigator. /// `navigate(to:)` with `.vault` pushes the vault settings view onto the stack navigator.
@MainActor @MainActor
func test_navigateTo_vault() throws { func test_navigateTo_vault() throws {

View File

@ -35,9 +35,6 @@ public enum SettingsRoute: Equatable, Hashable {
/// A route that dismisses the current view. /// A route that dismisses the current view.
case dismiss case dismiss
/// A route to enable and configure flight recorder.
case enableFlightRecorder
/// A route to the export vault settings view or export to file view depending on feature flag. /// A route to the export vault settings view or export to file view depending on feature flag.
case exportVault case exportVault
@ -47,8 +44,8 @@ public enum SettingsRoute: Equatable, Hashable {
/// A route to the export vault to file view. /// A route to the export vault to file view.
case exportVaultToFile case exportVaultToFile
/// A route to the flight recorder logs screen. /// A route to a flight recorder view.
case flightRecorderLogs case flightRecorder(FlightRecorderRoute)
/// A route to view the folders in the vault. /// A route to view the folders in the vault.
case folders case folders
@ -83,9 +80,6 @@ public enum SettingsRoute: Equatable, Hashable {
/// A route to the share sheet to share a URL. /// A route to the share sheet to share a URL.
case shareURL(URL) case shareURL(URL)
/// A route to the share sheet to share multiple URLs.
case shareURLs([URL])
/// A route to the vault settings view. /// A route to the vault settings view.
case vault case vault

View File

@ -14,6 +14,7 @@ class MockAppModule:
ExportCXFModule, ExportCXFModule,
ExtensionSetupModule, ExtensionSetupModule,
FileSelectionModule, FileSelectionModule,
FlightRecorderModule,
GeneratorModule, GeneratorModule,
ImportCXFModule, ImportCXFModule,
ImportLoginsModule, ImportLoginsModule,
@ -38,6 +39,7 @@ class MockAppModule:
var extensionSetupCoordinator = MockCoordinator<ExtensionSetupRoute, Void>() var extensionSetupCoordinator = MockCoordinator<ExtensionSetupRoute, Void>()
var fileSelectionDelegate: FileSelectionDelegate? var fileSelectionDelegate: FileSelectionDelegate?
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, Void>() var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, Void>()
var flightRecorderCoordinator = MockCoordinator<FlightRecorderRoute, Void>()
var generatorCoordinator = MockCoordinator<GeneratorRoute, Void>() var generatorCoordinator = MockCoordinator<GeneratorRoute, Void>()
var importCXFCoordinator = MockCoordinator<ImportCXFRoute, Void>() var importCXFCoordinator = MockCoordinator<ImportCXFRoute, Void>()
var importLoginsCoordinator = MockCoordinator<ImportLoginsRoute, ImportLoginsEvent>() var importLoginsCoordinator = MockCoordinator<ImportLoginsRoute, ImportLoginsEvent>()
@ -109,6 +111,12 @@ class MockAppModule:
return fileSelectionCoordinator.asAnyCoordinator() return fileSelectionCoordinator.asAnyCoordinator()
} }
func makeFlightRecorderCoordinator(
stackNavigator _: StackNavigator,
) -> AnyCoordinator<FlightRecorderRoute, Void> {
flightRecorderCoordinator.asAnyCoordinator()
}
func makeGeneratorCoordinator( func makeGeneratorCoordinator(
delegate _: GeneratorCoordinatorDelegate?, delegate _: GeneratorCoordinatorDelegate?,
stackNavigator _: StackNavigator, stackNavigator _: StackNavigator,

View File

@ -20,6 +20,7 @@ xcassets:
inputs: inputs:
- BitwardenResources/Colors.xcassets - BitwardenResources/Colors.xcassets
- BitwardenResources/Icons.xcassets - BitwardenResources/Icons.xcassets
- BitwardenResources/Illustrations.xcassets
outputs: outputs:
- templateName: swift5 - templateName: swift5
output: SharedAssets.swift output: SharedAssets.swift