[BWA-85] Add debug screen (#166)

This commit is contained in:
Katherine Bertelsen 2024-10-30 11:17:16 -05:00 committed by GitHub
parent 40f90b85cf
commit 83e8934bfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1636 additions and 75 deletions

View File

@ -94,8 +94,8 @@ custom_rules:
severity: warning
todo_without_jira:
name: "TODO without JIRA"
regex: "(TODO|TO DO|FIX|FIXME|FIX ME|todo)(?!: BIT-[0-9]{1,})" # "TODO: BIT-123"
message: "All TODOs must be followed by a JIRA reference, for example: \"TODO: BIT-123\""
regex: "(TODO|TO DO|FIX|FIXME|FIX ME|todo)(?!: BWA-[0-9]{1,})" # "TODO: BWA-123"
message: "All TODOs must be followed by a JIRA reference, for example: \"TODO: BWA-123\""
match_kinds:
- comment
severity: warning

View File

@ -5,6 +5,11 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// MARK: Properties
/// The processor that manages application level logic.
var appProcessor: AppProcessor? {
(UIApplication.shared.delegate as? AppDelegateType)?.appProcessor
}
/// Whether the app is still starting up. This ensures the splash view isn't dismissed on start
/// up until the processor has shown the initial view.
var isStartingUp = true
@ -24,7 +29,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
guard let appProcessor = (UIApplication.shared.delegate as? AppDelegateType)?.appProcessor else {
guard let appProcessor else {
if (UIApplication.shared.delegate as? AppDelegateType)?.isTesting == true {
// If the app is running tests, show a testing view.
window = buildSplashWindow(windowScene: windowScene)
@ -34,7 +39,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
let rootViewController = RootViewController()
let appWindow = UIWindow(windowScene: windowScene)
let appWindow = ShakeWindow(windowScene: windowScene) { [weak self] in
#if DEBUG_MENU
self?.appProcessor?.showDebugMenu()
#endif
}
appWindow.rootViewController = rootViewController
appWindow.makeKeyAndVisible()
window = appWindow
@ -92,4 +101,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private func showSplash() {
splashWindow?.alpha = 1
}
#if DEBUG_MENU
/// Handle the triple-tap gesture and launch the debug menu.
@objc
private func handleTripleTapGesture() {
appProcessor?.showDebugMenu()
}
#endif
#if DEBUG_MENU
/// Add the triple-tap gesture recognizer to the window.
private func addTripleTapGestureRecognizer(to window: UIWindow) {
let tapGesture = UITapGestureRecognizer(
target: self,
action: #selector(handleTripleTapGesture)
)
tapGesture.numberOfTapsRequired = 3
tapGesture.numberOfTouchesRequired = 1
window.addGestureRecognizer(tapGesture)
}
#endif
}

View File

@ -151,8 +151,8 @@ class DefaultKeychainRepository: KeychainRepository {
)
if let resultDictionary = foundItem as? [String: Any],
let data = resultDictionary[kSecValueData as String] as? Data {
let string = String(decoding: data, as: UTF8.self)
let data = resultDictionary[kSecValueData as String] as? Data,
let string = String(data: data, encoding: .utf8) {
guard !string.isEmpty else {
throw KeychainServiceError.keyNotFound(item)
}

View File

@ -0,0 +1,91 @@
import Foundation
// MARK: - ServerConfig
/// Model that represents the configuration provided by the server at a particular time.
///
struct ServerConfig: Equatable, Codable, Sendable {
// MARK: Properties
/// The environment URLs of the server.
let environment: EnvironmentServerConfig?
/// The particular time of the server configuration.
let date: Date
/// Feature flags to configure the client.
let featureStates: [FeatureFlag: AnyCodable]
/// The git hash of the server.
let gitHash: String
/// Third party server information.
let server: ThirdPartyServerConfig?
/// The version of the server.
let version: String
init(date: Date, responseModel: ConfigResponseModel) {
environment = responseModel.environment.map(EnvironmentServerConfig.init)
self.date = date
let features: [(FeatureFlag, AnyCodable)]
features = responseModel.featureStates.compactMap { key, value in
guard let flag = FeatureFlag(rawValue: key) else { return nil }
return (flag, value)
}
featureStates = Dictionary(uniqueKeysWithValues: features)
gitHash = responseModel.gitHash
server = responseModel.server.map(ThirdPartyServerConfig.init)
version = responseModel.version
}
}
// MARK: - ThirdPartyServerConfig
/// Model for third-party configuration of the server.
///
struct ThirdPartyServerConfig: Equatable, Codable {
/// The name of the third party configuration.
let name: String
/// The URL of the third party configuration.
let url: String
init(responseModel: ThirdPartyConfigResponseModel) {
name = responseModel.name
url = responseModel.url
}
}
// MARK: - EnvironmentServerConfig
/// Model for the environment URLs in a server configuration.
struct EnvironmentServerConfig: Equatable, Codable {
/// The API URL.
let api: String?
/// The Cloud Region (e.g. "US")
let cloudRegion: String?
/// The Identity URL.
let identity: String?
/// The Notifications URL.
let notifications: String?
/// The SSO URL.
let sso: String?
/// The Vault URL.
let vault: String?
init(responseModel: EnvironmentServerConfigResponseModel) {
api = responseModel.api
cloudRegion = responseModel.cloudRegion
identity = responseModel.identity
notifications = responseModel.notifications
sso = responseModel.sso
vault = responseModel.vault
}
}

View File

@ -0,0 +1,26 @@
import Foundation
import XCTest
@testable import AuthenticatorShared
final class ServerConfigTests: AuthenticatorTestCase {
// MARK: Tests
/// `init` properly converts feature flags
func test_init_featureFlags() {
let model = ConfigResponseModel(
environment: nil,
featureStates: [
"vault-onboarding": .bool(true),
"test-remote-feature-flag": .bool(false),
"not-a-real-feature-flag": .int(42),
],
gitHash: "123",
server: nil,
version: "1.2.3"
)
let subject = ServerConfig(date: Date(), responseModel: model)
XCTAssertEqual(subject.featureStates, [.testRemoteFeatureFlag: .bool(false)])
}
}

View File

@ -4,7 +4,7 @@ import Foundation
/// An enum to represent a feature flag sent by the server
///
enum FeatureFlag: String, Codable {
enum FeatureFlag: String, CaseIterable, Codable {
// MARK: Feature Flags
/// A feature flag that determines whether or not the password manager sync capability is enabled.
@ -12,28 +12,72 @@ enum FeatureFlag: String, Codable {
// MARK: Test Flags
/// A test feature flag that has a local boolean default.
case testLocalBoolFlag = "test-local-bool-flag"
/// A test feature flag that isn't remotely configured and has no initial value.
case testLocalFeatureFlag = "test-local-feature-flag"
/// A test feature flag that has a local integer default.
case testLocalIntFlag = "test-local-int-flag"
/// A test feature flag that has an initial boolean value and is not remotely configured.
case testLocalInitialBoolFlag = "test-local-initial-bool-flag"
/// A test feature flag that has a local string default.
case testLocalStringFlag = "test-local-string-flag"
/// A test feature flag that has an initial integer value and is not remotely configured.
case testLocalInitialIntFlag = "test-local-initial-int-flag"
/// A test feature flag to represent a value that doesn't have a local default.
case testRemoteFlag
/// A test feature flag that has an initial string value and is not remotely configured.
case testLocalInitialStringFlag = "test-local-initial-string-flag"
// MARK: Static Properties
/// A test feature flag that can be remotely configured.
case testRemoteFeatureFlag = "test-remote-feature-flag"
/// The values to start the value for each flag at locally.
/// A test feature flag that has an initial boolean value and is not remotely configured.
case testRemoteInitialBoolFlag = "test-remote-initial-bool-flag"
/// A test feature flag that has an initial integer value and is not remotely configured.
case testRemoteInitialIntFlag = "test-remote-initial-int-flag"
/// A test feature flag that has an initial string value and is not remotely configured.
case testRemoteInitialStringFlag = "test-remote-initial-string-flag"
// MARK: Type Properties
/// An array of feature flags available in the debug menu.
static var debugMenuFeatureFlags: [FeatureFlag] {
allCases.filter { !$0.rawValue.hasPrefix("test-") }
}
/// The initial values for feature flags.
/// If `isRemotelyConfigured` is true for the flag, then this will get overridden by the server;
/// but if `isRemotelyConfigured` is false for the flag, then the value here will be used.
/// This is a helpful way to manage local feature flags.
static let initialLocalValues: [FeatureFlag: AnyCodable] = [
static let initialValues: [FeatureFlag: AnyCodable] = [
.enablePasswordManagerSync: .bool(false),
.testLocalBoolFlag: .bool(true),
.testLocalIntFlag: .int(42),
.testLocalStringFlag: .string("Test String"),
.testLocalInitialBoolFlag: .bool(true),
.testLocalInitialIntFlag: .int(42),
.testLocalInitialStringFlag: .string("Test String"),
.testRemoteInitialBoolFlag: .bool(true),
.testRemoteInitialIntFlag: .int(42),
.testRemoteInitialStringFlag: .string("Test String"),
]
// MARK: Instance Properties
/// Whether this feature can be enabled remotely.
var isRemotelyConfigured: Bool {
switch self {
case .enablePasswordManagerSync,
.testLocalFeatureFlag,
.testLocalInitialBoolFlag,
.testLocalInitialIntFlag,
.testLocalInitialStringFlag:
false
case .testRemoteFeatureFlag,
.testRemoteInitialBoolFlag,
.testRemoteInitialIntFlag,
.testRemoteInitialStringFlag:
true
}
}
/// The display name of the feature flag.
var name: String {
rawValue.split(separator: "-").map(\.localizedCapitalized).joined(separator: " ")
}
}

View File

@ -0,0 +1,22 @@
import XCTest
@testable import AuthenticatorShared
final class FeatureFlagTests: AuthenticatorTestCase {
// MARK: Tests
/// `debugMenuFeatureFlags` does not include any test flags
func test_debugMenu_testFlags() {
let actual = FeatureFlag.debugMenuFeatureFlags.map(\.rawValue)
let filtered = actual.filter { $0.hasPrefix("test-") }
XCTAssertEqual(filtered, [])
}
/// `name` formats the raw value of a feature flag
func test_name() {
XCTAssertEqual(FeatureFlag.testLocalFeatureFlag.name, "Test Local Feature Flag")
XCTAssertEqual(FeatureFlag.testLocalInitialBoolFlag.name, "Test Local Initial Bool Flag")
XCTAssertEqual(FeatureFlag.testLocalInitialIntFlag.name, "Test Local Initial Int Flag")
XCTAssertEqual(FeatureFlag.testLocalInitialStringFlag.name, "Test Local Initial String Flag")
}
}

View File

@ -0,0 +1,55 @@
import Foundation
import Networking
// MARK: - ConfigResponseModel
/// API response model for the configuration request.
///
struct ConfigResponseModel: Equatable, JSONResponse {
// MARK: Properties
/// The environment URLs of the server.
let environment: EnvironmentServerConfigResponseModel?
/// Feature flags to configure the client.
let featureStates: [String: AnyCodable]
/// The git hash of the server.
let gitHash: String
/// Third party server information.
let server: ThirdPartyConfigResponseModel?
/// The version of the server.
let version: String
}
/// API response model for third-party configuration in a configuration response.
struct ThirdPartyConfigResponseModel: Equatable, JSONResponse {
/// The name of the third party configuration.
let name: String
/// The URL of the third party configuration.
let url: String
}
/// API response model for the environment URLs in a configuration response.
struct EnvironmentServerConfigResponseModel: Equatable, JSONResponse {
/// The API URL.
let api: String?
/// The Cloud Region (e.g. "US")
let cloudRegion: String?
/// The Identity URL.
let identity: String?
/// The Notifications URL.
let notifications: String?
/// The SSO URL.
let sso: String?
/// The Vault URL.
let vault: String?
}

View File

@ -1,3 +1,4 @@
import Combine
import Foundation
import OSLog
@ -7,6 +8,19 @@ import OSLog
/// This is significantly pared down from the `ConfigService` in the PM app.
///
protocol ConfigService {
/// Retrieves the current configuration. This will return the on-disk configuration if available,
/// or will retrieve it from the server if not. It will also retrieve the configuration from
/// the server if it is outdated or if the `forceRefresh` argument is `true`. Configurations
/// retrieved from the server are saved to disk.
///
/// - Parameters:
/// - forceRefresh: If true, forces refreshing the configuration from the server.
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
/// - Returns: A server configuration if able.
///
@discardableResult
func getConfig(forceRefresh: Bool, isPreAuth: Bool) async -> ServerConfig?
/// Retrieves a boolean feature flag. This will use the on-disk configuration if available,
/// or will retrieve it from the server if not. It will also retrieve the configuration from
/// the server if it is outdated or if the `forceRefresh` argument is `true`.
@ -15,8 +29,9 @@ protocol ConfigService {
/// - flag: The feature flag to retrieve
/// - defaultValue: The default value to use if the flag is not in the server configuration
/// - forceRefresh: If true, forces refreshing the configuration from the server before retrieval
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
/// - Returns: The value for the feature flag
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool) async -> Bool
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool, isPreAuth: Bool) async -> Bool
/// Retrieves an integer feature flag. This will use the on-disk configuration if available,
/// or will retrieve it from the server if not. It will also retrieve the configuration from
@ -26,8 +41,9 @@ protocol ConfigService {
/// - flag: The feature flag to retrieve
/// - defaultValue: The default value to use if the flag is not in the server configuration
/// - forceRefresh: If true, forces refreshing the configuration from the server before retrieval
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
/// - Returns: The value for the feature flag
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool) async -> Int
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool, isPreAuth: Bool) async -> Int
/// Retrieves a string feature flag. This will use the on-disk configuration if available,
/// or will retrieve it from the server if not. It will also retrieve the configuration from
@ -37,21 +53,49 @@ protocol ConfigService {
/// - flag: The feature flag to retrieve
/// - defaultValue: The default value to use if the flag is not in the server configuration
/// - forceRefresh: If true, forces refreshing the configuration from the server before retrieval
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account.
/// - Returns: The value for the feature flag
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String?, forceRefresh: Bool) async -> String?
func getFeatureFlag(
_ flag: FeatureFlag,
defaultValue: String?,
forceRefresh: Bool,
isPreAuth: Bool
) async -> String?
// MARK: Debug Feature Flags
/// Retrieves the debug menu feature flags.
///
func getDebugFeatureFlags() async -> [DebugMenuFeatureFlag]
/// Toggles the value of a debug feature flag in the app's settings store.
///
func toggleDebugFeatureFlag(
name: String,
newValue: Bool?
) async -> [DebugMenuFeatureFlag]
/// Refreshes the list of debug feature flags by reloading their values from the settings store.
///
func refreshDebugFeatureFlags() async -> [DebugMenuFeatureFlag]
}
extension ConfigService {
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool = false) async -> Bool {
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false)
@discardableResult
func getConfig(isPreAuth: Bool = false) async -> ServerConfig? {
await getConfig(forceRefresh: false, isPreAuth: isPreAuth)
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int = 0) async -> Int {
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false)
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool = false, isPreAuth: Bool = false) async -> Bool {
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false, isPreAuth: isPreAuth)
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String? = nil) async -> String? {
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false)
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int = 0, isPreAuth: Bool = false) async -> Int {
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false, isPreAuth: isPreAuth)
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String? = nil, isPreAuth: Bool = false) async -> String? {
await getFeatureFlag(flag, defaultValue: defaultValue, forceRefresh: false, isPreAuth: isPreAuth)
}
}
@ -62,35 +106,141 @@ extension ConfigService {
class DefaultConfigService: ConfigService {
// MARK: Properties
/// The App Settings Store used for storing and retrieving values from User Defaults.
private let appSettingsStore: AppSettingsStore
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
/// The service used by the application to manage account state.
private let stateService: StateService
/// The service used to get the present time.
private let timeProvider: TimeProvider
// MARK: Initialization
/// Initialize a `DefaultConfigService`.
///
/// - Parameters:
/// - appSettingsStore: The App Settings Store used for storing and retrieving values from User Defaults.
/// - configApiService: The API service to make config requests.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - stateService: The service used by the application to manage account state.
/// - timeProvider: The services used to get the present time.
///
init(
errorReporter: ErrorReporter
appSettingsStore: AppSettingsStore,
errorReporter: ErrorReporter,
stateService: StateService,
timeProvider: TimeProvider
) {
self.appSettingsStore = appSettingsStore
self.errorReporter = errorReporter
self.stateService = stateService
self.timeProvider = timeProvider
}
// MARK: Methods
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool = false, forceRefresh: Bool = false) async -> Bool {
FeatureFlag.initialLocalValues[flag]?.boolValue ?? defaultValue
@discardableResult
func getConfig(forceRefresh: Bool, isPreAuth: Bool) async -> ServerConfig? {
nil
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int = 0, forceRefresh: Bool = false) async -> Int {
FeatureFlag.initialLocalValues[flag]?.intValue ?? defaultValue
func getFeatureFlag(
_ flag: FeatureFlag,
defaultValue: Bool = false,
forceRefresh: Bool = false,
isPreAuth: Bool = false
) async -> Bool {
#if DEBUG_MENU
if let userDefaultValue = appSettingsStore.debugFeatureFlag(name: flag.rawValue) {
return userDefaultValue
}
#endif
return FeatureFlag.initialValues[flag]?.boolValue
?? defaultValue
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String? = nil, forceRefresh: Bool = false) async -> String? {
FeatureFlag.initialLocalValues[flag]?.stringValue ?? defaultValue
func getFeatureFlag(
_ flag: FeatureFlag,
defaultValue: Int = 0,
forceRefresh: Bool = false,
isPreAuth: Bool = false
) async -> Int {
FeatureFlag.initialValues[flag]?.intValue
?? defaultValue
}
func getFeatureFlag(
_ flag: FeatureFlag,
defaultValue: String? = nil,
forceRefresh: Bool = false,
isPreAuth: Bool = false
) async -> String? {
FeatureFlag.initialValues[flag]?.stringValue
?? defaultValue
}
func getDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
let remoteFeatureFlags = await getConfig()?.featureStates ?? [:]
let flags = FeatureFlag.debugMenuFeatureFlags.map { feature in
let userDefaultValue = appSettingsStore.debugFeatureFlag(name: feature.rawValue)
let remoteFlagValue = remoteFeatureFlags[feature]?.boolValue ?? false
return DebugMenuFeatureFlag(
feature: feature,
isEnabled: userDefaultValue ?? remoteFlagValue
)
}
return flags
}
func toggleDebugFeatureFlag(name: String, newValue: Bool?) async -> [DebugMenuFeatureFlag] {
appSettingsStore.overrideDebugFeatureFlag(
name: name,
value: newValue
)
return await getDebugFeatureFlags()
}
func refreshDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
for feature in FeatureFlag.debugMenuFeatureFlags {
appSettingsStore.overrideDebugFeatureFlag(
name: feature.rawValue,
value: nil
)
}
return await getDebugFeatureFlags()
}
// MARK: Private
/// Gets the server config in state depending on if the call is being done before authentication.
/// - Parameters:
/// - config: Config to set
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account
func getStateServerConfig(isPreAuth: Bool) async throws -> ServerConfig? {
guard !isPreAuth else {
return await stateService.getPreAuthServerConfig()
}
return try? await stateService.getServerConfig()
}
/// Sets the server config in state depending on if the call is being done before authentication.
/// - Parameters:
/// - config: Config to set
/// - isPreAuth: If true, the call is coming before the user is authenticated or when adding a new account
/// - userId: The userId to set the server config to.
func setStateServerConfig(_ config: ServerConfig, isPreAuth: Bool, userId: String? = nil) async throws {
guard !isPreAuth else {
await stateService.setPreAuthServerConfig(config: config)
return
}
try? await stateService.setServerConfig(config, userId: userId)
}
}

View File

@ -5,63 +5,175 @@ import XCTest
final class ConfigServiceTests: AuthenticatorTestCase {
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
var errorReporter: MockErrorReporter!
var now: Date!
var stateService: MockStateService!
var subject: DefaultConfigService!
var timeProvider: MockTimeProvider!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appSettingsStore = MockAppSettingsStore()
errorReporter = MockErrorReporter()
now = Date(year: 2024, month: 2, day: 14, hour: 8, minute: 0, second: 0)
stateService = MockStateService()
timeProvider = MockTimeProvider(.mockTime(now))
subject = DefaultConfigService(
errorReporter: errorReporter
appSettingsStore: appSettingsStore,
errorReporter: errorReporter,
stateService: stateService,
timeProvider: timeProvider
)
}
override func tearDown() {
super.tearDown()
appSettingsStore = nil
errorReporter = nil
stateService = nil
subject = nil
timeProvider = nil
}
// MARK: Tests
// MARK: Tests - getConfig remote interactions
// TODO: BWA-92 to backfill these tests, or obviate it by pulling the ConfigService into a shared library.
// MARK: Tests - getConfig initial values
/// `getFeatureFlag(:)` returns the initial value for local-only booleans if it is configured.
func test_getFeatureFlag_initialValue_localBool() async {
let value = await subject.getFeatureFlag(
.testLocalInitialBoolFlag,
defaultValue: false,
forceRefresh: false
)
XCTAssertTrue(value)
}
/// `getFeatureFlag(:)` returns the initial value for local-only integers if it is configured.
func test_getFeatureFlag_initialValue_localInt() async {
let value = await subject.getFeatureFlag(
.testLocalInitialIntFlag,
defaultValue: 10,
forceRefresh: false
)
XCTAssertEqual(value, 42)
}
/// `getFeatureFlag(:)` returns the initial value for local-only strings if it is configured.
func test_getFeatureFlag_initialValue_localString() async {
let value = await subject.getFeatureFlag(
.testLocalInitialStringFlag,
defaultValue: "Default",
forceRefresh: false
)
XCTAssertEqual(value, "Test String")
}
/// `getFeatureFlag(:)` returns the initial value for remote-configured booleans if it is configured.
func test_getFeatureFlag_initialValue_remoteBool() async {
let value = await subject.getFeatureFlag(
.testRemoteInitialBoolFlag,
defaultValue: false,
forceRefresh: false
)
XCTAssertTrue(value)
}
/// `getFeatureFlag(:)` returns the initial value for remote-configured integers if it is configured.
func test_getFeatureFlag_initialValue_remoteInt() async {
let value = await subject.getFeatureFlag(
.testRemoteInitialIntFlag,
defaultValue: 10,
forceRefresh: false
)
XCTAssertEqual(value, 42)
}
/// `getFeatureFlag(:)` returns the initial value for remote-configured integers if it is configured.
func test_getFeatureFlag_initialValue_remoteString() async {
let value = await subject.getFeatureFlag(
.testRemoteInitialStringFlag,
defaultValue: "Default",
forceRefresh: false
)
XCTAssertEqual(value, "Test String")
}
// MARK: Tests - getFeatureFlag
/// `getFeatureFlag(:)` returns the default value for booleans
func test_getFeatureFlag_bool_fallback() async {
let value = await subject.getFeatureFlag(.testRemoteFlag, defaultValue: false, forceRefresh: false)
XCTAssertFalse(value)
}
/// `getFeatureFlag(:)` returns the default local value for booleans if it is configured.
func test_getFeatureFlag_bool_locallyConfigured() async {
let value = await subject.getFeatureFlag(.testLocalBoolFlag, defaultValue: false, forceRefresh: false)
stateService.serverConfig["1"] = ServerConfig(
date: Date(year: 2024, month: 2, day: 14, hour: 7, minute: 50, second: 0),
responseModel: ConfigResponseModel(
environment: nil,
featureStates: [:],
gitHash: "75238191",
server: nil,
version: "2024.4.0"
)
)
let value = await subject.getFeatureFlag(.testRemoteFeatureFlag, defaultValue: true, forceRefresh: false)
XCTAssertTrue(value)
}
/// `getFeatureFlag(:)` returns the default value for integers
func test_getFeatureFlag_int_fallback() async {
let value = await subject.getFeatureFlag(.testRemoteFlag, defaultValue: 10, forceRefresh: false)
XCTAssertEqual(value, 10)
}
/// `getFeatureFlag(:)` returns the default local value for integers if it is configured.
func test_getFeatureFlag_int_locallyConfigured() async {
let value = await subject.getFeatureFlag(.testLocalIntFlag, defaultValue: 10, forceRefresh: false)
XCTAssertEqual(value, 42)
stateService.serverConfig["1"] = ServerConfig(
date: Date(year: 2024, month: 2, day: 14, hour: 7, minute: 50, second: 0),
responseModel: ConfigResponseModel(
environment: nil,
featureStates: [:],
gitHash: "75238191",
server: nil,
version: "2024.4.0"
)
)
let value = await subject.getFeatureFlag(.testRemoteFeatureFlag, defaultValue: 30, forceRefresh: false)
XCTAssertEqual(value, 30)
}
/// `getFeatureFlag(:)` returns the default value for strings
func test_getFeatureFlag_string_fallback() async {
let value = await subject.getFeatureFlag(.testRemoteFlag, defaultValue: "Default", forceRefresh: false)
XCTAssertEqual(value, "Default")
stateService.serverConfig["1"] = ServerConfig(
date: Date(year: 2024, month: 2, day: 14, hour: 7, minute: 50, second: 0),
responseModel: ConfigResponseModel(
environment: nil,
featureStates: [:],
gitHash: "75238191",
server: nil,
version: "2024.4.0"
)
)
let value = await subject.getFeatureFlag(.testRemoteFeatureFlag, defaultValue: "fallback", forceRefresh: false)
XCTAssertEqual(value, "fallback")
}
/// `getFeatureFlag(:)` returns the default local value for integers if it is configured.
func test_getFeatureFlag_string_locallyConfigured() async {
let value = await subject.getFeatureFlag(.testLocalStringFlag, defaultValue: "Default", forceRefresh: false)
XCTAssertEqual(value, "Test String")
// MARK: Tests - Other
/// `toggleDebugFeatureFlag` will correctly change the value of the flag given.
func test_toggleDebugFeatureFlag() async throws {
let flags = await subject.toggleDebugFeatureFlag(
name: FeatureFlag.enablePasswordManagerSync.rawValue,
newValue: true
)
XCTAssertTrue(appSettingsStore.overrideDebugFeatureFlagCalled)
let flag = try XCTUnwrap(flags.first { $0.feature == .enablePasswordManagerSync })
XCTAssertTrue(flag.isEnabled)
}
/// `refreshDebugFeatureFlags` will reset the flags to the original state before overriding.
func test_refreshDebugFeatureFlags() async throws {
let flags = await subject.refreshDebugFeatureFlags()
XCTAssertTrue(appSettingsStore.overrideDebugFeatureFlagCalled)
let flag = try XCTUnwrap(flags.first { $0.feature == .enablePasswordManagerSync })
XCTAssertFalse(flag.isEnabled)
}
}

View File

@ -169,7 +169,10 @@ public class ServiceContainer: Services {
)
let configService = DefaultConfigService(
errorReporter: errorReporter
appSettingsStore: appSettingsStore,
errorReporter: errorReporter,
stateService: stateService,
timeProvider: timeProvider
)
let cryptographyKeyService = CryptographyKeyService(

View File

@ -47,12 +47,23 @@ protocol StateService: AnyObject {
///
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue
/// Gets the server config used by the app prior to the user authenticating.
/// - Returns: The server config used prior to user authentication.
func getPreAuthServerConfig() async -> ServerConfig?
/// Gets the user's encryption secret key.
///
/// - Returns: The user's encryption secret key.
///
func getSecretKey(userId: String?) async throws -> String?
/// Gets the server config for a user ID, as set by the server.
///
/// - Parameter userId: The user ID associated with the server config. Defaults to the active account if `nil`.
/// - Returns: The user's server config.
///
func getServerConfig(userId: String?) async throws -> ServerConfig?
/// Get whether to show website icons.
///
/// - Returns: Whether to show the website icons.
@ -87,6 +98,10 @@ protocol StateService: AnyObject {
///
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws
/// Sets the server config used prior to user authentication
/// - Parameter config: The server config to use prior to user authentication.
func setPreAuthServerConfig(config: ServerConfig) async
/// Sets the user's encryption secreet key.
///
/// - Parameters:
@ -94,6 +109,14 @@ protocol StateService: AnyObject {
///
func setSecretKey(_ key: String, userId: String?) async throws
/// Sets the server configuration as provided by a server for a user ID.
///
/// - Parameters:
/// - configModel: The config values to set as provided by the server.
/// - userId: The user ID associated with the server config.
///
func setServerConfig(_ config: ServerConfig?, userId: String?) async throws
/// Set whether to show the website icons.
///
/// - Parameter showWebIcons: Whether to show the website icons.
@ -199,11 +222,20 @@ actor DefaultStateService: StateService {
return appSettingsStore.clearClipboardValue(userId: userId)
}
func getPreAuthServerConfig() async -> ServerConfig? {
appSettingsStore.preAuthServerConfig
}
func getSecretKey(userId: String?) async throws -> String? {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.secretKey(userId: userId)
}
func getServerConfig(userId: String?) async throws -> ServerConfig? {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.serverConfig(userId: userId)
}
func getShowWebIcons() async -> Bool {
!appSettingsStore.disableWebIcons
}
@ -218,11 +250,20 @@ actor DefaultStateService: StateService {
appSettingsStore.setClearClipboardValue(clearClipboardValue, userId: userId)
}
func setPreAuthServerConfig(config: ServerConfig) async {
appSettingsStore.preAuthServerConfig = config
}
func setSecretKey(_ key: String, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setSecretKey(key, userId: userId)
}
func setServerConfig(_ config: ServerConfig?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setServerConfig(config, userId: userId)
}
func setShowWebIcons(_ showWebIcons: Bool) async {
appSettingsStore.disableWebIcons = !showWebIcons
showWebIconsSubject.send(showWebIcons)
@ -249,6 +290,16 @@ actor DefaultStateService: StateService {
}
}
extension StateService {
/// Gets the server config for the active account.
///
/// - Returns: The server config sent by the server for the active account.
///
func getServerConfig() async throws -> ServerConfig? {
try await getServerConfig(userId: nil)
}
}
// MARK: Biometrics
extension DefaultStateService {

View File

@ -4,6 +4,8 @@ import OSLog
// MARK: - AppSettingsStore
// swiftlint:disable file_length
/// A protocol for an object that persists app setting values.
///
protocol AppSettingsStore: AnyObject {
@ -28,6 +30,9 @@ protocol AppSettingsStore: AnyObject {
/// The app's last data migration version.
var migrationVersion: Int { get set }
/// The server config used prior to user authentication.
var preAuthServerConfig: ServerConfig? { get set }
/// The system biometric integrity state `Data`, base64 encoded.
///
/// - Parameter userId: The user ID associated with the Biometric Integrity State.
@ -52,6 +57,18 @@ protocol AppSettingsStore: AnyObject {
///
func clearClipboardValue(userId: String) -> ClearClipboardValue
/// Retrieves a feature flag value from the app's settings store.
///
/// This method fetches the value for a specified feature flag from the app's settings store.
/// The value is returned as a `Bool`. If the flag does not exist or cannot be decoded,
/// the method returns `nil`.
///
/// - Parameter name: The name of the feature flag to retrieve, represented as a `String`.
/// - Returns: The value of the feature flag as a `Bool`, or `nil` if the flag does not exist
/// or cannot be decoded.
///
func debugFeatureFlag(name: String) -> Bool?
/// Flag to identify if the user has previously synced with the named account. `true` if they have previously
/// synced with the named account, `false` if they have not synced previously.
///
@ -73,6 +90,19 @@ protocol AppSettingsStore: AnyObject {
///
func isBiometricAuthenticationEnabled(userId: String) -> Bool
/// Sets a feature flag value in the app's settings store.
///
/// This method updates or removes the value for a specified feature flag in the app's settings store.
/// If the `value` parameter is `nil`, the feature flag is removed from the store. Otherwise, the flag
/// is set to the provided boolean value.
///
/// - Parameters:
/// - name: The name of the feature flag to set or remove, represented as a `String`.
/// - value: The boolean value to assign to the feature flag. If `nil`, the feature flag will be removed
/// from the settings store.
///
func overrideDebugFeatureFlag(name: String, value: Bool?)
/// Gets the user's secret encryption key.
///
/// - Parameters:
@ -80,6 +110,12 @@ protocol AppSettingsStore: AnyObject {
///
func secretKey(userId: String) -> String?
/// The server configuration.
///
/// - Parameter userId: The user ID associated with the server config.
/// - Returns: The server config for that user ID.
func serverConfig(userId: String) -> ServerConfig?
/// Sets the user's Biometric Authentication Preference.
///
/// - Parameters:
@ -128,6 +164,14 @@ protocol AppSettingsStore: AnyObject {
/// - userId: The user ID
///
func setSecretKey(_ key: String, userId: String)
/// Sets the server config.
///
/// - Parameters:
/// - config: The server config for the user
/// - userId: The user ID.
///
func setServerConfig(_ config: ServerConfig?, userId: String)
}
// MARK: - DefaultAppSettingsStore
@ -251,11 +295,14 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case biometricIntegrityState(userId: String, bundleId: String)
case cardClosedState(card: ItemListCard)
case clearClipboardValue(userId: String)
case debugFeatureFlag(name: String)
case disableWebIcons
case hasSeenWelcomeTutorial
case hasSyncedAccount(name: String)
case migrationVersion
case preAuthServerConfig
case secretKey(userId: String)
case serverConfig(userId: String)
/// Returns the key used to store the data under for retrieving it later.
var storageKey: String {
@ -275,6 +322,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "cardClosedState_\(card)"
case let .clearClipboardValue(userId):
key = "clearClipboard_\(userId)"
case let .debugFeatureFlag(name):
key = "debugFeatureFlag_\(name)"
case .disableWebIcons:
key = "disableFavicon"
case .hasSeenWelcomeTutorial:
@ -283,8 +332,12 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "hasSyncedAccount_\(name)"
case .migrationVersion:
key = "migrationVersion"
case .preAuthServerConfig:
key = "preAuthServerConfig"
case let .secretKey(userId):
key = "secretKey_\(userId)"
case let .serverConfig(userId):
key = "serverConfig_\(userId)"
}
return "bwaPreferencesStorage:\(key)"
}
@ -320,6 +373,11 @@ extension DefaultAppSettingsStore: AppSettingsStore {
set { store(newValue, for: .migrationVersion) }
}
var preAuthServerConfig: ServerConfig? {
get { fetch(for: .preAuthServerConfig) }
set { store(newValue, for: .preAuthServerConfig) }
}
func biometricIntegrityState(userId: String) -> String? {
fetch(
for: .biometricIntegrityState(
@ -341,6 +399,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
return .never
}
func debugFeatureFlag(name: String) -> Bool? {
fetch(for: .debugFeatureFlag(name: name))
}
func hasSyncedAccount(name: String) -> Bool {
fetch(for: .hasSyncedAccount(name: name.hexSHA256Hash))
}
@ -349,10 +411,18 @@ extension DefaultAppSettingsStore: AppSettingsStore {
fetch(for: .biometricAuthEnabled(userId: userId))
}
func overrideDebugFeatureFlag(name: String, value: Bool?) {
store(value, for: .debugFeatureFlag(name: name))
}
func secretKey(userId: String) -> String? {
fetch(for: .secretKey(userId: userId))
}
func serverConfig(userId: String) -> ServerConfig? {
fetch(for: .serverConfig(userId: userId))
}
func setBiometricAuthenticationEnabled(_ isEnabled: Bool?, for userId: String) {
store(isEnabled, for: .biometricAuthEnabled(userId: userId))
}
@ -382,6 +452,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
func setSecretKey(_ key: String, userId: String) {
store(key, for: .secretKey(userId: userId))
}
func setServerConfig(_ config: ServerConfig?, userId: String) {
store(config, for: .serverConfig(userId: userId))
}
}
/// An enumeration of possible item list cards.

View File

@ -14,6 +14,8 @@ class MockAppSettingsStore: AppSettingsStore {
var lastUserShouldConnectToWatch = false
var localUserId: String = "localtest"
var migrationVersion = 0
var overrideDebugFeatureFlagCalled = false
var preAuthServerConfig: ServerConfig?
var rememberedEmail: String?
var rememberedOrgIdentifier: String?
@ -26,6 +28,7 @@ class MockAppSettingsStore: AppSettingsStore {
var disableAutoTotpCopyByUserId = [String: Bool]()
var encryptedPrivateKeys = [String: String]()
var encryptedUserKeys = [String: String]()
var featureFlags = [String: Bool]()
var hasSyncedAccountValues = [String: Bool]()
var lastActiveTime = [String: Date]()
var lastSyncTimeByUserId = [String: Date]()
@ -34,6 +37,7 @@ class MockAppSettingsStore: AppSettingsStore {
var pinKeyEncryptedUserKey = [String: String]()
var pinProtectedUserKey = [String: String]()
var secretKeys = [String: String]()
var serverConfig = [String: ServerConfig]()
var timeoutAction = [String: Int]()
var twoFactorTokens = [String: String]()
var vaultTimeout = [String: Int?]()
@ -52,8 +56,13 @@ class MockAppSettingsStore: AppSettingsStore {
clearClipboardValues[userId] ?? .never
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
clearClipboardValues[userId] = clearClipboardValue
func debugFeatureFlag(name: String) -> Bool? {
featureFlags[name]
}
func overrideDebugFeatureFlag(name: String, value: Bool?) {
overrideDebugFeatureFlagCalled = true
featureFlags[name] = value
}
func hasSyncedAccount(name: String) -> Bool {
@ -68,9 +77,21 @@ class MockAppSettingsStore: AppSettingsStore {
secretKeys[userId]
}
func serverConfig(userId: String) -> ServerConfig? {
serverConfig[userId]
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
clearClipboardValues[userId] = clearClipboardValue
}
func setSecretKey(_ key: String, userId: String) {
secretKeys[userId] = key
}
func setServerConfig(_ config: ServerConfig?, userId: String) {
serverConfig[userId] = config
}
}
// MARK: Biometrics

View File

@ -1,25 +1,61 @@
import Combine
import Foundation
@testable import AuthenticatorShared
@MainActor
class MockConfigService: ConfigService {
// MARK: Properties
var configMocker = InvocationMockerWithThrowingResult<(forceRefresh: Bool, isPreAuth: Bool), ServerConfig?>()
var debugFeatureFlags = [DebugMenuFeatureFlag]()
var featureFlagsBool = [FeatureFlag: Bool]()
var featureFlagsInt = [FeatureFlag: Int]()
var featureFlagsString = [FeatureFlag: String]()
var getDebugFeatureFlagsCalled = false
var refreshDebugFeatureFlagsCalled = false
var toggleDebugFeatureFlagCalled = false
nonisolated init() {}
// MARK: Methods
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool) async -> Bool {
func getConfig(forceRefresh: Bool, isPreAuth: Bool) async -> ServerConfig? {
try? configMocker.invoke(param: (forceRefresh: forceRefresh, isPreAuth: isPreAuth))
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Bool, forceRefresh: Bool, isPreAuth: Bool) async -> Bool {
featureFlagsBool[flag] ?? defaultValue
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool) async -> Int {
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: Int, forceRefresh: Bool, isPreAuth: Bool) async -> Int {
featureFlagsInt[flag] ?? defaultValue
}
func getFeatureFlag(_ flag: FeatureFlag, defaultValue: String?, forceRefresh: Bool) async -> String? {
func getFeatureFlag(
_ flag: FeatureFlag,
defaultValue: String?,
forceRefresh: Bool,
isPreAuth: Bool
) async -> String? {
featureFlagsString[flag] ?? defaultValue
}
func getDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
getDebugFeatureFlagsCalled = true
return debugFeatureFlags
}
func refreshDebugFeatureFlags() async -> [DebugMenuFeatureFlag] {
refreshDebugFeatureFlagsCalled = true
return debugFeatureFlags
}
func toggleDebugFeatureFlag(
name: String,
newValue: Bool?
) async -> [DebugMenuFeatureFlag] {
toggleDebugFeatureFlagCalled = true
return debugFeatureFlags
}
}

View File

@ -15,7 +15,9 @@ class MockStateService: StateService {
var getBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
var getBiometricIntegrityStateError: Error?
var getSecretKeyResult: Result<String, Error> = .success("qwerty")
var preAuthServerConfig: ServerConfig?
var secretKeyValues = [String: String]()
var serverConfig = [String: ServerConfig]()
var setBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
var setBiometricIntegrityStateError: Error?
var setSecretKeyResult: Result<Void, Error> = .success(())
@ -39,6 +41,15 @@ class MockStateService: StateService {
return clearClipboardValues[userId] ?? .never
}
func getPreAuthServerConfig() async -> ServerConfig? {
preAuthServerConfig
}
func getServerConfig(userId: String?) async throws -> ServerConfig? {
let userId = try unwrapUserId(userId)
return serverConfig[userId]
}
func getShowWebIcons() async -> Bool {
showWebIcons
}
@ -65,11 +76,20 @@ class MockStateService: StateService {
try getSecretKeyResult.get()
}
func setPreAuthServerConfig(config: ServerConfig) async {
preAuthServerConfig = config
}
func setSecretKey(_ key: String, userId: String?) async throws {
try setSecretKeyResult.get()
secretKeyValues[userId ?? "localtest"] = key
}
func setServerConfig(_ config: ServerConfig?, userId: String?) async throws {
let userId = try unwrapUserId(userId)
serverConfig[userId] = config
}
func showWebIconsPublisher() async -> AnyPublisher<Bool, Never> {
showWebIconsSubject.eraseToAnyPublisher()
}

View File

@ -0,0 +1,10 @@
import Foundation
// MARK: - DebugMenuAction
/// Actions that can be processed by a `DebugMenuProcessor`.
///
enum DebugMenuAction: Equatable {
/// The dismiss button was tapped.
case dismissTapped
}

View File

@ -0,0 +1,67 @@
import Foundation
/// A coordinator that manages navigation for the debug menu.
///
final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
// MARK: Types
typealias Services = HasAppSettingsStore
& HasConfigService
// MARK: Private Properties
/// The services used by this coordinator.
private let services: Services
// MARK: Properties
/// The stack navigator that is managed by this coordinator.
private(set) weak var stackNavigator: StackNavigator?
// MARK: Initialization
/// Creates a new `DebugMenuCoordinator`.
///
/// - Parameters:
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
init(
services: Services,
stackNavigator: StackNavigator
) {
self.services = services
self.stackNavigator = stackNavigator
}
// MARK: Methods
func navigate(
to route: DebugMenuRoute,
context: AnyObject?
) {
switch route {
case .dismiss:
stackNavigator?.dismiss()
}
}
/// Starts the process of displaying the debug menu.
func start() {
showDebugMenu()
}
// MARK: Private Methods
/// Configures and displays the debug menu.
private func showDebugMenu() {
let processor = DebugMenuProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: DebugMenuState()
)
let view = DebugMenuView(store: Store(processor: processor))
stackNavigator?.replace(view)
}
}

View File

@ -0,0 +1,59 @@
import SwiftUI
import XCTest
@testable import AuthenticatorShared
class DebugMenuCoordinatorTests: AuthenticatorTestCase {
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
var configService: MockConfigService!
var stackNavigator: MockStackNavigator!
var subject: DebugMenuCoordinator!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appSettingsStore = MockAppSettingsStore()
configService = MockConfigService()
stackNavigator = MockStackNavigator()
subject = DebugMenuCoordinator(
services: ServiceContainer.withMocks(
appSettingsStore: appSettingsStore,
configService: configService
),
stackNavigator: stackNavigator
)
}
override func tearDown() {
super.tearDown()
appSettingsStore = nil
configService = nil
stackNavigator = nil
subject = nil
}
// MARK: Tests
/// `navigate(to:)` with `.dismiss` dismisses the view.
@MainActor
func test_navigate_dismiss() throws {
subject.navigate(to: .dismiss)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .dismissed)
}
/// `start()` correctly shows the `DebugMenuView`.
@MainActor
func test_start() {
subject.start()
XCTAssertTrue(stackNavigator.actions.last?.view is DebugMenuView)
}
}

View File

@ -0,0 +1,20 @@
import Foundation
// MARK: - DebugMenuEffect
/// Effects that can be processed by a `DebugMenuProcessor`.
///
enum DebugMenuEffect: Equatable {
/// Triggers a refresh of feature flags, clearing local settings and re-fetching from the remote source.
case refreshFeatureFlags
/// Toggles a specific feature flag's state.
///
/// - Parameters:
/// - String: The identifier for the feature flag.
/// - Bool: The state to which the feature flag should be set (enabled or disabled).
case toggleFeatureFlag(String, Bool)
/// The view appeared and is ready to load data.
case viewAppeared
}

View File

@ -0,0 +1,19 @@
import Foundation
// MARK: - DebugMenuFeatureFlag
/// A structure representing a feature flag in the debug menu, including its enabled state.
/// This is used to display and manage feature flags within the debug menu interface.
///
struct DebugMenuFeatureFlag: Equatable, Identifiable {
/// A unique identifier for the feature flag, based on its raw value.
var id: String {
feature.rawValue
}
/// The feature flag enum that this instance represents.
let feature: FeatureFlag
/// A boolean value indicating whether the feature is enabled or not.
let isEnabled: Bool
}

View File

@ -0,0 +1,29 @@
import Foundation
// MARK: - DebugMenuModule
/// An object that builds coordinator for the debug menu.
@MainActor
protocol DebugMenuModule {
/// Initializes a coordinator for navigating between `DebugMenuRoute`s.
///
/// - Parameters:
/// - stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to `DebugMenuRoute`s.
///
func makeDebugMenuCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<DebugMenuRoute, Void>
}
extension DefaultAppModule: DebugMenuModule {
func makeDebugMenuCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<DebugMenuRoute, Void> {
DebugMenuCoordinator(
services: services,
stackNavigator: stackNavigator
)
.asAnyCoordinator()
}
}

View File

@ -0,0 +1,73 @@
import Foundation
// MARK: - DebugMenuProcessor
/// The processor used to manage state and handle actions for the `DebugMenuView`.
///
final class DebugMenuProcessor: StateProcessor<DebugMenuState, DebugMenuAction, DebugMenuEffect> {
// MARK: Types
typealias Services = HasConfigService
// MARK: Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<DebugMenuRoute, Void>
/// The services used by the processor.
private let services: Services
// MARK: Initialization
/// Initializes a `DebugMenuProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator used for navigation.
/// - services: The services used by the processor.
/// - state: The state of the debug menu.
///
init(
coordinator: AnyCoordinator<DebugMenuRoute, Void>,
services: Services,
state: DebugMenuState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func receive(_ action: DebugMenuAction) {
switch action {
case .dismissTapped:
coordinator.navigate(to: .dismiss)
}
}
override func perform(_ effect: DebugMenuEffect) async {
switch effect {
case .viewAppeared:
await fetchFlags()
case .refreshFeatureFlags:
await refreshFlags()
case let .toggleFeatureFlag(flag, newValue):
state.featureFlags = await services.configService.toggleDebugFeatureFlag(
name: flag,
newValue: newValue
)
}
}
// MARK: Private Functions
/// Fetch the current debug feature flags.
private func fetchFlags() async {
state.featureFlags = await services.configService.getDebugFeatureFlags()
}
/// Refreshes the feature flags by resetting their local values and fetching the latest configurations.
private func refreshFlags() async {
state.featureFlags = await services.configService.refreshDebugFeatureFlags()
}
}

View File

@ -0,0 +1,86 @@
import XCTest
@testable import AuthenticatorShared
class DebugMenuProcessorTests: AuthenticatorTestCase {
// MARK: Properties
var configService: MockConfigService!
var coordinator: MockCoordinator<DebugMenuRoute, Void>!
var subject: DebugMenuProcessor!
// MARK: Set Up & Tear Down
override func setUp() {
super.setUp()
configService = MockConfigService()
coordinator = MockCoordinator<DebugMenuRoute, Void>()
subject = DebugMenuProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
configService: configService
),
state: DebugMenuState(featureFlags: [])
)
}
override func tearDown() {
super.tearDown()
configService = nil
coordinator = nil
subject = nil
}
// MARK: Tests
/// `receive()` with `.dismissTapped` navigates to the `.dismiss` route.
@MainActor
func test_receive_dismissTapped() {
subject.receive(.dismissTapped)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}
/// `perform(.viewAppeared)` loads the correct feature flags.
@MainActor
func test_perform_appeared_loadsFeatureFlags() async {
XCTAssertTrue(subject.state.featureFlags.isEmpty)
let flag = DebugMenuFeatureFlag(
feature: .testLocalFeatureFlag,
isEnabled: false
)
configService.debugFeatureFlags = [flag]
await subject.perform(.viewAppeared)
XCTAssertTrue(subject.state.featureFlags.contains(flag))
}
/// `perform(.refreshFeatureFlags)` refreshs the current feature flags.
@MainActor
func test_perform_refreshFeatureFlags() async {
await subject.perform(.refreshFeatureFlags)
XCTAssertTrue(configService.refreshDebugFeatureFlagsCalled)
}
/// `perform(.toggleFeatureFlag)` changes the state of the feature flag.
@MainActor
func test_perform_toggleFeatureFlag() async {
let flag = DebugMenuFeatureFlag(
feature: .testLocalFeatureFlag,
isEnabled: true
)
await subject.perform(
.toggleFeatureFlag(
flag.feature.rawValue,
false
)
)
XCTAssertTrue(configService.toggleDebugFeatureFlagCalled)
}
}

View File

@ -0,0 +1,9 @@
import Foundation
// MARK: - DebugMenuRoute
/// A route to specific screens in the` DebugMenuView`
public enum DebugMenuRoute: Equatable, Hashable {
/// A route to dismiss the screen currently presented modally.
case dismiss
}

View File

@ -0,0 +1,10 @@
import Foundation
// MARK: - DebugMenuState
/// The state used to present the `DebugMenuView`.
///
struct DebugMenuState: Equatable, Sendable {
/// The current feature flags supported.
var featureFlags: [DebugMenuFeatureFlag] = []
}

View File

@ -0,0 +1,87 @@
import SwiftUI
// MARK: - DebugMenuView
/// Represents the debug menu for configuring app settings and feature flags.
///
struct DebugMenuView: View {
// MARK: Properties
/// The store used to render the view.
@ObservedObject var store: Store<DebugMenuState, DebugMenuAction, DebugMenuEffect>
// MARK: View
var body: some View {
List {
Section {
featureFlags
} header: {
featureFlagSectionHeader
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
store.send(.dismissTapped)
} label: {
Text(Localizations.close)
}
.accessibilityIdentifier("close-debug")
}
}
.navigationTitle("Debug Menu")
.task {
await store.perform(.viewAppeared)
}
}
/// The feature flags currently used in the app.
private var featureFlags: some View {
ForEach(store.state.featureFlags) { flag in
Toggle(
isOn: store.bindingAsync(
get: { _ in flag.isEnabled },
perform: { DebugMenuEffect.toggleFeatureFlag(flag.feature.rawValue, $0) }
)
) {
Text(flag.feature.name)
}
.toggleStyle(.bitwarden)
.accessibilityIdentifier(flag.feature.rawValue)
}
}
/// The header for the feature flags section.
private var featureFlagSectionHeader: some View {
HStack {
Text("Feature Flags")
Spacer()
AsyncButton {
await store.perform(.refreshFeatureFlags)
} label: {
Image(systemName: "arrow.clockwise")
}
.accessibilityLabel("RefreshFeatureFlagsButton")
}
}
}
#if DEBUG
#Preview {
DebugMenuView(
store: Store(
processor: StateProcessor(
state: .init(
featureFlags: [
.init(
feature: .enablePasswordManagerSync,
isEnabled: true
),
]
)
)
)
)
}
#endif

View File

@ -0,0 +1,82 @@
import SnapshotTesting
import XCTest
@testable import AuthenticatorShared
// MARK: - DebugMenuViewTests
class DebugMenuViewTests: AuthenticatorTestCase {
// MARK: Properties
var processor: MockProcessor<DebugMenuState, DebugMenuAction, DebugMenuEffect>!
var subject: DebugMenuView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(
state: DebugMenuState(
featureFlags: [
.init(
feature: .testLocalFeatureFlag,
isEnabled: false
),
]
)
)
let store = Store(processor: processor)
subject = DebugMenuView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Tapping the close button dispatches the `.dismissTapped` action.
@MainActor
func test_closeButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.close)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .dismissTapped)
}
/// Tests that the toggle fires off the correct effect.
@MainActor
func test_featureFlag_toggled() async throws {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
throw XCTSkip("Unable to run test in iOS 16, keep an eye on ViewInspector to see if it gets updated.")
}
let featureFlagName = FeatureFlag.testLocalFeatureFlag.rawValue
let toggle = try subject.inspect().find(viewWithAccessibilityIdentifier: featureFlagName).toggle()
try toggle.tap()
XCTAssertEqual(processor.effects.last, .toggleFeatureFlag(featureFlagName, true))
}
/// Test that the refresh button sends the correct effect.
@MainActor
func test_refreshFeatureFlags_tapped() async throws {
let button = try subject.inspect().find(asyncButtonWithAccessibilityLabel: "RefreshFeatureFlagsButton")
try await button.tap()
XCTAssertEqual(processor.effects.last, .refreshFeatureFlags)
}
/// Check the snapshot when feature flags are enabled and disabled.
@MainActor
func test_snapshot_debugMenuWithFeatureFlags() {
processor.state.featureFlags = [
.init(
feature: .enablePasswordManagerSync,
isEnabled: true
),
]
assertSnapshot(of: subject, as: .defaultPortrait)
}
}

View File

@ -0,0 +1,55 @@
import UIKit
/// A UIWindow subclass that detects and responds to shake gestures.
///
/// This window class allows you to provide a custom handler that will be called whenever a shake
/// gesture is detected. This can be particularly useful for triggering debug or testing actions only
/// in DEBUG_MENU mode, such as showing development menus or refreshing data.
///
public class ShakeWindow: UIWindow {
/// The callback to be invoked when a shake gesture is detected.
public var onShakeDetected: (() -> Void)?
/// Initializes a new ShakeWindow with a specific window scene and an optional shake detection handler.
///
/// - Parameters:
/// - windowScene: The UIWindowScene instance with which the window is associated.
/// - onShakeDetected: An optional closure that gets called when a shake gesture is detected.
///
public init(
windowScene: UIWindowScene,
onShakeDetected: (() -> Void)?
) {
self.onShakeDetected = onShakeDetected
super.init(windowScene: windowScene)
}
/// Required initializer for UIWindow subclass. Not implemented as ShakeWindow requires
/// a custom initialization method with shake detection handler.
///
/// - Parameter coder: An NSCoder instance for decoding the window.
///
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Overrides the default motionEnded function to detect shake motions.
/// If a shake motion is detected and we are in DEBUG_MENU mode,
/// the onShakeDetected closure is called.
///
/// - Parameters:
/// - motion: An event-subtype constant indicating the kind of motion.
/// - event: An object representing the event associated with the motion.
///
override public func motionEnded(
_ motion: UIEvent.EventSubtype,
with event: UIEvent?
) {
#if DEBUG_MENU
if motion == .motionShake {
onShakeDetected?()
}
#endif
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -11,6 +11,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// The types of modules used by this coordinator.
typealias Module = AuthModule
& DebugMenuModule
& ItemListModule
& TabModule
& TutorialModule
@ -76,6 +77,8 @@ class AppCoordinator: Coordinator, HasRootNavigator {
func navigate(to route: AppRoute, context _: AnyObject?) {
switch route {
case .debugMenu:
showDebugMenu()
case let .tab(tabRoute):
showTab(route: tabRoute)
}
@ -147,6 +150,31 @@ class AppCoordinator: Coordinator, HasRootNavigator {
navigationController.modalPresentationStyle = .overFullScreen
rootNavigator?.rootViewController?.present(navigationController, animated: false)
}
#if DEBUG_MENU
/// Configures and presents the debug menu.
///
/// Initializes feedback generator for haptic feedback. Sets up a `UINavigationController`
/// and creates / starts a `DebugMenuCoordinator` to manage the debug menu flow.
/// Presents the navigation controller and triggers haptic feedback upon completion.
///
private func showDebugMenu() {
let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
feedbackGenerator.prepare()
let stackNavigator = UINavigationController()
stackNavigator.navigationBar.prefersLargeTitles = true
stackNavigator.modalPresentationStyle = .fullScreen
let debugMenuCoordinator = module.makeDebugMenuCoordinator(stackNavigator: stackNavigator)
debugMenuCoordinator.start()
childCoordinator = debugMenuCoordinator
rootNavigator?.rootViewController?.topmostViewController().present(
stackNavigator,
animated: true,
completion: { feedbackGenerator.impactOccurred() }
)
}
#endif
}
// MARK: - AuthCoordinatorDelegate

View File

@ -73,4 +73,11 @@ public class AppProcessor {
await coordinator.handleEvent(.didStart)
}
}
#if DEBUG_MENU
/// Show the debug menu.
public func showDebugMenu() {
coordinator?.navigate(to: .debugMenu)
}
#endif
}

View File

@ -1,6 +1,9 @@
/// A top level route from the initial screen of the app to anywhere in the app.
///
public enum AppRoute: Equatable {
/// A route to the debug menu.
case debugMenu
/// A route to the tab interface.
case tab(TabRoute)
}

View File

@ -112,7 +112,7 @@ class FileSelectionCoordinator: NSObject, Coordinator, HasStackNavigator {
viewController.delegate = self
stackNavigator?.present(viewController)
} else {
// TODO: BIT-1466 Present an alert about camera permissions being needed.
// TODO: BWA-92 Present an alert about camera permissions being needed.
}
}
}

View File

@ -1,8 +1,9 @@
#include "./Common.xcconfig"
#include "./Base-Debug.xcconfig"
#include? "./Local.xcconfig"
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
CODE_SIGN_ENTITLEMENTS = Authenticator/Application/Support/Entitlements/Authenticator.entitlements
FIREBASE_CONFIG_FILENAME = GoogleService-Info.plist
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID)
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS $(BITWARDEN_FLAGS)

View File

@ -2,10 +2,11 @@
CODE_SIGN_IDENTITY = Apple Development
#include "./Common.xcconfig"
#include "./Base-Release.xcconfig"
#include? "./Local.xcconfig"
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
CODE_SIGN_ENTITLEMENTS = Authenticator/Application/Support/Entitlements/Authenticator.entitlements
FIREBASE_CONFIG_FILENAME = GoogleService-Info.plist
PRODUCT_BUNDLE_IDENTIFIER = com.bitwarden.authenticator
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PREVIEWS $(BITWARDEN_FLAGS)

View File

@ -0,0 +1,6 @@
#include "./Common.xcconfig"
#include "./Base-Debug.xcconfig"
#include? "./Local.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).authenticator-shared
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(BITWARDEN_FLAGS)

View File

@ -0,0 +1,6 @@
#include "./Common.xcconfig"
#include "./Base-Release.xcconfig"
#include? "./Local.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BASE_BUNDLE_ID).authenticator-shared
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) $(BITWARDEN_FLAGS)

View File

@ -1,4 +0,0 @@
#include "./Common.xcconfig"
#include? "./Local.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = com.bitwarden.authenticator.authenticator-shared

View File

@ -0,0 +1 @@
BITWARDEN_FLAGS = DEBUG_MENU

View File

@ -0,0 +1 @@
BITWARDEN_FLAGS =

View File

@ -5,6 +5,7 @@
class MockAppModule:
AppModule,
AuthModule,
DebugMenuModule,
FileSelectionModule,
ItemListModule,
TutorialModule,
@ -12,6 +13,7 @@ class MockAppModule:
var appCoordinator = MockCoordinator<AppRoute, AppEvent>()
var authCoordinator = MockCoordinator<AuthRoute, AuthEvent>()
var authRouter = MockRouter<AuthEvent, AuthRoute>(routeForEvent: { _ in .vaultUnlock })
var debugMenuCoordinator = MockCoordinator<DebugMenuRoute, Void>()
var fileSelectionDelegate: FileSelectionDelegate?
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, FileSelectionEvent>()
var itemListCoordinator = MockCoordinator<ItemListRoute, ItemListEvent>()
@ -37,6 +39,12 @@ class MockAppModule:
authRouter.asAnyRouter()
}
func makeDebugMenuCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<DebugMenuRoute, Void> {
debugMenuCoordinator.asAnyCoordinator()
}
func makeFileSelectionCoordinator(
delegate: FileSelectionDelegate,
stackNavigator _: StackNavigator

View File

@ -0,0 +1,160 @@
import Foundation
import XCTest
/// Errors that the invocation mockers can throw.
public enum InvocationMockerError: LocalizedError {
case paramVerificationFailed
case resultNotSet
public var errorDescription: String? {
switch self {
case .paramVerificationFailed:
return "The verification of the parameter failed."
case .resultNotSet:
return "The result of the InvocationMocker has not been set yet."
}
}
}
/// A mocker of a func invocation that has one parameter.
/// This is useful for tests where we need to verify a correct parameter is passed on invocation.
class InvocationMocker<TParam> {
var invokedParam: TParam?
var called = false
/// Executes the `verification` and if it passes returns the `result`, throwing otherwise.
/// - Parameter param: The parameter of the function to invoke.
/// - Returns: Returns the result setup.
func invoke(param: TParam?) {
called = true
invokedParam = param
}
/// Asserts by verifying the parameter which was passed to the invoked function.
/// - Parameters:
/// - verification: Verification to run.
/// - message: Message if fails.
/// - file: File where this was originated.
/// - line: Line number where this was originated.
func assert(
verification: (TParam?) -> Bool,
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
file: StaticString = #filePath,
line: UInt = #line
) {
XCTAssert(verification(invokedParam), message(), file: file, line: line)
}
/// Asserts by verifying the parameter which was passed to the invoked function.
/// This unwraps the parameter, but if can't be done then fails.
/// - Parameters:
/// - verification: Verification to run.
/// - message: Message if fails.
/// - file: File where this was originated.
/// - line: Line number where this was originated.
func assertUnwrapping(
verification: (TParam) -> Bool,
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
file: StaticString = #filePath,
line: UInt = #line
) {
guard let invokedParam else {
XCTFail("\(TParam.self) verification failed because parameter has not been set.")
return
}
XCTAssert(verification(invokedParam), message(), file: file, line: line)
}
}
/// A mocker of a func invocation that has one parameter, a result and can throw.
/// This is useful for tests where we need to verify a correct parameter is passed
/// to return the correct result.
class InvocationMockerWithThrowingResult<TParam, TResult> {
var called = false
var invokedParam: TParam?
var result: (TParam) throws -> TResult = { _ in throw InvocationMockerError.resultNotSet }
var verification: (TParam) -> Bool = { _ in true }
/// Asserts by verifying the parameter which was passed to the invoked function.
/// - Parameters:
/// - verification: Verification to run.
/// - message: Message if fails.
/// - file: File where this was originated.
/// - line: Line number where this was originated.
func assert(
verification: (TParam?) -> Bool,
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
file: StaticString = #filePath,
line: UInt = #line
) {
XCTAssert(verification(invokedParam), message(), file: file, line: line)
}
/// Asserts by verifying the parameter which was passed to the invoked function.
/// This unwraps the parameter, but if can't be done then fails.
/// - Parameters:
/// - verification: Verification to run.
/// - message: Message if fails.
/// - file: File where this was originated.
/// - line: Line number where this was originated.
func assertUnwrapping(
verification: (TParam) -> Bool,
_ message: @autoclosure () -> String = "\(TParam.self) verification failed",
file: StaticString = #filePath,
line: UInt = #line
) {
guard let invokedParam else {
XCTFail("\(TParam.self) verification failed because parameter has not been set.")
return
}
XCTAssert(verification(invokedParam), message(), file: file, line: line)
}
/// Executes the `verification` and if it passes returns the `result`, throwing otherwise.
/// - Parameter param: The parameter of the function to invoke.
/// - Returns: Returns the result setup.
func invoke(param: TParam) throws -> TResult {
called = true
guard verification(param) else {
XCTFail("\(TParam.self) verification failed.")
throw InvocationMockerError.paramVerificationFailed
}
invokedParam = param
return try result(param)
}
/// Sets up a verification to be executed and needs to pass in order to return the result.
/// - Parameter verification: Verification to run.
/// - Returns: `Self` for fluent coding.
func withVerification(verification: @escaping (TParam) -> Bool) -> Self {
self.verification = verification
return self
}
/// Sets up the result that will be returned if the verification passes.
/// - Parameter result: The result to return.
/// - Returns: `Self` for fluent coding
@discardableResult
func withResult(_ result: TResult) -> Self {
self.result = { _ in result }
return self
}
/// Sets up the result that will be returned if the verification passes.
/// - Parameter resultFunc: The result func to execute.
/// - Returns: `Self` for fluent coding
@discardableResult
func withResult(_ resultFunc: @escaping (TParam) throws -> TResult) -> Self {
result = resultFunc
return self
}
/// Sets up the error to throw if the verification passes.
/// - Parameter error: The error to throw.
/// - Returns: `Self` for fluent coding
@discardableResult
func throwing(_ error: Error) -> Self {
result = { _ in throw error }
return self
}
}

View File

@ -1,4 +1,6 @@
name: Authenticator
fileGroups:
- Configs
configs:
Debug: debug
Release: release
@ -149,8 +151,8 @@ targets:
type: framework
platform: iOS
configFiles:
Debug: Configs/AuthenticatorShared.xcconfig
Release: Configs/AuthenticatorShared.xcconfig
Debug: Configs/AuthenticatorShared-Debug.xcconfig
Release: Configs/AuthenticatorShared-Release.xcconfig
settings:
base:
APPLICATION_EXTENSION_API_ONLY: true