From 2b1e31c8d5af037a7efbdbb4d42fd288a99fc12a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gonc=CC=A7alves?= Date: Mon, 22 Sep 2025 16:04:57 +0100 Subject: [PATCH 1/5] pm-11707 Subscribe to search results live instead of a static array --- .../ItemList/ItemList/ItemListProcessor.swift | 52 ++++++++++++------- .../VaultGroup/VaultGroupProcessor.swift | 16 +++--- .../VaultGroup/VaultGroupProcessorTests.swift | 10 ++-- .../Vault/VaultList/VaultListProcessor.swift | 13 +++-- .../VaultList/VaultListProcessorTests.swift | 10 ++-- 5 files changed, 61 insertions(+), 40 deletions(-) diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift index 108d352c87..10ffc63f4d 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(on: 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. @@ -329,9 +348,6 @@ final class ItemListProcessor: 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 +265,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 d17facf9b5..92f2a7f3ff 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift @@ -113,7 +113,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: @@ -398,14 +398,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( @@ -413,12 +413,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 42191ed788..5e78a54a36 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -717,16 +717,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. From 9b994cb3ab06ac6b7a2a167429d44d6832375f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gonc=CC=A7alves?= Date: Mon, 29 Sep 2025 16:27:06 +0100 Subject: [PATCH 2/5] pm-11707 Refactor to improve code reuse --- .../AuthenticatorItemRepository.swift | 4 +- .../AuthenticatorItemRepositoryTests.swift | 6 +- .../MockAuthenticatorItemRepository.swift | 2 +- .../ItemList/ItemList/ItemListProcessor.swift | 130 ++++-------------- .../Utilities/HasTOTPCodesSections.swift | 28 ++++ .../Utilities/TOTPExpirationManager.swift | 92 +++++++++++++ 6 files changed, 151 insertions(+), 111 deletions(-) create mode 100644 AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift create mode 100644 AuthenticatorShared/UI/Vault/Utilities/TOTPExpirationManager.swift diff --git a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift index 3ec6eb4597..bae3adf525 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 { 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 10ffc63f4d..1e6d95e8b3 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -8,7 +8,7 @@ import Foundation // MARK: - ItemListProcessor /// A `Processor` that can process `ItemListAction` and `ItemListEffect` objects. -final class ItemListProcessor: StateProcessor { +final class ItemListProcessor: StateProcessor, HasTOTPCodesSections { // swiftlint:disable:previous type_body_length // MARK: Types @@ -25,6 +25,10 @@ final class ItemListProcessor: StateProcessor() @@ -210,13 +214,17 @@ 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, diff --git a/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift b/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift new file mode 100644 index 0000000000..9ac5cc195d --- /dev/null +++ b/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift @@ -0,0 +1,28 @@ +/// A protocol to work with processors that have TOTP sections. +@MainActor +protocol HasTOTPCodesSections { + /// The repository used by the application to manage vault data for the UI layer. + var authenticatorItemRepository: AuthenticatorItemRepository { get } + + /// Refreshes the TOTP Codes from items in sections using the corresponding manager. + func refreshTOTPCodes( + for items: [ItemListItem], + in sections: [ItemListSection], + using manager: TOTPExpirationManager? + ) async throws -> [ItemListSection] +} + +/// Extension of the `HasTOTPCodesSections` protocol for some common behavior. +extension HasTOTPCodesSections { + func refreshTOTPCodes( + for items: [ItemListItem], + in sections: [ItemListSection], + using manager: TOTPExpirationManager? + ) async throws -> [ItemListSection] { + let refreshedItems = try await authenticatorItemRepository.refreshTotpCodes(for: items) + let updatedSections = sections.updated(with: refreshedItems) + let allItems = updatedSections.flatMap(\.items) + manager?.configureTOTPRefreshScheduling(for: allItems) + return updatedSections + } +} diff --git a/AuthenticatorShared/UI/Vault/Utilities/TOTPExpirationManager.swift b/AuthenticatorShared/UI/Vault/Utilities/TOTPExpirationManager.swift new file mode 100644 index 0000000000..07ed583bb2 --- /dev/null +++ b/AuthenticatorShared/UI/Vault/Utilities/TOTPExpirationManager.swift @@ -0,0 +1,92 @@ +import BitwardenKit +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) + } +} From a4714b959253e43e37aeed3d0ab2f715397c7eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gonc=CC=A7alves?= Date: Mon, 6 Oct 2025 12:18:40 +0100 Subject: [PATCH 3/5] pm-11507 Refactor code to be shared by bwa and pm --- .../AnyTOTPRefreshingRepository.swift | 25 +++++++ .../AuthenticatorItemRepository.swift | 11 ++++ .../ItemList/ItemList/ItemListProcessor.swift | 20 +++++- .../ItemList/ItemList/ItemListSection.swift | 31 +++++++-- .../Utilities/HasTOTPCodesSections.swift | 25 ++----- .../Utilities/HasTOTPCodesSections.swift | 65 +++++++++++++++++++ 6 files changed, 152 insertions(+), 25 deletions(-) create mode 100644 AuthenticatorShared/Core/Vault/Repositories/AnyTOTPRefreshingRepository.swift create mode 100644 BitwardenKit/UI/Vault/Utilities/HasTOTPCodesSections.swift diff --git a/AuthenticatorShared/Core/Vault/Repositories/AnyTOTPRefreshingRepository.swift b/AuthenticatorShared/Core/Vault/Repositories/AnyTOTPRefreshingRepository.swift new file mode 100644 index 0000000000..33178f29b8 --- /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 bae3adf525..8e48fef32f 100644 --- a/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift +++ b/AuthenticatorShared/Core/Vault/Repositories/AuthenticatorItemRepository.swift @@ -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/UI/Vault/ItemList/ItemList/ItemListProcessor.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift index 1e6d95e8b3..3c68f07c3e 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -8,7 +8,7 @@ import Foundation // MARK: - ItemListProcessor /// A `Processor` that can process `ItemListAction` and `ItemListEffect` objects. -final class ItemListProcessor: StateProcessor, HasTOTPCodesSections { +final class ItemListProcessor: StateProcessor { // swiftlint:disable:previous type_body_length // MARK: Types @@ -611,3 +611,21 @@ 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 index 9ac5cc195d..69e0385d8b 100644 --- a/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift +++ b/AuthenticatorShared/UI/Vault/Utilities/HasTOTPCodesSections.swift @@ -1,28 +1,13 @@ -/// A protocol to work with processors that have TOTP sections. -@MainActor -protocol HasTOTPCodesSections { - /// The repository used by the application to manage vault data for the UI layer. - var authenticatorItemRepository: AuthenticatorItemRepository { get } +import BitwardenKit - /// Refreshes the TOTP Codes from items in sections using the corresponding manager. - func refreshTOTPCodes( - for items: [ItemListItem], - in sections: [ItemListSection], - using manager: TOTPExpirationManager? - ) async throws -> [ItemListSection] -} - -/// Extension of the `HasTOTPCodesSections` protocol for some common behavior. -extension HasTOTPCodesSections { +extension HasTOTPCodesSections where Item == ItemListItem, Section == ItemListSection { func refreshTOTPCodes( for items: [ItemListItem], in sections: [ItemListSection], using manager: TOTPExpirationManager? ) async throws -> [ItemListSection] { - let refreshedItems = try await authenticatorItemRepository.refreshTotpCodes(for: items) - let updatedSections = sections.updated(with: refreshedItems) - let allItems = updatedSections.flatMap(\.items) - manager?.configureTOTPRefreshScheduling(for: allItems) - return updatedSections + let updated = try await refreshTOTPCodes(for: items, in: sections) + manager?.configureTOTPRefreshScheduling(for: updated.flatMap(\.items)) + return updated } } 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] +} From 5c826d37b9edb21af40e228f3a05be4726b9f81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gonc=CC=A7alves?= Date: Mon, 6 Oct 2025 16:42:23 +0100 Subject: [PATCH 4/5] pm-11707 Remove unnecessary code --- .../UI/Vault/ItemList/ItemList/ItemListProcessor.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift index 3c68f07c3e..6c902ad234 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -25,10 +25,6 @@ final class ItemListProcessor: StateProcessor() @@ -624,7 +620,6 @@ extension ItemListProcessor: HasTOTPCodesSections { /// The type of repository that contains item to be refreshed. typealias Repository = AnyTOTPRefreshingRepository - var repository: AnyTOTPRefreshingRepository { AnyTOTPRefreshingRepository(services.authenticatorItemRepository) } From 262fed7e6fec91b9c8c63a753d581dce12ab397d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gonc=CC=A7alves?= Date: Fri, 10 Oct 2025 21:10:58 +0100 Subject: [PATCH 5/5] pm-11707 TOTPExpirationManager subscribes state's item on creation --- .../ItemList/ItemList/ItemListProcessor.swift | 19 ++++++++++------- .../Utilities/TOTPExpirationManager.swift | 16 ++++++++++++++ .../Utilities/HasTOTPCodesSections.swift | 6 +----- .../Utilities/TOTPExpirationManager.swift | 16 ++++++++++++++ .../VaultAutofillListProcessor.swift | 18 +++++++++------- .../VaultGroup/VaultGroupProcessor.swift | 21 ++++++++++++------- 6 files changed, 67 insertions(+), 29 deletions(-) diff --git a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift index 6c902ad234..8a7709d2f2 100644 --- a/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift +++ b/AuthenticatorShared/UI/Vault/ItemList/ItemList/ItemListProcessor.swift @@ -59,23 +59,28 @@ final class ItemListProcessor: StateProcessor 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/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