[PM-26063] Move FlightRecorder into BitwardenKit (#2133)
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
17
BitwardenKit/Core/Platform/Extensions/Task+Extensions.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
public extension Task where Success == Never, Failure == Never {
|
||||
/// Suspends the current task for at least the specified duration in seconds.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - delay: The number of seconds to sleep.
|
||||
/// - tolerance: The acceptable tolerance for the sleep duration in seconds. Defaults to 1 second.
|
||||
///
|
||||
static func sleep(forSeconds delay: Double, tolerance: Double = 1) async throws {
|
||||
if #available(iOS 16.0, *) {
|
||||
try await sleep(for: .seconds(delay), tolerance: .seconds(tolerance))
|
||||
} else {
|
||||
try await sleep(nanoseconds: UInt64(delay * Double(NSEC_PER_SEC)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
@testable import BitwardenKit
|
||||
|
||||
class FlightRecorderDataTests: BitwardenTestCase {
|
||||
// MARK: Tests
|
||||
@ -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),
|
||||
@ -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
|
||||
@ -1,7 +1,7 @@
|
||||
import BitwardenResources
|
||||
import XCTest
|
||||
|
||||
import BitwardenResources
|
||||
@testable import BitwardenShared
|
||||
@testable import BitwardenKit
|
||||
|
||||
class FlightRecorderLogMetadataTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
@ -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)
|
||||
@ -1,7 +1,7 @@
|
||||
import BitwardenResources
|
||||
import XCTest
|
||||
|
||||
import BitwardenResources
|
||||
@testable import BitwardenShared
|
||||
@testable import BitwardenKit
|
||||
|
||||
class FlightRecorderLoggingDurationTests: BitwardenTestCase {
|
||||
// MARK: Tests
|
||||
@ -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)",
|
||||
)
|
||||
@ -1,8 +1,9 @@
|
||||
import BitwardenKitMocks
|
||||
import Networking
|
||||
import TestHelpers
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
@testable import BitwardenKit
|
||||
|
||||
class FlightRecorderHTTPLoggerTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
@ -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<FlightRecorderData.LogMetadata?, Never> {
|
||||
public func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
|
||||
_ = 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<Bool, Never> {
|
||||
public func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
|
||||
_ = 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)
|
||||
@ -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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import Combine
|
||||
|
||||
@testable import BitwardenKit
|
||||
|
||||
@MainActor
|
||||
public final class MockFlightRecorder: FlightRecorder {
|
||||
public var activeLogSubject = CurrentValueSubject<FlightRecorderData.LogMetadata?, Never>(nil)
|
||||
public var deleteInactiveLogsCalled = false
|
||||
public var deleteInactiveLogsResult: Result<Void, Error> = .success(())
|
||||
public var deleteLogResult: Result<Void, Error> = .success(())
|
||||
public var deleteLogLogs = [FlightRecorderLogMetadata]()
|
||||
public var disableFlightRecorderCalled = false
|
||||
public var enableFlightRecorderCalled = false
|
||||
public var enableFlightRecorderDuration: FlightRecorderLoggingDuration?
|
||||
public var enableFlightRecorderResult: Result<Void, Error> = .success(())
|
||||
public var fetchLogsCalled = false
|
||||
public var fetchLogsResult: Result<[FlightRecorderLogMetadata], Error> = .success([])
|
||||
public var isEnabledSubject = CurrentValueSubject<Bool, Never>(false)
|
||||
public var logMessages = [String]()
|
||||
public var setFlightRecorderBannerDismissedCalled = false
|
||||
|
||||
public nonisolated init() {}
|
||||
|
||||
public func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
|
||||
activeLogSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func deleteInactiveLogs() async throws {
|
||||
deleteInactiveLogsCalled = true
|
||||
try deleteInactiveLogsResult.get()
|
||||
}
|
||||
|
||||
public func deleteLog(_ log: FlightRecorderLogMetadata) async throws {
|
||||
deleteLogLogs.append(log)
|
||||
try deleteLogResult.get()
|
||||
}
|
||||
|
||||
public func disableFlightRecorder() {
|
||||
disableFlightRecorderCalled = true
|
||||
}
|
||||
|
||||
public func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws {
|
||||
enableFlightRecorderCalled = true
|
||||
enableFlightRecorderDuration = duration
|
||||
try enableFlightRecorderResult.get()
|
||||
}
|
||||
|
||||
public func fetchLogs() async throws -> [FlightRecorderLogMetadata] {
|
||||
fetchLogsCalled = true
|
||||
return try fetchLogsResult.get()
|
||||
}
|
||||
|
||||
public func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
|
||||
isEnabledSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func log(_ message: String, file: String, line: UInt) async {
|
||||
logMessages.append(message)
|
||||
}
|
||||
|
||||
public func setFlightRecorderBannerDismissed() async {
|
||||
setFlightRecorderBannerDismissedCalled = true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import BitwardenKit
|
||||
import TestHelpers
|
||||
|
||||
public class MockFlightRecorderStateService: FlightRecorderStateService {
|
||||
public var activeAccountIdResult = Result<String, Error>.failure(BitwardenTestError.example)
|
||||
public var flightRecorderData: FlightRecorderData?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func getActiveAccountId() async throws -> String {
|
||||
try activeAccountIdResult.get()
|
||||
}
|
||||
|
||||
public func getFlightRecorderData() async -> FlightRecorderData? {
|
||||
flightRecorderData
|
||||
}
|
||||
|
||||
public func setFlightRecorderData(_ data: FlightRecorderData?) async {
|
||||
flightRecorderData = data
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
@testable import BitwardenKit
|
||||
|
||||
class FileManagerTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
@testable import BitwardenKit
|
||||
|
||||
public class MockFileManager: FileManagerProtocol {
|
||||
public var appendDataData: Data?
|
||||
public var appendDataResult: Result<Void, Error> = .success(())
|
||||
public var appendDataURL: URL?
|
||||
|
||||
public var attributesOfItemPath: String?
|
||||
public var attributesOfItemResult: Result<[FileAttributeKey: Any], Error> = .success([:])
|
||||
|
||||
public var createDirectoryURL: URL?
|
||||
public var createDirectoryCreateIntermediates: Bool?
|
||||
public var createDirectoryResult: Result<Void, Error> = .success(())
|
||||
|
||||
public var removeItemURLs = [URL]()
|
||||
public var removeItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
public var setIsExcludedFromBackupValue: Bool?
|
||||
public var setIsExcludedFromBackupURL: URL?
|
||||
public var setIsExcludedFromBackupResult: Result<Void, Error> = .success(())
|
||||
|
||||
public var writeDataData: Data?
|
||||
public var writeDataURL: URL?
|
||||
public var writeDataResult: Result<Void, Error> = .success(())
|
||||
|
||||
public init() {}
|
||||
|
||||
public func append(_ data: Data, to url: URL) throws {
|
||||
appendDataData = data
|
||||
appendDataURL = url
|
||||
try appendDataResult.get()
|
||||
}
|
||||
|
||||
public func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
|
||||
attributesOfItemPath = path
|
||||
return try attributesOfItemResult.get()
|
||||
}
|
||||
|
||||
public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool) throws {
|
||||
createDirectoryURL = url
|
||||
createDirectoryCreateIntermediates = createIntermediates
|
||||
try createDirectoryResult.get()
|
||||
}
|
||||
|
||||
public func removeItem(at url: URL) throws {
|
||||
removeItemURLs.append(url)
|
||||
try removeItemResult.get()
|
||||
}
|
||||
|
||||
public func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws {
|
||||
setIsExcludedFromBackupValue = value
|
||||
setIsExcludedFromBackupURL = url
|
||||
try setIsExcludedFromBackupResult.get()
|
||||
}
|
||||
|
||||
public func write(_ data: Data, to url: URL) throws {
|
||||
writeDataData = data
|
||||
writeDataURL = url
|
||||
try writeDataResult.get()
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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<SettingsRoute, SettingsEvent>
|
||||
private let coordinator: AnyCoordinator<FlightRecorderRoute, Void>
|
||||
|
||||
/// 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<SettingsRoute, SettingsEvent>,
|
||||
coordinator: AnyCoordinator<FlightRecorderRoute, Void>,
|
||||
services: Services,
|
||||
state: EnableFlightRecorderState,
|
||||
) {
|
||||
@ -2,12 +2,12 @@ import BitwardenKitMocks
|
||||
import TestHelpers
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
@testable import BitwardenKit
|
||||
|
||||
class EnableFlightRecorderProcessorTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
|
||||
var coordinator: MockCoordinator<FlightRecorderRoute, Void>!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var flightRecorder: MockFlightRecorder!
|
||||
var subject: EnableFlightRecorderProcessor!
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,4 +1,3 @@
|
||||
import BitwardenKit
|
||||
import BitwardenResources
|
||||
import SwiftUI
|
||||
|
||||
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 680 KiB After Width: | Height: | Size: 680 KiB |
@ -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 }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
import BitwardenKit
|
||||
|
||||
// MARK: - FlightRecorderLogsAction
|
||||
|
||||
/// Actions handled by the `FlightRecorderLogsProcessor`.
|
||||
@ -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<SettingsRoute, SettingsEvent>
|
||||
private let coordinator: AnyCoordinator<FlightRecorderRoute, Void>
|
||||
|
||||
/// 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<SettingsRoute, SettingsEvent>,
|
||||
coordinator: AnyCoordinator<FlightRecorderRoute, Void>,
|
||||
services: Services,
|
||||
state: FlightRecorderLogsState,
|
||||
) {
|
||||
@ -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<SettingsRoute, SettingsEvent>!
|
||||
var coordinator: MockCoordinator<FlightRecorderRoute, Void>!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var flightRecorder: MockFlightRecorder!
|
||||
var subject: FlightRecorderLogsProcessor!
|
||||
@ -1,5 +1,3 @@
|
||||
import BitwardenKit
|
||||
|
||||
// MARK: - FlightRecorderLogsState
|
||||
|
||||
/// An object that defines the current state of the `FlightRecorderLogsView`.
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
@ -0,0 +1,16 @@
|
||||
// MARK: - FlightRecorderModule
|
||||
|
||||
/// An object that builds coordinators for the flight recorder flow.
|
||||
///
|
||||
@MainActor
|
||||
public protocol FlightRecorderModule {
|
||||
/// Initializes a coordinator for navigating between `FlightRecorderRoute`s.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - stackNavigator: The stack navigator that will be used to navigate between routes.
|
||||
/// - Returns: A coordinator that can navigate to `FlightRecorderRoute`s.
|
||||
///
|
||||
func makeFlightRecorderCoordinator(
|
||||
stackNavigator: StackNavigator,
|
||||
) -> AnyCoordinator<FlightRecorderRoute, Void>
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
6
BitwardenResources/Illustrations.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
25
BitwardenResources/Illustrations.xcassets/secure-devices.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "secure-devices.pdf",
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "secure-devices-dark.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
BIN
BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices-dark.pdf
vendored
Normal file
BIN
BitwardenResources/Illustrations.xcassets/secure-devices.imageset/secure-devices.pdf
vendored
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
import Combine
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
@MainActor
|
||||
final class MockFlightRecorder: FlightRecorder {
|
||||
var activeLogSubject = CurrentValueSubject<FlightRecorderData.LogMetadata?, Never>(nil)
|
||||
var deleteInactiveLogsCalled = false
|
||||
var deleteInactiveLogsResult: Result<Void, Error> = .success(())
|
||||
var deleteLogResult: Result<Void, Error> = .success(())
|
||||
var deleteLogLogs = [FlightRecorderLogMetadata]()
|
||||
var disableFlightRecorderCalled = false
|
||||
var enableFlightRecorderCalled = false
|
||||
var enableFlightRecorderDuration: FlightRecorderLoggingDuration?
|
||||
var enableFlightRecorderResult: Result<Void, Error> = .success(())
|
||||
var fetchLogsCalled = false
|
||||
var fetchLogsResult: Result<[FlightRecorderLogMetadata], Error> = .success([])
|
||||
var isEnabledSubject = CurrentValueSubject<Bool, Never>(false)
|
||||
var logMessages = [String]()
|
||||
var setFlightRecorderBannerDismissedCalled = false
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
|
||||
activeLogSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func deleteInactiveLogs() async throws {
|
||||
deleteInactiveLogsCalled = true
|
||||
try deleteInactiveLogsResult.get()
|
||||
}
|
||||
|
||||
func deleteLog(_ log: FlightRecorderLogMetadata) async throws {
|
||||
deleteLogLogs.append(log)
|
||||
try deleteLogResult.get()
|
||||
}
|
||||
|
||||
func disableFlightRecorder() {
|
||||
disableFlightRecorderCalled = true
|
||||
}
|
||||
|
||||
func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws {
|
||||
enableFlightRecorderCalled = true
|
||||
enableFlightRecorderDuration = duration
|
||||
try enableFlightRecorderResult.get()
|
||||
}
|
||||
|
||||
func fetchLogs() async throws -> [FlightRecorderLogMetadata] {
|
||||
fetchLogsCalled = true
|
||||
return try fetchLogsResult.get()
|
||||
}
|
||||
|
||||
func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
|
||||
isEnabledSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func log(_ message: String, file: String, line: UInt) async {
|
||||
logMessages.append(message)
|
||||
}
|
||||
|
||||
func setFlightRecorderBannerDismissed() async {
|
||||
setFlightRecorderBannerDismissedCalled = true
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
class MockFileManager: FileManagerProtocol {
|
||||
var appendDataData: Data?
|
||||
var appendDataResult: Result<Void, Error> = .success(())
|
||||
var appendDataURL: URL?
|
||||
|
||||
var attributesOfItemPath: String?
|
||||
var attributesOfItemResult: Result<[FileAttributeKey: Any], Error> = .success([:])
|
||||
|
||||
var createDirectoryURL: URL?
|
||||
var createDirectoryCreateIntermediates: Bool?
|
||||
var createDirectoryResult: Result<Void, Error> = .success(())
|
||||
|
||||
var removeItemURLs = [URL]()
|
||||
var removeItemResult: Result<Void, Error> = .success(())
|
||||
|
||||
var setIsExcludedFromBackupValue: Bool?
|
||||
var setIsExcludedFromBackupURL: URL?
|
||||
var setIsExcludedFromBackupResult: Result<Void, Error> = .success(())
|
||||
|
||||
var writeDataData: Data?
|
||||
var writeDataURL: URL?
|
||||
var writeDataResult: Result<Void, Error> = .success(())
|
||||
|
||||
func append(_ data: Data, to url: URL) throws {
|
||||
appendDataData = data
|
||||
appendDataURL = url
|
||||
try appendDataResult.get()
|
||||
}
|
||||
|
||||
func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] {
|
||||
attributesOfItemPath = path
|
||||
return try attributesOfItemResult.get()
|
||||
}
|
||||
|
||||
func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool) throws {
|
||||
createDirectoryURL = url
|
||||
createDirectoryCreateIntermediates = createIntermediates
|
||||
try createDirectoryResult.get()
|
||||
}
|
||||
|
||||
func removeItem(at url: URL) throws {
|
||||
removeItemURLs.append(url)
|
||||
try removeItemResult.get()
|
||||
}
|
||||
|
||||
func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws {
|
||||
setIsExcludedFromBackupValue = value
|
||||
setIsExcludedFromBackupURL = url
|
||||
try setIsExcludedFromBackupResult.get()
|
||||
}
|
||||
|
||||
func write(_ data: Data, to url: URL) throws {
|
||||
writeDataData = data
|
||||
writeDataURL = url
|
||||
try writeDataResult.get()
|
||||
}
|
||||
}
|
||||
@ -62,3 +62,17 @@ extension DefaultAppModule: AppModule {
|
||||
).asAnyCoordinator()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DefaultAppModule + FlightRecorderModule
|
||||
|
||||
extension DefaultAppModule: FlightRecorderModule {
|
||||
public func makeFlightRecorderCoordinator(
|
||||
stackNavigator: StackNavigator,
|
||||
) -> AnyCoordinator<FlightRecorderRoute, Void> {
|
||||
FlightRecorderCoordinator(
|
||||
services: services,
|
||||
stackNavigator: stackNavigator,
|
||||
)
|
||||
.asAnyCoordinator()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -57,7 +57,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
|
||||
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<AboutState, AboutAction, AboutEffect>
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import BitwardenKit
|
||||
import BitwardenResources
|
||||
import XCTest
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ class MockAppModule:
|
||||
ExportCXFModule,
|
||||
ExtensionSetupModule,
|
||||
FileSelectionModule,
|
||||
FlightRecorderModule,
|
||||
GeneratorModule,
|
||||
ImportCXFModule,
|
||||
ImportLoginsModule,
|
||||
@ -38,6 +39,7 @@ class MockAppModule:
|
||||
var extensionSetupCoordinator = MockCoordinator<ExtensionSetupRoute, Void>()
|
||||
var fileSelectionDelegate: FileSelectionDelegate?
|
||||
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, Void>()
|
||||
var flightRecorderCoordinator = MockCoordinator<FlightRecorderRoute, Void>()
|
||||
var generatorCoordinator = MockCoordinator<GeneratorRoute, Void>()
|
||||
var importCXFCoordinator = MockCoordinator<ImportCXFRoute, Void>()
|
||||
var importLoginsCoordinator = MockCoordinator<ImportLoginsRoute, ImportLoginsEvent>()
|
||||
@ -109,6 +111,12 @@ class MockAppModule:
|
||||
return fileSelectionCoordinator.asAnyCoordinator()
|
||||
}
|
||||
|
||||
func makeFlightRecorderCoordinator(
|
||||
stackNavigator _: StackNavigator,
|
||||
) -> AnyCoordinator<FlightRecorderRoute, Void> {
|
||||
flightRecorderCoordinator.asAnyCoordinator()
|
||||
}
|
||||
|
||||
func makeGeneratorCoordinator(
|
||||
delegate _: GeneratorCoordinatorDelegate?,
|
||||
stackNavigator _: StackNavigator,
|
||||
|
||||
@ -20,6 +20,7 @@ xcassets:
|
||||
inputs:
|
||||
- BitwardenResources/Colors.xcassets
|
||||
- BitwardenResources/Icons.xcassets
|
||||
- BitwardenResources/Illustrations.xcassets
|
||||
outputs:
|
||||
- templateName: swift5
|
||||
output: SharedAssets.swift
|
||||
|
||||