[PM-26063] Move FlightRecorder into BitwardenKit (#2133)
@ -7,6 +7,7 @@ typealias Services = HasConfigService
|
|||||||
& HasEnvironmentService
|
& HasEnvironmentService
|
||||||
& HasErrorReportBuilder
|
& HasErrorReportBuilder
|
||||||
& HasErrorReporter
|
& HasErrorReporter
|
||||||
|
& HasFlightRecorder
|
||||||
& HasTimeProvider
|
& HasTimeProvider
|
||||||
|
|
||||||
/// A service container used for testing processors within `BitwardenKitTests`.
|
/// A service container used for testing processors within `BitwardenKitTests`.
|
||||||
@ -16,6 +17,7 @@ class ServiceContainer: Services {
|
|||||||
let environmentService: EnvironmentService
|
let environmentService: EnvironmentService
|
||||||
let errorReportBuilder: any ErrorReportBuilder
|
let errorReportBuilder: any ErrorReportBuilder
|
||||||
let errorReporter: ErrorReporter
|
let errorReporter: ErrorReporter
|
||||||
|
let flightRecorder: FlightRecorder
|
||||||
let timeProvider: TimeProvider
|
let timeProvider: TimeProvider
|
||||||
|
|
||||||
required init(
|
required init(
|
||||||
@ -23,12 +25,14 @@ class ServiceContainer: Services {
|
|||||||
environmentService: EnvironmentService,
|
environmentService: EnvironmentService,
|
||||||
errorReportBuilder: ErrorReportBuilder,
|
errorReportBuilder: ErrorReportBuilder,
|
||||||
errorReporter: ErrorReporter,
|
errorReporter: ErrorReporter,
|
||||||
|
flightRecorder: FlightRecorder,
|
||||||
timeProvider: TimeProvider,
|
timeProvider: TimeProvider,
|
||||||
) {
|
) {
|
||||||
self.configService = configService
|
self.configService = configService
|
||||||
self.errorReportBuilder = errorReportBuilder
|
self.errorReportBuilder = errorReportBuilder
|
||||||
self.environmentService = environmentService
|
self.environmentService = environmentService
|
||||||
self.errorReporter = errorReporter
|
self.errorReporter = errorReporter
|
||||||
|
self.flightRecorder = flightRecorder
|
||||||
self.timeProvider = timeProvider
|
self.timeProvider = timeProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,6 +43,7 @@ extension ServiceContainer {
|
|||||||
errorReportBuilder: ErrorReportBuilder = MockErrorReportBuilder(),
|
errorReportBuilder: ErrorReportBuilder = MockErrorReportBuilder(),
|
||||||
environmentService: EnvironmentService = MockEnvironmentService(),
|
environmentService: EnvironmentService = MockEnvironmentService(),
|
||||||
errorReporter: ErrorReporter = MockErrorReporter(),
|
errorReporter: ErrorReporter = MockErrorReporter(),
|
||||||
|
flightRecorder: FlightRecorder = MockFlightRecorder(),
|
||||||
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
|
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
|
||||||
) -> ServiceContainer {
|
) -> ServiceContainer {
|
||||||
self.init(
|
self.init(
|
||||||
@ -46,6 +51,7 @@ extension ServiceContainer {
|
|||||||
environmentService: environmentService,
|
environmentService: environmentService,
|
||||||
errorReportBuilder: errorReportBuilder,
|
errorReportBuilder: errorReportBuilder,
|
||||||
errorReporter: errorReporter,
|
errorReporter: errorReporter,
|
||||||
|
flightRecorder: flightRecorder,
|
||||||
timeProvider: timeProvider,
|
timeProvider: timeProvider,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
guard absoluteString.hasPrefix(prefix) else { return absoluteString }
|
||||||
return String(absoluteString.dropFirst(prefix.count))
|
return String(absoluteString.dropFirst(prefix.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Creates a new `URL` appending the provided query items to the url.
|
||||||
|
///
|
||||||
|
/// On iOS 16+, this method uses the method with the same name in Foundation. On iOS 15, this method
|
||||||
|
/// uses `URLComponents` to add the query items to the new url.
|
||||||
|
///
|
||||||
|
/// - Parameter queryItems: A list of `URLQueryItem`s to add to this url.
|
||||||
|
/// - Returns: A new `URL` with the query items appended.
|
||||||
|
///
|
||||||
|
func appending(queryItems: [URLQueryItem]) -> URL? {
|
||||||
|
if #available(iOS 16, *) {
|
||||||
|
// Set this variable to a non-optional `URL` type so that we are calling the function in Foundation,
|
||||||
|
// rather than recursively calling this method.
|
||||||
|
let url: URL = appending(queryItems: queryItems)
|
||||||
|
return url
|
||||||
|
} else {
|
||||||
|
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
components.queryItems = queryItems
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets whether the file should be excluded from backups.
|
||||||
|
///
|
||||||
|
/// - Parameter value: `true` if the file should be excluded from backups, or `false` otherwise.
|
||||||
|
///
|
||||||
|
func setIsExcludedFromBackup(_ value: Bool) throws {
|
||||||
|
var url = self
|
||||||
|
var values = URLResourceValues()
|
||||||
|
values.isExcludedFromBackup = value
|
||||||
|
try url.setResourceValues(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import BitwardenKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - FlightRecorderData
|
// MARK: - FlightRecorderData
|
||||||
@ -6,11 +5,11 @@ import Foundation
|
|||||||
/// A data model containing the persisted data necessary for the flight recorder. This stores the
|
/// A data model containing the persisted data necessary for the flight recorder. This stores the
|
||||||
/// metadata for the active and any inactive logs.
|
/// metadata for the active and any inactive logs.
|
||||||
///
|
///
|
||||||
struct FlightRecorderData: Codable, Equatable {
|
public struct FlightRecorderData: Codable, Equatable {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The current log, if the flight recorder is active.
|
/// The current log, if the flight recorder is active.
|
||||||
var activeLog: LogMetadata? {
|
public var activeLog: LogMetadata? {
|
||||||
didSet {
|
didSet {
|
||||||
guard let oldValue, oldValue.id != activeLog?.id else { return }
|
guard let oldValue, oldValue.id != activeLog?.id else { return }
|
||||||
inactiveLogs.insert(oldValue, at: 0)
|
inactiveLogs.insert(oldValue, at: 0)
|
||||||
@ -19,48 +18,62 @@ struct FlightRecorderData: Codable, Equatable {
|
|||||||
|
|
||||||
/// A list of previously recorded and inactive logs, which remain available on device until they
|
/// A list of previously recorded and inactive logs, which remain available on device until they
|
||||||
/// are deleted by the user or expire and are deleted by the app.
|
/// are deleted by the user or expire and are deleted by the app.
|
||||||
var inactiveLogs: [LogMetadata] = []
|
public var inactiveLogs: [LogMetadata] = []
|
||||||
|
|
||||||
// MARK: Computed Properties
|
// MARK: Computed Properties
|
||||||
|
|
||||||
/// The full list of logs containing the active and any inactive logs.
|
/// The full list of logs containing the active and any inactive logs.
|
||||||
var allLogs: [LogMetadata] {
|
public var allLogs: [LogMetadata] {
|
||||||
([activeLog] + inactiveLogs).compactMap(\.self)
|
([activeLog] + inactiveLogs).compactMap(\.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The upcoming date in which either the active log needs to end logging or an inactive log
|
/// The upcoming date in which either the active log needs to end logging or an inactive log
|
||||||
/// expires and needs to be removed.
|
/// expires and needs to be removed.
|
||||||
var nextLogLifecycleDate: Date? {
|
public var nextLogLifecycleDate: Date? {
|
||||||
let dates = [activeLog?.endDate].compactMap(\.self) + inactiveLogs.map(\.expirationDate)
|
let dates = [activeLog?.endDate].compactMap(\.self) + inactiveLogs.map(\.expirationDate)
|
||||||
return dates.min()
|
return dates.min()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Initialization
|
||||||
|
|
||||||
|
/// Initialize `FlightRecorderData`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - activeLog: The current log, if the flight recorder is active.
|
||||||
|
/// - inactiveLogs: A list of previously recorded and inactive logs, which remain available
|
||||||
|
/// on device until they are deleted by the user or expire and are deleted by the app.
|
||||||
|
///
|
||||||
|
public init(activeLog: LogMetadata? = nil, inactiveLogs: [LogMetadata] = []) {
|
||||||
|
self.activeLog = activeLog
|
||||||
|
self.inactiveLogs = inactiveLogs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FlightRecorderData {
|
public extension FlightRecorderData {
|
||||||
/// A data model containing the metadata for a flight recorder log.
|
/// A data model containing the metadata for a flight recorder log.
|
||||||
///
|
///
|
||||||
struct LogMetadata: Codable, Equatable, Identifiable {
|
struct LogMetadata: Codable, Equatable, Identifiable {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The duration for how long the flight recorder was enabled for the log.
|
/// The duration for how long the flight recorder was enabled for the log.
|
||||||
let duration: FlightRecorderLoggingDuration
|
public let duration: FlightRecorderLoggingDuration
|
||||||
|
|
||||||
/// The date when the logging will end.
|
/// The date when the logging will end.
|
||||||
let endDate: Date
|
public let endDate: Date
|
||||||
|
|
||||||
/// The file name of the file on disk.
|
/// The file name of the file on disk.
|
||||||
let fileName: String
|
public let fileName: String
|
||||||
|
|
||||||
/// Whether the flight recorder toast banner has been dismissed for this log.
|
/// Whether the flight recorder toast banner has been dismissed for this log.
|
||||||
@DefaultFalse var isBannerDismissed = false
|
@DefaultFalse public var isBannerDismissed = false
|
||||||
|
|
||||||
/// The date the logging was started.
|
/// The date the logging was started.
|
||||||
let startDate: Date
|
public let startDate: Date
|
||||||
|
|
||||||
// MARK: Computed Properties
|
// MARK: Computed Properties
|
||||||
|
|
||||||
/// The date when the flight recorder log will expire and be deleted.
|
/// The date when the flight recorder log will expire and be deleted.
|
||||||
var expirationDate: Date {
|
public var expirationDate: Date {
|
||||||
Calendar.current.date(
|
Calendar.current.date(
|
||||||
byAdding: .day,
|
byAdding: .day,
|
||||||
value: Constants.flightRecorderLogExpirationDays,
|
value: Constants.flightRecorderLogExpirationDays,
|
||||||
@ -69,7 +82,7 @@ extension FlightRecorderData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The formatted end date for the log.
|
/// The formatted end date for the log.
|
||||||
var formattedEndDate: String {
|
public var formattedEndDate: String {
|
||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateStyle = .short
|
dateFormatter.dateStyle = .short
|
||||||
dateFormatter.timeStyle = .none
|
dateFormatter.timeStyle = .none
|
||||||
@ -77,14 +90,14 @@ extension FlightRecorderData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The formatted end time for the log.
|
/// The formatted end time for the log.
|
||||||
var formattedEndTime: String {
|
public var formattedEndTime: String {
|
||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateStyle = .none
|
dateFormatter.dateStyle = .none
|
||||||
dateFormatter.timeStyle = .short
|
dateFormatter.timeStyle = .short
|
||||||
return dateFormatter.string(from: endDate)
|
return dateFormatter.string(from: endDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
var id: String {
|
public var id: String {
|
||||||
fileName
|
fileName
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,7 +109,7 @@ extension FlightRecorderData {
|
|||||||
/// - duration: The duration for how long the flight recorder was enabled for the log.
|
/// - duration: The duration for how long the flight recorder was enabled for the log.
|
||||||
/// - startDate: The date the logging was started.
|
/// - startDate: The date the logging was started.
|
||||||
///
|
///
|
||||||
init(duration: FlightRecorderLoggingDuration, startDate: Date) {
|
public init(duration: FlightRecorderLoggingDuration, startDate: Date) {
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
self.startDate = startDate
|
self.startDate = startDate
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class FlightRecorderDataTests: BitwardenTestCase {
|
class FlightRecorderDataTests: BitwardenTestCase {
|
||||||
// MARK: Tests
|
// MARK: Tests
|
||||||
@ -1,8 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
extension FlightRecorderLogMetadata {
|
public extension FlightRecorderLogMetadata {
|
||||||
|
// swiftlint:disable:next missing_docs
|
||||||
static func fixture(
|
static func fixture(
|
||||||
duration: FlightRecorderLoggingDuration = .twentyFourHours,
|
duration: FlightRecorderLoggingDuration = .twentyFourHours,
|
||||||
endDate: Date = Date(year: 2025, month: 4, day: 4),
|
endDate: Date = Date(year: 2025, month: 4, day: 4),
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import BitwardenKit
|
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@ -6,7 +5,7 @@ import Foundation
|
|||||||
|
|
||||||
/// A data model containing the metadata associated with a flight recorder log.
|
/// A data model containing the metadata associated with a flight recorder log.
|
||||||
///
|
///
|
||||||
struct FlightRecorderLogMetadata: Equatable, Identifiable {
|
public struct FlightRecorderLogMetadata: Equatable, Identifiable {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The duration for how long the flight recorder was enabled for the log.
|
/// The duration for how long the flight recorder was enabled for the log.
|
||||||
@ -22,7 +21,7 @@ struct FlightRecorderLogMetadata: Equatable, Identifiable {
|
|||||||
let fileSize: String
|
let fileSize: String
|
||||||
|
|
||||||
/// A unique identifier for the log.
|
/// A unique identifier for the log.
|
||||||
let id: String
|
public let id: String
|
||||||
|
|
||||||
/// Whether this represents the active log.
|
/// Whether this represents the active log.
|
||||||
let isActiveLog: Bool
|
let isActiveLog: Bool
|
||||||
@ -1,7 +1,7 @@
|
|||||||
|
import BitwardenResources
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
import BitwardenResources
|
@testable import BitwardenKit
|
||||||
@testable import BitwardenShared
|
|
||||||
|
|
||||||
class FlightRecorderLogMetadataTests: BitwardenTestCase {
|
class FlightRecorderLogMetadataTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@ -1,10 +1,9 @@
|
|||||||
import BitwardenKit
|
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// An enum that represents how long to enable the flight recorder.
|
/// An enum that represents how long to enable the flight recorder.
|
||||||
///
|
///
|
||||||
enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable {
|
public enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable {
|
||||||
/// The flight recorder is enabled for one hour.
|
/// The flight recorder is enabled for one hour.
|
||||||
case oneHour
|
case oneHour
|
||||||
|
|
||||||
@ -17,7 +16,7 @@ enum FlightRecorderLoggingDuration: CaseIterable, Codable, Menuable {
|
|||||||
/// The flight recorder is enabled for one week.
|
/// The flight recorder is enabled for one week.
|
||||||
case oneWeek
|
case oneWeek
|
||||||
|
|
||||||
var localizedName: String {
|
public var localizedName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .oneHour: Localizations.xHours(1)
|
case .oneHour: Localizations.xHours(1)
|
||||||
case .eightHours: Localizations.xHours(8)
|
case .eightHours: Localizations.xHours(8)
|
||||||
@ -1,7 +1,7 @@
|
|||||||
|
import BitwardenResources
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
import BitwardenResources
|
@testable import BitwardenKit
|
||||||
@testable import BitwardenShared
|
|
||||||
|
|
||||||
class FlightRecorderLoggingDurationTests: BitwardenTestCase {
|
class FlightRecorderLoggingDurationTests: BitwardenTestCase {
|
||||||
// MARK: Tests
|
// MARK: Tests
|
||||||
@ -4,7 +4,7 @@ import Networking
|
|||||||
|
|
||||||
/// An `HTTPLogger` that logs HTTP requests and responses to the flight recorder.
|
/// An `HTTPLogger` that logs HTTP requests and responses to the flight recorder.
|
||||||
///
|
///
|
||||||
final class FlightRecorderHTTPLogger: HTTPLogger {
|
public final class FlightRecorderHTTPLogger: HTTPLogger {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The service used by the application for recording temporary debug logs.
|
/// The service used by the application for recording temporary debug logs.
|
||||||
@ -16,19 +16,19 @@ final class FlightRecorderHTTPLogger: HTTPLogger {
|
|||||||
///
|
///
|
||||||
/// - Parameter flightRecorder: The service used by the application for recording temporary debug logs.
|
/// - Parameter flightRecorder: The service used by the application for recording temporary debug logs.
|
||||||
///
|
///
|
||||||
init(flightRecorder: FlightRecorder) {
|
public init(flightRecorder: FlightRecorder) {
|
||||||
self.flightRecorder = flightRecorder
|
self.flightRecorder = flightRecorder
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: HTTPLogger
|
// MARK: HTTPLogger
|
||||||
|
|
||||||
func logRequest(_ httpRequest: HTTPRequest) async {
|
public func logRequest(_ httpRequest: HTTPRequest) async {
|
||||||
await flightRecorder.log(
|
await flightRecorder.log(
|
||||||
"Request \(httpRequest.requestID): \(httpRequest.method.rawValue) \(httpRequest.url)",
|
"Request \(httpRequest.requestID): \(httpRequest.method.rawValue) \(httpRequest.url)",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logResponse(_ httpResponse: HTTPResponse) async {
|
public func logResponse(_ httpResponse: HTTPResponse) async {
|
||||||
await flightRecorder.log(
|
await flightRecorder.log(
|
||||||
"Response \(httpResponse.requestID): \(httpResponse.url) \(httpResponse.statusCode)",
|
"Response \(httpResponse.requestID): \(httpResponse.url) \(httpResponse.statusCode)",
|
||||||
)
|
)
|
||||||
@ -1,8 +1,9 @@
|
|||||||
|
import BitwardenKitMocks
|
||||||
import Networking
|
import Networking
|
||||||
import TestHelpers
|
import TestHelpers
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class FlightRecorderHTTPLoggerTests: BitwardenTestCase {
|
class FlightRecorderHTTPLoggerTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import BitwardenKit
|
|
||||||
@preconcurrency import Combine
|
@preconcurrency import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
@ -10,7 +9,7 @@ import OSLog
|
|||||||
/// A protocol for a service which can temporarily be enabled to collect logs for debugging to a
|
/// A protocol for a service which can temporarily be enabled to collect logs for debugging to a
|
||||||
/// local file.
|
/// local file.
|
||||||
///
|
///
|
||||||
protocol FlightRecorder: Sendable, BitwardenLogger {
|
public protocol FlightRecorder: Sendable, BitwardenLogger {
|
||||||
/// A publisher which publishes the active log of the flight recorder.
|
/// A publisher which publishes the active log of the flight recorder.
|
||||||
///
|
///
|
||||||
/// - Returns: A publisher for the active log of the flight recorder.
|
/// - Returns: A publisher for the active log of the flight recorder.
|
||||||
@ -64,11 +63,25 @@ protocol FlightRecorder: Sendable, BitwardenLogger {
|
|||||||
func setFlightRecorderBannerDismissed() async
|
func setFlightRecorderBannerDismissed() async
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FlightRecorder {
|
public extension FlightRecorder {
|
||||||
|
/// Appends a message to the active log, if logging is currently enabled.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - message: The message to append to the active log.
|
||||||
|
/// - file: The file that called the log method.
|
||||||
|
/// - line: The line number in the file that called the log method.
|
||||||
|
///
|
||||||
func log(_ message: String, file: String = #file, line: UInt = #line) async {
|
func log(_ message: String, file: String = #file, line: UInt = #line) async {
|
||||||
await log(message, file: file, line: line)
|
await log(message, file: file, line: line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Appends a message to the active log, if logging is currently enabled.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - message: The message to append to the active log.
|
||||||
|
/// - file: The file that called the log method.
|
||||||
|
/// - line: The line number in the file that called the log method.
|
||||||
|
///
|
||||||
nonisolated func log(_ message: String, file: String, line: UInt) {
|
nonisolated func log(_ message: String, file: String, line: UInt) {
|
||||||
Task {
|
Task {
|
||||||
await log(message, file: file, line: line)
|
await log(message, file: file, line: line)
|
||||||
@ -81,6 +94,9 @@ extension FlightRecorder {
|
|||||||
/// An enumeration of errors thrown by a `FlightRecorder`.
|
/// An enumeration of errors thrown by a `FlightRecorder`.
|
||||||
///
|
///
|
||||||
enum FlightRecorderError: Error {
|
enum FlightRecorderError: Error {
|
||||||
|
/// The container URL for the app group is unavailable.
|
||||||
|
case containerURLUnavailable
|
||||||
|
|
||||||
/// The stored flight recorder data doesn't exist.
|
/// The stored flight recorder data doesn't exist.
|
||||||
case dataUnavailable
|
case dataUnavailable
|
||||||
|
|
||||||
@ -119,6 +135,7 @@ extension FlightRecorderError: CustomNSError {
|
|||||||
case .logNotFound: 5
|
case .logNotFound: 5
|
||||||
case .removeExpiredLogError: 6
|
case .removeExpiredLogError: 6
|
||||||
case .writeMessageError: 7
|
case .writeMessageError: 7
|
||||||
|
case .containerURLUnavailable: 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -146,7 +163,8 @@ extension FlightRecorderError: CustomNSError {
|
|||||||
extension FlightRecorderError: Equatable {
|
extension FlightRecorderError: Equatable {
|
||||||
static func == (lhs: FlightRecorderError, rhs: FlightRecorderError) -> Bool {
|
static func == (lhs: FlightRecorderError, rhs: FlightRecorderError) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case (.dataUnavailable, .dataUnavailable),
|
case (.containerURLUnavailable, .containerURLUnavailable),
|
||||||
|
(.dataUnavailable, .dataUnavailable),
|
||||||
(.deletionNotPermitted, .deletionNotPermitted),
|
(.deletionNotPermitted, .deletionNotPermitted),
|
||||||
(.logNotFound, .logNotFound):
|
(.logNotFound, .logNotFound):
|
||||||
true
|
true
|
||||||
@ -165,7 +183,7 @@ extension FlightRecorderError: Equatable {
|
|||||||
|
|
||||||
/// A default implementation of a `FlightRecorder`.
|
/// A default implementation of a `FlightRecorder`.
|
||||||
///
|
///
|
||||||
actor DefaultFlightRecorder {
|
public actor DefaultFlightRecorder {
|
||||||
// MARK: Private Properties
|
// MARK: Private Properties
|
||||||
|
|
||||||
/// A subject containing the flight recorder data. This serves as a cache of the data after it
|
/// A subject containing the flight recorder data. This serves as a cache of the data after it
|
||||||
@ -190,7 +208,7 @@ actor DefaultFlightRecorder {
|
|||||||
private let fileManager: FileManagerProtocol
|
private let fileManager: FileManagerProtocol
|
||||||
|
|
||||||
/// The service used by the application to manage account state.
|
/// The service used by the application to manage account state.
|
||||||
private let stateService: StateService
|
private let stateService: FlightRecorderStateService
|
||||||
|
|
||||||
/// The service used to get the present time.
|
/// The service used to get the present time.
|
||||||
private let timeProvider: TimeProvider
|
private let timeProvider: TimeProvider
|
||||||
@ -213,12 +231,12 @@ actor DefaultFlightRecorder {
|
|||||||
/// - stateService: The service used by the application to manage account state.
|
/// - stateService: The service used by the application to manage account state.
|
||||||
/// - timeProvider: The service used to get the present time.
|
/// - timeProvider: The service used to get the present time.
|
||||||
///
|
///
|
||||||
init(
|
public init(
|
||||||
appInfoService: AppInfoService,
|
appInfoService: AppInfoService,
|
||||||
disableLogLifecycleTimerForTesting: Bool = false,
|
disableLogLifecycleTimerForTesting: Bool = false,
|
||||||
errorReporter: ErrorReporter,
|
errorReporter: ErrorReporter,
|
||||||
fileManager: FileManagerProtocol = FileManager.default,
|
fileManager: FileManagerProtocol = FileManager.default,
|
||||||
stateService: StateService,
|
stateService: FlightRecorderStateService,
|
||||||
timeProvider: TimeProvider,
|
timeProvider: TimeProvider,
|
||||||
) {
|
) {
|
||||||
self.appInfoService = appInfoService
|
self.appInfoService = appInfoService
|
||||||
@ -371,7 +389,10 @@ actor DefaultFlightRecorder {
|
|||||||
/// - Returns: A URL for the log file.
|
/// - Returns: A URL for the log file.
|
||||||
///
|
///
|
||||||
private func fileURL(for log: FlightRecorderData.LogMetadata) throws -> URL {
|
private func fileURL(for log: FlightRecorderData.LogMetadata) throws -> URL {
|
||||||
try FileManager.default.flightRecorderLogURL().appendingPathComponent(log.fileName)
|
guard let baseURL = try FileManager.default.flightRecorderLogURL() else {
|
||||||
|
throw FlightRecorderError.containerURLUnavailable
|
||||||
|
}
|
||||||
|
return baseURL.appendingPathComponent(log.fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the `FlightRecorderData`. If the data has already been loaded, it will be returned
|
/// Gets the `FlightRecorderData`. If the data has already been loaded, it will be returned
|
||||||
@ -419,12 +440,12 @@ actor DefaultFlightRecorder {
|
|||||||
// MARK: - DefaultFlightRecorder + FlightRecorder
|
// MARK: - DefaultFlightRecorder + FlightRecorder
|
||||||
|
|
||||||
extension DefaultFlightRecorder: FlightRecorder {
|
extension DefaultFlightRecorder: FlightRecorder {
|
||||||
func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
|
public func activeLogPublisher() async -> AnyPublisher<FlightRecorderData.LogMetadata?, Never> {
|
||||||
_ = await getFlightRecorderData() // Ensure data has already been loaded to the subject.
|
_ = await getFlightRecorderData() // Ensure data has already been loaded to the subject.
|
||||||
return dataSubject.map { $0?.activeLog }.eraseToAnyPublisher()
|
return dataSubject.map { $0?.activeLog }.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteInactiveLogs() async throws {
|
public func deleteInactiveLogs() async throws {
|
||||||
guard var data = await getFlightRecorderData() else {
|
guard var data = await getFlightRecorderData() else {
|
||||||
throw FlightRecorderError.dataUnavailable
|
throw FlightRecorderError.dataUnavailable
|
||||||
}
|
}
|
||||||
@ -437,7 +458,7 @@ extension DefaultFlightRecorder: FlightRecorder {
|
|||||||
await setFlightRecorderData(data)
|
await setFlightRecorderData(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteLog(_ log: FlightRecorderLogMetadata) async throws {
|
public func deleteLog(_ log: FlightRecorderLogMetadata) async throws {
|
||||||
guard var data = await getFlightRecorderData() else {
|
guard var data = await getFlightRecorderData() else {
|
||||||
throw FlightRecorderError.dataUnavailable
|
throw FlightRecorderError.dataUnavailable
|
||||||
}
|
}
|
||||||
@ -453,13 +474,13 @@ extension DefaultFlightRecorder: FlightRecorder {
|
|||||||
await setFlightRecorderData(data)
|
await setFlightRecorderData(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func disableFlightRecorder() async {
|
public func disableFlightRecorder() async {
|
||||||
guard var data = await getFlightRecorderData() else { return }
|
guard var data = await getFlightRecorderData() else { return }
|
||||||
data.activeLog = nil
|
data.activeLog = nil
|
||||||
await setFlightRecorderData(data)
|
await setFlightRecorderData(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws {
|
public func enableFlightRecorder(duration: FlightRecorderLoggingDuration) async throws {
|
||||||
let log = FlightRecorderData.LogMetadata(duration: duration, startDate: timeProvider.presentTime)
|
let log = FlightRecorderData.LogMetadata(duration: duration, startDate: timeProvider.presentTime)
|
||||||
try await createLogFile(for: log)
|
try await createLogFile(for: log)
|
||||||
|
|
||||||
@ -468,7 +489,7 @@ extension DefaultFlightRecorder: FlightRecorder {
|
|||||||
await setFlightRecorderData(data)
|
await setFlightRecorderData(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchLogs() async throws -> [FlightRecorderLogMetadata] {
|
public func fetchLogs() async throws -> [FlightRecorderLogMetadata] {
|
||||||
guard let data = await getFlightRecorderData() else { return [] }
|
guard let data = await getFlightRecorderData() else { return [] }
|
||||||
return try data.allLogs.map { log in
|
return try data.allLogs.map { log in
|
||||||
try FlightRecorderLogMetadata(
|
try FlightRecorderLogMetadata(
|
||||||
@ -484,12 +505,12 @@ extension DefaultFlightRecorder: FlightRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
|
public func isEnabledPublisher() async -> AnyPublisher<Bool, Never> {
|
||||||
_ = await getFlightRecorderData() // Ensure data has already been loaded to the subject.
|
_ = await getFlightRecorderData() // Ensure data has already been loaded to the subject.
|
||||||
return dataSubject.map { $0?.activeLog != nil }.eraseToAnyPublisher()
|
return dataSubject.map { $0?.activeLog != nil }.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(_ message: String, file: String, line: UInt) async {
|
public func log(_ message: String, file: String, line: UInt) async {
|
||||||
guard var data = await getFlightRecorderData(), let log = data.activeLog else { return }
|
guard var data = await getFlightRecorderData(), let log = data.activeLog else { return }
|
||||||
Logger.flightRecorder.debug("\(message)")
|
Logger.flightRecorder.debug("\(message)")
|
||||||
do {
|
do {
|
||||||
@ -506,7 +527,7 @@ extension DefaultFlightRecorder: FlightRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setFlightRecorderBannerDismissed() async {
|
public func setFlightRecorderBannerDismissed() async {
|
||||||
guard var data = await getFlightRecorderData(), data.activeLog != nil else { return }
|
guard var data = await getFlightRecorderData(), data.activeLog != nil else { return }
|
||||||
data.activeLog?.isBannerDismissed = true
|
data.activeLog?.isBannerDismissed = true
|
||||||
await setFlightRecorderData(data)
|
await setFlightRecorderData(data)
|
||||||
@ -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 TestHelpers
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
// swiftlint:disable file_length
|
// swiftlint:disable file_length
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
var errorReporter: MockErrorReporter!
|
var errorReporter: MockErrorReporter!
|
||||||
var fileManager: MockFileManager!
|
var fileManager: MockFileManager!
|
||||||
var logURL: URL!
|
var logURL: URL!
|
||||||
var stateService: MockStateService!
|
var stateService: MockFlightRecorderStateService!
|
||||||
var subject: FlightRecorder!
|
var subject: FlightRecorder!
|
||||||
var timeProvider: MockTimeProvider!
|
var timeProvider: MockTimeProvider!
|
||||||
|
|
||||||
@ -42,11 +42,10 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
appInfoService = MockAppInfoService()
|
appInfoService = MockAppInfoService()
|
||||||
errorReporter = MockErrorReporter()
|
errorReporter = MockErrorReporter()
|
||||||
fileManager = MockFileManager()
|
fileManager = MockFileManager()
|
||||||
stateService = MockStateService()
|
stateService = MockFlightRecorderStateService()
|
||||||
timeProvider = MockTimeProvider(.mockTime(Date(year: 2025, month: 1, day: 1)))
|
timeProvider = MockTimeProvider(.mockTime(Date(year: 2025, month: 1, day: 1)))
|
||||||
|
|
||||||
logURL = try XCTUnwrap(FileManager.default.flightRecorderLogURL()
|
logURL = try flightRecorderLogURL().appendingPathComponent("flight_recorder_2025-01-01-00-00-00.txt")
|
||||||
.appendingPathComponent("flight_recorder_2025-01-01-00-00-00.txt"))
|
|
||||||
|
|
||||||
subject = makeSubject()
|
subject = makeSubject()
|
||||||
}
|
}
|
||||||
@ -124,8 +123,8 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
try XCTAssertEqual(
|
try XCTAssertEqual(
|
||||||
fileManager.removeItemURLs,
|
fileManager.removeItemURLs,
|
||||||
[
|
[
|
||||||
FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName),
|
flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName),
|
||||||
FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName),
|
flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog))
|
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog))
|
||||||
@ -326,11 +325,10 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
activeLog: activeLog,
|
activeLog: activeLog,
|
||||||
inactiveLogs: [inactiveLog1, inactiveLog2],
|
inactiveLogs: [inactiveLog1, inactiveLog2],
|
||||||
)
|
)
|
||||||
let flightRecorderLogURL = try FileManager.default.flightRecorderLogURL()
|
|
||||||
|
|
||||||
let logs = try await subject.fetchLogs()
|
let logs = try await subject.fetchLogs()
|
||||||
|
|
||||||
XCTAssertEqual(
|
try XCTAssertEqual(
|
||||||
logs,
|
logs,
|
||||||
[
|
[
|
||||||
FlightRecorderLogMetadata.fixture(
|
FlightRecorderLogMetadata.fixture(
|
||||||
@ -341,7 +339,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
id: activeLog.id,
|
id: activeLog.id,
|
||||||
isActiveLog: true,
|
isActiveLog: true,
|
||||||
startDate: Date(year: 2025, month: 1, day: 1),
|
startDate: Date(year: 2025, month: 1, day: 1),
|
||||||
url: flightRecorderLogURL.appendingPathComponent(activeLog.fileName),
|
url: flightRecorderLogURL().appendingPathComponent(activeLog.fileName),
|
||||||
),
|
),
|
||||||
FlightRecorderLogMetadata.fixture(
|
FlightRecorderLogMetadata.fixture(
|
||||||
duration: .oneHour,
|
duration: .oneHour,
|
||||||
@ -351,7 +349,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
id: inactiveLog1.id,
|
id: inactiveLog1.id,
|
||||||
isActiveLog: false,
|
isActiveLog: false,
|
||||||
startDate: Date(year: 2025, month: 1, day: 2),
|
startDate: Date(year: 2025, month: 1, day: 2),
|
||||||
url: flightRecorderLogURL.appendingPathComponent(inactiveLog1.fileName),
|
url: flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName),
|
||||||
),
|
),
|
||||||
FlightRecorderLogMetadata.fixture(
|
FlightRecorderLogMetadata.fixture(
|
||||||
duration: .oneWeek,
|
duration: .oneWeek,
|
||||||
@ -361,7 +359,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
id: inactiveLog2.id,
|
id: inactiveLog2.id,
|
||||||
isActiveLog: false,
|
isActiveLog: false,
|
||||||
startDate: Date(year: 2025, month: 1, day: 3),
|
startDate: Date(year: 2025, month: 1, day: 3),
|
||||||
url: flightRecorderLogURL.appendingPathComponent(inactiveLog2.fileName),
|
url: flightRecorderLogURL().appendingPathComponent(inactiveLog2.fileName),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -483,7 +481,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
// Expired active log is removed.
|
// Expired active log is removed.
|
||||||
try XCTAssertEqual(
|
try XCTAssertEqual(
|
||||||
fileManager.removeItemURLs,
|
fileManager.removeItemURLs,
|
||||||
[FileManager.default.flightRecorderLogURL().appendingPathComponent(activeLog.fileName)],
|
[flightRecorderLogURL().appendingPathComponent(activeLog.fileName)],
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
stateService.flightRecorderData,
|
stateService.flightRecorderData,
|
||||||
@ -515,7 +513,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
// Expired inactive log is removed.
|
// Expired inactive log is removed.
|
||||||
try XCTAssertEqual(
|
try XCTAssertEqual(
|
||||||
fileManager.removeItemURLs,
|
fileManager.removeItemURLs,
|
||||||
[FileManager.default.flightRecorderLogURL().appendingPathComponent(expiredLog.fileName)],
|
[flightRecorderLogURL().appendingPathComponent(expiredLog.fileName)],
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
stateService.flightRecorderData,
|
stateService.flightRecorderData,
|
||||||
@ -538,7 +536,7 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
XCTAssertEqual(error, .removeExpiredLogError(BitwardenTestError.example))
|
XCTAssertEqual(error, .removeExpiredLogError(BitwardenTestError.example))
|
||||||
try XCTAssertEqual(
|
try XCTAssertEqual(
|
||||||
fileManager.removeItemURLs,
|
fileManager.removeItemURLs,
|
||||||
[FileManager.default.flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName)],
|
[flightRecorderLogURL().appendingPathComponent(inactiveLog1.fileName)],
|
||||||
)
|
)
|
||||||
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData())
|
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData())
|
||||||
}
|
}
|
||||||
@ -664,4 +662,11 @@ class FlightRecorderTests: BitwardenTestCase { // swiftlint:disable:this type_bo
|
|||||||
XCTAssertEqual(appendedMessage, "2025-01-01T00:00:00Z: Hello world!\n")
|
XCTAssertEqual(appendedMessage, "2025-01-01T00:00:00Z: Hello world!\n")
|
||||||
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog))
|
XCTAssertEqual(stateService.flightRecorderData, FlightRecorderData(activeLog: activeLog))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
/// Returns an unwrapped URL to the directory containing flight recorder logs.
|
||||||
|
private func flightRecorderLogURL() throws -> URL {
|
||||||
|
try XCTUnwrap(FileManager.default.flightRecorderLogURL())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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 }
|
var errorReporter: ErrorReporter { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Protocol for an object that provides a `FlightRecorder`.
|
||||||
|
///
|
||||||
|
public protocol HasFlightRecorder {
|
||||||
|
/// The service used by the application for recording temporary debug logs.
|
||||||
|
var flightRecorder: FlightRecorder { get }
|
||||||
|
}
|
||||||
|
|
||||||
/// Protocol for an object that provides a `TimeProvider`.
|
/// Protocol for an object that provides a `TimeProvider`.
|
||||||
///
|
///
|
||||||
public protocol HasTimeProvider {
|
public protocol HasTimeProvider {
|
||||||
|
|||||||
@ -17,6 +17,10 @@ public enum Constants {
|
|||||||
/// The device type, iOS = 1.
|
/// The device type, iOS = 1.
|
||||||
public static let deviceType: DeviceType = 1
|
public static let deviceType: DeviceType = 1
|
||||||
|
|
||||||
|
/// The number of days that a flight recorder log will remain on the device after the end date
|
||||||
|
/// before being automatically deleted.
|
||||||
|
static let flightRecorderLogExpirationDays = 30
|
||||||
|
|
||||||
/// The minimum number of minutes before attempting a server config sync again.
|
/// The minimum number of minutes before attempting a server config sync again.
|
||||||
public static let minimumConfigSyncInterval: TimeInterval = 60 * 60 // 60 minutes
|
public static let minimumConfigSyncInterval: TimeInterval = 60 * 60 // 60 minutes
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Foundation
|
|||||||
|
|
||||||
/// A protocol for an object that is used to perform filesystem tasks.
|
/// A protocol for an object that is used to perform filesystem tasks.
|
||||||
///
|
///
|
||||||
protocol FileManagerProtocol: AnyObject {
|
public protocol FileManagerProtocol: AnyObject {
|
||||||
/// Appends the given data to the file at the specified URL.
|
/// Appends the given data to the file at the specified URL.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@ -55,22 +55,22 @@ protocol FileManagerProtocol: AnyObject {
|
|||||||
// MARK: - FileManager + FileManagerProtocol
|
// MARK: - FileManager + FileManagerProtocol
|
||||||
|
|
||||||
extension FileManager: FileManagerProtocol {
|
extension FileManager: FileManagerProtocol {
|
||||||
func append(_ data: Data, to url: URL) throws {
|
public func append(_ data: Data, to url: URL) throws {
|
||||||
let handle = try FileHandle(forWritingTo: url)
|
let handle = try FileHandle(forWritingTo: url)
|
||||||
try handle.seekToEnd()
|
try handle.seekToEnd()
|
||||||
try handle.write(contentsOf: data)
|
try handle.write(contentsOf: data)
|
||||||
try handle.close()
|
try handle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {
|
public func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {
|
||||||
try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil)
|
try createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws {
|
public func setIsExcludedFromBackup(_ value: Bool, to url: URL) throws {
|
||||||
try url.setIsExcludedFromBackup(value)
|
try url.setIsExcludedFromBackup(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func write(_ data: Data, to url: URL) throws {
|
public func write(_ data: Data, to url: URL) throws {
|
||||||
try data.write(to: url)
|
try data.write(to: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class FileManagerTests: BitwardenTestCase {
|
class FileManagerTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// 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
|
import Foundation
|
||||||
|
|
||||||
// MARK: - EnableFlightRecorderProcessor
|
// MARK: - EnableFlightRecorderProcessor
|
||||||
@ -18,7 +17,7 @@ final class EnableFlightRecorderProcessor: StateProcessor<
|
|||||||
// MARK: Private Properties
|
// MARK: Private Properties
|
||||||
|
|
||||||
/// The `Coordinator` that handles navigation.
|
/// The `Coordinator` that handles navigation.
|
||||||
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
|
private let coordinator: AnyCoordinator<FlightRecorderRoute, Void>
|
||||||
|
|
||||||
/// The services used by this processor.
|
/// The services used by this processor.
|
||||||
private let services: Services
|
private let services: Services
|
||||||
@ -33,7 +32,7 @@ final class EnableFlightRecorderProcessor: StateProcessor<
|
|||||||
/// - state: The initial state of the processor.
|
/// - state: The initial state of the processor.
|
||||||
///
|
///
|
||||||
init(
|
init(
|
||||||
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
|
coordinator: AnyCoordinator<FlightRecorderRoute, Void>,
|
||||||
services: Services,
|
services: Services,
|
||||||
state: EnableFlightRecorderState,
|
state: EnableFlightRecorderState,
|
||||||
) {
|
) {
|
||||||
@ -2,12 +2,12 @@ import BitwardenKitMocks
|
|||||||
import TestHelpers
|
import TestHelpers
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class EnableFlightRecorderProcessorTests: BitwardenTestCase {
|
class EnableFlightRecorderProcessorTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
|
var coordinator: MockCoordinator<FlightRecorderRoute, Void>!
|
||||||
var errorReporter: MockErrorReporter!
|
var errorReporter: MockErrorReporter!
|
||||||
var flightRecorder: MockFlightRecorder!
|
var flightRecorder: MockFlightRecorder!
|
||||||
var subject: EnableFlightRecorderProcessor!
|
var subject: EnableFlightRecorderProcessor!
|
||||||
@ -1,11 +1,10 @@
|
|||||||
// swiftlint:disable:this file_name
|
// swiftlint:disable:this file_name
|
||||||
import BitwardenKit
|
|
||||||
import BitwardenKitMocks
|
import BitwardenKitMocks
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import SnapshotTesting
|
import SnapshotTesting
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class EnableFlightRecorderViewTests: BitwardenTestCase {
|
class EnableFlightRecorderViewTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@ -1,11 +1,10 @@
|
|||||||
// swiftlint:disable:this file_name
|
// swiftlint:disable:this file_name
|
||||||
import BitwardenKit
|
|
||||||
import BitwardenKitMocks
|
import BitwardenKitMocks
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import ViewInspectorTestHelpers
|
import ViewInspectorTestHelpers
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class EnableFlightRecorderViewTests: BitwardenTestCase {
|
class EnableFlightRecorderViewTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import BitwardenKit
|
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import SwiftUI
|
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
|
// MARK: - FlightRecorderLogsAction
|
||||||
|
|
||||||
/// Actions handled by the `FlightRecorderLogsProcessor`.
|
/// Actions handled by the `FlightRecorderLogsProcessor`.
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import BitwardenKit
|
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@ -19,7 +18,7 @@ final class FlightRecorderLogsProcessor: StateProcessor<
|
|||||||
// MARK: Private Properties
|
// MARK: Private Properties
|
||||||
|
|
||||||
/// The `Coordinator` that handles navigation.
|
/// The `Coordinator` that handles navigation.
|
||||||
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
|
private let coordinator: AnyCoordinator<FlightRecorderRoute, Void>
|
||||||
|
|
||||||
/// The services used by this processor.
|
/// The services used by this processor.
|
||||||
private let services: Services
|
private let services: Services
|
||||||
@ -34,7 +33,7 @@ final class FlightRecorderLogsProcessor: StateProcessor<
|
|||||||
/// - state: The initial state of the processor.
|
/// - state: The initial state of the processor.
|
||||||
///
|
///
|
||||||
init(
|
init(
|
||||||
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
|
coordinator: AnyCoordinator<FlightRecorderRoute, Void>,
|
||||||
services: Services,
|
services: Services,
|
||||||
state: FlightRecorderLogsState,
|
state: FlightRecorderLogsState,
|
||||||
) {
|
) {
|
||||||
@ -1,15 +1,14 @@
|
|||||||
import BitwardenKit
|
|
||||||
import BitwardenKitMocks
|
import BitwardenKitMocks
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import TestHelpers
|
import TestHelpers
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class FlightRecorderLogsProcessorTests: BitwardenTestCase {
|
class FlightRecorderLogsProcessorTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
|
var coordinator: MockCoordinator<FlightRecorderRoute, Void>!
|
||||||
var errorReporter: MockErrorReporter!
|
var errorReporter: MockErrorReporter!
|
||||||
var flightRecorder: MockFlightRecorder!
|
var flightRecorder: MockFlightRecorder!
|
||||||
var subject: FlightRecorderLogsProcessor!
|
var subject: FlightRecorderLogsProcessor!
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import BitwardenKit
|
|
||||||
|
|
||||||
// MARK: - FlightRecorderLogsState
|
// MARK: - FlightRecorderLogsState
|
||||||
|
|
||||||
/// An object that defines the current state of the `FlightRecorderLogsView`.
|
/// An object that defines the current state of the `FlightRecorderLogsView`.
|
||||||
@ -1,11 +1,10 @@
|
|||||||
// swiftlint:disable:this file_name
|
// swiftlint:disable:this file_name
|
||||||
import BitwardenKit
|
|
||||||
import BitwardenKitMocks
|
import BitwardenKitMocks
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import SnapshotTesting
|
import SnapshotTesting
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class FlightRecorderLogsViewTests: BitwardenTestCase {
|
class FlightRecorderLogsViewTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@ -1,10 +1,10 @@
|
|||||||
// swiftlint:disable:this file_name
|
// swiftlint:disable:this file_name
|
||||||
import BitwardenKit
|
|
||||||
import BitwardenKitMocks
|
import BitwardenKitMocks
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
|
import TestHelpers
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class FlightRecorderLogsViewTests: BitwardenTestCase {
|
class FlightRecorderLogsViewTests: BitwardenTestCase {
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import BitwardenKit
|
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@ -53,7 +52,7 @@ struct FlightRecorderLogsView: View {
|
|||||||
logsList
|
logsList
|
||||||
} else {
|
} else {
|
||||||
IllustratedMessageView(
|
IllustratedMessageView(
|
||||||
image: Asset.Images.Illustrations.secureDevices.swiftUIImage,
|
image: SharedAsset.Illustrations.secureDevices.swiftUIImage,
|
||||||
style: .mediumImage,
|
style: .mediumImage,
|
||||||
message: Localizations.noLogsRecorded,
|
message: Localizations.noLogsRecorded,
|
||||||
)
|
)
|
||||||
|
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)
|
.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 {
|
var isApp: Bool {
|
||||||
absoluteString.starts(with: Constants.iOSAppProtocol)
|
absoluteString.starts(with: Constants.iOSAppProtocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Methods
|
|
||||||
|
|
||||||
/// Creates a new `URL` appending the provided query items to the url.
|
|
||||||
///
|
|
||||||
/// On iOS 16+, this method uses the method with the same name in Foundation. On iOS 15, this method
|
|
||||||
/// uses `URLComponents` to add the query items to the new url.
|
|
||||||
///
|
|
||||||
/// - Parameter queryItems: A list of `URLQueryItem`s to add to this url.
|
|
||||||
/// - Returns: A new `URL` with the query items appended.
|
|
||||||
///
|
|
||||||
func appending(queryItems: [URLQueryItem]) -> URL? {
|
|
||||||
if #available(iOS 16, *) {
|
|
||||||
// Set this variable to a non-optional `URL` type so that we are calling the function in Foundation,
|
|
||||||
// rather than recursively calling this method.
|
|
||||||
let url: URL = appending(queryItems: queryItems)
|
|
||||||
return url
|
|
||||||
} else {
|
|
||||||
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
components.queryItems = queryItems
|
|
||||||
return components.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets whether the file should be excluded from backups.
|
|
||||||
///
|
|
||||||
/// - Parameter value: `true` if the file should be excluded from backups, or `false` otherwise.
|
|
||||||
///
|
|
||||||
func setIsExcludedFromBackup(_ value: Bool) throws {
|
|
||||||
var url = self
|
|
||||||
var values = URLResourceValues()
|
|
||||||
values.isExcludedFromBackup = value
|
|
||||||
try url.setResourceValues(values)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
|||||||
let fido2UserInterfaceHelper: Fido2UserInterfaceHelper
|
let fido2UserInterfaceHelper: Fido2UserInterfaceHelper
|
||||||
|
|
||||||
/// The service used by the application for recording temporary debug logs.
|
/// The service used by the application for recording temporary debug logs.
|
||||||
let flightRecorder: FlightRecorder
|
public let flightRecorder: FlightRecorder
|
||||||
|
|
||||||
/// The repository used by the application to manage generator data for the UI layer.
|
/// The repository used by the application to manage generator data for the UI layer.
|
||||||
let generatorRepository: GeneratorRepository
|
let generatorRepository: GeneratorRepository
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import AuthenticatorBridgeKit
|
|||||||
import BitwardenKit
|
import BitwardenKit
|
||||||
import BitwardenSdk
|
import BitwardenSdk
|
||||||
|
|
||||||
// swiftlint:disable file_length
|
|
||||||
|
|
||||||
/// The services provided by the `ServiceContainer`.
|
/// The services provided by the `ServiceContainer`.
|
||||||
typealias Services = HasAPIService
|
typealias Services = HasAPIService
|
||||||
& HasAccountAPIService
|
& HasAccountAPIService
|
||||||
@ -209,13 +207,6 @@ protocol HasFileAPIService {
|
|||||||
var fileAPIService: FileAPIService { get }
|
var fileAPIService: FileAPIService { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Protocol for an object that provides a `FlightRecorder`.
|
|
||||||
///
|
|
||||||
protocol HasFlightRecorder {
|
|
||||||
/// The service used by the application for recording temporary debug logs.
|
|
||||||
var flightRecorder: FlightRecorder { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol for an object that provides a `GeneratorRepository`.
|
/// Protocol for an object that provides a `GeneratorRepository`.
|
||||||
///
|
///
|
||||||
protocol HasGeneratorRepository {
|
protocol HasGeneratorRepository {
|
||||||
|
|||||||
@ -1437,7 +1437,7 @@ enum StateServiceError: LocalizedError {
|
|||||||
|
|
||||||
/// A default implementation of `StateService`.
|
/// A default implementation of `StateService`.
|
||||||
///
|
///
|
||||||
actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigStateService { // swiftlint:disable:this type_body_length line_length
|
actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigStateService, FlightRecorderStateService { // swiftlint:disable:this type_body_length line_length
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The language option currently selected for the app.
|
/// The language option currently selected for the app.
|
||||||
|
|||||||
@ -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.
|
/// The URL for the web vault if the user account doesn't have one specified.
|
||||||
static let defaultWebVaultHost = "bitwarden.com"
|
static let defaultWebVaultHost = "bitwarden.com"
|
||||||
|
|
||||||
/// The number of days that a flight recorder log will remain on the device after the end date
|
|
||||||
/// before being automatically deleted.
|
|
||||||
static let flightRecorderLogExpirationDays = 30
|
|
||||||
|
|
||||||
/// The length of a masked password.
|
/// The length of a masked password.
|
||||||
static let hiddenPasswordLength = 8
|
static let hiddenPasswordLength = 8
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
).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.
|
/// Confirm denying all the login requests.
|
||||||
///
|
///
|
||||||
/// - Parameter action: The action to perform if the user selects yes.
|
/// - Parameter action: The action to perform if the user selects yes.
|
||||||
|
|||||||
@ -31,42 +31,6 @@ class AlertSettingsTests: BitwardenTestCase {
|
|||||||
XCTAssertNil(subject.message)
|
XCTAssertNil(subject.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `confirmDeleteLog(action:)` constructs an `Alert` with the title,
|
|
||||||
/// message, yes, and cancel buttons to confirm deleting a log.
|
|
||||||
func test_confirmDeleteLog() async throws {
|
|
||||||
var actionCalled = false
|
|
||||||
let subject = Alert.confirmDeleteLog(isBulkDeletion: false) { actionCalled = true }
|
|
||||||
|
|
||||||
XCTAssertEqual(subject.alertActions.count, 2)
|
|
||||||
XCTAssertEqual(subject.preferredStyle, .alert)
|
|
||||||
XCTAssertEqual(subject.title, Localizations.doYouReallyWantToDeleteThisLog)
|
|
||||||
XCTAssertNil(subject.message)
|
|
||||||
|
|
||||||
try await subject.tapAction(title: Localizations.cancel)
|
|
||||||
XCTAssertFalse(actionCalled)
|
|
||||||
|
|
||||||
try await subject.tapAction(title: Localizations.yes)
|
|
||||||
XCTAssertTrue(actionCalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `confirmDeleteLog(action:)` constructs an `Alert` with the title,
|
|
||||||
/// message, yes, and cancel buttons to confirm deleting all logs.
|
|
||||||
func test_confirmDeleteLog_bulkDeletion() async throws {
|
|
||||||
var actionCalled = false
|
|
||||||
let subject = Alert.confirmDeleteLog(isBulkDeletion: true) { actionCalled = true }
|
|
||||||
|
|
||||||
XCTAssertEqual(subject.alertActions.count, 2)
|
|
||||||
XCTAssertEqual(subject.preferredStyle, .alert)
|
|
||||||
XCTAssertEqual(subject.title, Localizations.doYouReallyWantToDeleteAllRecordedLogs)
|
|
||||||
XCTAssertNil(subject.message)
|
|
||||||
|
|
||||||
try await subject.tapAction(title: Localizations.cancel)
|
|
||||||
XCTAssertFalse(actionCalled)
|
|
||||||
|
|
||||||
try await subject.tapAction(title: Localizations.yes)
|
|
||||||
XCTAssertTrue(actionCalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `confirmDenyingAllRequests(action:)` constructs an `Alert` with the title,
|
/// `confirmDenyingAllRequests(action:)` constructs an `Alert` with the title,
|
||||||
/// message, yes, and cancel buttons to confirm denying all login requests
|
/// message, yes, and cancel buttons to confirm denying all login requests
|
||||||
func test_confirmDenyingAllRequests() {
|
func test_confirmDenyingAllRequests() {
|
||||||
|
|||||||
@ -57,7 +57,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
|
|||||||
await streamFlightRecorderLog()
|
await streamFlightRecorderLog()
|
||||||
case let .toggleFlightRecorder(isOn):
|
case let .toggleFlightRecorder(isOn):
|
||||||
if isOn {
|
if isOn {
|
||||||
coordinator.navigate(to: .enableFlightRecorder)
|
coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
|
||||||
} else {
|
} else {
|
||||||
await services.flightRecorder.disableFlightRecorder()
|
await services.flightRecorder.disableFlightRecorder()
|
||||||
}
|
}
|
||||||
@ -92,7 +92,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
|
|||||||
case .versionTapped:
|
case .versionTapped:
|
||||||
handleVersionTapped()
|
handleVersionTapped()
|
||||||
case .viewFlightRecorderLogsTapped:
|
case .viewFlightRecorderLogsTapped:
|
||||||
coordinator.navigate(to: .flightRecorderLogs)
|
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
|
||||||
case .webVaultTapped:
|
case .webVaultTapped:
|
||||||
coordinator.showAlert(.webVaultAlert {
|
coordinator.showAlert(.webVaultAlert {
|
||||||
self.state.url = self.services.environmentService.webVaultURL
|
self.state.url = self.services.environmentService.webVaultURL
|
||||||
|
|||||||
@ -122,7 +122,7 @@ class AboutProcessorTests: BitwardenTestCase {
|
|||||||
|
|
||||||
await subject.perform(.toggleFlightRecorder(true))
|
await subject.perform(.toggleFlightRecorder(true))
|
||||||
|
|
||||||
XCTAssertEqual(coordinator.routes, [.enableFlightRecorder])
|
XCTAssertEqual(coordinator.routes, [.flightRecorder(.enableFlightRecorder)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
|
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
|
||||||
@ -232,7 +232,7 @@ class AboutProcessorTests: BitwardenTestCase {
|
|||||||
func test_receive_viewFlightRecorderLogsTapped() {
|
func test_receive_viewFlightRecorderLogsTapped() {
|
||||||
subject.receive(.viewFlightRecorderLogsTapped)
|
subject.receive(.viewFlightRecorderLogsTapped)
|
||||||
|
|
||||||
XCTAssertEqual(coordinator.routes, [.flightRecorderLogs])
|
XCTAssertEqual(coordinator.routes, [.flightRecorder(.flightRecorderLogs)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `receive(_:)` with `.webVaultTapped` shows an alert for navigating to the web vault
|
/// `receive(_:)` with `.webVaultTapped` shows an alert for navigating to the web vault
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import BitwardenKit
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@ -55,6 +55,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
|
|||||||
typealias Module = AddEditFolderModule
|
typealias Module = AddEditFolderModule
|
||||||
& AuthModule
|
& AuthModule
|
||||||
& ExportCXFModule
|
& ExportCXFModule
|
||||||
|
& FlightRecorderModule
|
||||||
& ImportLoginsModule
|
& ImportLoginsModule
|
||||||
& LoginRequestModule
|
& LoginRequestModule
|
||||||
& NavigatorBuilderModule
|
& NavigatorBuilderModule
|
||||||
@ -152,8 +153,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
|
|||||||
showAccountSecurity()
|
showAccountSecurity()
|
||||||
case let .addEditFolder(folder):
|
case let .addEditFolder(folder):
|
||||||
showAddEditFolder(folder, delegate: context as? AddEditFolderDelegate)
|
showAddEditFolder(folder, delegate: context as? AddEditFolderDelegate)
|
||||||
case .enableFlightRecorder:
|
|
||||||
showEnableFlightRecorder()
|
|
||||||
case .appearance:
|
case .appearance:
|
||||||
showAppearance()
|
showAppearance()
|
||||||
case .appExtension:
|
case .appExtension:
|
||||||
@ -174,8 +173,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
|
|||||||
showExportVaultToApp()
|
showExportVaultToApp()
|
||||||
case .exportVaultToFile:
|
case .exportVaultToFile:
|
||||||
showExportVaultToFile()
|
showExportVaultToFile()
|
||||||
case .flightRecorderLogs:
|
case let .flightRecorder(route):
|
||||||
showFlightRecorderLogs()
|
showFlightRecorder(route: route)
|
||||||
case .folders:
|
case .folders:
|
||||||
showFolders()
|
showFolders()
|
||||||
case .importLogins:
|
case .importLogins:
|
||||||
@ -194,8 +193,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
|
|||||||
showSettings(presentationMode: presentationMode)
|
showSettings(presentationMode: presentationMode)
|
||||||
case let .shareURL(url):
|
case let .shareURL(url):
|
||||||
showShareSheet([url])
|
showShareSheet([url])
|
||||||
case let .shareURLs(urls):
|
|
||||||
showShareSheet(urls)
|
|
||||||
case .vault:
|
case .vault:
|
||||||
showVault()
|
showVault()
|
||||||
case .vaultUnlockSetup:
|
case .vaultUnlockSetup:
|
||||||
@ -343,17 +340,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
|
|||||||
stackNavigator?.present(DeleteAccountView(store: Store(processor: processor)))
|
stackNavigator?.present(DeleteAccountView(store: Store(processor: processor)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the enable flight recorder screen.
|
|
||||||
///
|
|
||||||
private func showEnableFlightRecorder() {
|
|
||||||
let processor = EnableFlightRecorderProcessor(
|
|
||||||
coordinator: asAnyCoordinator(),
|
|
||||||
services: services,
|
|
||||||
state: EnableFlightRecorderState(),
|
|
||||||
)
|
|
||||||
stackNavigator?.present(EnableFlightRecorderView(store: Store(processor: processor)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows the share sheet to share one or more items.
|
/// Shows the share sheet to share one or more items.
|
||||||
///
|
///
|
||||||
/// - Parameter items: The items to share.
|
/// - Parameter items: The items to share.
|
||||||
@ -400,16 +386,15 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
|
|||||||
stackNavigator?.present(navigationController)
|
stackNavigator?.present(navigationController)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the flight recorder logs screen.
|
/// Shows a flight recorder view.
|
||||||
///
|
///
|
||||||
private func showFlightRecorderLogs() {
|
/// - Parameter route: A `FlightRecorderRoute` to navigate to.
|
||||||
let processor = FlightRecorderLogsProcessor(
|
///
|
||||||
coordinator: asAnyCoordinator(),
|
private func showFlightRecorder(route: FlightRecorderRoute) {
|
||||||
services: services,
|
guard let stackNavigator else { return }
|
||||||
state: FlightRecorderLogsState(),
|
let coordinator = module.makeFlightRecorderCoordinator(stackNavigator: stackNavigator)
|
||||||
)
|
coordinator.start()
|
||||||
let view = FlightRecorderLogsView(store: Store(processor: processor), timeProvider: services.timeProvider)
|
coordinator.navigate(to: route)
|
||||||
stackNavigator?.present(view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the folders screen.
|
/// Shows the folders screen.
|
||||||
|
|||||||
@ -175,17 +175,6 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty
|
|||||||
XCTAssertEqual(action.type, .dismissed)
|
XCTAssertEqual(action.type, .dismissed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `navigate(to:)` with `.enableFlightRecorder` presents the enable flight recorder view.
|
|
||||||
@MainActor
|
|
||||||
func test_navigateTo_enableFlightRecorder() throws {
|
|
||||||
subject.navigate(to: .enableFlightRecorder)
|
|
||||||
|
|
||||||
let action = try XCTUnwrap(stackNavigator.actions.last)
|
|
||||||
XCTAssertEqual(action.type, .presented)
|
|
||||||
XCTAssertTrue(action.view is EnableFlightRecorderView)
|
|
||||||
XCTAssertEqual(action.embedInNavigationController, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `navigate(to:)` with `.exportVault` presents the export vault to file view when
|
/// `navigate(to:)` with `.exportVault` presents the export vault to file view when
|
||||||
/// Credential Exchange flag to export is disabled.
|
/// Credential Exchange flag to export is disabled.
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -262,15 +251,14 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty
|
|||||||
XCTAssertEqual(module.importLoginsCoordinator.routes.last, .importLogins(.settings))
|
XCTAssertEqual(module.importLoginsCoordinator.routes.last, .importLogins(.settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `navigate(to:)` with `.flightRecorderLogs` presents the flight recorder logs view.
|
/// `navigate(to:)` with `.flightRecorder` starts flight recorder coordinator and navigates to
|
||||||
|
/// the enable flight recorder view.
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_navigateTo_flightRecorderLogs() throws {
|
func test_navigateTo_flightRecorder() throws {
|
||||||
subject.navigate(to: .flightRecorderLogs)
|
subject.navigate(to: .flightRecorder(.enableFlightRecorder))
|
||||||
|
|
||||||
let action = try XCTUnwrap(stackNavigator.actions.last)
|
XCTAssertTrue(module.flightRecorderCoordinator.isStarted)
|
||||||
XCTAssertEqual(action.type, .presented)
|
XCTAssertEqual(module.flightRecorderCoordinator.routes.last, .enableFlightRecorder)
|
||||||
XCTAssertTrue(action.view is FlightRecorderLogsView)
|
|
||||||
XCTAssertEqual(action.embedInNavigationController, true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `navigate(to:)` with `.lockVault` navigates the user to the login view.
|
/// `navigate(to:)` with `.lockVault` navigates the user to the login view.
|
||||||
@ -404,16 +392,6 @@ class SettingsCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this ty
|
|||||||
XCTAssertTrue(action.view is UIActivityViewController)
|
XCTAssertTrue(action.view is UIActivityViewController)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `navigate(to:)` with `.shareURL(_:)` presents an activity view controller to share the URLs.
|
|
||||||
@MainActor
|
|
||||||
func test_navigateTo_shareURLs() throws {
|
|
||||||
subject.navigate(to: .shareURLs([.example]))
|
|
||||||
|
|
||||||
let action = try XCTUnwrap(stackNavigator.actions.last)
|
|
||||||
XCTAssertEqual(action.type, .presented)
|
|
||||||
XCTAssertTrue(action.view is UIActivityViewController)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `navigate(to:)` with `.vault` pushes the vault settings view onto the stack navigator.
|
/// `navigate(to:)` with `.vault` pushes the vault settings view onto the stack navigator.
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_navigateTo_vault() throws {
|
func test_navigateTo_vault() throws {
|
||||||
|
|||||||
@ -35,9 +35,6 @@ public enum SettingsRoute: Equatable, Hashable {
|
|||||||
/// A route that dismisses the current view.
|
/// A route that dismisses the current view.
|
||||||
case dismiss
|
case dismiss
|
||||||
|
|
||||||
/// A route to enable and configure flight recorder.
|
|
||||||
case enableFlightRecorder
|
|
||||||
|
|
||||||
/// A route to the export vault settings view or export to file view depending on feature flag.
|
/// A route to the export vault settings view or export to file view depending on feature flag.
|
||||||
case exportVault
|
case exportVault
|
||||||
|
|
||||||
@ -47,8 +44,8 @@ public enum SettingsRoute: Equatable, Hashable {
|
|||||||
/// A route to the export vault to file view.
|
/// A route to the export vault to file view.
|
||||||
case exportVaultToFile
|
case exportVaultToFile
|
||||||
|
|
||||||
/// A route to the flight recorder logs screen.
|
/// A route to a flight recorder view.
|
||||||
case flightRecorderLogs
|
case flightRecorder(FlightRecorderRoute)
|
||||||
|
|
||||||
/// A route to view the folders in the vault.
|
/// A route to view the folders in the vault.
|
||||||
case folders
|
case folders
|
||||||
@ -83,9 +80,6 @@ public enum SettingsRoute: Equatable, Hashable {
|
|||||||
/// A route to the share sheet to share a URL.
|
/// A route to the share sheet to share a URL.
|
||||||
case shareURL(URL)
|
case shareURL(URL)
|
||||||
|
|
||||||
/// A route to the share sheet to share multiple URLs.
|
|
||||||
case shareURLs([URL])
|
|
||||||
|
|
||||||
/// A route to the vault settings view.
|
/// A route to the vault settings view.
|
||||||
case vault
|
case vault
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class MockAppModule:
|
|||||||
ExportCXFModule,
|
ExportCXFModule,
|
||||||
ExtensionSetupModule,
|
ExtensionSetupModule,
|
||||||
FileSelectionModule,
|
FileSelectionModule,
|
||||||
|
FlightRecorderModule,
|
||||||
GeneratorModule,
|
GeneratorModule,
|
||||||
ImportCXFModule,
|
ImportCXFModule,
|
||||||
ImportLoginsModule,
|
ImportLoginsModule,
|
||||||
@ -38,6 +39,7 @@ class MockAppModule:
|
|||||||
var extensionSetupCoordinator = MockCoordinator<ExtensionSetupRoute, Void>()
|
var extensionSetupCoordinator = MockCoordinator<ExtensionSetupRoute, Void>()
|
||||||
var fileSelectionDelegate: FileSelectionDelegate?
|
var fileSelectionDelegate: FileSelectionDelegate?
|
||||||
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, Void>()
|
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, Void>()
|
||||||
|
var flightRecorderCoordinator = MockCoordinator<FlightRecorderRoute, Void>()
|
||||||
var generatorCoordinator = MockCoordinator<GeneratorRoute, Void>()
|
var generatorCoordinator = MockCoordinator<GeneratorRoute, Void>()
|
||||||
var importCXFCoordinator = MockCoordinator<ImportCXFRoute, Void>()
|
var importCXFCoordinator = MockCoordinator<ImportCXFRoute, Void>()
|
||||||
var importLoginsCoordinator = MockCoordinator<ImportLoginsRoute, ImportLoginsEvent>()
|
var importLoginsCoordinator = MockCoordinator<ImportLoginsRoute, ImportLoginsEvent>()
|
||||||
@ -109,6 +111,12 @@ class MockAppModule:
|
|||||||
return fileSelectionCoordinator.asAnyCoordinator()
|
return fileSelectionCoordinator.asAnyCoordinator()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeFlightRecorderCoordinator(
|
||||||
|
stackNavigator _: StackNavigator,
|
||||||
|
) -> AnyCoordinator<FlightRecorderRoute, Void> {
|
||||||
|
flightRecorderCoordinator.asAnyCoordinator()
|
||||||
|
}
|
||||||
|
|
||||||
func makeGeneratorCoordinator(
|
func makeGeneratorCoordinator(
|
||||||
delegate _: GeneratorCoordinatorDelegate?,
|
delegate _: GeneratorCoordinatorDelegate?,
|
||||||
stackNavigator _: StackNavigator,
|
stackNavigator _: StackNavigator,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ xcassets:
|
|||||||
inputs:
|
inputs:
|
||||||
- BitwardenResources/Colors.xcassets
|
- BitwardenResources/Colors.xcassets
|
||||||
- BitwardenResources/Icons.xcassets
|
- BitwardenResources/Icons.xcassets
|
||||||
|
- BitwardenResources/Illustrations.xcassets
|
||||||
outputs:
|
outputs:
|
||||||
- templateName: swift5
|
- templateName: swift5
|
||||||
output: SharedAssets.swift
|
output: SharedAssets.swift
|
||||||
|
|||||||