diff --git a/AuthenticatorShared/Core/Platform/Models/Enum/DefaultSaveOption.swift b/AuthenticatorShared/Core/Platform/Models/Enum/DefaultSaveOption.swift new file mode 100644 index 00000000..a3ad4915 --- /dev/null +++ b/AuthenticatorShared/Core/Platform/Models/Enum/DefaultSaveOption.swift @@ -0,0 +1,35 @@ +import Foundation + +// MARK: - DefaultSaveOption + +/// The default location for saving newly added keys via QR code scan and Manual entry. +/// +enum DefaultSaveOption: String, Equatable, Menuable { + /// Ask where to save a code each time a QR code is scanned. + case none + + /// Save the code locally without showing any prompt. + case saveLocally + + /// Take the user to the Bitwarden PM app to save the code without prompt. + case saveToBitwarden + + /// All of the cases to show in the menu, in order. + public static let allCases: [Self] = [ + .saveToBitwarden, + .saveLocally, + .none, + ] + + /// The name of the value to display in the menu. + var localizedName: String { + switch self { + case .none: + Localizations.none + case .saveLocally: + Localizations.saveLocally + case .saveToBitwarden: + Localizations.saveToBitwarden + } + } +} diff --git a/AuthenticatorShared/Core/Platform/Models/Enum/DefaultSaveOptionTests.swift b/AuthenticatorShared/Core/Platform/Models/Enum/DefaultSaveOptionTests.swift new file mode 100644 index 00000000..9d7cabeb --- /dev/null +++ b/AuthenticatorShared/Core/Platform/Models/Enum/DefaultSaveOptionTests.swift @@ -0,0 +1,33 @@ +import XCTest + +@testable import AuthenticatorShared + +class DefaultSaveOptionTests: AuthenticatorTestCase { + // MARK: Tests + + /// `allCases` returns all of the cases in the correct order. + func test_allCases() { + XCTAssertEqual( + DefaultSaveOption.allCases, + [ + .saveToBitwarden, + .saveLocally, + .none, + ] + ) + } + + /// `localizedName` returns the correct values. + func test_localizedName() { + XCTAssertEqual(DefaultSaveOption.none.localizedName, Localizations.none) + XCTAssertEqual(DefaultSaveOption.saveLocally.localizedName, Localizations.saveLocally) + XCTAssertEqual(DefaultSaveOption.saveToBitwarden.localizedName, Localizations.saveToBitwarden) + } + + /// `rawValue` returns the correct values. + func test_rawValues() { + XCTAssertEqual(DefaultSaveOption.none.rawValue, "none") + XCTAssertEqual(DefaultSaveOption.saveLocally.rawValue, "saveLocally") + XCTAssertEqual(DefaultSaveOption.saveToBitwarden.rawValue, "saveToBitwarden") + } +} diff --git a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift index c263e310..ae783a49 100644 --- a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -21,6 +21,12 @@ protocol AppSettingsStore: AnyObject { /// Whether to disable the website icons. var disableWebIcons: Bool { get set } + /// The default save location for new keys. + var defaultSaveOption: DefaultSaveOption { get set } + + /// Whether the user has seen the default save options prompt. + var hasSeenDefaultSaveOptionPrompt: Bool { get } + /// Whether the user has seen the welcome tutorial. var hasSeenWelcomeTutorial: Bool { get set } @@ -296,6 +302,7 @@ extension DefaultAppSettingsStore: AppSettingsStore { case cardClosedState(card: ItemListCard) case clearClipboardValue(userId: String) case debugFeatureFlag(name: String) + case defaultSaveOption case disableWebIcons case hasSeenWelcomeTutorial case hasSyncedAccount(name: String) @@ -324,6 +331,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "clearClipboard_\(userId)" case let .debugFeatureFlag(name): key = "debugFeatureFlag_\(name)" + case .defaultSaveOption: + key = "defaultSaveOption" case .disableWebIcons: key = "disableFavicon" case .hasSeenWelcomeTutorial: @@ -363,6 +372,21 @@ extension DefaultAppSettingsStore: AppSettingsStore { set { store(newValue, for: .disableWebIcons) } } + var defaultSaveOption: DefaultSaveOption { + get { + guard let rawValue: String = fetch(for: .defaultSaveOption), + let value = DefaultSaveOption(rawValue: rawValue) + else { return .none } + + return value + } + set { store(newValue.rawValue, for: .defaultSaveOption) } + } + + var hasSeenDefaultSaveOptionPrompt: Bool { + fetch(for: .defaultSaveOption) != nil + } + var hasSeenWelcomeTutorial: Bool { get { fetch(for: .hasSeenWelcomeTutorial) } set { store(newValue, for: .hasSeenWelcomeTutorial) } diff --git a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift index c67f95ec..5149adf5 100644 --- a/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift +++ b/AuthenticatorShared/Core/Platform/Services/Stores/AppSettingsStoreTests.swift @@ -141,6 +141,39 @@ class AppSettingsStoreTests: AuthenticatorTestCase { XCTAssertEqual(userDefaults.integer(forKey: "bwaPreferencesStorage:clearClipboard_2"), -1) } + /// `defaultSaveOption` returns `.none` if there isn't a previously stored value or if a previously + /// stored value is not a valid option + func test_defaultSaveOption_isInitiallyNone() { + XCTAssertEqual(subject.defaultSaveOption, .none) + + userDefaults.set("An invalid value", forKey: "bwaPreferencesStorage:defaultSaveOption") + XCTAssertEqual(subject.defaultSaveOption, .none) + } + + /// `defaultSaveOption` can be used to get and set the default save option.. + func test_defaultSaveOption_withValue() { + subject.defaultSaveOption = .saveToBitwarden + XCTAssertEqual(subject.defaultSaveOption, .saveToBitwarden) + XCTAssertEqual(userDefaults.string(forKey: "bwaPreferencesStorage:defaultSaveOption"), "saveToBitwarden") + + subject.defaultSaveOption = .saveLocally + XCTAssertEqual(subject.defaultSaveOption, .saveLocally) + XCTAssertEqual(userDefaults.string(forKey: "bwaPreferencesStorage:defaultSaveOption"), "saveLocally") + + subject.defaultSaveOption = .none + XCTAssertEqual(subject.defaultSaveOption, .none) + XCTAssertEqual(userDefaults.string(forKey: "bwaPreferencesStorage:defaultSaveOption"), "none") + } + + /// `hasSeenDefaultSaveOptionPrompt` returns `false` if there isn't a 'defaultSaveOption` value stored, and `true` + /// when there is a value stored. + func test_hasSeenDefaultSaveOptionPrompt() { + XCTAssertFalse(subject.hasSeenDefaultSaveOptionPrompt) + + subject.defaultSaveOption = .none + XCTAssertTrue(subject.hasSeenDefaultSaveOptionPrompt) + } + /// `disableWebIcons` returns `false` if there isn't a previously stored value. func test_disableWebIcons_isInitiallyFalse() { XCTAssertFalse(subject.disableWebIcons) diff --git a/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift b/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift index c5e23a89..a9d39f2f 100644 --- a/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift +++ b/AuthenticatorShared/Core/Platform/Services/Stores/TestHelpers/MockAppSettingsStore.swift @@ -10,6 +10,8 @@ class MockAppSettingsStore: AppSettingsStore { var appLocale: String? var appTheme: String? var disableWebIcons = false + var defaultSaveOption: DefaultSaveOption = .none + var hasSeenDefaultSaveOptionPrompt = false var hasSeenWelcomeTutorial = false var lastUserShouldConnectToWatch = false var localUserId: String = "localtest" diff --git a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 8e0d15b9..d924c07c 100644 --- a/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/AuthenticatorShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -132,3 +132,16 @@ "MoveToBitwarden" = "Move to Bitwarden"; "AddCodeToBitwarden" = "Add code to Bitwarden"; "AddCodeLocally" = "Add code locally"; +"ScanComplete" = "Scan complete"; +"SaveThisAuthenticatorKeyHereOrAddItToALoginInYourBitwardenApp" = "Save this authenticator key here, or add it to a login in your Bitwarden app."; +"SaveHere" = "Save here"; +"TakeMeToBitwarden" = "Take me to Bitwarden"; +"SetSaveToBitwardenAsYourDefaultSaveOption" = "Set \"Save to Bitwarden\" as your default save option?"; +"SetSaveLocallyAsYourDefaultSaveOption" = "Set \"Save locally\" as your default save option?"; +"YouCanUpdateYourDefaultAnytimeInSettings" = "You can update your default anytime in settings."; +"YesSetDefault" = "Yes, set default"; +"NoAskMe" = "No, ask me"; +"DefaultSaveOption" = "Default save option"; +"SaveToBitwarden" = "Save to Bitwarden"; +"SaveLocally" = "Save locally"; +"None" = "None"; diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift index a86030ae..4519d4d3 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsAction.swift @@ -10,6 +10,9 @@ enum SettingsAction: Equatable { /// The url has been opened so clear the value in the state. case clearURL + /// The default save option was changed. + case defaultSaveChanged(DefaultSaveOption) + /// The export items button was tapped. case exportItemsTapped diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift index 63694efb..ed3f4084 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsProcessor.swift @@ -7,7 +7,9 @@ import OSLog final class SettingsProcessor: StateProcessor { // MARK: Types - typealias Services = HasBiometricsRepository + typealias Services = HasAppSettingsStore + & HasAuthenticatorItemRepository + & HasBiometricsRepository & HasConfigService & HasErrorReporter & HasExportItemsService @@ -65,6 +67,9 @@ final class SettingsProcessor: StateProcessor! var subject: SettingsProcessor! @@ -14,11 +16,15 @@ class SettingsProcessorTests: AuthenticatorTestCase { override func setUp() { super.setUp() + appSettingsStore = MockAppSettingsStore() + authItemRepository = MockAuthenticatorItemRepository() configService = MockConfigService() coordinator = MockCoordinator() subject = SettingsProcessor( coordinator: coordinator.asAnyCoordinator(), services: ServiceContainer.withMocks( + appSettingsStore: appSettingsStore, + authenticatorItemRepository: authItemRepository, configService: configService ), state: SettingsState() @@ -28,27 +34,65 @@ class SettingsProcessorTests: AuthenticatorTestCase { override func tearDown() { super.tearDown() + appSettingsStore = nil + authItemRepository = nil + configService = nil coordinator = nil subject = nil } // MARK: Tests - /// Performing `.loadData` with the password manager sync disabled sets - /// `state.shouldShowSyncButton` to `false`. - func test_perform_loadData_syncDisabled() async throws { + /// Performing `.loadData` sets the 'defaultSaveOption' to the current value in 'AppSettingsStore'. + func test_perform_loadData_defaultSaveOption() async throws { + configService.featureFlagsBool[.enablePasswordManagerSync] = true + appSettingsStore.defaultSaveOption = .saveToBitwarden + await subject.perform(.loadData) + + XCTAssertEqual(subject.state.defaultSaveOption, .saveToBitwarden) + } + + /// Performing `.loadData` sets the sync related flags correctly when the feature flag is + /// disabled and the sync is off. + func test_perform_loadData_syncFlagDisabled_syncOff() async throws { + configService.featureFlagsBool[.enablePasswordManagerSync] = false + authItemRepository.pmSyncEnabled = false + await subject.perform(.loadData) + + XCTAssertFalse(subject.state.shouldShowDefaultSaveOption) + XCTAssertFalse(subject.state.shouldShowSyncButton) + } + + /// Performing `.loadData` sets the sync related flags correctly when the feature flag is + /// enabled and the sync is off. + func test_perform_loadData_syncFlagEnabled_syncOff() async throws { + configService.featureFlagsBool[.enablePasswordManagerSync] = true + authItemRepository.pmSyncEnabled = false + await subject.perform(.loadData) + + XCTAssertFalse(subject.state.shouldShowDefaultSaveOption) + XCTAssertTrue(subject.state.shouldShowSyncButton) + } + + /// Performing `.loadData` sets the sync related flags correctly when the feature flag is + /// disabled and the sync is on. + func test_perform_loadData_syncFlagDisabled_syncOn() async throws { configService.featureFlagsBool[.enablePasswordManagerSync] = false + authItemRepository.pmSyncEnabled = true await subject.perform(.loadData) + XCTAssertFalse(subject.state.shouldShowDefaultSaveOption) XCTAssertFalse(subject.state.shouldShowSyncButton) } - /// Performing `.loadData` with the password manager sync enabled sets - /// `state.shouldShowSyncButton` to `true`. - func test_perform_loadData_syncEnabled() async throws { + /// Performing `.loadData` sets the sync related flags correctly when the feature flag is + /// enabled and the sync is on. + func test_perform_loadData_syncFlagEnabled_syncOn() async throws { configService.featureFlagsBool[.enablePasswordManagerSync] = true + authItemRepository.pmSyncEnabled = true await subject.perform(.loadData) + XCTAssertTrue(subject.state.shouldShowDefaultSaveOption) XCTAssertTrue(subject.state.shouldShowSyncButton) } @@ -61,6 +105,15 @@ class SettingsProcessorTests: AuthenticatorTestCase { XCTAssertEqual(subject.state.url, ExternalLinksConstants.backupInformation) } + /// Receiving `.defaultSaveChanged` updates the user's `defaultSaveOption` app setting. + func test_receive_defaultSaveChanged() { + subject.state.defaultSaveOption = .none + subject.receive(.defaultSaveChanged(.saveLocally)) + + XCTAssertEqual(appSettingsStore.defaultSaveOption, .saveLocally) + XCTAssertEqual(subject.state.defaultSaveOption, .saveLocally) + } + /// Receiving `.exportItemsTapped` navigates to the export vault screen. func test_receive_exportVaultTapped() { subject.receive(.exportItemsTapped) diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift index 9c67a603..bf10fbe0 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsState.swift @@ -17,6 +17,13 @@ struct SettingsState: Equatable { /// The current language selection. var currentLanguage: LanguageOption = .default + /// The current default save option. + var defaultSaveOption: DefaultSaveOption = .none + + /// A flag to indicate if we should show the default save option menu. + /// Defaults to false, which indicates we should not show the menu. + var shouldShowDefaultSaveOption = false + /// A flag to indicate if we should show the "Sync with the Bitwarden app" button /// Defaults to false, which indicates we should not show the button. var shouldShowSyncButton = false diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift index 20660eac..e76eb694 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsView.swift @@ -110,8 +110,9 @@ struct SettingsView: View { externalLinkRow( Localizations.syncWithTheBitwardenApp, action: .syncWithBitwardenAppTapped, - hasDivider: false + hasDivider: store.state.shouldShowDefaultSaveOption ) + defaultSaveOption } } .cornerRadius(10) @@ -155,6 +156,22 @@ struct SettingsView: View { .cornerRadius(10) } + /// The application's default save option picker view + @ViewBuilder private var defaultSaveOption: some View { + if store.state.shouldShowDefaultSaveOption { + SettingsMenuField( + title: Localizations.defaultSaveOption, + options: DefaultSaveOption.allCases, + hasDivider: false, + selection: store.binding( + get: \.defaultSaveOption, + send: SettingsAction.defaultSaveChanged + ) + ) + .accessibilityIdentifier("DefaultSaveOptionChooser") + } + } + /// The application's color theme picker view private var theme: some View { VStack(alignment: .leading, spacing: 8) { @@ -226,21 +243,48 @@ struct SettingsView: View { // MARK: - Previews #if DEBUG -#Preview { - NavigationView { - SettingsView( - store: Store( - processor: StateProcessor( - state: SettingsState( - biometricUnlockStatus: .available( - .faceID, - enabled: false, - hasValidIntegrity: true +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + SettingsView( + store: Store( + processor: StateProcessor( + state: SettingsState( + biometricUnlockStatus: .available( + .faceID, + enabled: false, + hasValidIntegrity: true + ) + ) + ) + ) + ) + }.previewDisplayName("SettingsView") + + NavigationView { + SettingsView( + store: Store( + processor: StateProcessor( + state: SettingsState( + shouldShowSyncButton: true + ) + ) + ) + ) + }.previewDisplayName("With Sync Row") + + NavigationView { + SettingsView( + store: Store( + processor: StateProcessor( + state: SettingsState( + shouldShowDefaultSaveOption: true, + shouldShowSyncButton: true ) ) ) ) - ) + }.previewDisplayName("With Sync & Default Options") } } #endif diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift index dd31cab7..7de72307 100644 --- a/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift +++ b/AuthenticatorShared/UI/Platform/Settings/Settings/SettingsViewTests.swift @@ -42,6 +42,16 @@ class SettingsViewTests: AuthenticatorTestCase { XCTAssertEqual(processor.dispatchedActions.last, .appThemeChanged(.dark)) } + /// Updating the value of the default save option sends the `.defaultSaveOptionChanged()` action. + func test_defaultSaveOptionChanged_updateValue() throws { + processor.state.shouldShowDefaultSaveOption = true + processor.state.shouldShowSyncButton = true + processor.state.defaultSaveOption = .none + let menuField = try subject.inspect().find(settingsMenuField: Localizations.defaultSaveOption) + try menuField.select(newValue: DefaultSaveOption.saveToBitwarden) + XCTAssertEqual(processor.dispatchedActions.last, .defaultSaveChanged(.saveToBitwarden)) + } + /// Tapping the backup button dispatches the `.backupTapped` action. func test_backupButton_tap() throws { let button = try subject.inspect().find(button: Localizations.backup) @@ -108,4 +118,14 @@ class SettingsViewTests: AuthenticatorTestCase { as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5] ) } + + /// Tests the view renders correctly with `shouldShowDefaultSaveOption` and `shouldShowSyncButton` set to `true`. + func test_viewRenderWithSyncRowAndDefaultSaveOption() { + processor.state.shouldShowDefaultSaveOption = true + processor.state.shouldShowSyncButton = true + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5] + ) + } } diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.1.png b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.1.png new file mode 100644 index 00000000..b7509e74 Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.1.png differ diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.2.png b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.2.png new file mode 100644 index 00000000..e5493e16 Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.2.png differ diff --git a/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.3.png b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.3.png new file mode 100644 index 00000000..28170e18 Binary files /dev/null and b/AuthenticatorShared/UI/Platform/Settings/Settings/__Snapshots__/SettingsViewTests/test_viewRenderWithSyncRowAndDefaultSaveOption.3.png differ diff --git a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift index 2fe67718..28e5f14a 100644 --- a/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift +++ b/AuthenticatorShared/UI/Platform/Settings/SettingsCoordinator.swift @@ -11,7 +11,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { typealias Module = FileSelectionModule & TutorialModule - typealias Services = HasAuthenticatorItemRepository + typealias Services = HasAppSettingsStore + & HasAuthenticatorItemRepository & HasBiometricsRepository & HasCameraService & HasConfigService diff --git a/AuthenticatorShared/UI/Vault/Extensions/Alert+Scan.swift b/AuthenticatorShared/UI/Vault/Extensions/Alert+Scan.swift new file mode 100644 index 00000000..39dc6bfb --- /dev/null +++ b/AuthenticatorShared/UI/Vault/Extensions/Alert+Scan.swift @@ -0,0 +1,46 @@ +import UIKit + +// MARK: Alert+Scan + +extension Alert { + /// An alert asking if the user would like to save a scanned key locally or send it to Bitwarden. + /// + /// - Parameters: + /// - saveLocallyAction: The action to perform if the user chooses to save the key locally. + /// - sendToBitwardenAction: The action to perform if the user chooses to send the key to Bitwarden. + /// - Returns: An alert asking the user where they want to store the key. + /// + static func determineScanSaveLocation(saveLocallyAction: @escaping () async -> Void, + sendToBitwardenAction: @escaping () async -> Void) -> Alert { + Alert( + title: Localizations.scanComplete, + message: Localizations.saveThisAuthenticatorKeyHereOrAddItToALoginInYourBitwardenApp, + alertActions: [ + AlertAction(title: Localizations.saveHere, style: .default) { _, _ in await saveLocallyAction() }, + AlertAction(title: Localizations.takeMeToBitwarden, + style: .default) { _, _ in await sendToBitwardenAction() }, + ] + ) + } + + /// An alert asking if the user would like to set their default save option. + /// + /// - Parameters: + /// - yesAction: The action to perform if the user chooses to set the default. + /// - noAction: The action to perform if the user choose to not set the default. + /// - Returns: An alert asking the user if they want to save their save location as the default. + /// + static func confirmDefaultSaveOption(title: String, + yesAction: @escaping () async -> Void, + noAction: @escaping () async -> Void) -> Alert { + Alert( + title: title, + message: Localizations.youCanUpdateYourDefaultAnytimeInSettings, + alertActions: [ + AlertAction(title: Localizations.yesSetDefault, style: .default) { _, _ in await yesAction() }, + AlertAction(title: Localizations.noAskMe, + style: .default) { _, _ in await noAction() }, + ] + ) + } +} diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift index 5fee5c48..3b94f148 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -172,7 +172,7 @@ final class ItemListProcessor: StateProcessor, key: String ) { - let dismissAction = DismissAction(action: { [weak self] in - Task { - await self?.parseAndValidateAutomaticCaptureKey(key) + Task { + guard await services.authenticatorItemRepository.isPasswordManagerSyncActive() else { + captureCoordinator.navigate( + to: .dismiss(parseKeyAndDismiss(key, sendToBitwarden: false)) + ) + return } - }) - captureCoordinator.navigate(to: .dismiss(dismissAction)) + + if services.appSettingsStore.hasSeenDefaultSaveOptionPrompt { + switch services.appSettingsStore.defaultSaveOption { + case .saveLocally: + captureCoordinator.navigate(to: .dismiss(parseKeyAndDismiss(key, sendToBitwarden: false))) + case .saveToBitwarden: + captureCoordinator.navigate(to: .dismiss(parseKeyAndDismiss(key, sendToBitwarden: true))) + case .none: + coordinator.showAlert(.determineScanSaveLocation( + saveLocallyAction: { [weak self] in + captureCoordinator.navigate( + to: .dismiss(self?.parseKeyAndDismiss(key, sendToBitwarden: false)) + ) + }, sendToBitwardenAction: { [weak self] in + captureCoordinator.navigate( + to: .dismiss(self?.parseKeyAndDismiss(key, sendToBitwarden: true)) + ) + } + )) + } + } else { + coordinator.showAlert(.determineScanSaveLocation( + saveLocallyAction: { [weak self] in + let dismissAction = DismissAction(action: { [weak self] in + self?.confirmDefaultSaveAlert(key: key, sendToBitwarden: false) + }) + captureCoordinator.navigate(to: .dismiss(dismissAction)) + }, sendToBitwardenAction: { [weak self] in + let dismissAction = DismissAction(action: { [weak self] in + self?.confirmDefaultSaveAlert(key: key, sendToBitwarden: true) + }) + captureCoordinator.navigate(to: .dismiss(dismissAction)) + } + )) + } + } } - func parseAndValidateAutomaticCaptureKey(_ key: String) async { + func parseAndValidateAutomaticCaptureKey(_ key: String, sendToBitwarden: Bool) async { do { let authKeyModel = try services.totpService.getTOTPConfiguration(key: key) let loginTotpState = LoginTOTPState(authKeyModel: authKeyModel) @@ -454,9 +491,7 @@ extension ItemListProcessor: AuthenticatorKeyCaptureDelegate { totpKey: key, username: accountName ) - try await services.authenticatorItemRepository.addAuthenticatorItem(newItem) - state.toast = Toast(text: Localizations.verificationCodeAdded) - await perform(.refresh) + try await storeNewItem(newItem, sendToBitwarden: sendToBitwarden) } catch { coordinator.showAlert(.totpScanFailureAlert()) } @@ -500,13 +535,7 @@ extension ItemListProcessor: AuthenticatorKeyCaptureDelegate { totpKey: key, username: nil ) - if sendToBitwarden { - await moveItemToBitwarden(item: newItem) - } else { - try await services.authenticatorItemRepository.addAuthenticatorItem(newItem) - state.toast = Toast(text: Localizations.verificationCodeAdded) - await perform(.refresh) - } + try await storeNewItem(newItem, sendToBitwarden: sendToBitwarden) } catch { coordinator.showAlert(.totpScanFailureAlert()) } @@ -533,6 +562,69 @@ extension ItemListProcessor: AuthenticatorKeyCaptureDelegate { }) captureCoordinator.navigate(to: .dismiss(dismissAction)) } + + /// Display an alert asking the user if they would like to save their choice as their default save option. + /// + /// After handling their answer to this and saving the option to the `AppSettingsStore`, the key will be + /// processed and handled based on what is passed in `sendToBitwarden`. + /// + /// - Parameters: + /// - key: The key that was captured + /// - sendToBitwarden: `true` if the user previously chose to save the key to the Bitwarden app, + /// `false` if they have chosen to store it locally. + /// + private func confirmDefaultSaveAlert(key: String, sendToBitwarden: Bool) { + let title = sendToBitwarden ? + Localizations.setSaveToBitwardenAsYourDefaultSaveOption : + Localizations.setSaveLocallyAsYourDefaultSaveOption + let option: DefaultSaveOption = sendToBitwarden ? .saveToBitwarden : .saveLocally + + coordinator.showAlert(.confirmDefaultSaveOption( + title: title, + yesAction: { [weak self] in + self?.services.appSettingsStore.defaultSaveOption = option + await self?.parseAndValidateAutomaticCaptureKey(key, sendToBitwarden: sendToBitwarden) + }, noAction: { [weak self] in + self?.services.appSettingsStore.defaultSaveOption = .none + await self?.parseAndValidateAutomaticCaptureKey(key, sendToBitwarden: sendToBitwarden) + } + )) + } + + /// Wrap the `parseAndValidateAutomaticCaptureKey` call in a dismiss action so that the coordinator first dismisses + /// the QR code scan screen and then parses and handles the `key`. + /// + /// - Parameters: + /// - key: The key that was captured by the QR code scan. + /// - sendToBitwarden: `true` if the code should be sent to the Bitwarden app, + /// `false` if it should be stored locally. + /// - Returns: The `DismissAction` to pass to the `.`dismiss` route of the capture coordinator. + /// + private func parseKeyAndDismiss(_ key: String, sendToBitwarden: Bool) -> DismissAction { + DismissAction(action: { [weak self] in + Task { + await self?.parseAndValidateAutomaticCaptureKey(key, sendToBitwarden: sendToBitwarden) + } + }) + } + + /// Store the new item - either send it to the Bitwarden app (if `sendToBitwarden` is `true`) or + /// store it locally (if `sendToBitwarden` is `false`) + /// + /// - Parameters: + /// - newItem: The new `AuthenticatorItemView` that was parsed from a manual or automatic capture. + /// - sendToBitwarden: `true` if the item should be sent to the Bitwarden app, + /// `false` if it should be stored locally. + /// + private func storeNewItem(_ newItem: AuthenticatorItemView, sendToBitwarden: Bool) async throws { + if sendToBitwarden { + await moveItemToBitwarden(item: newItem) + } else { + try await services.authenticatorItemRepository.addAuthenticatorItem(newItem) + state.toast = Toast(text: Localizations.verificationCodeAdded) + await perform(.refresh) + } + } } // MARK: - MoreOptionsAction diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessorTests.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessorTests.swift index acf0291e..15e887c9 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessorTests.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessorTests.swift @@ -277,7 +277,7 @@ class ItemListProcessorTests: AuthenticatorTestCase { // swiftlint:disable:this /// `perform(:_)` with `.moveToBitwardenPressed()` with a local item stores the item in the shared /// store and launches the Bitwarden app via the new item deep link. func test_perform_moveToBitwardenPressed_localItem() async throws { - configService.featureFlagsBool[.enablePasswordManagerSync] = true + authItemRepository.pmSyncEnabled = true application.canOpenUrlResponse = true let expected = AuthenticatorItemView.fixture() let localItem = ItemListItem.fixture(totp: .fixture(itemView: expected)) @@ -292,7 +292,7 @@ class ItemListProcessorTests: AuthenticatorTestCase { // swiftlint:disable:this /// `perform(:_)` with `.moveToBitwardenPressed()` captures any errors thrown, logs them, and shows an /// error alert. func test_perform_moveToBitwardenPressed_error() async throws { - configService.featureFlagsBool[.enablePasswordManagerSync] = true + authItemRepository.pmSyncEnabled = true application.canOpenUrlResponse = true let localItem = ItemListItem.fixture() authItemRepository.tempItemErrorToThrow = AuthenticatorTestError.example @@ -592,11 +592,14 @@ class ItemListProcessorTests: AuthenticatorTestCase { // swiftlint:disable:this // MARK: AuthenticatorKeyCaptureDelegate Tests - /// `didCompleteAutomaticCapture` failure + /// `didCompleteAutomaticCapture` failure when the user has opted to save locally by default. func test_didCompleteAutomaticCapture_failure() { + appSettingsStore.hasSeenDefaultSaveOptionPrompt = true + appSettingsStore.defaultSaveOption = .saveLocally totpService.getTOTPConfigResult = .failure(TOTPServiceError.invalidKeyFormat) let captureCoordinator = MockCoordinator() subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: "1234") + waitFor(captureCoordinator.routes.last != nil) var dismissAction: DismissAction? if case let .dismiss(onDismiss) = captureCoordinator.routes.last { dismissAction = onDismiss @@ -618,27 +621,326 @@ class ItemListProcessorTests: AuthenticatorTestCase { // swiftlint:disable:this XCTAssertNil(subject.state.toast) } - /// `didCompleteAutomaticCapture` success - func test_didCompleteAutomaticCapture_success() throws { + /// `didCompleteAutomaticCapture` success when the user has opted to be asked by default and + /// chooses the save locally option. + func test_didCompleteAutomaticCapture_hasSeenPrompt_noneLocalSaveChosen() async throws { + authItemRepository.pmSyncEnabled = true + application.canOpenUrlResponse = true + appSettingsStore.hasSeenDefaultSaveOptionPrompt = true + appSettingsStore.defaultSaveOption = .none let key = String.base32Key let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) totpService.getTOTPConfigResult = .success(keyConfig) authItemRepository.itemListSubject.value = [ItemListSection.fixture()] let captureCoordinator = MockCoordinator() subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { !self.coordinator.alertShown.isEmpty } + + let alert = try XCTUnwrap(coordinator.alertShown.first) + XCTAssertEqual(alert.alertActions.count, 2) + let saveLocallyOption = try XCTUnwrap(alert.alertActions.first) + XCTAssertEqual(saveLocallyOption.title, Localizations.saveHere) + await saveLocallyOption.handler?(saveLocallyOption, []) + var dismissAction: DismissAction? if case let .dismiss(onDismiss) = captureCoordinator.routes.last { dismissAction = onDismiss } XCTAssertNotNil(dismissAction) dismissAction?.action() - waitFor(!authItemRepository.addAuthItemAuthItems.isEmpty) - waitFor(subject.state.loadingState != .loading(nil)) - guard let item = authItemRepository.addAuthItemAuthItems.first - else { - XCTFail("Unable to get authenticator item") - return + + try await waitForAsync { !self.authItemRepository.addAuthItemAuthItems.isEmpty } + try await waitForAsync { self.subject.state.loadingState != .loading(nil) } + let item = try XCTUnwrap(authItemRepository.addAuthItemAuthItems.first) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + } + + /// `didCompleteAutomaticCapture` success when the user has opted to be asked by default and + /// chooses the save locally option. + func test_didCompleteAutomaticCapture_hasSeenPrompt_noneSaveToBitwardenChosen() async throws { + authItemRepository.pmSyncEnabled = true + application.canOpenUrlResponse = true + appSettingsStore.hasSeenDefaultSaveOptionPrompt = true + appSettingsStore.defaultSaveOption = .none + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { !self.coordinator.alertShown.isEmpty } + + let alert = try XCTUnwrap(coordinator.alertShown.first) + XCTAssertEqual(alert.alertActions.count, 2) + let saveLocallyOption = try XCTUnwrap(alert.alertActions[1]) + XCTAssertEqual(saveLocallyOption.title, Localizations.takeMeToBitwarden) + await saveLocallyOption.handler?(saveLocallyOption, []) + + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + + try await waitForAsync { self.authItemRepository.tempItem != nil } + let item = try XCTUnwrap(authItemRepository.tempItem) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + + try await waitForAsync { self.subject.state.url != nil } + XCTAssertEqual(subject.state.url, ExternalLinksConstants.passwordManagerNewItem) + } + + /// `didCompleteAutomaticCapture` success when the user has opted to save locally by default. + func test_didCompleteAutomaticCapture_hasSeenPrompt_saveLocally() async throws { + appSettingsStore.hasSeenDefaultSaveOptionPrompt = true + appSettingsStore.defaultSaveOption = .saveLocally + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { captureCoordinator.routes.last != nil } + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + try await waitForAsync { !self.authItemRepository.addAuthItemAuthItems.isEmpty } + try await waitForAsync { self.subject.state.loadingState != .loading(nil) } + let item = try XCTUnwrap(authItemRepository.addAuthItemAuthItems.first) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + } + + /// `didCompleteAutomaticCapture` success when the user has opted to save to Bitwarden by default. + func test_didCompleteAutomaticCapture_hasSeenPrompt_saveToBitwarden() async throws { + authItemRepository.pmSyncEnabled = true + application.canOpenUrlResponse = true + appSettingsStore.hasSeenDefaultSaveOptionPrompt = true + appSettingsStore.defaultSaveOption = .saveToBitwarden + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { captureCoordinator.routes.last != nil } + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + try await waitForAsync { self.authItemRepository.tempItem != nil } + try await waitForAsync { self.subject.state.url != nil } + let item = try XCTUnwrap(authItemRepository.tempItem) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + XCTAssertEqual(subject.state.url, ExternalLinksConstants.passwordManagerNewItem) + } + + /// `didCompleteAutomaticCapture` success when the user has no default save option set, chooses + /// to save locally and choose to not set that as their default. + func test_didCompleteAutomaticCapture_noDefault_saveLocally_noToDefault() async throws { + authItemRepository.pmSyncEnabled = true + appSettingsStore.hasSeenDefaultSaveOptionPrompt = false + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { !self.coordinator.alertShown.isEmpty } + + let alert = try XCTUnwrap(coordinator.alertShown.first) + XCTAssertEqual(alert.alertActions.count, 2) + let saveLocallyOption = try XCTUnwrap(alert.alertActions.first) + XCTAssertEqual(saveLocallyOption.title, Localizations.saveHere) + await saveLocallyOption.handler?(saveLocallyOption, []) + + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + + try await waitForAsync { self.coordinator.alertShown.count > 1 } + + let secondAlert = try XCTUnwrap(coordinator.alertShown[1]) + XCTAssertEqual(secondAlert.alertActions.count, 2) + let noOption = try XCTUnwrap(secondAlert.alertActions[1]) + XCTAssertEqual(noOption.title, Localizations.noAskMe) + Task { + await noOption.handler?(noOption, []) + } + + try await waitForAsync { !self.authItemRepository.addAuthItemAuthItems.isEmpty } + try await waitForAsync { self.subject.state.loadingState != .loading(nil) } + let item = try XCTUnwrap(authItemRepository.addAuthItemAuthItems.first) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + XCTAssertEqual(appSettingsStore.defaultSaveOption, .none) + } + + /// `didCompleteAutomaticCapture` success when the user has no default save option set, chooses + /// to save locally and choose to set that as their default. + func test_didCompleteAutomaticCapture_noDefault_saveLocally_yesToDefault() async throws { + authItemRepository.pmSyncEnabled = true + appSettingsStore.hasSeenDefaultSaveOptionPrompt = false + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { !self.coordinator.alertShown.isEmpty } + + let alert = try XCTUnwrap(coordinator.alertShown.first) + XCTAssertEqual(alert.alertActions.count, 2) + let saveLocallyOption = try XCTUnwrap(alert.alertActions.first) + XCTAssertEqual(saveLocallyOption.title, Localizations.saveHere) + await saveLocallyOption.handler?(saveLocallyOption, []) + + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + + try await waitForAsync { self.coordinator.alertShown.count > 1 } + + let secondAlert = try XCTUnwrap(coordinator.alertShown[1]) + XCTAssertEqual(secondAlert.alertActions.count, 2) + let yesOption = try XCTUnwrap(secondAlert.alertActions.first) + XCTAssertEqual(yesOption.title, Localizations.yesSetDefault) + Task { + await yesOption.handler?(yesOption, []) + } + + try await waitForAsync { !self.authItemRepository.addAuthItemAuthItems.isEmpty } + try await waitForAsync { self.subject.state.loadingState != .loading(nil) } + let item = try XCTUnwrap(authItemRepository.addAuthItemAuthItems.first) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + XCTAssertEqual(appSettingsStore.defaultSaveOption, .saveLocally) + } + + /// `didCompleteAutomaticCapture` success when the user has no default save option set, chooses + /// to save to Bitwarden and choose to not set that as their default. + func test_didCompleteAutomaticCapture_noDefault_saveToBitwarden_noToDefault() async throws { + authItemRepository.pmSyncEnabled = true + application.canOpenUrlResponse = true + appSettingsStore.hasSeenDefaultSaveOptionPrompt = false + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { !self.coordinator.alertShown.isEmpty } + + let alert = try XCTUnwrap(coordinator.alertShown.first) + XCTAssertEqual(alert.alertActions.count, 2) + let saveToBitwardenOption = try XCTUnwrap(alert.alertActions[1]) + XCTAssertEqual(saveToBitwardenOption.title, Localizations.takeMeToBitwarden) + await saveToBitwardenOption.handler?(saveToBitwardenOption, []) + + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + + try await waitForAsync { self.coordinator.alertShown.count > 1 } + + let secondAlert = try XCTUnwrap(coordinator.alertShown[1]) + XCTAssertEqual(secondAlert.alertActions.count, 2) + let noOption = try XCTUnwrap(secondAlert.alertActions[1]) + XCTAssertEqual(noOption.title, Localizations.noAskMe) + await noOption.handler?(noOption, []) + + try await waitForAsync { self.authItemRepository.tempItem != nil } + try await waitForAsync { self.subject.state.url != nil } + let item = try XCTUnwrap(authItemRepository.tempItem) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + XCTAssertEqual(subject.state.url, ExternalLinksConstants.passwordManagerNewItem) + XCTAssertEqual(appSettingsStore.defaultSaveOption, .none) + } + + /// `didCompleteAutomaticCapture` success when the user has no default save option set, chooses + /// to save to Bitwarden and choose to set that as their default. + func test_didCompleteAutomaticCapture_noDefault_saveToBitwarden_yesToDefault() async throws { + authItemRepository.pmSyncEnabled = true + application.canOpenUrlResponse = true + appSettingsStore.hasSeenDefaultSaveOptionPrompt = false + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { !self.coordinator.alertShown.isEmpty } + + let alert = try XCTUnwrap(coordinator.alertShown.first) + XCTAssertEqual(alert.alertActions.count, 2) + let saveLocallyOption = try XCTUnwrap(alert.alertActions[1]) + XCTAssertEqual(saveLocallyOption.title, Localizations.takeMeToBitwarden) + await saveLocallyOption.handler?(saveLocallyOption, []) + + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + + try await waitForAsync { self.coordinator.alertShown.count > 1 } + + let secondAlert = try XCTUnwrap(coordinator.alertShown[1]) + XCTAssertEqual(secondAlert.alertActions.count, 2) + let yesOption = try XCTUnwrap(secondAlert.alertActions.first) + XCTAssertEqual(yesOption.title, Localizations.yesSetDefault) + await yesOption.handler?(yesOption, []) + + try await waitForAsync { self.authItemRepository.tempItem != nil } + try await waitForAsync { self.subject.state.url != nil } + let item = try XCTUnwrap(authItemRepository.tempItem) + XCTAssertEqual(item.name, "") + XCTAssertEqual(item.totpKey, String.base32Key) + XCTAssertEqual(subject.state.url, ExternalLinksConstants.passwordManagerNewItem) + XCTAssertEqual(appSettingsStore.defaultSaveOption, .saveToBitwarden) + } + + /// `didCompleteAutomaticCapture` should not show any prompts or look at the defaults when the sync + /// is not active (either feature flag is disabled, or the user hasn't yet turned sync on). It should revert to the + /// pre-existing behavior and save the code locally. + func test_didCompleteAutomaticCapture_syncNotActive() async throws { + authItemRepository.pmSyncEnabled = false + let key = String.base32Key + let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) + totpService.getTOTPConfigResult = .success(keyConfig) + authItemRepository.itemListSubject.value = [ItemListSection.fixture()] + let captureCoordinator = MockCoordinator() + subject.didCompleteAutomaticCapture(captureCoordinator.asAnyCoordinator(), key: key) + try await waitForAsync { captureCoordinator.routes.last != nil } + var dismissAction: DismissAction? + if case let .dismiss(onDismiss) = captureCoordinator.routes.last { + dismissAction = onDismiss + } + XCTAssertNotNil(dismissAction) + dismissAction?.action() + try await waitForAsync { !self.authItemRepository.addAuthItemAuthItems.isEmpty } + try await waitForAsync { self.subject.state.loadingState != .loading(nil) } + let item = try XCTUnwrap(authItemRepository.addAuthItemAuthItems.first) XCTAssertEqual(item.name, "") XCTAssertEqual(item.totpKey, String.base32Key) } @@ -702,7 +1004,7 @@ class ItemListProcessorTests: AuthenticatorTestCase { // swiftlint:disable:this /// `didCompleteManualCapture` success with `sendToBitwarden` item func test_didCompleteManualCapture_sendToBitwardenSuccess() throws { - configService.featureFlagsBool[.enablePasswordManagerSync] = true + authItemRepository.pmSyncEnabled = true application.canOpenUrlResponse = true let key = String.otpAuthUriKeyComplete let keyConfig = try XCTUnwrap(TOTPKeyModel(authenticatorKey: key)) diff --git a/GlobalTestHelpers/Support/AuthenticatorTestCase.swift b/GlobalTestHelpers/Support/AuthenticatorTestCase.swift index 63f0b27f..878a6ec4 100644 --- a/GlobalTestHelpers/Support/AuthenticatorTestCase.swift +++ b/GlobalTestHelpers/Support/AuthenticatorTestCase.swift @@ -213,4 +213,59 @@ open class AuthenticatorTestCase: XCTestCase { line: line ) } + + /// Wait for a condition asynchronously to be true. The test will fail if the condition isn't met before the + /// specified timeout. + /// + /// - Parameters: + /// - condition: Return `true` to continue or `false` to keep waiting. + /// - timeout: How long to wait before failing. + /// - failureMessage: Message to display when the condition fails to be met. + /// - file: The file in which the failure occurred. Defaults to the file name of the test + /// case in which the function was called from. + /// - line: The line number in which the failure occurred. Defaults to the line number on + /// which this function was called from. + /// + open func waitForAsync( + _ condition: @escaping () -> Bool, + timeout: TimeInterval = 10.0, + failureMessage: String = "waitForAsync condition wasn't met within the time limit", + file: StaticString = #file, + line: UInt = #line + ) async throws { + let start = Date() + let limit = Date(timeIntervalSinceNow: timeout) + + while !condition(), limit > Date() { + try await Task.sleep(nanoseconds: 2 * 100_000_000) + } + + warnIfNeeded(start: start, line: line) + + XCTAssert(condition(), failureMessage, file: file, line: line) + } + + /// Warns if `functionName` took more than `afterSeconds` to complete + /// - Parameters: + /// - start: When `waitFor` started + /// - afterSeconds: The seconds that have passed since `start` to check against + /// - functionName: The function name + /// - line: File line were this was originated + private func warnIfNeeded( + start: Date, + afterSeconds: Int = 3, + functionName: String = #function, + line: UInt = #line + ) { + // If the condition took more than 3 seconds to satisfy, add a warning to the logs to look into it. + let elapsed = Date().timeIntervalSince(start) + if elapsed > 3 { + let numberFormatter = NumberFormatter() + numberFormatter.maximumFractionDigits = 3 + numberFormatter.minimumFractionDigits = 3 + numberFormatter.minimumIntegerDigits = 1 + let elapsedString: String = numberFormatter.string(from: NSNumber(value: elapsed)) ?? "nil" + print("warning: \(name) line \(line) `\(functionName)` took \(elapsedString) seconds") + } + } }