diff --git a/AuthenticatorShared/Core/Vault/Repositories/AnyTOTPRefreshingRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/AnyTOTPRefreshingRepository.swift new file mode 100644 index 0000000000..7286884011 --- /dev/null +++ b/AuthenticatorShared/Core/Vault/Repositories/AnyTOTPRefreshingRepository.swift @@ -0,0 +1,25 @@ +import BitwardenKit + +/// A struct to allow the use of `AuthenticatorItemRepository` in a generic context. +struct AnyTOTPRefreshingRepository: TOTPRefreshingRepository { + // MARK: Types + + /// The type os item in the list to be refreshed. + typealias Item = ItemListItem + + // MARK: Properties + + private let base: AuthenticatorItemRepository + + // MARK: Initialization + + init(_ base: AuthenticatorItemRepository) { + self.base = base + } + + // MARK: Methods + + func refreshTOTPCodes(for items: [ItemListItem]) async throws -> [ItemListItem] { + try await base.refreshTotpCodes(for: items) + } +} diff --git a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift index 3ec6eb4597..8e48fef32f 100644 --- a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift +++ b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift @@ -54,7 +54,7 @@ protocol AuthenticatorItemRepository: AnyObject { /// - items: The list of items that need updated TOTP codes. /// - Returns: A list of items with updated TOTP codes. /// - func refreshTotpCodes(on items: [ItemListItem]) async throws -> [ItemListItem] + func refreshTotpCodes(for items: [ItemListItem]) async throws -> [ItemListItem] /// Create a temporary shared item based on a `AuthenticatorItemView` for sharing with the BWPM app. /// This method will store it as a temporary item in the shared store. @@ -330,7 +330,7 @@ extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository { await sharedItemService.isSyncOn() } - func refreshTotpCodes(on items: [ItemListItem]) async throws -> [ItemListItem] { + func refreshTotpCodes(for items: [ItemListItem]) async throws -> [ItemListItem] { try await items.asyncMap { item in let keyModel: TOTPKeyModel? switch item.itemType { @@ -409,4 +409,15 @@ extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository { .eraseToAnyPublisher() .values } +} + +extension DefaultAuthenticatorItemRepository: TOTPRefreshingRepository { + // MARK: Types + + /// The type os item in the list to be refreshed. + typealias Item = ItemListItem + + func refreshTOTPCodes(for items: [ItemListItem]) async throws -> [ItemListItem] { + try await refreshTotpCodes(for: items) + } } // swiftlint:disable:this file_length diff --git a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepositoryTests.swift b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepositoryTests.swift index 49bcf996a7..2628a9a9bf 100644 --- a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepositoryTests.swift +++ b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepositoryTests.swift @@ -149,7 +149,7 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable func test_refreshTotpCodes_errorSharedTotp() async throws { let item = ItemListItem.fixtureShared(totp: .fixture(itemView: .fixture(totpKey: nil))) - let result = try await subject.refreshTotpCodes(on: [item]) + let result = try await subject.refreshTotpCodes(for: [item]) let actual = try XCTUnwrap(result[0]) let error = try XCTUnwrap(errorReporter.errors[0] as? TOTPServiceError) XCTAssertEqual( @@ -166,7 +166,7 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable func test_refreshTotpCodes_errorTotp() async throws { let item = ItemListItem.fixture(totp: .fixture(itemView: .fixture(totpKey: nil))) - let result = try await subject.refreshTotpCodes(on: [item]) + let result = try await subject.refreshTotpCodes(for: [item]) let actual = try XCTUnwrap(result[0]) let error = try XCTUnwrap(errorReporter.errors[0] as? TOTPServiceError) XCTAssertEqual( @@ -191,7 +191,7 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable let item = ItemListItem.fixture() let sharedItem = ItemListItem.fixtureShared() - let result = try await subject.refreshTotpCodes(on: [item, sharedItem, .syncError()]) + let result = try await subject.refreshTotpCodes(for: [item, sharedItem, .syncError()]) let actual = try XCTUnwrap(result[0]) XCTAssertEqual(actual.id, item.id) diff --git a/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift index e87df99a34..53680a1b05 100644 --- a/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift +++ b/AuthenticatorShared/Core/Vault/Repositories/TestHelpers/MockAuthenticatorItemRepository.swift @@ -63,7 +63,7 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository { pmSyncEnabled } - func refreshTotpCodes(on items: [ItemListItem]) async throws -> [ItemListItem] { + func refreshTotpCodes(for items: [ItemListItem]) async throws -> [ItemListItem] { refreshedTotpTime = timeProvider.presentTime refreshedTotpCodes = items return try refreshTotpCodesResult.get() diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift index 108d352c87..8a7709d2f2 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -38,6 +38,9 @@ final class ItemListProcessor: StateProcessor [ItemListItem] { + private func searchItems(for searchText: String) async { guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return [] + state.searchResults = [] + return } do { let result = try await services.authenticatorItemRepository.searchItemListPublisher( searchText: searchText ) for try await items in result { - let itemList = try await services.authenticatorItemRepository.refreshTotpCodes(on: items) - groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: itemList) - return itemList + state.searchResults = try await services.authenticatorItemRepository.refreshTotpCodes(for: items) + searchTotpExpirationManager?.configureTOTPRefreshScheduling(for: state.searchResults) } } catch { services.errorReporter.log(error: error) } - return [] } /// Subscribe to receive foreground notifications so that we can refresh the item list when the app is relaunched. @@ -319,7 +347,7 @@ final class ItemListProcessor: StateProcessor Void)? - - // MARK: Private Properties - - /// All items managed by the object, grouped by TOTP period. - /// - private(set) var itemsByInterval = [UInt32: [ItemListItem]]() - - /// A model to provide time to calculate the countdown. - /// - private var timeProvider: any TimeProvider - - /// A timer that triggers `checkForExpirations` to manage code expirations. - /// - private var updateTimer: Timer? - - /// Initializes a new countdown timer - /// - /// - Parameters - /// - timeProvider: A protocol providing the present time as a `Date`. - /// Used to calculate time remaining for a present TOTP code. - /// - onExpiration: A closure to call on code expiration for a list of vault items. - /// - init( - timeProvider: any TimeProvider, - onExpiration: (([ItemListItem]) -> Void)? - ) { - self.timeProvider = timeProvider - self.onExpiration = onExpiration - updateTimer = Timer.scheduledTimer( - withTimeInterval: 0.25, - repeats: true, - block: { _ in - self.checkForExpirations() - } - ) - } - - /// Clear out any timers tracking TOTP code expiration - deinit { - cleanup() - } - - // MARK: Methods - - /// Configures TOTP code refresh scheduling - /// - /// - Parameter items: The vault list items that may require code expiration tracking. - /// - func configureTOTPRefreshScheduling(for items: [ItemListItem]) { - var newItemsByInterval = [UInt32: [ItemListItem]]() - items.forEach { item in - if let totpCodeModel = item.totpCodeModel { - newItemsByInterval[totpCodeModel.period, default: []].append(item) - } - } - itemsByInterval = newItemsByInterval - } - - /// A function to remove any outstanding timers - /// - func cleanup() { - updateTimer?.invalidate() - updateTimer = nil - } - - private func checkForExpirations() { - var expired = [ItemListItem]() - var notExpired = [UInt32: [ItemListItem]]() - itemsByInterval.forEach { period, items in - let sortedItems: [Bool: [ItemListItem]] = TOTPExpirationCalculator.listItemsByExpiration( - items, - timeProvider: timeProvider - ) - expired.append(contentsOf: sortedItems[true] ?? []) - notExpired[period] = sortedItems[false] - } - itemsByInterval = notExpired - guard !expired.isEmpty else { return } - onExpiration?(expired) - } -} - extension ItemListProcessor: AuthenticatorKeyCaptureDelegate { func didCompleteAutomaticCapture( _ captureCoordinator: AnyCoordinator, @@ -675,3 +610,20 @@ extension ItemListProcessor: AuthenticatorItemOperationDelegate { state.toast = Toast(text: Localizations.itemDeleted) } } + +// MARK: - HasTOTPCodesSection + +extension ItemListProcessor: HasTOTPCodesSections { + // MARK: Types + + /// The type of item in the list. + typealias Item = ItemListItem + /// The type of section in the list. + typealias Section = ItemListSection + /// The type of repository that contains item to be refreshed. + typealias Repository = AnyTOTPRefreshingRepository + + var repository: AnyTOTPRefreshingRepository { + AnyTOTPRefreshingRepository(services.authenticatorItemRepository) + } +} diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListSection.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListSection.swift index f4ef7434aa..7f9c3da6a6 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListSection.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListSection.swift @@ -1,14 +1,37 @@ -/// Data model for a section of items in the item list +import BitwardenKit + +/// Data model for a section of items in the item list. /// public struct ItemListSection: Equatable, Identifiable { // MARK: Properties - /// The identifier for the section + /// The identifier for the section. public let id: String - /// The list of items in the section + /// The list of items in the section. public let items: [ItemListItem] - /// The name of the section, displayed as a section header + /// The name of the section, displayed as a section header. public let name: String } + +/// The section of items in the item list. +/// +extension ItemListSection: TOTPUpdatableSection { + // MARK: Types + + /// The type of item in the list section. + public typealias Item = ItemListItem + + // MARK: Methods + + /// Update the array of sections with a batch of refreshed items. + /// + /// - Parameters: + /// - items: An array of updated items that should replace matching items in the current sections. + /// - sections: The array of sections to update. + /// - Returns: A new array of sections with the updated items applied. + public static func updated(with items: [ItemListItem], from sections: [ItemListSection]) -> [ItemListSection] { + sections.updated(with: items) + } +} diff --git a/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift b/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift new file mode 100644 index 0000000000..69e0385d8b --- /dev/null +++ b/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift @@ -0,0 +1,13 @@ +import BitwardenKit + +extension HasTOTPCodesSections where Item == ItemListItem, Section == ItemListSection { + func refreshTOTPCodes( + for items: [ItemListItem], + in sections: [ItemListSection], + using manager: TOTPExpirationManager? + ) async throws -> [ItemListSection] { + let updated = try await refreshTOTPCodes(for: items, in: sections) + manager?.configureTOTPRefreshScheduling(for: updated.flatMap(\.items)) + return updated + } +} diff --git a/AuthenticatorShared/UI/Vault/Utilities/TOTPExpirationManager.swift b/AuthenticatorShared/UI/Vault/Utilities/TOTPExpirationManager.swift new file mode 100644 index 0000000000..7f5b9981de --- /dev/null +++ b/AuthenticatorShared/UI/Vault/Utilities/TOTPExpirationManager.swift @@ -0,0 +1,108 @@ +import BitwardenKit +import Combine +import Foundation + +/// A class to manage TOTP code expirations for the ItemListProcessor and batch refresh calls. +/// +class TOTPExpirationManager { + // MARK: Properties + + /// A closure to call on expiration + /// + var onExpiration: (([ItemListItem]) -> Void)? + + // MARK: Private Properties + + /// All items managed by the object, grouped by TOTP period. + /// + private(set) var itemsByInterval = [UInt32: [ItemListItem]]() + + /// A model to provide time to calculate the countdown. + /// + private var timeProvider: any TimeProvider + + /// A timer that triggers `checkForExpirations` to manage code expirations. + /// + private var updateTimer: Timer? + + /// Initializes a new countdown timer + /// + /// - Parameters + /// - timeProvider: A protocol providing the present time as a `Date`. + /// Used to calculate time remaining for a present TOTP code. + /// - onExpiration: A closure to call on code expiration for a list of vault items. + /// + init( + timeProvider: any TimeProvider, + onExpiration: (([ItemListItem]) -> Void)? + ) { + self.timeProvider = timeProvider + self.onExpiration = onExpiration + updateTimer = Timer.scheduledTimer( + withTimeInterval: 0.25, + repeats: true, + block: { _ in + self.checkForExpirations() + } + ) + } + + /// Clear out any timers tracking TOTP code expiration + deinit { + cleanup() + } + + // MARK: Methods + + /// Configures TOTP code refresh scheduling + /// + /// - Parameter items: The vault list items that may require code expiration tracking. + /// + func configureTOTPRefreshScheduling(for items: [ItemListItem]) { + var newItemsByInterval = [UInt32: [ItemListItem]]() + items.forEach { item in + if let totpCodeModel = item.totpCodeModel { + newItemsByInterval[totpCodeModel.period, default: []].append(item) + } + } + itemsByInterval = newItemsByInterval + } + + /// A function to remove any outstanding timers + /// + func cleanup() { + updateTimer?.invalidate() + updateTimer = nil + } + + private func checkForExpirations() { + var expired = [ItemListItem]() + var notExpired = [UInt32: [ItemListItem]]() + itemsByInterval.forEach { period, items in + let sortedItems: [Bool: [ItemListItem]] = TOTPExpirationCalculator.listItemsByExpiration( + items, + timeProvider: timeProvider + ) + expired.append(contentsOf: sortedItems[true] ?? []) + notExpired[period] = sortedItems[false] + } + itemsByInterval = notExpired + guard !expired.isEmpty else { return } + onExpiration?(expired) + } +} + +class NewTOTPExpirationManager: TOTPExpirationManager { + private var cancellable: AnyCancellable? + + init( + timeProvider: any TimeProvider, + onExpiration: (([ItemListItem]) -> Void)?, + itemPublisher: AnyPublisher<[ItemListSection]?, Never> + ) { + super.init(timeProvider: timeProvider, onExpiration: onExpiration) + cancellable = itemPublisher.sink { [weak self] sections in + self?.configureTOTPRefreshScheduling(for: sections?.flatMap(\.items) ?? []) + } + } +} diff --git a/BitwardenKit/UI/Vault/Utilities/HasTOTPCodesSections.swift b/BitwardenKit/UI/Vault/Utilities/HasTOTPCodesSections.swift new file mode 100644 index 0000000000..54f68f89ab --- /dev/null +++ b/BitwardenKit/UI/Vault/Utilities/HasTOTPCodesSections.swift @@ -0,0 +1,65 @@ +import Foundation + +/// A protocol to work with processors that have TOTP sections. +@MainActor +public protocol HasTOTPCodesSections { + // MARK: Types + + /// The type of item contained in each section. + associatedtype Item + /// The type of section contained in the list. + associatedtype Section: TOTPUpdatableSection where Section.Item == Item + /// The type of repository that has item to refresh. + associatedtype Repository: TOTPRefreshingRepository where Repository.Item == Item + + // MARK: Methods + + /// The repository used to refresh TOTP codes. + var repository: Repository { get } + + /// Refresh the TOTP codes for items in the given sections. + func refreshTOTPCodes(for items: [Item], in sections: [Section]) async throws -> [Section] +} + +public extension HasTOTPCodesSections { + /// Refresh the TOTP codes for items in the given sections. + func refreshTOTPCodes(for items: [Item], in sections: [Section]) async throws -> [Section] { + let refreshed = try await repository.refreshTOTPCodes(for: items) + return Section.updated(with: refreshed, from: sections) + } +} + +/// The repository used to refresh TOTP codes. +/// +public protocol TOTPRefreshingRepository { + // MARK: Types + + /// The type of item contained in each section. + associatedtype Item + + // MARK: Methods + + /// Refresh TOTP codes for the given items. + func refreshTOTPCodes(for items: [Item]) async throws -> [Item] +} + +/// A section type that supports updating its items with refreshed values. +/// +public protocol TOTPUpdatableSection { + // MARK: Types + + /// The type of item contained in each section. + associatedtype Item + /// The list of item contained in the section. + var items: [Item] { get } + + // MARK: Methods + + /// Update the array of sections with a batch of refreshed items. + /// + /// - Parameters: + /// - items: An array of updated items that should replace matching items in the current sections. + /// - sections: The array of sections to update. + /// - Returns: A new array of sections with the updated items applied. + static func updated(with items: [Item], from sections: [Self]) -> [Self] +} diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift index 4591a707d0..79f9c7263e 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift @@ -337,7 +337,7 @@ class DefaultVaultRepository { // swiftlint:disable:this type_body_length /// The service to get server-specified configuration. private let configService: ConfigService - + /// The helper functions for collections. private let collectionHelper: CollectionHelper diff --git a/BitwardenShared/UI/Vault/Utilities/HasTOTPCodesSections.swift b/BitwardenShared/UI/Vault/Utilities/HasTOTPCodesSections.swift index 672b89676b..4f2096fc85 100644 --- a/BitwardenShared/UI/Vault/Utilities/HasTOTPCodesSections.swift +++ b/BitwardenShared/UI/Vault/Utilities/HasTOTPCodesSections.swift @@ -7,8 +7,7 @@ protocol HasTOTPCodesSections { /// Refreshes the TOTP Codes from items in sections using the corresponding manager. func refreshTOTPCodes( for items: [VaultListItem], - in sections: [VaultListSection], - using manager: TOTPExpirationManager? + in sections: [VaultListSection] ) async throws -> [VaultListSection] } @@ -17,12 +16,9 @@ extension HasTOTPCodesSections { func refreshTOTPCodes( for items: [VaultListItem], in sections: [VaultListSection], - using manager: TOTPExpirationManager? ) async throws -> [VaultListSection] { let refreshedItems = try await vaultRepository.refreshTOTPCodes(for: items) let updatedSections = sections.updated(with: refreshedItems) - let allItems = updatedSections.flatMap(\.items) - manager?.configureTOTPRefreshScheduling(for: allItems) return updatedSections } } diff --git a/BitwardenShared/UI/Vault/Utilities/TOTPExpirationManager.swift b/BitwardenShared/UI/Vault/Utilities/TOTPExpirationManager.swift index b6ff938023..9c43fe3c7b 100644 --- a/BitwardenShared/UI/Vault/Utilities/TOTPExpirationManager.swift +++ b/BitwardenShared/UI/Vault/Utilities/TOTPExpirationManager.swift @@ -1,4 +1,5 @@ import BitwardenKit +import Combine import Foundation /// A protocol to manage TOTP code expirations for `VaultListItem`s and batch refresh calls. @@ -107,3 +108,18 @@ class DefaultTOTPExpirationManager: TOTPExpirationManager { onExpiration?(expired) } } + +class NewTOTPExpirationManager: DefaultTOTPExpirationManager { + private var cancellable: AnyCancellable? + + init( + timeProvider: any TimeProvider, + onExpiration: (([VaultListItem]) -> Void)?, + itemPublisher: AnyPublisher<[VaultListSection]?, Never> + ) { + super.init(timeProvider: timeProvider, onExpiration: onExpiration) + cancellable = itemPublisher.sink { [weak self] sections in + self?.configureTOTPRefreshScheduling(for: sections?.flatMap(\.items) ?? []) + } + } +} diff --git a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift index 38bc3db03f..9ac04ffbfb 100644 --- a/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/AutofillList/VaultAutofillListProcessor.swift @@ -279,21 +279,25 @@ class VaultAutofillListProcessor: StateProcessor [VaultListItem] { + private func searchGroup(for searchText: String) async { guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return [] + state.searchResults = [] + return } do { let result = try await services.vaultRepository.searchVaultListPublisher( @@ -267,12 +270,12 @@ final class VaultGroupProcessor: StateProcessor< filter: VaultListFilter(filterType: state.searchVaultFilterType) ) for try await ciphers in result { - return ciphers + state.searchResults = ciphers + searchTotpExpirationManager?.configureTOTPRefreshScheduling(for: state.searchResults) } } catch { services.errorReporter.log(error: error) } - return [] } /// Streams the user's organizations. diff --git a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift index a279714c53..64594bfb91 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift @@ -249,12 +249,14 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty /// `perform(.search)` with a keyword should update search results in state. @MainActor - func test_perform_search() async { + func test_perform_search() { let searchResult: [CipherListView] = [.fixture(name: "example")] vaultRepository.searchVaultListSubject.value = searchResult.compactMap { VaultListItem(cipherListView: $0) } subject.state.searchVaultFilterType = .organization(.fixture(id: "id1")) - await subject.perform(.search("example")) - XCTAssertEqual(subject.state.searchResults.count, 1) + let task = Task { + await subject.perform(.search("example")) + } + waitFor(!subject.state.searchResults.isEmpty) XCTAssertEqual( vaultRepository.searchVaultListFilterType?.filterType, .organization(.fixture(id: "id1")) @@ -263,6 +265,8 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty subject.state.searchResults, try [VaultListItem.fixture(cipherListView: XCTUnwrap(searchResult.first))] ) + + task.cancel() } /// `perform(.search)` throws error and error is logged. diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift index 119ff34660..65f2d74a3d 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift @@ -114,7 +114,7 @@ final class VaultListProcessor: StateProcessor< case .refreshVault: await refreshVault(syncWithPeriodicCheck: false) case let .search(text): - state.searchResults = await searchVault(for: text) + await searchVault(for: text) case .streamAccountSetupProgress: await streamAccountSetupProgress() case .streamFlightRecorderLog: @@ -419,14 +419,14 @@ extension VaultListProcessor { ) } - /// Searches the vault using the provided string, and returns any matching results. + /// Searches the vault using the provided string and sets to state any matching results. /// /// - Parameter searchText: The string to use when searching the vault. - /// - Returns: An array of `VaultListItem`s. If no results can be found, an empty array will be returned. /// - private func searchVault(for searchText: String) async -> [VaultListItem] { + private func searchVault(for searchText: String) async { guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return [] + state.searchResults = [] + return } do { let result = try await services.vaultRepository.searchVaultListPublisher( @@ -434,12 +434,11 @@ extension VaultListProcessor { filter: VaultListFilter(filterType: state.searchVaultFilterType) ) for try await ciphers in result { - return ciphers + state.searchResults = ciphers } } catch { services.errorReporter.log(error: error) } - return [] } /// Sets the user's import logins progress. diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift index a62aa64175..af0278e037 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -784,16 +784,20 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ /// `perform(.search)` with a keyword should update search results in state. @MainActor - func test_perform_search() async { + func test_perform_search() { let searchResult: [CipherListView] = [.fixture(name: "example")] vaultRepository.searchVaultListSubject.value = searchResult.compactMap { VaultListItem(cipherListView: $0) } - await subject.perform(.search("example")) + let task = Task { + await subject.perform(.search("example")) + } - XCTAssertEqual(subject.state.searchResults.count, 1) + waitFor(!subject.state.searchResults.isEmpty) XCTAssertEqual( subject.state.searchResults, try [VaultListItem.fixture(cipherListView: XCTUnwrap(searchResult.first))] ) + + task.cancel() } /// `perform(.search)` throws error and error is logged.