diff --git a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift index 4347cf4ed8..d8350f9c6c 100644 --- a/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift +++ b/BitwardenShared/Core/Platform/Repositories/SettingsRepository.swift @@ -35,6 +35,8 @@ protocol SettingsRepository: AnyObject { /// Get the current value of the allow sync on refresh value. func getAllowSyncOnRefresh() async throws -> Bool + func getAutofillFilter() async throws -> AutofillFilter + /// Get the current value of the connect to watch setting. func getConnectToWatch() async throws -> Bool @@ -62,6 +64,8 @@ protocol SettingsRepository: AnyObject { /// func updateAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool) async throws + func updateAutofillFilter(_ filter: AutofillFilter) async throws + /// Update the cached value of the connect to watch setting. /// /// - Parameter connectToWatch: Whether to connect to the watch app. @@ -179,6 +183,14 @@ extension DefaultSettingsRepository: SettingsRepository { try await stateService.getAllowSyncOnRefresh() } + func getAutofillFilter() async throws -> AutofillFilter { + let filter = try await stateService.getAutofillFilter() + guard let filter else { + return AutofillFilter(idType: .none) + } + return filter + } + func getConnectToWatch() async throws -> Bool { try await stateService.getConnectToWatch() } @@ -203,6 +215,14 @@ extension DefaultSettingsRepository: SettingsRepository { try await stateService.setAllowSyncOnRefresh(allowSyncOnRefresh) } + func updateAutofillFilter(_ filter: AutofillFilter) async throws { + guard filter.idType != .none else { + try await stateService.setAutofillFilter(nil) + return + } + try await stateService.setAutofillFilter(filter) + } + func updateConnectToWatch(_ connectToWatch: Bool) async throws { try await stateService.setConnectToWatch(connectToWatch) } diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index 9042241645..9e5b25a77b 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -129,6 +129,8 @@ protocol StateService: AnyObject { /// - Returns: The app theme. /// func getAppTheme() async -> AppTheme + + func getAutofillFilter(userId: String?) async throws -> AutofillFilter? /// Get the active user's Biometric Authentication Preference. /// @@ -442,6 +444,8 @@ protocol StateService: AnyObject { /// - Parameter appTheme: The new app theme. /// func setAppTheme(_ appTheme: AppTheme) async + + func setAutofillFilter(_ filter: AutofillFilter?, userId: String?) async throws /// Sets the user's Biometric Authentication Preference. /// @@ -822,6 +826,10 @@ extension StateService { func getAppRehydrationState() async throws -> AppRehydrationState? { try await getAppRehydrationState(userId: nil) } + + func getAutofillFilter() async throws -> AutofillFilter? { + try await getAutofillFilter(userId: nil) + } /// Gets the clear clipboard value for the active account. /// @@ -1050,6 +1058,10 @@ extension StateService { func setAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool) async throws { try await setAllowSyncOnRefresh(allowSyncOnRefresh, userId: nil) } + + func setAutofillFilter(_ filter: AutofillFilter?) async throws { + try await setAutofillFilter(filter, userId: nil) + } /// Sets the clear clipboard value for the active account. /// @@ -1425,6 +1437,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le AppTheme(appSettingsStore.appTheme) } + func getAutofillFilter(userId: String?) async throws -> AutofillFilter? { + let userId = try userId ?? getActiveAccountUserId() + return appSettingsStore.autofillFilter(userId: userId) + } + func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue { let userId = try userId ?? getActiveAccountUserId() return appSettingsStore.clearClipboardValue(userId: userId) @@ -1687,6 +1704,11 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le appSettingsStore.appTheme = appTheme.value appThemeSubject.send(appTheme) } + + func setAutofillFilter(_ filter: AutofillFilter?, userId: String?) async throws { + let userId = try userId ?? getActiveAccountUserId() + appSettingsStore.setAutofillFilter(filter, userId: userId) + } func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws { let userId = try userId ?? getActiveAccountUserId() diff --git a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift index 566a11c9b2..318937f35e 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/AppSettingsStore.swift @@ -93,6 +93,8 @@ protocol AppSettingsStore: AnyObject { /// - Parameter userId: The user ID associated with this state. /// - Returns: The rehydration state. func appRehydrationState(userId: String) -> AppRehydrationState? + + func autofillFilter(userId: String) -> AutofillFilter? /// Gets the time after which the clipboard should be cleared. /// @@ -283,6 +285,8 @@ protocol AppSettingsStore: AnyObject { /// - userId: The user ID associated with the sync on refresh setting. /// func setAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool?, userId: String) + + func setAutofillFilter(_ filter: AutofillFilter?, userId: String) /// Sets the user's Biometric Authentication Preference. /// @@ -677,6 +681,7 @@ extension DefaultAppSettingsStore: AppSettingsStore { case accountSetupVaultUnlock(userId: String) case addSitePromptShown case allowSyncOnRefresh(userId: String) + case autofillFilter(userId: String) case appId case appLocale case appRehydrationState(userId: String) @@ -734,6 +739,8 @@ extension DefaultAppSettingsStore: AppSettingsStore { key = "addSitePromptShown" case let .allowSyncOnRefresh(userId): key = "syncOnRefresh_\(userId)" + case let .autofillFilter(userId): + key = "autofillFilter_\(userId)" case .appId: key = "appId" case .appLocale: @@ -924,6 +931,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { func appRehydrationState(userId: String) -> AppRehydrationState? { fetch(for: .appRehydrationState(userId: userId)) } + + func autofillFilter(userId: String) -> AutofillFilter? { + fetch(for: .autofillFilter(userId: userId)) + } func clearClipboardValue(userId: String) -> ClearClipboardValue { if let rawValue: Int = fetch(for: .clearClipboardValue(userId: userId)), @@ -1026,6 +1037,10 @@ extension DefaultAppSettingsStore: AppSettingsStore { func setAllowSyncOnRefresh(_ allowSyncOnRefresh: Bool?, userId: String) { store(allowSyncOnRefresh, for: .allowSyncOnRefresh(userId: userId)) } + + func setAutofillFilter(_ filter: AutofillFilter?, userId: String) { + store(filter, for: .autofillFilter(userId: userId)) + } func setAppRehydrationState(_ state: AppRehydrationState?, userId: String) { store(state, for: .appRehydrationState(userId: userId)) diff --git a/BitwardenShared/Core/Platform/Utilities/AutofillFilter.swift b/BitwardenShared/Core/Platform/Utilities/AutofillFilter.swift new file mode 100644 index 0000000000..05e7c40bf9 --- /dev/null +++ b/BitwardenShared/Core/Platform/Utilities/AutofillFilter.swift @@ -0,0 +1,28 @@ +import BitwardenSdk + +struct AutofillFilter: Menuable, Codable { + var idType: AutofillFilterType + var folderName: String? + + var localizedName: String { + switch idType { + case .none: + "None" + case .favorites: + Localizations.favorites + case .folder: + folderName ?? "Unknown" + } + } + + init(idType: AutofillFilterType, folderName: String? = nil) { + self.idType = idType + self.folderName = folderName + } +} + +enum AutofillFilterType: Equatable, Hashable, Codable { + case none + case favorites + case folder(Uuid) +} diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift index 29519bb1ea..4e19a33f1f 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift @@ -504,6 +504,7 @@ class DefaultVaultRepository { // swiftlint:disable:this type_body_length searchText: String, filterType: VaultFilterType, isActive: Bool, + isAutofilling: Bool = false, cipherFilter: ((CipherView) -> Bool)? = nil ) async throws -> AnyPublisher<[CipherView], Error> { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) @@ -519,7 +520,7 @@ class DefaultVaultRepository { // swiftlint:disable:this type_body_length cipher.type != .sshKey || isSSHKeyVaultItemEnabled } - return try await cipherService.ciphersPublisher().asyncTryMap { ciphers -> [CipherView] in + return try await cipherService.ciphersPublisher(isAutofilling: isAutofilling).asyncTryMap { ciphers -> [CipherView] in // Convert the Ciphers to CipherViews and filter appropriately. let matchingCiphers = try await ciphers.asyncMap { cipher in try await self.clientService.vault().ciphers().decrypt(cipher: cipher) @@ -1226,7 +1227,7 @@ extension DefaultVaultRepository: VaultRepository { } return try await Publishers.CombineLatest( - cipherService.ciphersPublisher(), + cipherService.ciphersPublisher(isAutofilling: true), availableFido2CredentialsPublisher ) .asyncTryMap { ciphers, availableFido2Credentials in @@ -1266,7 +1267,8 @@ extension DefaultVaultRepository: VaultRepository { searchPublisher( searchText: searchText, filterType: filterType, - isActive: true + isActive: true, + isAutofilling: true ) { cipher in cipher.type == .login }, diff --git a/BitwardenShared/Core/Vault/Services/CipherService.swift b/BitwardenShared/Core/Vault/Services/CipherService.swift index 7b20789052..7b8d6cdf5b 100644 --- a/BitwardenShared/Core/Vault/Services/CipherService.swift +++ b/BitwardenShared/Core/Vault/Services/CipherService.swift @@ -133,7 +133,13 @@ protocol CipherService { /// /// - Returns: The list of encrypted ciphers. /// - func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> + func ciphersPublisher(isAutofilling: Bool) async throws -> AnyPublisher<[Cipher], Error> +} + +extension CipherService { + func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> { + try await ciphersPublisher(isAutofilling: false) + } } // MARK: - DefaultCipherService @@ -362,8 +368,28 @@ extension DefaultCipherService { // MARK: Publishers - func ciphersPublisher() async throws -> AnyPublisher<[Cipher], Error> { + func ciphersPublisher(isAutofilling: Bool) async throws -> AnyPublisher<[Cipher], Error> { let userId = try await stateService.getActiveAccountId() - return cipherDataStore.cipherPublisher(userId: userId) + let ciphersPublisher = cipherDataStore.cipherPublisher(userId: userId) + if !isAutofilling { + return ciphersPublisher + } + let autofillFilter = try await stateService.getAutofillFilter(userId: userId) + guard let autofillFilter else { + return ciphersPublisher + } + return switch autofillFilter.idType { + case .none: + ciphersPublisher + case .favorites: + ciphersPublisher.map { ciphers in + ciphers.filter{ $0.favorite } + } + .eraseToAnyPublisher() + case let .folder(folderId): + ciphersPublisher.map { ciphers in + ciphers.filter { $0.folderId == folderId } + }.eraseToAnyPublisher() + } } } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift index 657e3dbdd6..0644f36e0e 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift @@ -5,6 +5,9 @@ enum AutoFillAction: Equatable { /// The app extension button was tapped. case appExtensionTapped + + /// The autofill filter was changed. + case autofillFilterChanged(AutofillFilter) /// The default URI match type was changed. case defaultUriMatchTypeChanged(UriMatchType) diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift index 081a3304e7..582be494e6 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift @@ -47,6 +47,7 @@ final class AutoFillProcessor: StateProcessor AutofillFilter? in + guard let id = folder.id else { + return nil + } + return AutofillFilter( + idType: .folder(id), + folderName: folder.name + ) + } + var autofillFilterOptions = [ + AutofillFilter(idType: .none), + AutofillFilter(idType: .favorites) + ] + if !folderOptions.isEmpty { + autofillFilterOptions.append(contentsOf: folderOptions) + } + state.autofillFilterOptions = autofillFilterOptions + } + } catch { + services.errorReporter.log(error: error) + } + } + /// Streams the state of the badges in the settings tab. /// private func streamSettingsBadge() async { diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillState.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillState.swift index 10cd51b23e..d293295b15 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillState.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillState.swift @@ -5,6 +5,13 @@ struct AutoFillState { // MARK: Properties + var autofillFilter: AutofillFilter = AutofillFilter(idType: .none) + + var autofillFilterOptions: [AutofillFilter] = [ + AutofillFilter(idType: .none), + AutofillFilter(idType: .favorites) + ] + /// The state of the badges in the settings tab. var badgeState: SettingsBadgeState? diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillView.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillView.swift index 20fe543569..04311c6b43 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillView.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillView.swift @@ -90,6 +90,21 @@ struct AutoFillView: View { .styleGuide(.subheadline) .foregroundColor(Color(asset: Asset.Colors.textSecondary)) } + + VStack(spacing: 2) { + SettingsMenuField( + title: "Autofill vault filter", + options: store.state.autofillFilterOptions, + hasDivider: false, + selection: store.binding( + get: \.autofillFilter, + send: AutoFillAction.autofillFilterChanged + ) + ) + .cornerRadius(10) + .padding(.bottom, 8) + .accessibilityIdentifier("AutofillFilterChooser") + } } }