diff --git a/BitwardenKit/Application/TestHelpers/ServiceContainer.swift b/BitwardenKit/Application/TestHelpers/ServiceContainer.swift index 9defca499..6d94a3563 100644 --- a/BitwardenKit/Application/TestHelpers/ServiceContainer.swift +++ b/BitwardenKit/Application/TestHelpers/ServiceContainer.swift @@ -7,6 +7,7 @@ typealias Services = HasConfigService & HasEnvironmentService & HasErrorReportBuilder & HasErrorReporter + & HasFlightRecorder & HasTimeProvider /// A service container used for testing processors within `BitwardenKitTests`. @@ -16,6 +17,7 @@ class ServiceContainer: Services { let environmentService: EnvironmentService let errorReportBuilder: any ErrorReportBuilder let errorReporter: ErrorReporter + let flightRecorder: FlightRecorder let timeProvider: TimeProvider required init( @@ -23,12 +25,14 @@ class ServiceContainer: Services { environmentService: EnvironmentService, errorReportBuilder: ErrorReportBuilder, errorReporter: ErrorReporter, + flightRecorder: FlightRecorder, timeProvider: TimeProvider, ) { self.configService = configService self.errorReportBuilder = errorReportBuilder self.environmentService = environmentService self.errorReporter = errorReporter + self.flightRecorder = flightRecorder self.timeProvider = timeProvider } } @@ -39,6 +43,7 @@ extension ServiceContainer { errorReportBuilder: ErrorReportBuilder = MockErrorReportBuilder(), environmentService: EnvironmentService = MockEnvironmentService(), errorReporter: ErrorReporter = MockErrorReporter(), + flightRecorder: FlightRecorder = MockFlightRecorder(), timeProvider: TimeProvider = MockTimeProvider(.currentTime), ) -> ServiceContainer { self.init( @@ -46,6 +51,7 @@ extension ServiceContainer { environmentService: environmentService, errorReportBuilder: errorReportBuilder, errorReporter: errorReporter, + flightRecorder: flightRecorder, timeProvider: timeProvider, ) } diff --git a/BitwardenKit/Core/Platform/Extensions/FileManager+Extensions.swift b/BitwardenKit/Core/Platform/Extensions/FileManager+Extensions.swift new file mode 100644 index 000000000..f491124a3 --- /dev/null +++ b/BitwardenKit/Core/Platform/Extensions/FileManager+Extensions.swift @@ -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) + } +} diff --git a/BitwardenKit/Core/Platform/Extensions/Task+Extensions.swift b/BitwardenKit/Core/Platform/Extensions/Task+Extensions.swift new file mode 100644 index 000000000..5c2149e94 --- /dev/null +++ b/BitwardenKit/Core/Platform/Extensions/Task+Extensions.swift @@ -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))) + } + } +} diff --git a/BitwardenKit/Core/Platform/Extensions/URL.swift b/BitwardenKit/Core/Platform/Extensions/URL.swift index ef9787200..297a0d660 100644 --- a/BitwardenKit/Core/Platform/Extensions/URL.swift +++ b/BitwardenKit/Core/Platform/Extensions/URL.swift @@ -31,4 +31,40 @@ public extension URL { guard absoluteString.hasPrefix(prefix) else { return absoluteString } 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) + } } diff --git a/BitwardenShared/Core/Platform/Models/Data/FlightRecorderData.swift b/BitwardenKit/Core/Platform/Models/Data/FlightRecorderData.swift similarity index 70% rename from BitwardenShared/Core/Platform/Models/Data/FlightRecorderData.swift rename to BitwardenKit/Core/Platform/Models/Data/FlightRecorderData.swift index 15dc65cad..fab0b279c 100644 --- a/BitwardenShared/Core/Platform/Models/Data/FlightRecorderData.swift +++ b/BitwardenKit/Core/Platform/Models/Data/FlightRecorderData.swift @@ -1,4 +1,3 @@ -import BitwardenKit import Foundation // MARK: - FlightRecorderData @@ -6,11 +5,11 @@ import Foundation /// A data model containing the persisted data necessary for the flight recorder. This stores the /// metadata for the active and any inactive logs. /// -struct FlightRecorderData: Codable, Equatable { +public struct FlightRecorderData: Codable, Equatable { // MARK: Properties /// The current log, if the flight recorder is active. - var activeLog: LogMetadata? { + public var activeLog: LogMetadata? { didSet { guard let oldValue, oldValue.id != activeLog?.id else { return } 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 /// are deleted by the user or expire and are deleted by the app. - var inactiveLogs: [LogMetadata] = [] + public var inactiveLogs: [LogMetadata] = [] // MARK: Computed Properties /// The full list of logs containing the active and any inactive logs. - var allLogs: [LogMetadata] { + public var allLogs: [LogMetadata] { ([activeLog] + inactiveLogs).compactMap(\.self) } /// The upcoming date in which either the active log needs to end logging or an inactive log /// expires and needs to be removed. - var nextLogLifecycleDate: Date? { + public var nextLogLifecycleDate: Date? { let dates = [activeLog?.endDate].compactMap(\.self) + inactiveLogs.map(\.expirationDate) 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. /// struct LogMetadata: Codable, Equatable, Identifiable { // MARK: Properties /// 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. - let endDate: Date + public let endDate: Date /// 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. - @DefaultFalse var isBannerDismissed = false + @DefaultFalse public var isBannerDismissed = false /// The date the logging was started. - let startDate: Date + public let startDate: Date // MARK: Computed Properties /// The date when the flight recorder log will expire and be deleted. - var expirationDate: Date { + public var expirationDate: Date { Calendar.current.date( byAdding: .day, value: Constants.flightRecorderLogExpirationDays, @@ -69,7 +82,7 @@ extension FlightRecorderData { } /// The formatted end date for the log. - var formattedEndDate: String { + public var formattedEndDate: String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .none @@ -77,14 +90,14 @@ extension FlightRecorderData { } /// The formatted end time for the log. - var formattedEndTime: String { + public var formattedEndTime: String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .none dateFormatter.timeStyle = .short return dateFormatter.string(from: endDate) } - var id: String { + public var id: String { fileName } @@ -96,7 +109,7 @@ extension FlightRecorderData { /// - duration: The duration for how long the flight recorder was enabled for the log. /// - startDate: The date the logging was started. /// - init(duration: FlightRecorderLoggingDuration, startDate: Date) { + public init(duration: FlightRecorderLoggingDuration, startDate: Date) { self.duration = duration self.startDate = startDate diff --git a/BitwardenShared/Core/Platform/Models/Data/FlightRecorderDataTests.swift b/BitwardenKit/Core/Platform/Models/Data/FlightRecorderDataTests.swift similarity index 99% rename from BitwardenShared/Core/Platform/Models/Data/FlightRecorderDataTests.swift rename to BitwardenKit/Core/Platform/Models/Data/FlightRecorderDataTests.swift index ef3e708d5..3a09b5269 100644 --- a/BitwardenShared/Core/Platform/Models/Data/FlightRecorderDataTests.swift +++ b/BitwardenKit/Core/Platform/Models/Data/FlightRecorderDataTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class FlightRecorderDataTests: BitwardenTestCase { // MARK: Tests diff --git a/BitwardenShared/Core/Platform/Models/Domain/Fixtures/FlightRecorderLogMetadata+Fixtures.swift b/BitwardenKit/Core/Platform/Models/Domain/Fixtures/FlightRecorderLogMetadata+Fixtures.swift similarity index 87% rename from BitwardenShared/Core/Platform/Models/Domain/Fixtures/FlightRecorderLogMetadata+Fixtures.swift rename to BitwardenKit/Core/Platform/Models/Domain/Fixtures/FlightRecorderLogMetadata+Fixtures.swift index 9c782bc79..4efbf5ca9 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/Fixtures/FlightRecorderLogMetadata+Fixtures.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/Fixtures/FlightRecorderLogMetadata+Fixtures.swift @@ -1,8 +1,9 @@ import Foundation -@testable import BitwardenShared +@testable import BitwardenKit -extension FlightRecorderLogMetadata { +public extension FlightRecorderLogMetadata { + // swiftlint:disable:next missing_docs static func fixture( duration: FlightRecorderLoggingDuration = .twentyFourHours, endDate: Date = Date(year: 2025, month: 4, day: 4), diff --git a/BitwardenShared/Core/Platform/Models/Domain/FlightRecorderLogMetadata.swift b/BitwardenKit/Core/Platform/Models/Domain/FlightRecorderLogMetadata.swift similarity index 96% rename from BitwardenShared/Core/Platform/Models/Domain/FlightRecorderLogMetadata.swift rename to BitwardenKit/Core/Platform/Models/Domain/FlightRecorderLogMetadata.swift index 5381df343..a49ffa856 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/FlightRecorderLogMetadata.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/FlightRecorderLogMetadata.swift @@ -1,4 +1,3 @@ -import BitwardenKit import BitwardenResources import Foundation @@ -6,7 +5,7 @@ import Foundation /// A data model containing the metadata associated with a flight recorder log. /// -struct FlightRecorderLogMetadata: Equatable, Identifiable { +public struct FlightRecorderLogMetadata: Equatable, Identifiable { // MARK: Properties /// The duration for how long the flight recorder was enabled for the log. @@ -22,7 +21,7 @@ struct FlightRecorderLogMetadata: Equatable, Identifiable { let fileSize: String /// A unique identifier for the log. - let id: String + public let id: String /// Whether this represents the active log. let isActiveLog: Bool diff --git a/BitwardenShared/Core/Platform/Models/Domain/FlightRecorderLogMetadataTests.swift b/BitwardenKit/Core/Platform/Models/Domain/FlightRecorderLogMetadataTests.swift similarity index 99% rename from BitwardenShared/Core/Platform/Models/Domain/FlightRecorderLogMetadataTests.swift rename to BitwardenKit/Core/Platform/Models/Domain/FlightRecorderLogMetadataTests.swift index a9f28b53c..b0542d195 100644 --- a/BitwardenShared/Core/Platform/Models/Domain/FlightRecorderLogMetadataTests.swift +++ b/BitwardenKit/Core/Platform/Models/Domain/FlightRecorderLogMetadataTests.swift @@ -1,7 +1,7 @@ +import BitwardenResources import XCTest -import BitwardenResources -@testable import BitwardenShared +@testable import BitwardenKit class FlightRecorderLogMetadataTests: BitwardenTestCase { // MARK: Properties diff --git a/BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDuration.swift b/BitwardenKit/Core/Platform/Models/Enum/FlightRecorderLoggingDuration.swift similarity index 93% rename from BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDuration.swift rename to BitwardenKit/Core/Platform/Models/Enum/FlightRecorderLoggingDuration.swift index df0aadc87..c9f31392f 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDuration.swift +++ b/BitwardenKit/Core/Platform/Models/Enum/FlightRecorderLoggingDuration.swift @@ -1,10 +1,9 @@ -import BitwardenKit import BitwardenResources import Foundation /// 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. case oneHour @@ -17,7 +16,7 @@ enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable { /// The flight recorder is enabled for one week. case oneWeek - var localizedName: String { + public var localizedName: String { switch self { case .oneHour: Localizations.xHours(1) case .eightHours: Localizations.xHours(8) diff --git a/BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDurationTests.swift b/BitwardenKit/Core/Platform/Models/Enum/FlightRecorderLoggingDurationTests.swift similarity index 98% rename from BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDurationTests.swift rename to BitwardenKit/Core/Platform/Models/Enum/FlightRecorderLoggingDurationTests.swift index 9ec4e67b6..aa0d32f72 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/FlightRecorderLoggingDurationTests.swift +++ b/BitwardenKit/Core/Platform/Models/Enum/FlightRecorderLoggingDurationTests.swift @@ -1,7 +1,7 @@ +import BitwardenResources import XCTest -import BitwardenResources -@testable import BitwardenShared +@testable import BitwardenKit class FlightRecorderLoggingDurationTests: BitwardenTestCase { // MARK: Tests diff --git a/BitwardenShared/Core/Platform/Services/API/FlightRecorderHTTPLogger.swift b/BitwardenKit/Core/Platform/Services/API/FlightRecorderHTTPLogger.swift similarity index 78% rename from BitwardenShared/Core/Platform/Services/API/FlightRecorderHTTPLogger.swift rename to BitwardenKit/Core/Platform/Services/API/FlightRecorderHTTPLogger.swift index c71ffa045..6ed39f18b 100644 --- a/BitwardenShared/Core/Platform/Services/API/FlightRecorderHTTPLogger.swift +++ b/BitwardenKit/Core/Platform/Services/API/FlightRecorderHTTPLogger.swift @@ -4,7 +4,7 @@ import Networking /// An `HTTPLogger` that logs HTTP requests and responses to the flight recorder. /// -final class FlightRecorderHTTPLogger: HTTPLogger { +public final class FlightRecorderHTTPLogger: HTTPLogger { // MARK: Properties /// 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. /// - init(flightRecorder: FlightRecorder) { + public init(flightRecorder: FlightRecorder) { self.flightRecorder = flightRecorder } // MARK: HTTPLogger - func logRequest(_ httpRequest: HTTPRequest) async { + public func logRequest(_ httpRequest: HTTPRequest) async { await flightRecorder.log( "Request \(httpRequest.requestID): \(httpRequest.method.rawValue) \(httpRequest.url)", ) } - func logResponse(_ httpResponse: HTTPResponse) async { + public func logResponse(_ httpResponse: HTTPResponse) async { await flightRecorder.log( "Response \(httpResponse.requestID): \(httpResponse.url) \(httpResponse.statusCode)", ) diff --git a/BitwardenShared/Core/Platform/Services/API/FlightRecorderHTTPLoggerTests.swift b/BitwardenKit/Core/Platform/Services/API/FlightRecorderHTTPLoggerTests.swift similarity index 97% rename from BitwardenShared/Core/Platform/Services/API/FlightRecorderHTTPLoggerTests.swift rename to BitwardenKit/Core/Platform/Services/API/FlightRecorderHTTPLoggerTests.swift index aa230107b..d8215e5ac 100644 --- a/BitwardenShared/Core/Platform/Services/API/FlightRecorderHTTPLoggerTests.swift +++ b/BitwardenKit/Core/Platform/Services/API/FlightRecorderHTTPLoggerTests.swift @@ -1,8 +1,9 @@ +import BitwardenKitMocks import Networking import TestHelpers import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class FlightRecorderHTTPLoggerTests: BitwardenTestCase { // MARK: Properties diff --git a/BitwardenShared/Core/Platform/Services/FlightRecorder.swift b/BitwardenKit/Core/Platform/Services/FlightRecorder.swift similarity index 90% rename from BitwardenShared/Core/Platform/Services/FlightRecorder.swift rename to BitwardenKit/Core/Platform/Services/FlightRecorder.swift index cb005473b..4c981e88f 100644 --- a/BitwardenShared/Core/Platform/Services/FlightRecorder.swift +++ b/BitwardenKit/Core/Platform/Services/FlightRecorder.swift @@ -1,4 +1,3 @@ -import BitwardenKit @preconcurrency import Combine import Foundation 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 /// local file. /// -protocol FlightRecorder: Sendable, BitwardenLogger { +public protocol FlightRecorder: Sendable, BitwardenLogger { /// A publisher which publishes 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 } -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 { 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) { Task { await log(message, file: file, line: line) @@ -81,6 +94,9 @@ extension FlightRecorder { /// An enumeration of errors thrown by a `FlightRecorder`. /// enum FlightRecorderError: Error { + /// The container URL for the app group is unavailable. + case containerURLUnavailable + /// The stored flight recorder data doesn't exist. case dataUnavailable @@ -119,6 +135,7 @@ extension FlightRecorderError: CustomNSError { case .logNotFound: 5 case .removeExpiredLogError: 6 case .writeMessageError: 7 + case .containerURLUnavailable: 8 } } @@ -146,7 +163,8 @@ extension FlightRecorderError: CustomNSError { extension FlightRecorderError: Equatable { static func == (lhs: FlightRecorderError, rhs: FlightRecorderError) -> Bool { switch (lhs, rhs) { - case (.dataUnavailable, .dataUnavailable), + case (.containerURLUnavailable, .containerURLUnavailable), + (.dataUnavailable, .dataUnavailable), (.deletionNotPermitted, .deletionNotPermitted), (.logNotFound, .logNotFound): true @@ -165,7 +183,7 @@ extension FlightRecorderError: Equatable { /// A default implementation of a `FlightRecorder`. /// -actor DefaultFlightRecorder { +public actor DefaultFlightRecorder { // MARK: Private Properties /// 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 /// 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. private let timeProvider: TimeProvider @@ -213,12 +231,12 @@ actor DefaultFlightRecorder { /// - stateService: The service used by the application to manage account state. /// - timeProvider: The service used to get the present time. /// - init( + public init( appInfoService: AppInfoService, disableLogLifecycleTimerForTesting: Bool = false, errorReporter: ErrorReporter, fileManager: FileManagerProtocol = FileManager.default, - stateService: StateService, + stateService: FlightRecorderStateService, timeProvider: TimeProvider, ) { self.appInfoService = appInfoService @@ -371,7 +389,10 @@ actor DefaultFlightRecorder { /// - Returns: A URL for the log file. /// 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 @@ -419,12 +440,12 @@ actor DefaultFlightRecorder { // MARK: - DefaultFlightRecorder + FlightRecorder extension DefaultFlightRecorder: FlightRecorder { - func activeLogPublisher() async -> AnyPublisher { + public func activeLogPublisher() async -> AnyPublisher { _ = await getFlightRecorderData() // Ensure data has already been loaded to the subject. return dataSubject.map { $0?.activeLog }.eraseToAnyPublisher() } - func deleteInactiveLogs() async throws { + public func deleteInactiveLogs() async throws { guard var data = await getFlightRecorderData() else { throw FlightRecorderError.dataUnavailable } @@ -437,7 +458,7 @@ extension DefaultFlightRecorder: FlightRecorder { await setFlightRecorderData(data) } - func deleteLog(_ log: FlightRecorderLogMetadata) async throws { + public func deleteLog(_ log: FlightRecorderLogMetadata) async throws { guard var data = await getFlightRecorderData() else { throw FlightRecorderError.dataUnavailable } @@ -453,13 +474,13 @@ extension DefaultFlightRecorder: FlightRecorder { await setFlightRecorderData(data) } - func disableFlightRecorder() async { + public func disableFlightRecorder() async { guard var data = await getFlightRecorderData() else { return } data.activeLog = nil 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) try await createLogFile(for: log) @@ -468,7 +489,7 @@ extension DefaultFlightRecorder: FlightRecorder { await setFlightRecorderData(data) } - func fetchLogs() async throws -> [FlightRecorderLogMetadata] { + public func fetchLogs() async throws -> [FlightRecorderLogMetadata] { guard let data = await getFlightRecorderData() else { return [] } return try data.allLogs.map { log in try FlightRecorderLogMetadata( @@ -484,12 +505,12 @@ extension DefaultFlightRecorder: FlightRecorder { } } - func isEnabledPublisher() async -> AnyPublisher { + public func isEnabledPublisher() async -> AnyPublisher { _ = await getFlightRecorderData() // Ensure data has already been loaded to the subject. 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 } Logger.flightRecorder.debug("\(message)") 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 } data.activeLog?.isBannerDismissed = true await setFlightRecorderData(data) diff --git a/BitwardenKit/Core/Platform/Services/FlightRecorderStateService.swift b/BitwardenKit/Core/Platform/Services/FlightRecorderStateService.swift new file mode 100644 index 000000000..85367593e --- /dev/null +++ b/BitwardenKit/Core/Platform/Services/FlightRecorderStateService.swift @@ -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 +} diff --git a/BitwardenShared/Core/Platform/Services/FlightRecorderTests.swift b/BitwardenKit/Core/Platform/Services/FlightRecorderTests.swift similarity index 95% rename from BitwardenShared/Core/Platform/Services/FlightRecorderTests.swift rename to BitwardenKit/Core/Platform/Services/FlightRecorderTests.swift index 9375e1723..db6e0da47 100644 --- a/BitwardenShared/Core/Platform/Services/FlightRecorderTests.swift +++ b/BitwardenKit/Core/Platform/Services/FlightRecorderTests.swift @@ -4,7 +4,7 @@ import InlineSnapshotTesting import TestHelpers import XCTest -@testable import BitwardenShared +@testable import BitwardenKit // swiftlint:disable file_length @@ -15,7 +15,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo var errorReporter: MockErrorReporter! var fileManager: MockFileManager! var logURL: URL! - var stateService: MockStateService! + var stateService: MockFlightRecorderStateService! var subject: FlightRecorder! var timeProvider: MockTimeProvider! @@ -42,11 +42,10 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo appInfoService = MockAppInfoService() errorReporter = MockErrorReporter() fileManager = MockFileManager() - stateService = MockStateService() + stateService = MockFlightRecorderStateService() timeProvider = MockTimeProvider(.mockTime(Date(year: 2025, month: 1, day: 1))) - logURL = try XCTUnwrap(FileManager.default.flightRecorderLogURL() - .appendingPathComponent("flight_recorder_2025-01-01-00-00-00.txt")) + logURL = try flightRecorderLogURL().appendingPathComponent("flight_recorder_2025-01-01-00-00-00.txt") subject = makeSubject() } @@ -124,8 +123,8 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo try XCTAssertEqual( fileManager.removeItemURLs, [ - FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName), - FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName), + flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName), + flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName), ], ) XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog)) @@ -326,11 +325,10 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo activeLog: activeLog, inactiveLogs: [inactiveLog1, inactiveLog2], ) - let flightRecorderLogURL = try FileManager.default.flightRecorderLogURL() let logs = try await subject.fetchLogs() - XCTAssertEqual( + try XCTAssertEqual( logs, [ FlightRecorderLogMetadata.fixture( @@ -341,7 +339,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo id: activeLog.id, isActiveLog: true, startDate: Date(year: 2025, month: 1, day: 1), - url: flightRecorderLogURL.appendingPathComponent(activeLog.fileName), + url: flightRecorderLogURL().appendingPathComponent(activeLog.fileName), ), FlightRecorderLogMetadata.fixture( duration: .oneHour, @@ -351,7 +349,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo id: inactiveLog1.id, isActiveLog: false, startDate: Date(year: 2025, month: 1, day: 2), - url: flightRecorderLogURL.appendingPathComponent(inactiveLog1.fileName), + url: flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName), ), FlightRecorderLogMetadata.fixture( duration: .oneWeek, @@ -361,7 +359,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo id: inactiveLog2.id, isActiveLog: false, 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. try XCTAssertEqual( fileManager.removeItemURLs, - [FileManager.default.flightRecorderLogURL().appendingPathComponent(activeLog.fileName)], + [flightRecorderLogURL().appendingPathComponent(activeLog.fileName)], ) XCTAssertEqual( stateService.flightRecorderData, @@ -515,7 +513,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo // Expired inactive log is removed. try XCTAssertEqual( fileManager.removeItemURLs, - [FileManager.default.flightRecorderLogURL().appendingPathComponent(expiredLog.fileName)], + [flightRecorderLogURL().appendingPathComponent(expiredLog.fileName)], ) XCTAssertEqual( stateService.flightRecorderData, @@ -538,7 +536,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo XCTAssertEqual(error, .removeExpiredLogError(BitwardenTestError.example)) try XCTAssertEqual( fileManager.removeItemURLs, - [FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName)], + [flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName)], ) 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(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()) + } } diff --git a/BitwardenKit/Core/Platform/Services/Mocks/MockFlightRecorder.swift b/BitwardenKit/Core/Platform/Services/Mocks/MockFlightRecorder.swift new file mode 100644 index 000000000..0201e0055 --- /dev/null +++ b/BitwardenKit/Core/Platform/Services/Mocks/MockFlightRecorder.swift @@ -0,0 +1,64 @@ +import Combine + +@testable import BitwardenKit + +@MainActor +public final class MockFlightRecorder: FlightRecorder { + public var activeLogSubject = CurrentValueSubject(nil) + public var deleteInactiveLogsCalled = false + public var deleteInactiveLogsResult: Result = .success(()) + public var deleteLogResult: Result = .success(()) + public var deleteLogLogs = [FlightRecorderLogMetadata]() + public var disableFlightRecorderCalled = false + public var enableFlightRecorderCalled = false + public var enableFlightRecorderDuration: FlightRecorderLoggingDuration? + public var enableFlightRecorderResult: Result = .success(()) + public var fetchLogsCalled = false + public var fetchLogsResult: Result<[FlightRecorderLogMetadata], Error> = .success([]) + public var isEnabledSubject = CurrentValueSubject(false) + public var logMessages = [String]() + public var setFlightRecorderBannerDismissedCalled = false + + public nonisolated init() {} + + public func activeLogPublisher() async -> AnyPublisher { + 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 { + isEnabledSubject.eraseToAnyPublisher() + } + + public func log(_ message: String, file: String, line: UInt) async { + logMessages.append(message) + } + + public func setFlightRecorderBannerDismissed() async { + setFlightRecorderBannerDismissedCalled = true + } +} diff --git a/BitwardenKit/Core/Platform/Services/Mocks/MockFlightRecorderStateService.swift b/BitwardenKit/Core/Platform/Services/Mocks/MockFlightRecorderStateService.swift new file mode 100644 index 000000000..ad001a9a5 --- /dev/null +++ b/BitwardenKit/Core/Platform/Services/Mocks/MockFlightRecorderStateService.swift @@ -0,0 +1,21 @@ +import BitwardenKit +import TestHelpers + +public class MockFlightRecorderStateService: FlightRecorderStateService { + public var activeAccountIdResult = Result.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 + } +} diff --git a/BitwardenKit/Core/Platform/Services/Services.swift b/BitwardenKit/Core/Platform/Services/Services.swift index a74d8f1ed..089704b14 100644 --- a/BitwardenKit/Core/Platform/Services/Services.swift +++ b/BitwardenKit/Core/Platform/Services/Services.swift @@ -28,6 +28,13 @@ public protocol HasErrorReporter { 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`. /// public protocol HasTimeProvider { diff --git a/BitwardenKit/Core/Platform/Utilities/Constants.swift b/BitwardenKit/Core/Platform/Utilities/Constants.swift index 22033d764..ea8f96cc4 100644 --- a/BitwardenKit/Core/Platform/Utilities/Constants.swift +++ b/BitwardenKit/Core/Platform/Utilities/Constants.swift @@ -17,6 +17,10 @@ public enum Constants { /// The device type, iOS = 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. public static let minimumConfigSyncInterval: TimeInterval = 60 * 60 // 60 minutes diff --git a/BitwardenShared/Core/Platform/Utilities/FileManager.swift b/BitwardenKit/Core/Platform/Utilities/FileManager.swift similarity index 87% rename from BitwardenShared/Core/Platform/Utilities/FileManager.swift rename to BitwardenKit/Core/Platform/Utilities/FileManager.swift index 27ccd992e..76ac84068 100644 --- a/BitwardenShared/Core/Platform/Utilities/FileManager.swift +++ b/BitwardenKit/Core/Platform/Utilities/FileManager.swift @@ -4,7 +4,7 @@ import Foundation /// 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. /// /// - Parameters: @@ -55,22 +55,22 @@ protocol FileManagerProtocol: AnyObject { // MARK: - 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) try handle.seekToEnd() try handle.write(contentsOf: data) 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) } - func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws { + public func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws { 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) } } diff --git a/BitwardenShared/Core/Platform/Utilities/FileManagerTests.swift b/BitwardenKit/Core/Platform/Utilities/FileManagerTests.swift similarity index 98% rename from BitwardenShared/Core/Platform/Utilities/FileManagerTests.swift rename to BitwardenKit/Core/Platform/Utilities/FileManagerTests.swift index 20079fec4..a124778f9 100644 --- a/BitwardenShared/Core/Platform/Utilities/FileManagerTests.swift +++ b/BitwardenKit/Core/Platform/Utilities/FileManagerTests.swift @@ -1,6 +1,6 @@ import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class FileManagerTests: BitwardenTestCase { // MARK: Properties diff --git a/BitwardenKit/Core/Platform/Utilities/Mocks/MockFileManager.swift b/BitwardenKit/Core/Platform/Utilities/Mocks/MockFileManager.swift new file mode 100644 index 000000000..9b4e209b9 --- /dev/null +++ b/BitwardenKit/Core/Platform/Utilities/Mocks/MockFileManager.swift @@ -0,0 +1,63 @@ +import Foundation + +@testable import BitwardenKit + +public class MockFileManager: FileManagerProtocol { + public var appendDataData: Data? + public var appendDataResult: Result = .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 = .success(()) + + public var removeItemURLs = [URL]() + public var removeItemResult: Result = .success(()) + + public var setIsExcludedFromBackupValue: Bool? + public var setIsExcludedFromBackupURL: URL? + public var setIsExcludedFromBackupResult: Result = .success(()) + + public var writeDataData: Data? + public var writeDataURL: URL? + public var writeDataResult: Result = .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() + } +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/Alert+FlightRecorder.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/Alert+FlightRecorder.swift new file mode 100644 index 000000000..077e35f02 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/Alert+FlightRecorder.swift @@ -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), + ], + ) + } +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/AlertFlightRecorderTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/AlertFlightRecorderTests.swift new file mode 100644 index 000000000..b3a130c2f --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/AlertFlightRecorderTests.swift @@ -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) + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderAction.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderAction.swift similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderAction.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderAction.swift diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderEffect.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderEffect.swift similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderEffect.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderEffect.swift diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderProcessor.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderProcessor.swift similarity index 92% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderProcessor.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderProcessor.swift index 8c8db8e7a..f20465c8e 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderProcessor.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderProcessor.swift @@ -1,4 +1,3 @@ -import BitwardenKit import Foundation // MARK: - EnableFlightRecorderProcessor @@ -18,7 +17,7 @@ final class EnableFlightRecorderProcessor: StateProcessor< // MARK: Private Properties /// The `Coordinator` that handles navigation. - private let coordinator: AnyCoordinator + private let coordinator: AnyCoordinator /// The services used by this processor. private let services: Services @@ -33,7 +32,7 @@ final class EnableFlightRecorderProcessor: StateProcessor< /// - state: The initial state of the processor. /// init( - coordinator: AnyCoordinator, + coordinator: AnyCoordinator, services: Services, state: EnableFlightRecorderState, ) { diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderProcessorTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderProcessorTests.swift similarity index 96% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderProcessorTests.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderProcessorTests.swift index 009c6558d..18950c4f8 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderProcessorTests.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderProcessorTests.swift @@ -2,12 +2,12 @@ import BitwardenKitMocks import TestHelpers import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class EnableFlightRecorderProcessorTests: BitwardenTestCase { // MARK: Properties - var coordinator: MockCoordinator! + var coordinator: MockCoordinator! var errorReporter: MockErrorReporter! var flightRecorder: MockFlightRecorder! var subject: EnableFlightRecorderProcessor! diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderState.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderState.swift similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderState.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderState.swift diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView+SnapshotTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView+SnapshotTests.swift similarity index 95% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView+SnapshotTests.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView+SnapshotTests.swift index ffba1418e..eadbce7bc 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView+SnapshotTests.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView+SnapshotTests.swift @@ -1,11 +1,10 @@ // swiftlint:disable:this file_name -import BitwardenKit import BitwardenKitMocks import BitwardenResources import SnapshotTesting import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class EnableFlightRecorderViewTests: BitwardenTestCase { // MARK: Properties diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView+ViewInspectorTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView+ViewInspectorTests.swift similarity index 97% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView+ViewInspectorTests.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView+ViewInspectorTests.swift index 2ee2f2833..88f68b130 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView+ViewInspectorTests.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView+ViewInspectorTests.swift @@ -1,11 +1,10 @@ // swiftlint:disable:this file_name -import BitwardenKit import BitwardenKitMocks import BitwardenResources import ViewInspectorTestHelpers import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class EnableFlightRecorderViewTests: BitwardenTestCase { // MARK: Properties diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView.swift similarity index 99% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView.swift index 06e7afd3d..fb5c4ee4f 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/EnableFlightRecorderView.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/EnableFlightRecorderView.swift @@ -1,4 +1,3 @@ -import BitwardenKit import BitwardenResources import SwiftUI diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.1.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.1.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.1.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.1.png diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.2.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.2.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.2.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.2.png diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.3.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.3.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.3.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/EnableFlightRecorder/__Snapshots__/EnableFlightRecorderViewTests/test_snapshot_enableFlightRecorder.3.png diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderCoordinator.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderCoordinator.swift new file mode 100644 index 000000000..2ac0c0e84 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderCoordinator.swift @@ -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 } +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderCoordinatorTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderCoordinatorTests.swift new file mode 100644 index 000000000..20bf0a4a7 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderCoordinatorTests.swift @@ -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) + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsAction.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsAction.swift similarity index 96% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsAction.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsAction.swift index ede6248f4..8197000aa 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsAction.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsAction.swift @@ -1,5 +1,3 @@ -import BitwardenKit - // MARK: - FlightRecorderLogsAction /// Actions handled by the `FlightRecorderLogsProcessor`. diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsEffect.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsEffect.swift similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsEffect.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsEffect.swift diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsProcessor.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsProcessor.swift similarity index 96% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsProcessor.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsProcessor.swift index 65d28fab0..9d4427fd8 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsProcessor.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsProcessor.swift @@ -1,4 +1,3 @@ -import BitwardenKit import BitwardenResources import Foundation @@ -19,7 +18,7 @@ final class FlightRecorderLogsProcessor: StateProcessor< // MARK: Private Properties /// The `Coordinator` that handles navigation. - private let coordinator: AnyCoordinator + private let coordinator: AnyCoordinator /// The services used by this processor. private let services: Services @@ -34,7 +33,7 @@ final class FlightRecorderLogsProcessor: StateProcessor< /// - state: The initial state of the processor. /// init( - coordinator: AnyCoordinator, + coordinator: AnyCoordinator, services: Services, state: FlightRecorderLogsState, ) { diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsProcessorTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsProcessorTests.swift similarity index 98% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsProcessorTests.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsProcessorTests.swift index dc528e4b1..bbfd8dea4 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsProcessorTests.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsProcessorTests.swift @@ -1,15 +1,14 @@ -import BitwardenKit import BitwardenKitMocks import BitwardenResources import TestHelpers import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class FlightRecorderLogsProcessorTests: BitwardenTestCase { // MARK: Properties - var coordinator: MockCoordinator! + var coordinator: MockCoordinator! var errorReporter: MockErrorReporter! var flightRecorder: MockFlightRecorder! var subject: FlightRecorderLogsProcessor! diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsState.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsState.swift similarity index 96% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsState.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsState.swift index 8ec841ce7..f4599ffe6 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsState.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsState.swift @@ -1,5 +1,3 @@ -import BitwardenKit - // MARK: - FlightRecorderLogsState /// An object that defines the current state of the `FlightRecorderLogsView`. diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView+SnapshotTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView+SnapshotTests.swift similarity index 98% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView+SnapshotTests.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView+SnapshotTests.swift index 1dcd9678b..0c8b197db 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView+SnapshotTests.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView+SnapshotTests.swift @@ -1,11 +1,10 @@ // swiftlint:disable:this file_name -import BitwardenKit import BitwardenKitMocks import BitwardenResources import SnapshotTesting import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class FlightRecorderLogsViewTests: BitwardenTestCase { // MARK: Properties diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView+ViewInspectorTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView+ViewInspectorTests.swift similarity index 98% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView+ViewInspectorTests.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView+ViewInspectorTests.swift index 99781430d..f89c6f22d 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView+ViewInspectorTests.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView+ViewInspectorTests.swift @@ -1,10 +1,10 @@ // swiftlint:disable:this file_name -import BitwardenKit import BitwardenKitMocks import BitwardenResources +import TestHelpers import XCTest -@testable import BitwardenShared +@testable import BitwardenKit class FlightRecorderLogsViewTests: BitwardenTestCase { // MARK: Properties diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView.swift similarity index 98% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView.swift index 8cc106dd4..ce9aedc8d 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/FlightRecorderLogsView.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/FlightRecorderLogsView.swift @@ -1,4 +1,3 @@ -import BitwardenKit import BitwardenResources import SwiftUI @@ -53,7 +52,7 @@ struct FlightRecorderLogsView: View { logsList } else { IllustratedMessageView( - image: Asset.Images.Illustrations.secureDevices.swiftUIImage, + image: SharedAsset.Illustrations.secureDevices.swiftUIImage, style: .mediumImage, message: Localizations.noLogsRecorded, ) diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.1.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.1.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.1.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.1.png diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.2.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.2.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.2.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.2.png diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.3.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.3.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.3.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_empty.3.png diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.1.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.1.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.1.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.1.png diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.2.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.2.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.2.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.2.png diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.3.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.3.png similarity index 100% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.3.png rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderLogs/__Snapshots__/FlightRecorderLogsViewTests/test_snapshot_flightRecorderLogs_populated.3.png diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderModule.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderModule.swift new file mode 100644 index 000000000..688ebe72b --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderModule.swift @@ -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 +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderRoute.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderRoute.swift new file mode 100644 index 000000000..92bbd15ad --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderRoute.swift @@ -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]) +} diff --git a/BitwardenResources/Illustrations.xcassets/Contents.json b/BitwardenResources/Illustrations.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/BitwardenResources/Illustrations.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/Contents.json b/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/Contents.json new file mode 100644 index 000000000..27aa29450 --- /dev/null +++ b/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/Contents.json @@ -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 + } +} diff --git a/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices-dark.pdf b/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices-dark.pdf new file mode 100644 index 000000000..089c47713 Binary files /dev/null and b/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices-dark.pdf differ diff --git a/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices.pdf b/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices.pdf new file mode 100644 index 000000000..d8c3f5d99 Binary files /dev/null and b/BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices.pdf differ diff --git a/BitwardenShared/Core/Platform/Extensions/FileManager+Extensions.swift b/BitwardenShared/Core/Platform/Extensions/FileManager+Extensions.swift index 3ea087685..63cb5d3b1 100644 --- a/BitwardenShared/Core/Platform/Extensions/FileManager+Extensions.swift +++ b/BitwardenShared/Core/Platform/Extensions/FileManager+Extensions.swift @@ -30,13 +30,4 @@ extension FileManager { ) .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) - } } diff --git a/BitwardenShared/Core/Platform/Extensions/Task+Extensions.swift b/BitwardenShared/Core/Platform/Extensions/Task+Extensions.swift deleted file mode 100644 index 8396b19e0..000000000 --- a/BitwardenShared/Core/Platform/Extensions/Task+Extensions.swift +++ /dev/null @@ -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))) - } - } -} diff --git a/BitwardenShared/Core/Platform/Extensions/URL.swift b/BitwardenShared/Core/Platform/Extensions/URL.swift index a642abbb7..340802171 100644 --- a/BitwardenShared/Core/Platform/Extensions/URL.swift +++ b/BitwardenShared/Core/Platform/Extensions/URL.swift @@ -44,40 +44,4 @@ extension URL { var isApp: Bool { 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) - } } diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index a56b30719..2a14556f4 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -98,7 +98,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le let fido2UserInterfaceHelper: Fido2UserInterfaceHelper /// 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. let generatorRepository: GeneratorRepository diff --git a/BitwardenShared/Core/Platform/Services/Services.swift b/BitwardenShared/Core/Platform/Services/Services.swift index 4c6d243d6..75e9520ac 100644 --- a/BitwardenShared/Core/Platform/Services/Services.swift +++ b/BitwardenShared/Core/Platform/Services/Services.swift @@ -2,8 +2,6 @@ import AuthenticatorBridgeKit import BitwardenKit import BitwardenSdk -// swiftlint:disable file_length - /// The services provided by the `ServiceContainer`. typealias Services = HasAPIService & HasAccountAPIService @@ -209,13 +207,6 @@ protocol HasFileAPIService { 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 HasGeneratorRepository { diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 36ff5d830..ae2511d30 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -1437,7 +1437,7 @@ enum StateServiceError: LocalizedError { /// 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 /// The language option currently selected for the app. diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockFlightRecorder.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockFlightRecorder.swift deleted file mode 100644 index 11ec96e2e..000000000 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockFlightRecorder.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Combine - -@testable import BitwardenShared - -@MainActor -final class MockFlightRecorder: FlightRecorder { - var activeLogSubject = CurrentValueSubject(nil) - var deleteInactiveLogsCalled = false - var deleteInactiveLogsResult: Result = .success(()) - var deleteLogResult: Result = .success(()) - var deleteLogLogs = [FlightRecorderLogMetadata]() - var disableFlightRecorderCalled = false - var enableFlightRecorderCalled = false - var enableFlightRecorderDuration: FlightRecorderLoggingDuration? - var enableFlightRecorderResult: Result = .success(()) - var fetchLogsCalled = false - var fetchLogsResult: Result<[FlightRecorderLogMetadata], Error> = .success([]) - var isEnabledSubject = CurrentValueSubject(false) - var logMessages = [String]() - var setFlightRecorderBannerDismissedCalled = false - - nonisolated init() {} - - func activeLogPublisher() async -> AnyPublisher { - 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 { - isEnabledSubject.eraseToAnyPublisher() - } - - func log(_ message: String, file: String, line: UInt) async { - logMessages.append(message) - } - - func setFlightRecorderBannerDismissed() async { - setFlightRecorderBannerDismissedCalled = true - } -} diff --git a/BitwardenShared/Core/Platform/Utilities/Constants.swift b/BitwardenShared/Core/Platform/Utilities/Constants.swift index 1e49c2e26..5c16ec680 100644 --- a/BitwardenShared/Core/Platform/Utilities/Constants.swift +++ b/BitwardenShared/Core/Platform/Utilities/Constants.swift @@ -26,10 +26,6 @@ extension Constants { /// The URL for the web vault if the user account doesn't have one specified. 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. static let hiddenPasswordLength = 8 diff --git a/BitwardenShared/Core/Platform/Utilities/TestHelpers/MockFileManager.swift b/BitwardenShared/Core/Platform/Utilities/TestHelpers/MockFileManager.swift deleted file mode 100644 index b59dc439d..000000000 --- a/BitwardenShared/Core/Platform/Utilities/TestHelpers/MockFileManager.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -@testable import BitwardenShared - -class MockFileManager: FileManagerProtocol { - var appendDataData: Data? - var appendDataResult: Result = .success(()) - var appendDataURL: URL? - - var attributesOfItemPath: String? - var attributesOfItemResult: Result<[FileAttributeKey: Any], Error> = .success([:]) - - var createDirectoryURL: URL? - var createDirectoryCreateIntermediates: Bool? - var createDirectoryResult: Result = .success(()) - - var removeItemURLs = [URL]() - var removeItemResult: Result = .success(()) - - var setIsExcludedFromBackupValue: Bool? - var setIsExcludedFromBackupURL: URL? - var setIsExcludedFromBackupResult: Result = .success(()) - - var writeDataData: Data? - var writeDataURL: URL? - var writeDataResult: Result = .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() - } -} diff --git a/BitwardenShared/UI/Platform/Application/AppModule.swift b/BitwardenShared/UI/Platform/Application/AppModule.swift index e621c4197..4e3536503 100644 --- a/BitwardenShared/UI/Platform/Application/AppModule.swift +++ b/BitwardenShared/UI/Platform/Application/AppModule.swift @@ -62,3 +62,17 @@ extension DefaultAppModule: AppModule { ).asAnyCoordinator() } } + +// MARK: - DefaultAppModule + FlightRecorderModule + +extension DefaultAppModule: FlightRecorderModule { + public func makeFlightRecorderCoordinator( + stackNavigator: StackNavigator, + ) -> AnyCoordinator { + FlightRecorderCoordinator( + services: services, + stackNavigator: stackNavigator, + ) + .asAnyCoordinator() + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Extensions/Alert+Settings.swift b/BitwardenShared/UI/Platform/Settings/Extensions/Alert+Settings.swift index f67fbaa4c..a412fc4f6 100644 --- a/BitwardenShared/UI/Platform/Settings/Extensions/Alert+Settings.swift +++ b/BitwardenShared/UI/Platform/Settings/Extensions/Alert+Settings.swift @@ -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. /// /// - Parameter action: The action to perform if the user selects yes. diff --git a/BitwardenShared/UI/Platform/Settings/Extensions/AlertSettingsTests.swift b/BitwardenShared/UI/Platform/Settings/Extensions/AlertSettingsTests.swift index f9cfb32c2..318457bf2 100644 --- a/BitwardenShared/UI/Platform/Settings/Extensions/AlertSettingsTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Extensions/AlertSettingsTests.swift @@ -31,42 +31,6 @@ class AlertSettingsTests: BitwardenTestCase { 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, /// message, yes, and cancel buttons to confirm denying all login requests func test_confirmDenyingAllRequests() { diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift index 50c7a808f..c69adc6f2 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift @@ -57,7 +57,7 @@ final class AboutProcessor: StateProcessor await streamFlightRecorderLog() case let .toggleFlightRecorder(isOn): if isOn { - coordinator.navigate(to: .enableFlightRecorder) + coordinator.navigate(to: .flightRecorder(.enableFlightRecorder)) } else { await services.flightRecorder.disableFlightRecorder() } @@ -92,7 +92,7 @@ final class AboutProcessor: StateProcessor case .versionTapped: handleVersionTapped() case .viewFlightRecorderLogsTapped: - coordinator.navigate(to: .flightRecorderLogs) + coordinator.navigate(to: .flightRecorder(.flightRecorderLogs)) case .webVaultTapped: coordinator.showAlert(.webVaultAlert { self.state.url = self.services.environmentService.webVaultURL diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift index b5ee2bbfd..819aceaff 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift @@ -122,7 +122,7 @@ class AboutProcessorTests: BitwardenTestCase { 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. @@ -232,7 +232,7 @@ class AboutProcessorTests: BitwardenTestCase { func test_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 diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/AboutStateTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutStateTests.swift similarity index 98% rename from BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/AboutStateTests.swift rename to BitwardenShared/UI/Platform/Settings/Settings/About/AboutStateTests.swift index 80808797f..7a67bfb42 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/FlightRecorderLogs/AboutStateTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutStateTests.swift @@ -1,3 +1,4 @@ +import BitwardenKit import BitwardenResources import XCTest diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift index 264f14728..99f39ca1c 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -55,6 +55,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d typealias Module = AddEditFolderModule & AuthModule & ExportCXFModule + & FlightRecorderModule & ImportLoginsModule & LoginRequestModule & NavigatorBuilderModule @@ -152,8 +153,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d showAccountSecurity() case let .addEditFolder(folder): showAddEditFolder(folder, delegate: context as? AddEditFolderDelegate) - case .enableFlightRecorder: - showEnableFlightRecorder() case .appearance: showAppearance() case .appExtension: @@ -174,8 +173,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d showExportVaultToApp() case .exportVaultToFile: showExportVaultToFile() - case .flightRecorderLogs: - showFlightRecorderLogs() + case let .flightRecorder(route): + showFlightRecorder(route: route) case .folders: showFolders() case .importLogins: @@ -194,8 +193,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d showSettings(presentationMode: presentationMode) case let .shareURL(url): showShareSheet([url]) - case let .shareURLs(urls): - showShareSheet(urls) case .vault: showVault() case .vaultUnlockSetup: @@ -343,17 +340,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d 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. /// /// - Parameter items: The items to share. @@ -400,16 +386,15 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d stackNavigator?.present(navigationController) } - /// Shows the flight recorder logs screen. + /// Shows a flight recorder view. /// - 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) + /// - Parameter route: A `FlightRecorderRoute` to navigate to. + /// + private func showFlightRecorder(route: FlightRecorderRoute) { + guard let stackNavigator else { return } + let coordinator = module.makeFlightRecorderCoordinator(stackNavigator: stackNavigator) + coordinator.start() + coordinator.navigate(to: route) } /// Shows the folders screen. diff --git a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift index 6d3e225f2..576fb9722 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsCoordinatorTests.swift @@ -175,17 +175,6 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty 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 /// Credential Exchange flag to export is disabled. @MainActor @@ -262,15 +251,14 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty 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 - func test_navigateTo_flightRecorderLogs() throws { - subject.navigate(to: .flightRecorderLogs) + func test_navigateTo_flightRecorder() throws { + subject.navigate(to: .flightRecorder(.enableFlightRecorder)) - let action = try XCTUnwrap(stackNavigator.actions.last) - XCTAssertEqual(action.type, .presented) - XCTAssertTrue(action.view is FlightRecorderLogsView) - XCTAssertEqual(action.embedInNavigationController, true) + XCTAssertTrue(module.flightRecorderCoordinator.isStarted) + XCTAssertEqual(module.flightRecorderCoordinator.routes.last, .enableFlightRecorder) } /// `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) } - /// `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. @MainActor func test_navigateTo_vault() throws { diff --git a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift index 9c9c070fb..260ad3f3a 100644 --- a/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift +++ b/BitwardenShared/UI/Platform/Settings/SettingsRoute.swift @@ -35,9 +35,6 @@ public enum SettingsRoute: 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 export vault settings view or export to file view depending on feature flag. case exportVault @@ -47,8 +44,8 @@ public enum SettingsRoute: Equatable, Hashable { /// A route to the export vault to file view. case exportVaultToFile - /// A route to the flight recorder logs screen. - case flightRecorderLogs + /// A route to a flight recorder view. + case flightRecorder(FlightRecorderRoute) /// A route to view the folders in the vault. case folders @@ -83,9 +80,6 @@ public enum SettingsRoute: Equatable, Hashable { /// 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]) - /// A route to the vault settings view. case vault diff --git a/GlobalTestHelpers/MockAppModule.swift b/GlobalTestHelpers/MockAppModule.swift index b0348e0e6..43b54b528 100644 --- a/GlobalTestHelpers/MockAppModule.swift +++ b/GlobalTestHelpers/MockAppModule.swift @@ -14,6 +14,7 @@ class MockAppModule: ExportCXFModule, ExtensionSetupModule, FileSelectionModule, + FlightRecorderModule, GeneratorModule, ImportCXFModule, ImportLoginsModule, @@ -38,6 +39,7 @@ class MockAppModule: var extensionSetupCoordinator = MockCoordinator() var fileSelectionDelegate: FileSelectionDelegate? var fileSelectionCoordinator = MockCoordinator() + var flightRecorderCoordinator = MockCoordinator() var generatorCoordinator = MockCoordinator() var importCXFCoordinator = MockCoordinator() var importLoginsCoordinator = MockCoordinator() @@ -109,6 +111,12 @@ class MockAppModule: return fileSelectionCoordinator.asAnyCoordinator() } + func makeFlightRecorderCoordinator( + stackNavigator _: StackNavigator, + ) -> AnyCoordinator { + flightRecorderCoordinator.asAnyCoordinator() + } + func makeGeneratorCoordinator( delegate _: GeneratorCoordinatorDelegate?, stackNavigator _: StackNavigator, diff --git a/swiftgen-bwr.yml b/swiftgen-bwr.yml index 6b6ab0e8e..873b654ec 100644 --- a/swiftgen-bwr.yml +++ b/swiftgen-bwr.yml @@ -20,6 +20,7 @@ xcassets: inputs: - BitwardenResources/Colors.xcassets - BitwardenResources/Icons.xcassets + - BitwardenResources/Illustrations.xcassets outputs: - templateName: swift5 output: SharedAssets.swift