[PM-26061] Consolidate Debug Menu to BitwardenKit (#2177)

This commit is contained in:
Katherine Bertelsen 2025-12-09 12:09:44 -06:00 committed by GitHub
parent b7863001b1
commit b72a3ed6dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 213 additions and 757 deletions

View File

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

View File

@ -1,75 +0,0 @@
import BitwardenKit
import Foundation
/// A coordinator that manages navigation for the debug menu.
///
final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
// MARK: Types
typealias Services = HasAppSettingsStore
& HasConfigService
& HasErrorAlertServices.ErrorAlertServices
// 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)
}
}
// MARK: - HasErrorAlertServices
extension DebugMenuCoordinator: HasErrorAlertServices {
var errorAlertServices: ErrorAlertServices { services }
}

View File

@ -1,60 +0,0 @@
import BitwardenKitMocks
import SwiftUI
import XCTest
@testable import AuthenticatorShared
class DebugMenuCoordinatorTests: BitwardenTestCase {
// 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

@ -1,20 +0,0 @@
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

@ -1,75 +0,0 @@
import BitwardenKit
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):
await services.configService.toggleDebugFeatureFlag(
name: flag,
newValue: newValue,
)
state.featureFlags = await services.configService.getDebugFeatureFlags(FeatureFlag.allCases)
}
}
// MARK: Private Functions
/// Fetch the current debug feature flags.
private func fetchFlags() async {
state.featureFlags = await services.configService.getDebugFeatureFlags(FeatureFlag.allCases)
}
/// Refreshes the feature flags by resetting their local values and fetching the latest configurations.
private func refreshFlags() async {
state.featureFlags = await services.configService.refreshDebugFeatureFlags(FeatureFlag.allCases)
}
}

View File

@ -1,88 +0,0 @@
import BitwardenKit
import BitwardenKitMocks
import XCTest
@testable import AuthenticatorShared
class DebugMenuProcessorTests: BitwardenTestCase {
// 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: .testFeatureFlag,
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: .testFeatureFlag,
isEnabled: true,
)
await subject.perform(
.toggleFeatureFlag(
flag.feature.rawValue,
false,
),
)
XCTAssertTrue(configService.toggleDebugFeatureFlagCalled)
}
}

View File

@ -1,11 +0,0 @@
import BitwardenKit
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

@ -1,58 +0,0 @@
// swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks
import BitwardenResources
import SnapshotTesting
import XCTest
@testable import AuthenticatorShared
// MARK: - DebugMenuViewTests
class DebugMenuViewTests: BitwardenTestCase {
// 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: .testFeatureFlag,
isEnabled: false,
),
],
),
)
let store = Store(processor: processor)
subject = DebugMenuView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Check the snapshot when feature flags are enabled and disabled.
@MainActor
func disabletest_snapshot_debugMenuWithFeatureFlags() {
processor.state.featureFlags = [
.init(
feature: .testFeatureFlag,
isEnabled: true,
),
]
assertSnapshot(of: subject, as: .defaultPortrait)
}
}

View File

@ -1,75 +0,0 @@
// swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks
import BitwardenResources
import ViewInspector
import ViewInspectorTestHelpers
import XCTest
@testable import AuthenticatorShared
// MARK: - DebugMenuViewTests
class DebugMenuViewTests: BitwardenTestCase {
// 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: .testFeatureFlag,
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.testFeatureFlag.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 disabletest_refreshFeatureFlags_tapped() async throws {
let button = try subject.inspect().find(asyncButtonWithAccessibilityLabel: "RefreshFeatureFlagsButton")
try await button.tap()
XCTAssertEqual(processor.effects.last, .refreshFeatureFlags)
}
}

View File

@ -1,85 +0,0 @@
import BitwardenKit
import BitwardenResources
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: [
],
),
),
),
)
}
#endif

View File

@ -1,55 +0,0 @@
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
}
}

View File

@ -26,6 +26,9 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// The coordinator currently being displayed.
private var childCoordinator: AnyObject?
/// Whether the debug menu is currently being shown.
private(set) var isShowingDebugMenu = false
// MARK: Properties
/// The module to use for creating child coordinators.
@ -84,9 +87,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
func navigate(to route: AppRoute, context _: AnyObject?) {
switch route {
case .debugMenu:
#if DEBUG_MENU
showDebugMenu()
#endif
case let .tab(tabRoute):
showTab(route: tabRoute)
}
@ -160,7 +161,6 @@ class AppCoordinator: Coordinator, HasRootNavigator {
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`
@ -168,22 +168,23 @@ class AppCoordinator: Coordinator, HasRootNavigator {
/// Presents the navigation controller and triggers haptic feedback upon completion.
///
private func showDebugMenu() {
guard !isShowingDebugMenu else { return }
let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
feedbackGenerator.prepare()
let stackNavigator = UINavigationController()
stackNavigator.navigationBar.prefersLargeTitles = true
stackNavigator.modalPresentationStyle = .fullScreen
let debugMenuCoordinator = module.makeDebugMenuCoordinator(stackNavigator: stackNavigator)
let debugMenuCoordinator = module.makeDebugMenuCoordinator(delegate: self, stackNavigator: stackNavigator)
debugMenuCoordinator.start()
childCoordinator = debugMenuCoordinator
rootNavigator?.rootViewController?.topmostViewController().present(
stackNavigator,
animated: true,
completion: { feedbackGenerator.impactOccurred() },
)
isShowingDebugMenu = true
}
#endif
}
// MARK: - AuthCoordinatorDelegate
@ -194,6 +195,14 @@ extension AppCoordinator: AuthCoordinatorDelegate {
}
}
// MARK: - DebugMenuCoordinatorDelegate
extension AppCoordinator: DebugMenuCoordinatorDelegate {
func didDismissDebugMenu() {
isShowingDebugMenu = false
}
}
// MARK: - HasErrorAlertServices
extension AppCoordinator: HasErrorAlertServices {

View File

@ -58,6 +58,22 @@ extension DefaultAppModule: AppModule {
}
}
// MARK: - DefaultAppModule + DebugMenuModule
extension DefaultAppModule: DebugMenuModule {
public func makeDebugMenuCoordinator(
delegate: DebugMenuCoordinatorDelegate,
stackNavigator: StackNavigator,
) -> AnyCoordinator<DebugMenuRoute, Void> {
DebugMenuCoordinator(
delegate: delegate,
services: services,
stackNavigator: stackNavigator,
)
.asAnyCoordinator()
}
}
// MARK: - DefaultAppModule + FlightRecorderModule
extension DefaultAppModule: FlightRecorderModule {

View File

@ -38,6 +38,19 @@ class AppModuleTests: BitwardenTestCase {
XCTAssertNotNil(rootViewController.childViewController)
}
/// `makeDebugMenuCoordinator()` builds the debug menu coordinator.
@MainActor
func test_makeDebugMenuCoordinator() {
let navigationController = UINavigationController()
let coordinator = subject.makeDebugMenuCoordinator(
delegate: MockDebugMenuCoordinatorDelegate(),
stackNavigator: navigationController,
)
coordinator.start()
XCTAssertEqual(navigationController.viewControllers.count, 1)
XCTAssertTrue(navigationController.viewControllers[0] is UIHostingController<DebugMenuView>)
}
/// `makeNavigationController()` builds a navigation controller.
@MainActor
func test_makeNavigationController() {

View File

@ -0,0 +1,22 @@
// MARK: - Array + Extensions
public extension Array {
/// Safely access elements in an array by index without running into an out-of-bounds error.
/// This works like normal array subscript access, but if the index is out of bounds, then
/// returns nil instead of throwing an error. This can be useful in cases, particularly in tests,
/// where we want to access array elements by index number, and not have additional error handling
/// if the index in question does not exist in the array.
///
/// This can be used in a subscript. For example, `array[safeIndex: 2]`.
///
/// - Parameters:
/// - safeIndex: The position of the element to access.
/// - Returns: The element at the specified index if it is within bounds, otherwise `nil`.
subscript(safeIndex index: Int) -> Element? {
guard index >= 0, index < endIndex else {
return nil
}
return self[index]
}
}

View File

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

View File

@ -62,7 +62,7 @@ class ErrorReportBuilderTests: BitwardenTestCase {
callStack: exampleCallStack,
)
// swiftlint:disable line_length
assertInlineSnapshot(of: errorReport.replacingHexAddresses(), as: .lines) {
assertInlineSnapshot(of: errorReport.zeroingUnwantedHexStrings(), as: .lines) {
#"""
Swift.DecodingError.keyNotFound(TestKeys(stringValue: "ciphers", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"ciphers\", intValue: nil).", underlyingError: nil))
The data couldnt be read because it is missing.
@ -78,6 +78,7 @@ class ErrorReportBuilderTests: BitwardenTestCase {
AuthenticatorBridgeKitMocks: 0x0000000000000000
BitwardenKit: 0x0000000000000000
BitwardenKitMocks: 0x0000000000000000
BitwardenSdk_0000000000000000_PackageProduct: 0x0000000000000000
BitwardenResources: 0x0000000000000000
AuthenticatorBridgeKit: 0x0000000000000000
@ -99,7 +100,7 @@ class ErrorReportBuilderTests: BitwardenTestCase {
for: BitwardenTestError.example,
callStack: exampleCallStack,
)
assertInlineSnapshot(of: errorReport.replacingHexAddresses(), as: .lines) {
assertInlineSnapshot(of: errorReport.zeroingUnwantedHexStrings(), as: .lines) {
"""
TestHelpers.BitwardenTestError.example
An example error used to test throwing capabilities.
@ -115,6 +116,7 @@ class ErrorReportBuilderTests: BitwardenTestCase {
AuthenticatorBridgeKitMocks: 0x0000000000000000
BitwardenKit: 0x0000000000000000
BitwardenKitMocks: 0x0000000000000000
BitwardenSdk_0000000000000000_PackageProduct: 0x0000000000000000
BitwardenResources: 0x0000000000000000
AuthenticatorBridgeKit: 0x0000000000000000
@ -134,7 +136,7 @@ class ErrorReportBuilderTests: BitwardenTestCase {
for: BitwardenTestError.example,
callStack: exampleCallStack,
)
assertInlineSnapshot(of: errorReport.replacingHexAddresses(), as: .lines) {
assertInlineSnapshot(of: errorReport.zeroingUnwantedHexStrings(), as: .lines) {
"""
TestHelpers.BitwardenTestError.example
An example error used to test throwing capabilities.
@ -150,6 +152,7 @@ class ErrorReportBuilderTests: BitwardenTestCase {
AuthenticatorBridgeKitMocks: 0x0000000000000000
BitwardenKit: 0x0000000000000000
BitwardenKitMocks: 0x0000000000000000
BitwardenSdk_0000000000000000_PackageProduct: 0x0000000000000000
BitwardenResources: 0x0000000000000000
AuthenticatorBridgeKit: 0x0000000000000000
@ -165,10 +168,18 @@ class ErrorReportBuilderTests: BitwardenTestCase {
private extension String {
/// Replaces any hex addresses within a string with all zeros.
func replacingHexAddresses() -> String {
let pattern = "0x[0-9a-fA-F]{12,16}" // Matches 12 to 16 hex digits after '0x'
let replacement = "0x0000000000000000"
func zeroingUnwantedHexStrings() -> String {
let hexAddressPattern = "0x[0-9a-fA-F]{12,16}" // Matches 12 to 16 hex digits after '0x'
let hexAddressReplacement = "0x0000000000000000"
let sdkAddressPattern = "_[0-9a-fA-F]{12,16}_" // Matches 12 to 16 hex digits between underscores
let sdkAddressReplacement = "_0000000000000000_"
return applyingRegularExpression(pattern: hexAddressPattern, replacement: hexAddressReplacement)
.applyingRegularExpression(pattern: sdkAddressPattern, replacement: sdkAddressReplacement)
}
func applyingRegularExpression(pattern: String, replacement: String) -> String {
do {
let regex = try NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(startIndex ..< endIndex, in: self)

View File

@ -3,8 +3,11 @@ 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.
/// gesture is detected. **Note:** The shake detection only functions when compiled with the
/// `DEBUG_MENU` conditional compilation flag. In release builds, shake gestures are ignored.
///
/// This is particularly useful for triggering debug menus or testing actions in development builds
/// while ensuring the functionality is completely removed from production builds.
///
public class ShakeWindow: UIWindow {
/// The callback to be invoked when a shake gesture is detected.

View File

@ -1,5 +1,3 @@
import Foundation
// MARK: - DebugMenuAction
/// Actions that can be processed by a `DebugMenuProcessor`.
@ -11,4 +9,6 @@ enum DebugMenuAction: Equatable {
case generateCrash
/// The generate error report button was tapped.
case generateErrorReport
/// The generate SDK error report button was tapped.
case generateSdkErrorReport
}

View File

@ -1,9 +1,8 @@
import BitwardenKit
import Foundation
/// An object that is notified when the debug menu is dismissed.
///
protocol DebugMenuCoordinatorDelegate: AnyObject {
public protocol DebugMenuCoordinatorDelegate: AnyObject { // sourcery: AutoMockable
/// The debug menu has been dismissed.
///
func didDismissDebugMenu()
@ -11,11 +10,10 @@ protocol DebugMenuCoordinatorDelegate: AnyObject {
/// A coordinator that manages navigation for the debug menu.
///
final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
public final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
// MARK: Types
typealias Services = HasAppSettingsStore
& HasConfigService
public typealias Services = HasConfigService
& HasErrorAlertServices.ErrorAlertServices
& HasErrorReporter
@ -30,7 +28,7 @@ final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
// MARK: Properties
/// The stack navigator that is managed by this coordinator.
private(set) weak var stackNavigator: StackNavigator?
public private(set) weak var stackNavigator: StackNavigator?
// MARK: Initialization
@ -41,7 +39,7 @@ final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
init(
public init(
delegate: DebugMenuCoordinatorDelegate,
services: Services,
stackNavigator: StackNavigator,
@ -53,7 +51,7 @@ final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
// MARK: Methods
func navigate(
public func navigate(
to route: DebugMenuRoute,
context: AnyObject?,
) {
@ -66,7 +64,7 @@ final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
}
/// Starts the process of displaying the debug menu.
func start() {
public func start() {
showDebugMenu()
}
@ -88,5 +86,5 @@ final class DebugMenuCoordinator: Coordinator, HasStackNavigator {
// MARK: - HasErrorAlertServices
extension DebugMenuCoordinator: HasErrorAlertServices {
var errorAlertServices: ErrorAlertServices { services }
public var errorAlertServices: ErrorAlertServices { services }
}

View File

@ -1,13 +1,11 @@
import BitwardenKit
import BitwardenKitMocks
import SwiftUI
import XCTest
@testable import BitwardenShared
class DebugMenuCoordinatorTests: BitwardenTestCase {
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
var configService: MockConfigService!
var delegate: MockDebugMenuCoordinatorDelegate!
var stackNavigator: MockStackNavigator!
@ -18,7 +16,6 @@ class DebugMenuCoordinatorTests: BitwardenTestCase {
override func setUp() {
super.setUp()
appSettingsStore = MockAppSettingsStore()
configService = MockConfigService()
delegate = MockDebugMenuCoordinatorDelegate()
stackNavigator = MockStackNavigator()
@ -26,7 +23,6 @@ class DebugMenuCoordinatorTests: BitwardenTestCase {
subject = DebugMenuCoordinator(
delegate: delegate,
services: ServiceContainer.withMocks(
appSettingsStore: appSettingsStore,
configService: configService,
),
stackNavigator: stackNavigator,
@ -36,7 +32,6 @@ class DebugMenuCoordinatorTests: BitwardenTestCase {
override func tearDown() {
super.tearDown()
appSettingsStore = nil
configService = nil
delegate = nil
stackNavigator = nil
@ -45,6 +40,12 @@ class DebugMenuCoordinatorTests: BitwardenTestCase {
// MARK: Tests
/// The coordinator has error alert services.
@MainActor
func test_errorAlertServices() {
XCTAssertNotNil(subject.errorAlertServices)
}
/// `navigate(to:)` with `.dismiss` dismisses the view.
@MainActor
func test_navigate_dismiss() throws {
@ -63,11 +64,3 @@ class DebugMenuCoordinatorTests: BitwardenTestCase {
XCTAssertTrue(stackNavigator.actions.last?.view is DebugMenuView)
}
}
class MockDebugMenuCoordinatorDelegate: DebugMenuCoordinatorDelegate {
var didDismissDebugMenuCalled = false
func didDismissDebugMenu() {
didDismissDebugMenuCalled = true
}
}

View File

@ -1,5 +1,3 @@
import Foundation
// MARK: - DebugMenuEffect
/// Effects that can be processed by a `DebugMenuProcessor`.

View File

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

View File

@ -1,4 +1,3 @@
import BitwardenKit
import BitwardenSdk
import Foundation
@ -20,6 +19,18 @@ final class DebugMenuProcessor: StateProcessor<DebugMenuState, DebugMenuAction,
/// The services used by the processor.
private let services: Services
// MARK: Computed Properties
/// The current feature flags. This requires `FeatureFlag` to have been extended in the executable's
/// namespace to conform to `CaseIterable`.
private var currentFeatureFlags: [FeatureFlag] {
guard let featureFlagType = FeatureFlag.self as? any CaseIterable.Type,
let flags = featureFlagType.allCases as? [FeatureFlag] else {
return []
}
return flags
}
// MARK: Initialization
/// Initializes a `DebugMenuProcessor`.
@ -48,10 +59,21 @@ final class DebugMenuProcessor: StateProcessor<DebugMenuState, DebugMenuAction,
case .generateCrash:
preconditionFailure("Generated crash from debug view.")
case .generateErrorReport:
services.errorReporter.log(
error: FlightRecorderError.fileSizeError(
NSError(
domain: "Generated Error",
code: 0,
userInfo: [
"AdditionalMessage": "Generated error report from debug view.",
],
),
),
)
case .generateSdkErrorReport:
services.errorReporter.log(error: BitwardenSdk.BitwardenError.Api(ApiError.ResponseContent(
message: "Generated error report from debug view.",
message: "Generated SDK error report from debug view.",
)))
services.errorReporter.log(error: KeychainServiceError.osStatusError(1))
}
}
@ -66,7 +88,7 @@ final class DebugMenuProcessor: StateProcessor<DebugMenuState, DebugMenuAction,
name: flag,
newValue: newValue,
)
state.featureFlags = await services.configService.getDebugFeatureFlags(FeatureFlag.allCases)
state.featureFlags = await services.configService.getDebugFeatureFlags(currentFeatureFlags)
}
}
@ -74,11 +96,11 @@ final class DebugMenuProcessor: StateProcessor<DebugMenuState, DebugMenuAction,
/// Fetch the current debug feature flags.
private func fetchFlags() async {
state.featureFlags = await services.configService.getDebugFeatureFlags(FeatureFlag.allCases)
state.featureFlags = await services.configService.getDebugFeatureFlags(currentFeatureFlags)
}
/// Refreshes the feature flags by resetting their local values and fetching the latest configurations.
private func refreshFlags() async {
state.featureFlags = await services.configService.refreshDebugFeatureFlags(FeatureFlag.allCases)
state.featureFlags = await services.configService.refreshDebugFeatureFlags(currentFeatureFlags)
}
}

View File

@ -1,9 +1,8 @@
import BitwardenKit
import BitwardenKitMocks
import BitwardenSdk
import XCTest
@testable import BitwardenShared
@testable import BitwardenKit
class DebugMenuProcessorTests: BitwardenTestCase {
// MARK: Properties
@ -66,7 +65,7 @@ class DebugMenuProcessorTests: BitwardenTestCase {
XCTAssertTrue(subject.state.featureFlags.contains(flag))
}
/// `perform(.refreshFeatureFlags)` refreshs the current feature flags.
/// `perform(.refreshFeatureFlags)` refreshes the current feature flags.
@MainActor
func test_perform_refreshFeatureFlags() async {
await subject.perform(.refreshFeatureFlags)
@ -91,19 +90,33 @@ class DebugMenuProcessorTests: BitwardenTestCase {
XCTAssertTrue(configService.toggleDebugFeatureFlagCalled)
}
/// `receive()` with `.generateErrorReport` generates error reports on the error reporter.
/// `receive()` with `.generateErrorReport` sends an error report to the error reporter.
@MainActor
func test_receive_generateErrorReport() {
subject.receive(.generateErrorReport)
XCTAssertEqual(
errorReporter.errors[0] as? BitwardenSdk.BitwardenError,
BitwardenSdk.BitwardenError.Api(ApiError.ResponseContent(
message: "Generated error report from debug view.",
)),
errorReporter.errors[safeIndex: 0] as? FlightRecorderError,
FlightRecorderError.fileSizeError(
NSError(
domain: "Generated Error",
code: 0,
userInfo: [
"AdditionalMessage": "Generated error report from debug view.",
],
),
),
)
}
/// `receive()` with `.generateSdkErrorReport` sends an SDK error report to the error reporter.
@MainActor
func test_receive_generateSdkErrorReport() {
subject.receive(.generateSdkErrorReport)
XCTAssertEqual(
errorReporter.errors[1] as? KeychainServiceError,
KeychainServiceError.osStatusError(1),
errorReporter.errors[safeIndex: 0] as? BitwardenSdk.BitwardenError,
BitwardenSdk.BitwardenError.Api(ApiError.ResponseContent(
message: "Generated SDK error report from debug view.",
)),
)
}
}

View File

@ -1,5 +1,3 @@
import Foundation
// MARK: - DebugMenuRoute
/// A route to specific screens in the` DebugMenuView`

View File

@ -1,6 +1,3 @@
import BitwardenKit
import Foundation
// MARK: - DebugMenuState
/// The state used to present the `DebugMenuView`.

View File

@ -1,11 +1,10 @@
// swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks
import BitwardenResources
import SnapshotTesting
import XCTest
@testable import BitwardenShared
@testable import BitwardenKit
// MARK: - DebugMenuViewTests

View File

@ -1,11 +1,10 @@
// swiftlint:disable:this file_name
import BitwardenKit
import BitwardenKitMocks
import BitwardenResources
import ViewInspectorTestHelpers
import XCTest
@testable import BitwardenShared
@testable import BitwardenKit
// MARK: - DebugMenuViewTests
@ -47,7 +46,7 @@ class DebugMenuViewTests: BitwardenTestCase {
/// Tapping the close button dispatches the `.dismissTapped` action.
@MainActor
func test_closeButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.close)
let button = try subject.inspect().findCloseToolbarButton()
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .dismissTapped)
}
@ -80,6 +79,14 @@ class DebugMenuViewTests: BitwardenTestCase {
XCTAssertEqual(processor.dispatchedActions.last, .generateErrorReport)
}
/// Tapping the generate SDK error report button dispatches the `.generateSdkErrorReport` action.
@MainActor
func test_generateSdkErrorReport_tap() throws {
let button = try subject.inspect().find(button: Localizations.generateSdkErrorReport)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .generateSdkErrorReport)
}
/// Test that the refresh button sends the correct effect.
@MainActor
func disabletest_refreshFeatureFlags_tapped() async throws {

View File

@ -1,4 +1,3 @@
import BitwardenKit
import BitwardenResources
import SwiftUI
@ -6,7 +5,7 @@ import SwiftUI
/// Represents the debug menu for configuring app settings and feature flags.
///
struct DebugMenuView: View {
public struct DebugMenuView: View {
// MARK: Properties
/// The store used to render the view.
@ -14,7 +13,7 @@ struct DebugMenuView: View {
// MARK: View
var body: some View {
public var body: some View {
List {
Section {
featureFlags
@ -29,12 +28,9 @@ struct DebugMenuView: View {
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
closeToolbarButton {
store.send(.dismissTapped)
} label: {
Text(Localizations.close)
}
.accessibilityIdentifier("close-debug")
}
}
.navigationTitle("Debug Menu")
@ -52,6 +48,12 @@ struct DebugMenuView: View {
Text(Localizations.generateErrorReport)
}
.accessibilityIdentifier("GenerateErrorReportButton")
Button {
store.send(.generateSdkErrorReport)
} label: {
Text(Localizations.generateSdkErrorReport)
}
.accessibilityIdentifier("GenerateSdkErrorReportButton")
Button {
store.send(.generateCrash)
} label: {

View File

@ -1052,6 +1052,7 @@
"GotIt" = "Got it";
"GenerateCrash" = "Generate crash";
"GenerateErrorReport" = "Generate error report";
"GenerateSdkErrorReport" = "Generate SDK error report";
"AllowAuthenticatorSyncing" = "Allow authenticator syncing";
"AuthenticatorSync" = "Authenticator sync";
"NoAccountFoundPleaseLogInAgainIfYouContinueToSeeThisError" = "No account found. Please log in again if you continue to see this error.";

View File

@ -1,11 +0,0 @@
extension Array {
/// Gets the element on the index when inside the bounds
/// of the array, otherwise returns `nil`.
subscript(safeIndex index: Int) -> Element? {
guard index >= 0, index < endIndex else {
return nil
}
return self[index]
}
}

View File

@ -64,6 +64,22 @@ extension DefaultAppModule: AppModule {
}
}
// MARK: - DefaultAppModule + DebugMenuModule
extension DefaultAppModule: DebugMenuModule {
public func makeDebugMenuCoordinator(
delegate: DebugMenuCoordinatorDelegate,
stackNavigator: StackNavigator,
) -> AnyCoordinator<DebugMenuRoute, Void> {
DebugMenuCoordinator(
delegate: delegate,
services: services,
stackNavigator: stackNavigator,
)
.asAnyCoordinator()
}
}
// MARK: - DefaultAppModule + FlightRecorderModule
extension DefaultAppModule: FlightRecorderModule {

View File

@ -1,34 +0,0 @@
import BitwardenKit
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:
/// - delegate: The delegate for the debug menu coordinator.
/// - stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to `DebugMenuRoute`s.
///
func makeDebugMenuCoordinator(
delegate: DebugMenuCoordinatorDelegate,
stackNavigator: StackNavigator,
) -> AnyCoordinator<DebugMenuRoute, Void>
}
extension DefaultAppModule: DebugMenuModule {
func makeDebugMenuCoordinator(
delegate: DebugMenuCoordinatorDelegate,
stackNavigator: StackNavigator,
) -> AnyCoordinator<DebugMenuRoute, Void> {
DebugMenuCoordinator(
delegate: delegate,
services: services,
stackNavigator: stackNavigator,
)
.asAnyCoordinator()
}
}

View File

@ -1,9 +0,0 @@
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

@ -23,6 +23,7 @@ class MockAppModule:
var authRouter = MockRouter<AuthEvent, AuthRoute>(routeForEvent: { _ in .vaultUnlock })
var authenticatorItemCoordinator = MockCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>()
var debugMenuCoordinator = MockCoordinator<DebugMenuRoute, Void>()
var debugMenuCoordinatorDelegate: DebugMenuCoordinatorDelegate?
var fileSelectionDelegate: FileSelectionDelegate?
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, FileSelectionEvent>()
var flightRecorderCoordinator = MockCoordinator<FlightRecorderRoute, Void>()
@ -61,9 +62,11 @@ class MockAppModule:
}
func makeDebugMenuCoordinator(
delegate: DebugMenuCoordinatorDelegate,
stackNavigator: StackNavigator,
) -> AnyCoordinator<DebugMenuRoute, Void> {
debugMenuCoordinator.asAnyCoordinator()
debugMenuCoordinatorDelegate = delegate
return debugMenuCoordinator.asAnyCoordinator()
}
func makeFileSelectionCoordinator(

View File

@ -263,6 +263,7 @@ targets:
- target: BitwardenKit/AuthenticatorBridgeKitMocks
- target: BitwardenKit/BitwardenKitMocks
- target: BitwardenKit/TestHelpers
- package: BitwardenSdk
- package: SnapshotTesting
product: InlineSnapshotTesting
randomExecutionOrder: true
@ -287,6 +288,7 @@ targets:
- target: BitwardenKit/AuthenticatorBridgeKitMocks
- target: BitwardenKit/BitwardenKitMocks
- target: BitwardenKit/TestHelpers
- package: BitwardenSdk
- package: SnapshotTesting
- package: SnapshotTesting
product: InlineSnapshotTesting
@ -313,5 +315,6 @@ targets:
- target: BitwardenKit/BitwardenKitMocks
- target: BitwardenKit/TestHelpers
- target: BitwardenKit/ViewInspectorTestHelpers
- package: BitwardenSdk
- package: ViewInspector
randomExecutionOrder: true

View File

@ -141,6 +141,7 @@ targets:
- "**/sourcery.yml"
buildPhase: none
dependencies:
- package: BitwardenSdk
- package: SwiftUIIntrospect
- target: BitwardenResources
- target: Networking
@ -190,6 +191,7 @@ targets:
- target: BitwardenKit
- target: BitwardenKitMocks
- target: TestHelpers
- package: BitwardenSdk
- package: SnapshotTesting
product: InlineSnapshotTesting
randomExecutionOrder: true

View File

@ -184,6 +184,7 @@ targets:
- target: BitwardenKit/BitwardenKit
- target: BitwardenKit/BitwardenResources
- target: BitwardenKit/Networking
- package: BitwardenSdk
- package: Firebase
product: FirebaseCrashlytics
preBuildScripts:
@ -247,6 +248,7 @@ targets:
- "**/*Tests.*"
- "**/TestHelpers/*"
dependencies:
- package: BitwardenSdk
- target: BitwardenShared
BitwardenActionExtensionTests:
type: bundle.unit-test
@ -282,6 +284,7 @@ targets:
- "**/*Tests.*"
- "**/TestHelpers/*"
dependencies:
- package: BitwardenSdk
- target: BitwardenShared
BitwardenAutoFillExtensionTests:
type: bundle.unit-test
@ -317,6 +320,7 @@ targets:
- "**/*Tests.*"
- "**/TestHelpers/*"
dependencies:
- package: BitwardenSdk
- target: BitwardenShared
BitwardenShareExtensionTests:
type: bundle.unit-test
@ -425,6 +429,7 @@ targets:
- target: BitwardenKit/AuthenticatorBridgeKitMocks
- target: BitwardenKit/BitwardenKitMocks
- target: BitwardenKit/TestHelpers
- package: BitwardenSdk
- package: SnapshotTesting
product: InlineSnapshotTesting
randomExecutionOrder: true
@ -455,6 +460,7 @@ targets:
- target: BitwardenShared
- target: BitwardenKit/BitwardenKitMocks
- target: BitwardenKit/TestHelpers
- package: BitwardenSdk
- package: SnapshotTesting
- package: SnapshotTesting
product: InlineSnapshotTesting
@ -487,6 +493,7 @@ targets:
- target: BitwardenKit/BitwardenKitMocks
- target: BitwardenKit/TestHelpers
- target: BitwardenKit/ViewInspectorTestHelpers
- package: BitwardenSdk
- package: ViewInspector
randomExecutionOrder: true