From 4dddce66c6f7f34a57e02a136bdc6722dafb222c Mon Sep 17 00:00:00 2001 From: Nathan Ansel Date: Tue, 5 Sep 2023 10:49:04 -0500 Subject: [PATCH] BIT-174 Adds app-wide navigation layer --- Bitwarden/Application/AppDelegate.swift | 4 + Bitwarden/Application/SceneDelegate.swift | 37 +++++ .../Application/SceneDelegateTests.swift | 61 ++++++++ Bitwarden/Application/Support/Info.plist | 147 ++++++++++-------- .../Support/TestInstanceFactory.swift | 11 ++ .../Support/TestingAppDelegate.swift | 18 +-- Bitwarden/Application/main.swift | 8 + Bitwarden/BitwardenApp.swift | 10 -- Bitwarden/ContentView.swift | 19 --- .../Services/API/APIServiceTests.swift | 2 +- .../Platform/Application/AppCoordinator.swift | 46 ++++++ .../Application/AppCoordinatorTests.swift | 45 ++++++ .../UI/Platform/Application/AppModule.swift | 28 ++++ .../{Utilities/Route.swift => AppRoute.swift} | 4 +- .../Application/Appearance/UITests.swift | 37 +++++ .../Extensions/DispatchTimeInterval.swift | 49 ++++++ .../Utilities/AnyCoordinatorTests.swift | 42 +++++ .../Application/Utilities/Navigator.swift | 7 +- .../Application/Utilities/Processor.swift | 2 +- .../Application/Utilities/RootNavigator.swift | 12 ++ .../Utilities/RootViewController.swift | 31 ++++ .../Utilities/RootViewControllerTests.swift | 57 +++++++ .../Utilities/StackNavigator.swift | 2 +- .../Utilities/StackNavigatorTests.swift | 38 ++++- .../Utilities/StateProcessor.swift | 2 +- .../Application/Utilities/Store.swift | 2 +- .../Application/Utilities/StoreTests.swift | 18 ++- GlobalTestHelpers/MockAppModule.swift | 13 ++ GlobalTestHelpers/MockCoordinator.swift | 6 +- GlobalTestHelpers/MockProcessor.swift | 14 +- 30 files changed, 636 insertions(+), 136 deletions(-) create mode 100644 Bitwarden/Application/AppDelegate.swift create mode 100644 Bitwarden/Application/SceneDelegate.swift create mode 100644 Bitwarden/Application/SceneDelegateTests.swift create mode 100644 Bitwarden/Application/TestHelpers/Support/TestInstanceFactory.swift create mode 100644 Bitwarden/Application/main.swift delete mode 100644 Bitwarden/BitwardenApp.swift delete mode 100644 Bitwarden/ContentView.swift create mode 100644 BitwardenShared/UI/Platform/Application/AppCoordinator.swift create mode 100644 BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift create mode 100644 BitwardenShared/UI/Platform/Application/AppModule.swift rename BitwardenShared/UI/Platform/Application/{Utilities/Route.swift => AppRoute.swift} (60%) create mode 100644 BitwardenShared/UI/Platform/Application/Appearance/UITests.swift create mode 100644 BitwardenShared/UI/Platform/Application/TestHelpers/Extensions/DispatchTimeInterval.swift create mode 100644 BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift create mode 100644 BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift create mode 100644 BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift create mode 100644 GlobalTestHelpers/MockAppModule.swift diff --git a/Bitwarden/Application/AppDelegate.swift b/Bitwarden/Application/AppDelegate.swift new file mode 100644 index 000000000..6c8e8ab0d --- /dev/null +++ b/Bitwarden/Application/AppDelegate.swift @@ -0,0 +1,4 @@ +import BitwardenShared +import UIKit + +class AppDelegate: UIResponder, UIApplicationDelegate {} diff --git a/Bitwarden/Application/SceneDelegate.swift b/Bitwarden/Application/SceneDelegate.swift new file mode 100644 index 000000000..85b5bc4a5 --- /dev/null +++ b/Bitwarden/Application/SceneDelegate.swift @@ -0,0 +1,37 @@ +import BitwardenShared +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + // MARK: Properties + + /// The root module to use to create sub-coordinators. + var appModule: AppModule = DefaultAppModule() + + /// The root coordinator of this scene. + var appCoordinator: AnyCoordinator? + + /// The main window for this scene. + var window: UIWindow? + + // MARK: Methods + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = (scene as? UIWindowScene) else { + return + } + + let appWindow = UIWindow(windowScene: windowScene) + let rootViewController = RootViewController() + let coordinator = appModule.makeAppCoordinator(navigator: rootViewController) + coordinator.start() + + appWindow.rootViewController = rootViewController + appWindow.makeKeyAndVisible() + appCoordinator = coordinator + window = appWindow + } +} diff --git a/Bitwarden/Application/SceneDelegateTests.swift b/Bitwarden/Application/SceneDelegateTests.swift new file mode 100644 index 000000000..96016a557 --- /dev/null +++ b/Bitwarden/Application/SceneDelegateTests.swift @@ -0,0 +1,61 @@ +import BitwardenShared +import XCTest + +@testable import Bitwarden + +// MARK: - SceneDelegateTests + +class SceneDelegateTests: BitwardenTestCase { + // MARK: Properties + + var appCoordinator: MockCoordinator! + var appModule: MockAppModule! + var subject: SceneDelegate! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + appCoordinator = MockCoordinator() + appModule = MockAppModule() + appModule.appCoordinator = appCoordinator.asAnyCoordinator() + subject = SceneDelegate() + subject.appModule = appModule + } + + override func tearDown() { + super.tearDown() + appModule = nil + subject = nil + } + + // MARK: Tests + + /// `scene(_:willConnectTo:options:)` with a `UIWindowScene` creates the app's UI. + func test_sceneWillConnectTo_withWindowScene() throws { + let session = TestInstanceFactory.create(UISceneSession.self) + let scene = TestInstanceFactory.create(UIWindowScene.self, properties: [ + "session": session, + ]) + let options = TestInstanceFactory.create(UIScene.ConnectionOptions.self) + subject.scene(scene, willConnectTo: session, options: options) + + XCTAssertNotNil(subject.appCoordinator) + XCTAssertNotNil(subject.window) + XCTAssertTrue(appCoordinator.isStarted) + } + + /// `scene(_:willConnectTo:options:)` without a `UIWindowScene` fails to create the app's UI. + func test_sceneWillConnectTo_withNonWindowScene() throws { + let session = TestInstanceFactory.create(UISceneSession.self) + let scene = TestInstanceFactory.create(UIScene.self, properties: [ + "session": session, + ]) + let options = TestInstanceFactory.create(UIScene.ConnectionOptions.self) + subject.scene(scene, willConnectTo: session, options: options) + + XCTAssertNil(subject.appCoordinator) + XCTAssertNil(subject.window) + XCTAssertFalse(appCoordinator.isStarted) + } +} diff --git a/Bitwarden/Application/Support/Info.plist b/Bitwarden/Application/Support/Info.plist index 54497016a..23894d1b8 100644 --- a/Bitwarden/Application/Support/Info.plist +++ b/Bitwarden/Application/Support/Info.plist @@ -2,44 +2,18 @@ - MinimumOSVersion - 15.0 + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + en CFBundleDisplayName Bitwarden - CFBundleName - Bitwarden CFBundleExecutable $(EXECUTABLE_NAME) - CFBundleIdentifier - com.8bit.bitwarden - CFBundleShortVersionString - 2023.7.1 - CFBundleVersion - 1 CFBundleIconName AppIcon - CFBundleURLTypes - - - CFBundleURLSchemes - - bitwarden - org-appextension-feature-password-management - - CFBundleTypeRole - Editor - CFBundleURLName - com.8bit.bitwarden.url - - - CFBundleURLName - com.8bit.bitwarden - CFBundleURLSchemes - - otpauth - - - + CFBundleIdentifier + com.8bit.bitwarden CFBundleLocalizations en @@ -78,21 +52,68 @@ el th - CFBundleDevelopmentRegion - en - UISupportedInterfaceOrientations + CFBundleName + Bitwarden + CFBundleShortVersionString + 2023.7.1 + CFBundleURLTypes - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortraitUpsideDown + + CFBundleTypeRole + Editor + CFBundleURLName + com.8bit.bitwarden.url + CFBundleURLSchemes + + bitwarden + org-appextension-feature-password-management + + + + CFBundleURLName + com.8bit.bitwarden + CFBundleURLSchemes + + otpauth + + - UISupportedInterfaceOrientations~ipad + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + ITSEncryptionExportComplianceCode + ecf076d3-4824-4d7b-b716-2a9a47d7d296 + MinimumOSVersion + 15.0 + NFCReaderUsageDescription + Use Yubikeys for two-factor authentication. + NSCameraUsageDescription + Scan QR codes + NSFaceIDUsageDescription + Use Face ID to unlock your vault. + NSPhotoLibraryUsageDescription + This app requires access to the photo library in order to share files. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UIBackgroundModes - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight + remote-notification UILaunchStoryboardName LaunchScreen @@ -100,34 +121,30 @@ LaunchScreen UIMainStoryboardFile~ipad LaunchScreen - UIViewControllerBasedStatusBarAppearance - - UIBackgroundModes - - remote-notification - - UIStatusBarHidden - UIRequiredDeviceCapabilities arm64 - CADisableMinimumFrameDurationOnPhone + UIStatusBarHidden + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + XSAppIconAssets Resources/Assets.xcassets/AppIcons.appiconset - ITSAppUsesNonExemptEncryption - - ITSEncryptionExportComplianceCode - ecf076d3-4824-4d7b-b716-2a9a47d7d296 - NSPhotoLibraryUsageDescription - This app requires access to the photo library in order to share files. - NSCameraUsageDescription - Scan QR codes - NSFaceIDUsageDescription - Use Face ID to unlock your vault. - NFCReaderUsageDescription - Use Yubikeys for two-factor authentication. diff --git a/Bitwarden/Application/TestHelpers/Support/TestInstanceFactory.swift b/Bitwarden/Application/TestHelpers/Support/TestInstanceFactory.swift new file mode 100644 index 000000000..1df5616c8 --- /dev/null +++ b/Bitwarden/Application/TestHelpers/Support/TestInstanceFactory.swift @@ -0,0 +1,11 @@ +import Foundation + +enum TestInstanceFactory { + static func create(_ type: T.Type, properties: [String: Any] = [:]) -> T { + let instance = type.init() + for property in properties { + instance.setValue(property.value, forKey: property.key) + } + return instance + } +} diff --git a/Bitwarden/Application/TestHelpers/Support/TestingAppDelegate.swift b/Bitwarden/Application/TestHelpers/Support/TestingAppDelegate.swift index 9bb84c1b4..ab846ffcc 100644 --- a/Bitwarden/Application/TestHelpers/Support/TestingAppDelegate.swift +++ b/Bitwarden/Application/TestHelpers/Support/TestingAppDelegate.swift @@ -2,17 +2,7 @@ import UIKit @testable import Bitwarden -class TestingAppDelegate: NSObject, UIApplicationDelegate { - var window: UIWindow? - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - let window = UIWindow(frame: UIScreen.main.bounds) - window.rootViewController = UIViewController() - window.makeKeyAndVisible() - self.window = window - return true - } -} +/// A replacement for `AppDelegate` that allows for checking that certain app delegate methods get called at the +/// appropriate times during unit tests. +/// +class TestingAppDelegate: NSObject, UIApplicationDelegate {} diff --git a/Bitwarden/Application/main.swift b/Bitwarden/Application/main.swift new file mode 100644 index 000000000..7bca45d3b --- /dev/null +++ b/Bitwarden/Application/main.swift @@ -0,0 +1,8 @@ +import UIKit + +/// Determine the app delegate class that should be used during this launch of the app. If the `TestingAppDelegate` +/// can be found, the app was launched in a test environment, and we should use the `TestingAppDelegate` for +/// handling all app lifecycle events. +private let appDelegateClass: AnyClass = NSClassFromString("BitwardenTests.TestingAppDelegate") ?? AppDelegate.self + +UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass)) diff --git a/Bitwarden/BitwardenApp.swift b/Bitwarden/BitwardenApp.swift deleted file mode 100644 index 3cbb4fbf4..000000000 --- a/Bitwarden/BitwardenApp.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -@main -struct BitwardenApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/Bitwarden/ContentView.swift b/Bitwarden/ContentView.swift deleted file mode 100644 index f54deaba8..000000000 --- a/Bitwarden/ContentView.swift +++ /dev/null @@ -1,19 +0,0 @@ -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - .padding() - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/BitwardenShared/Core/Platform/Services/API/APIServiceTests.swift b/BitwardenShared/Core/Platform/Services/API/APIServiceTests.swift index 7d73bc8cf..14bb5af05 100644 --- a/BitwardenShared/Core/Platform/Services/API/APIServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/API/APIServiceTests.swift @@ -19,7 +19,7 @@ class APIServiceTests: BitwardenTestCase { } /// `init(client:)` sets the default base URLs for the HTTP services. - func testInitDefaultURLs() { + func test_init_defaultURLs() { let apiServiceBaseURL = subject.apiService.baseURL XCTAssertEqual(apiServiceBaseURL, URL(string: "https://vault.bitwarden.com/api")!) diff --git a/BitwardenShared/UI/Platform/Application/AppCoordinator.swift b/BitwardenShared/UI/Platform/Application/AppCoordinator.swift new file mode 100644 index 000000000..8b1d55bd6 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/AppCoordinator.swift @@ -0,0 +1,46 @@ +import UIKit + +// MARK: - AppCoordinator + +/// A coordinator that manages the app's top-level navigation. +/// +public class AppCoordinator: Coordinator { + // MARK: Properties + + /// The navigator to use for presenting screens. + public let navigator: RootNavigator + + // MARK: Initialization + + /// Creates a new `AppCoordinator`. + /// + /// - Parameter navigator: The navigator to use for presenting screens. + /// + public init(navigator: RootNavigator) { + self.navigator = navigator + } + + // MARK: Methods + + public func navigate(to route: AppRoute, context: AnyObject?) { + switch route { + case .onboarding: + showOnboarding() + } + } + + public func start() { + showOnboarding() + } + + // MARK: Private Methods + + /// Shows the onboarding navigator. + private func showOnboarding() { + // Temporary view controller for testing purposes. Will be replaced with real functionality in BIT-155 + let viewController = UIViewController() + viewController.view.backgroundColor = .systemBlue + let navController = UINavigationController(rootViewController: viewController) + navigator.show(child: navController) + } +} diff --git a/BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift b/BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift new file mode 100644 index 000000000..58312ae87 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/AppCoordinatorTests.swift @@ -0,0 +1,45 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - AppCoordinatorTests + +@MainActor +class AppCoordinatorTests: BitwardenTestCase { + // MARK: Properties + + var navigator: MockRootNavigator! + var subject: AppCoordinator! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + navigator = MockRootNavigator() + subject = AppCoordinator(navigator: navigator) + } + + override func tearDown() { + super.tearDown() + navigator = nil + subject = nil + } + + // MARK: Tests + + /// `start()` initializes the UI correctly. + func test_start() { + subject.start() + + // Placeholder assertion until functionality is implemented in BIT-155 + XCTAssertTrue(navigator.navigatorShown is StackNavigator) + } + + /// `navigate(to:)` with `.onboarding` presents the correct navigator. + func test_navigateTo_onboarding() throws { + subject.navigate(to: .onboarding) + + // Placeholder assertion until functionality is implemented in BIT-155 + XCTAssertTrue(navigator.navigatorShown is StackNavigator) + } +} diff --git a/BitwardenShared/UI/Platform/Application/AppModule.swift b/BitwardenShared/UI/Platform/Application/AppModule.swift new file mode 100644 index 000000000..561e198ca --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/AppModule.swift @@ -0,0 +1,28 @@ +// MARK: AppModule + +/// An object that builds coordinators for the app. +@MainActor +public protocol AppModule: AnyObject { + /// Initializes a coordinator for navigating between `Route`s. + /// + /// - Parameter navigator: The object that will be used to navigate between routes. + /// - Returns: A coordinator that can navigate to `Route`s. + /// + func makeAppCoordinator(navigator: RootNavigator) -> AnyCoordinator +} + +// MARK: - DefaultAppModule + +/// The default app module that can be used to build coordinators. +@MainActor +public class DefaultAppModule { + /// Creates a new `DefaultAppModule`. + public init() {} +} + +extension DefaultAppModule: AppModule { + public func makeAppCoordinator(navigator: RootNavigator) -> AnyCoordinator { + AppCoordinator(navigator: navigator) + .asAnyCoordinator() + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/Route.swift b/BitwardenShared/UI/Platform/Application/AppRoute.swift similarity index 60% rename from BitwardenShared/UI/Platform/Application/Utilities/Route.swift rename to BitwardenShared/UI/Platform/Application/AppRoute.swift index 2d28c4d96..63a9de937 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/Route.swift +++ b/BitwardenShared/UI/Platform/Application/AppRoute.swift @@ -1,3 +1,5 @@ /// A top level route from the initial screen of the app to anywhere in the app. /// -public enum Route: Equatable {} +public enum AppRoute: Equatable { + case onboarding +} diff --git a/BitwardenShared/UI/Platform/Application/Appearance/UITests.swift b/BitwardenShared/UI/Platform/Application/Appearance/UITests.swift new file mode 100644 index 000000000..5dd6c4e33 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Appearance/UITests.swift @@ -0,0 +1,37 @@ +import Foundation +import XCTest + +@testable import BitwardenShared + +// MARK: - UITests + +@MainActor +class UITests: BitwardenTestCase { + // MARK: Tests + + func test_duration_animated() { + UI.animated = true + let duration = UI.duration(3) + XCTAssertEqual(duration, 3) + } + + func test_duration_notAnimated() { + UI.animated = false + let duration = UI.duration(3) + XCTAssertEqual(duration, 0) + } + + func test_after_animated() { + UI.animated = true + let after = UI.after(3) + let duration = (DispatchTime.now() + 3).distance(to: after).totalSeconds + XCTAssertEqual(duration, 0, accuracy: 0.1) + } + + func test_after_notAnimated() { + UI.animated = false + let after = UI.after(3) + let duration = DispatchTime.now().distance(to: after).totalSeconds + XCTAssertEqual(duration, 0, accuracy: 0.1) + } +} diff --git a/BitwardenShared/UI/Platform/Application/TestHelpers/Extensions/DispatchTimeInterval.swift b/BitwardenShared/UI/Platform/Application/TestHelpers/Extensions/DispatchTimeInterval.swift new file mode 100644 index 000000000..576b2ddf9 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/TestHelpers/Extensions/DispatchTimeInterval.swift @@ -0,0 +1,49 @@ +import Foundation + +extension DispatchTimeInterval: Comparable { + /// The total number of nanoseconds in this duration. + var totalNanoseconds: Int64 { + switch self { + case let .nanoseconds(value): + return Int64(value) + case let .microseconds(value): + return Int64(value) * 1000 + case let .milliseconds(value): + return Int64(value) * 1_000_000 + case let .seconds(value): + return Int64(value) * 1_000_000_000 + case .never: + fatalError("Infinite nanoseconds") + @unknown default: + fatalError("Unhandled case in DispatchTimeInterval") + } + } + + /// The total number of seconds in this duration. + var totalSeconds: Double { + switch self { + case let .nanoseconds(value): + return Double(value) / 1_000_000_000 + case let .microseconds(value): + return Double(value) / 1_000_000 + case let .milliseconds(value): + return Double(value) / 1000 + case let .seconds(value): + return Double(value) + case .never: + fatalError("Infinite seconds") + @unknown default: + fatalError("Unhandled case in DispatchTimeInterval") + } + } + + public static func < (lhs: DispatchTimeInterval, rhs: DispatchTimeInterval) -> Bool { + if lhs == .never { + return false + } + if rhs == .never { + return true + } + return lhs.totalNanoseconds < rhs.totalNanoseconds + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift b/BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift new file mode 100644 index 000000000..61f96bfd7 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Utilities/AnyCoordinatorTests.swift @@ -0,0 +1,42 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - AnyCoordinatorTests + +@MainActor +class AnyCoordinatorTests: BitwardenTestCase { + // MARK: Properties + + var coordinator: MockCoordinator! + var subject: AnyCoordinator! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + coordinator = MockCoordinator() + subject = AnyCoordinator(coordinator) + } + + override func tearDown() { + super.tearDown() + coordinator = nil + subject = nil + } + + // MARK: Tests + + /// `start()` calls the `start()` method on the wrapped coordinator. + func test_start() { + subject.start() + XCTAssertTrue(coordinator.isStarted) + } + + /// `navigate(to:context:)` calls the `navigate(to:context:)` method on the wrapped coordinator. + func test_navigate_onboarding() { + subject.navigate(to: .onboarding, context: "🤖" as NSString) + XCTAssertEqual(coordinator.contexts as? [NSString], ["🤖" as NSString]) + XCTAssertEqual(coordinator.routes, [.onboarding]) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/Navigator.swift b/BitwardenShared/UI/Platform/Application/Utilities/Navigator.swift index a8bf05493..90e102e15 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/Navigator.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/Navigator.swift @@ -4,4 +4,9 @@ import SwiftUI /// A protocol for an object that can navigate between screens and show alerts. @MainActor -public protocol Navigator: AnyObject {} +public protocol Navigator: AnyObject { + // MARK: Properties + + /// The root view controller of this `Navigator`. + var rootViewController: UIViewController? { get } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/Processor.swift b/BitwardenShared/UI/Platform/Application/Utilities/Processor.swift index 690b56b4b..3ca76d27f 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/Processor.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/Processor.swift @@ -6,9 +6,9 @@ import Foundation /// @MainActor public protocol Processor: AnyObject, Sendable { - associatedtype State: Sendable associatedtype Action: Sendable associatedtype Effect: Sendable + associatedtype State: Sendable /// The processor's current state. var state: State { get } diff --git a/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift b/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift index 7d60f80f1..3be2e4a81 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/RootNavigator.swift @@ -11,3 +11,15 @@ public protocol RootNavigator: Navigator { /// - Parameter child: The navigator to show. func show(child: Navigator?) } + +// MARK: - RootViewController + +extension RootViewController: RootNavigator { + public var rootViewController: UIViewController? { + self + } + + public func show(child: Navigator?) { + childViewController = child?.rootViewController + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift b/BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift new file mode 100644 index 000000000..014914959 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Utilities/RootViewController.swift @@ -0,0 +1,31 @@ +import UIKit + +/// The root view controller for the app. +/// +/// This view controller is the entry point into the application, and all screens are presented within this view +/// controller. +/// +public class RootViewController: UIViewController { + // MARK: Properties + + /// The child view controller currently being displayed within this root view controller. + /// + /// Setting this value will remove the previously displayed view controller and immediately replace it with + /// the new value. This replacement is not animated. + /// + public var childViewController: UIViewController? { + didSet { + if let fromViewController = oldValue { + fromViewController.willMove(toParent: nil) + fromViewController.view.removeFromSuperview() + fromViewController.removeFromParent() + } + + if let toViewController = childViewController { + addChild(toViewController) + view.addConstrained(subview: toViewController.view) + toViewController.didMove(toParent: self) + } + } + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift b/BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift new file mode 100644 index 000000000..56984075a --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Utilities/RootViewControllerTests.swift @@ -0,0 +1,57 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - RootViewControllerTests + +@MainActor +class RootViewControllerTests: BitwardenTestCase { + // MARK: Properties + + var subject: RootViewController! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + subject = RootViewController() + setKeyWindowRoot(viewController: subject) + } + + override func tearDown() { + super.tearDown() + subject = nil + } + + // MARK: Tests + + /// `childViewController` swaps between different view controllers. + func test_childViewController_withNewViewController() { + let viewController1 = UIViewController() + subject.childViewController = viewController1 + XCTAssertTrue(subject.children.contains(viewController1)) + + let viewController2 = UIViewController() + subject.childViewController = viewController2 + XCTAssertTrue(subject.children.contains(viewController2)) + XCTAssertFalse(subject.children.contains(viewController1)) + } + + /// `childViewController` removes the current view controller when set to `nil`. + func test_childViewController_nil() { + let viewController1 = UIViewController() + subject.childViewController = viewController1 + XCTAssertTrue(subject.children.contains(viewController1)) + + subject.childViewController = nil + XCTAssertTrue(subject.children.isEmpty) + XCTAssertTrue(subject.view.subviews.isEmpty) + } + + /// `rootViewController` returns itself, instead of the current `childViewController`. + func test_rootViewController() { + let viewController = UIViewController() + subject.childViewController = viewController + XCTAssertIdentical(subject.rootViewController, subject) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift index 394124b4d..8bcd9f39d 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigator.swift @@ -74,7 +74,7 @@ extension StackNavigator { // MARK: - UINavigationController extension UINavigationController: StackNavigator { - var rootViewController: UIViewController? { + public var rootViewController: UIViewController? { self } diff --git a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift index db059545a..0d89107c4 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/StackNavigatorTests.swift @@ -3,24 +3,40 @@ import XCTest @testable import BitwardenShared +// MARK: - StackNavigatorTests + @MainActor class StackNavigatorTests: BitwardenTestCase { + // MARK: Properties + var subject: UINavigationController! + // MARK: Setup & Teardown + override func setUp() { super.setUp() subject = UINavigationController() setKeyWindowRoot(viewController: subject) } + // MARK: Tests + /// `present(_:animated:)` presents the hosted view. - func testPresent() { + func test_present() { subject.present(EmptyView(), animated: false) XCTAssertTrue(subject.presentedViewController is UIHostingController) } + /// `present(_:animated:)` presents the hosted view. + func test_present_overFullscreen() { + subject.present(EmptyView(), animated: false, overFullscreen: true) + XCTAssertEqual(subject.presentedViewController?.modalPresentationStyle, .overFullScreen) + XCTAssertEqual(subject.presentedViewController?.view.backgroundColor, .clear) + XCTAssertTrue(subject.presentedViewController is UIHostingController) + } + /// `present(_:animated:)` presents the hosted view on existing presented views. - func testPresentOnPresentedView() { + func test_present_onPresentedView() { subject.present(EmptyView(), animated: false) subject.present(ScrollView {}, animated: false) XCTAssertTrue(subject.presentedViewController is UIHostingController) @@ -32,20 +48,20 @@ class StackNavigatorTests: BitwardenTestCase { } /// `dismiss(animated:)` dismisses the hosted view. - func testDismiss() { + func test_dismiss() { subject.present(EmptyView(), animated: false) subject.dismiss(animated: false) waitFor(subject.presentedViewController == nil) } /// `push(_:animated:)` pushes the hosted view. - func testPush() { + func test_push() { subject.push(EmptyView(), animated: false) XCTAssertTrue(subject.topViewController is UIHostingController) } /// `pop(animated:)` pops the hosted view. - func testPop() { + func test_pop() { subject.push(EmptyView(), animated: false) subject.push(EmptyView(), animated: false) subject.pop(animated: false) @@ -53,8 +69,18 @@ class StackNavigatorTests: BitwardenTestCase { XCTAssertTrue(subject.topViewController is UIHostingController) } + /// `popToRoot(animated:)` pops to the root hosted view. + func test_popToRoot() { + subject.push(EmptyView(), animated: false) + subject.push(EmptyView(), animated: false) + subject.push(EmptyView(), animated: false) + subject.popToRoot(animated: false) + XCTAssertEqual(subject.viewControllers.count, 1) + XCTAssertTrue(subject.topViewController is UIHostingController) + } + /// `replace(_:animated:)` replaces the hosted view. - func testReplace() { + func test_replace() { subject.push(EmptyView(), animated: false) subject.replace(Text("replaced"), animated: false) XCTAssertEqual(subject.viewControllers.count, 1) diff --git a/BitwardenShared/UI/Platform/Application/Utilities/StateProcessor.swift b/BitwardenShared/UI/Platform/Application/Utilities/StateProcessor.swift index e9c69fb53..fa0310046 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/StateProcessor.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/StateProcessor.swift @@ -4,7 +4,7 @@ import Combine /// A generic `Processor` which may be subclassed to easily build a `Processor` with the typical /// properties, connections, and behaviors. -open class StateProcessor: Processor { +open class StateProcessor: Processor { // MARK: Properties /// The processor's current state. diff --git a/BitwardenShared/UI/Platform/Application/Utilities/Store.swift b/BitwardenShared/UI/Platform/Application/Utilities/Store.swift index bcf6a1ddd..a54c9cb01 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/Store.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/Store.swift @@ -32,7 +32,7 @@ open class Store: Observabl /// - Parameter processor: The `Processor` that will receive actions from the store and update /// the store's state. /// - public init(processor: P) where P.Action == Action, P.State == State, P.Effect == Effect { + public init(processor: P) where P.Action == Action, P.Effect == Effect, P.State == State { state = processor.state receive = { processor.receive($0) } perform = { await processor.perform($0) } diff --git a/BitwardenShared/UI/Platform/Application/Utilities/StoreTests.swift b/BitwardenShared/UI/Platform/Application/Utilities/StoreTests.swift index 567b4bce3..797241762 100644 --- a/BitwardenShared/UI/Platform/Application/Utilities/StoreTests.swift +++ b/BitwardenShared/UI/Platform/Application/Utilities/StoreTests.swift @@ -3,11 +3,17 @@ import XCTest @testable import BitwardenShared +// MARK: - StoreTests + @MainActor class StoreTests: XCTestCase { + // MARK: Properties + var processor: MockProcessor! var subject: Store! + // MARK: Setup & Teardown + override func setUp() { super.setUp() @@ -15,8 +21,10 @@ class StoreTests: XCTestCase { subject = Store(processor: processor) } + // MARK: Tests + /// `send(_:)` forwards the action to the processor for processing. - func testSendAction() { + func test_send_action() { subject.send(.increment) XCTAssertEqual(processor.dispatchedActions, [.increment]) processor.dispatchedActions.removeAll() @@ -26,13 +34,13 @@ class StoreTests: XCTestCase { } /// `perform(_:)` forwards the effect to the processor for performing. - func testPerformEffect() async { + func test_perform_effect() async { await subject.perform(.something) XCTAssertEqual(processor.effects, [.something]) } /// `child(_:)` creates a child store that maps actions, state, and effects from its parent. - func testChildStore() async { + func test_child() async { let childStore = subject.child(state: { $0.child }, mapAction: { .child($0) }, mapEffect: { .child($0) }) XCTAssertEqual(childStore.state.value, "🐣") @@ -49,7 +57,7 @@ class StoreTests: XCTestCase { /// `binding(get:send:)` creates a binding from a value in the state and sends an action to the /// processor when the binding's value changes. - func testBinding() { + func test_binding() { let binding = subject.binding(get: { $0.counter }, send: { .counterChanged($0) }) XCTAssertEqual(binding.wrappedValue, 0) @@ -64,7 +72,7 @@ class StoreTests: XCTestCase { /// `binding(get:)` creates a binding from a value in the state that does not update the state when the binding's /// value is changed. - func testBindingGetOnly() { + func test_binding_getOnly() { let binding = subject.binding(get: { $0.counter }) XCTAssertEqual(binding.wrappedValue, 0) diff --git a/GlobalTestHelpers/MockAppModule.swift b/GlobalTestHelpers/MockAppModule.swift new file mode 100644 index 000000000..16c1546b0 --- /dev/null +++ b/GlobalTestHelpers/MockAppModule.swift @@ -0,0 +1,13 @@ +import BitwardenShared + +// MARK: - MockAppModule + +class MockAppModule: AppModule { + var appCoordinator: AnyCoordinator? + + func makeAppCoordinator( + navigator: RootNavigator + ) -> AnyCoordinator { + appCoordinator ?? MockCoordinator().asAnyCoordinator() + } +} diff --git a/GlobalTestHelpers/MockCoordinator.swift b/GlobalTestHelpers/MockCoordinator.swift index d0bd1e08b..89a0247cd 100644 --- a/GlobalTestHelpers/MockCoordinator.swift +++ b/GlobalTestHelpers/MockCoordinator.swift @@ -1,11 +1,11 @@ @testable import BitwardenShared -class MockCoordinator: Coordinator { +class MockCoordinator: Coordinator { var contexts: [AnyObject?] = [] var isStarted: Bool = false - var routes: [T] = [] + var routes: [Route] = [] - func navigate(to route: T, context: AnyObject?) { + func navigate(to route: Route, context: AnyObject?) { routes.append(route) contexts.append(context) } diff --git a/GlobalTestHelpers/MockProcessor.swift b/GlobalTestHelpers/MockProcessor.swift index 928ff7ab0..05f37f057 100644 --- a/GlobalTestHelpers/MockProcessor.swift +++ b/GlobalTestHelpers/MockProcessor.swift @@ -1,25 +1,25 @@ import BitwardenShared import Combine -class MockProcessor: Processor { - var dispatchedActions = [ActionType]() +class MockProcessor: Processor { + var dispatchedActions = [Action]() var effects: [Effect] = [] - let stateSubject: CurrentValueSubject + let stateSubject: CurrentValueSubject - var state: StateType { + var state: State { get { stateSubject.value } set { stateSubject.value = newValue } } - var statePublisher: AnyPublisher { + var statePublisher: AnyPublisher { stateSubject.eraseToAnyPublisher() } - init(state: StateType) { + init(state: State) { stateSubject = CurrentValueSubject(state) } - func receive(_ action: ActionType) { + func receive(_ action: Action) { dispatchedActions.append(action) }