[PM-11707] Fix search results update dynamically (#1966)

This commit is contained in:
Carlos Gonçalves 2025-10-28 10:21:05 +00:00 committed by GitHub
parent 3fd6ca6c48
commit fae5c46d74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 639 additions and 238 deletions

View File

@ -71,6 +71,9 @@ public class ServiceContainer: Services {
/// Provides the present time for TOTP Code Calculation.
let timeProvider: TimeProvider
/// The factory to create TOTP expiration managers.
let totpExpirationManagerFactory: TOTPExpirationManagerFactory
/// The service used by the application to validate TOTP keys and produce TOTP values.
let totpService: TOTPService
@ -98,6 +101,7 @@ public class ServiceContainer: Services {
/// - pasteboardService: The service used by the application for sharing data with other apps.
/// - stateService: The service for managing account state.
/// - timeProvider: Provides the present time for TOTP Code Calculation.
/// - totpExpirationManagerFactory: The factory to create TOTP expiration managers.
/// - totpService: The service used by the application to validate TOTP keys and produce TOTP values.
///
init(
@ -119,6 +123,7 @@ public class ServiceContainer: Services {
pasteboardService: PasteboardService,
stateService: StateService,
timeProvider: TimeProvider,
totpExpirationManagerFactory: TOTPExpirationManagerFactory,
totpService: TOTPService,
) {
self.application = application
@ -139,6 +144,7 @@ public class ServiceContainer: Services {
self.pasteboardService = pasteboardService
self.timeProvider = timeProvider
self.stateService = stateService
self.totpExpirationManagerFactory = totpExpirationManagerFactory
self.totpService = totpService
}
@ -181,6 +187,7 @@ public class ServiceContainer: Services {
)
let timeProvider = CurrentTime()
let totpExpirationManagerFactory = DefaultTOTPExpirationManagerFactory(timeProvider: timeProvider)
let biometricsRepository = DefaultBiometricsRepository(
biometricsService: biometricsService,
@ -244,7 +251,7 @@ public class ServiceContainer: Services {
)
let sharedCryptographyService = DefaultAuthenticatorCryptographyService(
sharedKeychainRepository: sharedKeychainRepository,
sharedKeychainRepository: sharedKeychainRepository
)
let sharedDataStore = AuthenticatorBridgeDataStore(
@ -306,6 +313,7 @@ public class ServiceContainer: Services {
pasteboardService: pasteboardService,
stateService: stateService,
timeProvider: timeProvider,
totpExpirationManagerFactory: totpExpirationManagerFactory,
totpService: totpService,
)
}

View File

@ -16,8 +16,9 @@ typealias Services = HasAppInfoService
& HasNotificationCenterService
& HasPasteboardService
& HasStateService
& HasTOTPService
& HasTimeProvider
& HasTOTPExpirationManagerFactory
& HasTOTPService
/// Protocol for an object that provides an `Application`
///
@ -122,3 +123,10 @@ protocol HasTimeProvider {
/// Provides the present time for TOTP Code Calculation.
var timeProvider: TimeProvider { get }
}
/// Protocol for an object that provides a `TOTPExpirationManagerFactory`.
///
protocol HasTOTPExpirationManagerFactory {
/// Factory to create TOTP expiration managers.
var totpExpirationManagerFactory: TOTPExpirationManagerFactory { get }
}

View File

@ -25,7 +25,8 @@ extension ServiceContainer {
pasteboardService: PasteboardService = MockPasteboardService(),
stateService: StateService = MockStateService(),
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
totpService: TOTPService = MockTOTPService(),
totpExpirationManagerFactory: TOTPExpirationManagerFactory = MockTOTPExpirationManagerFactory(),
totpService: TOTPService = MockTOTPService()
) -> ServiceContainer {
ServiceContainer(
application: application,
@ -46,7 +47,8 @@ extension ServiceContainer {
pasteboardService: pasteboardService,
stateService: stateService,
timeProvider: timeProvider,
totpService: totpService,
totpExpirationManagerFactory: totpExpirationManagerFactory,
totpService: totpService
)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -144,12 +144,12 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable
XCTAssertTrue(syncActive)
}
/// `refreshTotpCodes(on:)` logs an error when it can't update the TOTP code on a
/// `refreshTOTPCodes(on:)` logs an error when it can't update the TOTP code on a
/// .sharedTotp item, and returns the item as-is.
func test_refreshTotpCodes_errorSharedTotp() async throws {
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(
@ -161,12 +161,12 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable
XCTAssertEqual(actual.accountName, item.accountName)
}
/// `refreshTotpCodes(on:)` logs an error when it can't update the TOTP code on a
/// `refreshTOTPCodes(on:)` logs an error when it can't update the TOTP code on a
/// .totp item, and returns the item as-is.
func test_refreshTotpCodes_errorTotp() async throws {
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(
@ -178,8 +178,8 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable
XCTAssertEqual(actual.accountName, item.accountName)
}
/// `refreshTotpCodes(on:)` updates the TOTP codes on items.
func test_refreshTotpCodes_success() async throws {
/// `refreshTOTPCodes(on:)` updates the TOTP codes on items.
func test_refreshTOTPCodes_success() async throws {
let newCode = "987654"
let newCodeModel = TOTPCodeModel(
code: newCode,
@ -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)

View File

@ -21,6 +21,7 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository {
var pmSyncEnabled = false
var refreshTOTPCodesCalled = true
var refreshTotpCodesResult: Result<[ItemListItem], Error> = .success([])
var refreshedTotpTime: Date?
var refreshedTotpCodes: [ItemListItem] = []
@ -63,9 +64,10 @@ 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
refreshTOTPCodesCalled = true
return try refreshTotpCodesResult.get()
}

View File

@ -21,8 +21,9 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
& HasErrorReporter
& HasNotificationCenterService
& HasPasteboardService
& HasTOTPService
& HasTimeProvider
& HasTOTPExpirationManagerFactory
& HasTOTPService
// MARK: Private Properties
@ -38,6 +39,9 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
/// An object to manage TOTP code expirations and batch refresh calls for the group.
private var groupTotpExpirationManager: TOTPExpirationManager?
/// An object to manage TOTP code expirations and batch refresh calls for search results.
private var searchTotpExpirationManager: TOTPExpirationManager?
// MARK: Initialization
/// Creates a new `ItemListProcessor`.
@ -56,21 +60,16 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
self.services = services
super.init(state: state)
groupTotpExpirationManager = TOTPExpirationManager(
timeProvider: services.timeProvider,
onExpiration: { [weak self] expiredItems in
guard let self else { return }
Task {
await self.refreshTOTPCodes(for: expiredItems)
}
},
)
initTotpExpirationManagers()
setupForegroundNotification()
}
deinit {
groupTotpExpirationManager?.cleanup()
groupTotpExpirationManager = nil
searchTotpExpirationManager?.cleanup()
searchTotpExpirationManager = nil
}
// MARK: Methods
@ -107,7 +106,7 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
await determineItemListCardState()
await streamItemList()
case let .search(text):
state.searchResults = await searchItems(for: text)
await searchItems(for: text)
case .streamItemList:
await streamItemList()
}
@ -154,15 +153,39 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
private func deleteItem(_ id: String) async {
do {
try await services.authenticatorItemRepository.deleteAuthenticatorItem(id)
if !state.searchText.isEmpty {
state.searchResults = await searchItems(for: state.searchText)
}
state.toast = Toast(title: Localizations.itemDeleted)
} catch {
services.errorReporter.log(error: error)
}
}
/// Initializes the TOTP expiration managers so the TOTP codes are refreshed automatically.
///
func initTotpExpirationManagers() {
groupTotpExpirationManager = services.totpExpirationManagerFactory.create(
itemPublisher: statePublisher.map(\.loadingState.data)
.eraseToAnyPublisher(),
onExpiration: { [weak self] expiredItems in
guard let self else { return }
Task {
await self.refreshTOTPCodes(for: expiredItems)
}
},
)
searchTotpExpirationManager = services.totpExpirationManagerFactory.create(
itemPublisher: statePublisher
.map { state in [ItemListSection(id: "", items: state.searchResults, name: "")]
}
.eraseToAnyPublisher(),
onExpiration: { [weak self] expiredSearchItems in
guard let self else { return }
Task {
await self.refreshTOTPCodes(searchItems: expiredSearchItems)
}
},
)
}
/// Generates and copies a TOTP code for the cipher's TOTP key.
///
/// - Parameter totpKey: The TOTP key used to generate a TOTP code.
@ -201,16 +224,32 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
/// Refreshes the vault group's TOTP Codes.
///
private func refreshTOTPCodes(for items: [ItemListItem]) async {
guard case let .data(currentSections) = state.loadingState else { return }
guard case let .data(currentSections) = state.loadingState, !currentSections.isEmpty
else {
return
}
do {
let refreshedItems = try await services.authenticatorItemRepository.refreshTotpCodes(on: items)
let updatedSections = currentSections.updated(with: refreshedItems)
let allItems = updatedSections.flatMap(\.items)
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: allItems)
state.loadingState = .data(updatedSections)
if !state.searchResults.isEmpty {
state.searchResults = await searchItems(for: state.searchText)
}
let refreshedItems = try await refreshTOTPCodes(
for: items,
in: currentSections,
)
state.loadingState = .data(refreshedItems)
} catch {
services.errorReporter.log(error: error)
}
}
/// Refreshes TOTP Codes for the search results.
///
private func refreshTOTPCodes(searchItems: [ItemListItem]) async {
do {
let refreshedItems = try await refreshTOTPCodes(
for: searchItems,
in: [
ItemListSection(id: "", items: state.searchResults, name: ""),
],
)
state.searchResults = refreshedItems[0].items
} catch {
services.errorReporter.log(error: error)
}
@ -246,29 +285,27 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
}
}
/// Searches items using the provided string, and returns any matching results.
/// Searches items using the provided string and sets to state any matching results.
///
/// - Parameters:
/// - searchText: The string to use when searching items.
/// - Returns: An array of `ItemListItem` objects. If no results can be found, an empty array will be returned.
///
private func searchItems(for searchText: String) async -> [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 +356,7 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
if shouldShowAccountSyncToast(name: section.name) {
showToast = true
}
let itemList = try await services.authenticatorItemRepository.refreshTotpCodes(on: section.items)
let itemList = try await services.authenticatorItemRepository.refreshTotpCodes(for: section.items)
let sortedList = itemList.sorted(by: ItemListItem.localizedNameComparator)
return ItemListSection(id: section.id, items: sortedList, name: section.name)
}
@ -329,9 +366,6 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
if showToast {
state.toast = Toast(title: Localizations.accountsSyncedFromBitwardenApp)
}
if !state.searchText.isEmpty {
state.searchResults = await searchItems(for: state.searchText)
}
}
} catch {
services.errorReporter.log(error: error)
@ -361,96 +395,6 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
}
}
/// A class to manage TOTP code expirations for the ItemListProcessor and batch refresh calls.
///
private 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)
}
}
extension ItemListProcessor: AuthenticatorKeyCaptureDelegate {
func didCompleteAutomaticCapture(
_ captureCoordinator: AnyCoordinator<AuthenticatorKeyCaptureRoute, AuthenticatorKeyCaptureEvent>,
@ -480,7 +424,7 @@ extension ItemListProcessor: AuthenticatorKeyCaptureDelegate {
captureCoordinator.navigate(
to: .dismiss(self?.parseKeyAndDismiss(key, sendToBitwarden: true)),
)
},
}
))
}
} else {
@ -675,3 +619,20 @@ extension ItemListProcessor: AuthenticatorItemOperationDelegate {
state.toast = Toast(title: 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)
}
}

View File

@ -24,6 +24,9 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
var pasteboardService: MockPasteboardService!
var totpService: MockTOTPService!
var subject: ItemListProcessor!
var totpExpirationManagerForItems: MockTOTPExpirationManager!
var totpExpirationManagerForSearchItems: MockTOTPExpirationManager!
var totpExpirationManagerFactory: MockTOTPExpirationManagerFactory!
// MARK: Setup & Teardown
@ -41,6 +44,14 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
pasteboardService = MockPasteboardService()
totpService = MockTOTPService()
totpExpirationManagerForItems = MockTOTPExpirationManager()
totpExpirationManagerForSearchItems = MockTOTPExpirationManager()
totpExpirationManagerFactory = MockTOTPExpirationManagerFactory()
totpExpirationManagerFactory.createResults = [
totpExpirationManagerForItems,
totpExpirationManagerForSearchItems,
]
let services = ServiceContainer.withMocks(
application: application,
appSettingsStore: appSettingsStore,
@ -50,6 +61,7 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
errorReporter: errorReporter,
notificationCenterService: notificationCenterService,
pasteboardService: pasteboardService,
totpExpirationManagerFactory: totpExpirationManagerFactory,
totpService: totpService,
)
@ -173,62 +185,40 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
/// `perform(_:)` with `.appeared` handles TOTP Code expiration
/// with updates the state's TOTP codes.
@MainActor
func test_perform_appeared_totpExpired_single() throws { // swiftlint:disable:this function_body_length
func test_perform_appeared_totpExpired_single() throws {
let firstItem = ItemListItem.fixture(
totp: .fixture(
totpCode: TOTPCodeModel(
code: "",
code: "123456",
codeGenerationDate: Date(timeIntervalSinceNow: -61),
period: 30,
),
),
)
let firstSection = ItemListSection(
id: "",
items: [firstItem],
name: "Items",
)
let secondItem = ItemListItem.fixture(
let resultSection = ItemListSection(id: "", items: [firstItem], name: "Items")
subject.state.loadingState = .data([resultSection])
let firstItemRefreshed = ItemListItem.fixture(
totp: .fixture(
totpCode: TOTPCodeModel(
code: "345678",
code: "234567",
codeGenerationDate: Date(timeIntervalSinceNow: -61),
period: 30,
),
),
)
let secondSection = ItemListSection(
id: "",
items: [secondItem],
name: "Items",
)
let thirdModel = TOTPCodeModel(
code: "654321",
codeGenerationDate: Date(),
period: 30,
)
let thirdItem = ItemListItem.fixture(
totp: .fixture(
totpCode: thirdModel,
),
)
let thirdResultSection = ItemListSection(id: "", items: [thirdItem], name: "Items")
authItemRepository.refreshTotpCodesResult = .success([firstItemRefreshed])
authItemRepository.refreshTotpCodesResult = .success([secondItem])
let task = Task {
await subject.perform(.appeared)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[0] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
authItemRepository.itemListSubject.send([firstSection])
waitFor(subject.state.loadingState.data == [secondSection])
authItemRepository.refreshTotpCodesResult = .success([thirdItem])
waitFor(subject.state.loadingState.data == [thirdResultSection])
onExpiration([firstItemRefreshed])
task.cancel()
XCTAssertEqual([secondItem], authItemRepository.refreshedTotpCodes)
let first = try XCTUnwrap(subject.state.loadingState.data?.first)
XCTAssertEqual(first, thirdResultSection)
waitFor(!authItemRepository.refreshedTotpCodes.isEmpty)
XCTAssertEqual([firstItemRefreshed], authItemRepository.refreshedTotpCodes)
}
/// `perform(:_)` with `.copyPressed()` with a local item copies the code to the pasteboard
@ -430,48 +420,34 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
totp: .fixture(
totpCode: TOTPCodeModel(
code: "123456",
codeGenerationDate: Date(timeIntervalSinceNow: -61),
period: 30,
),
),
)
let firstSection = ItemListSection(id: "", items: [firstItem], name: "Items")
subject.state.loadingState = .data([firstSection])
let secondItem = ItemListItem.fixtureShared(
totp: .fixture(
totpCode: TOTPCodeModel(
code: "345678",
codeGenerationDate: Date(timeIntervalSinceNow: -61),
period: 30,
),
),
)
let thirdItem = ItemListItem.fixture(
totp: .fixture(
totpCode: TOTPCodeModel(
code: "654321",
codeGenerationDate: Date(),
period: 30,
),
),
)
authItemRepository.refreshTotpCodesResult = .success([secondItem])
let task = Task {
subject.receive(.searchTextChanged("text"))
await subject.perform(.search("text"))
subject.state.searchResults = [firstItem]
let firstItemRefreshed = ItemListItem.fixture(
totp: .fixture(
totpCode: TOTPCodeModel(
code: "234567",
codeGenerationDate: Date(),
period: 30,
),
),
)
authItemRepository.refreshTotpCodesResult = .success([firstItemRefreshed])
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[1] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
authItemRepository.searchItemListSubject.send([firstItem])
waitFor(!subject.state.searchResults.isEmpty)
XCTAssertEqual(subject.state.searchResults, [secondItem])
onExpiration([firstItemRefreshed])
authItemRepository.refreshTotpCodesResult = .success([thirdItem])
waitFor(authItemRepository.refreshedTotpCodes == [secondItem])
waitFor(subject.state.searchResults == [thirdItem])
task.cancel()
waitFor(!authItemRepository.refreshedTotpCodes.isEmpty)
XCTAssertEqual(subject.state.searchResults, authItemRepository.refreshedTotpCodes)
}
/// `perform(_:)` with `.streamItemList` starts streaming vault items. When there are no shared
@ -662,6 +638,77 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
XCTAssertNil(subject.state.url)
}
/// `refreshTOTPCodes(for:)` does nothing if state.loadingState is nil
@MainActor
func test_refreshTOTPCodes_forItemsReturnsEmpty() {
let items = [
ItemListItem.fixture(
totp: .fixture(
totpCode: TOTPCodeModel(code: "123456",
codeGenerationDate: Date(year: 2023, month: 12, day: 31),
period: 30))),
ItemListItem.fixtureShared(
totp: .fixture(
totpCode: TOTPCodeModel(code: "654321",
codeGenerationDate: Date(year: 2023, month: 12, day: 31),
period: 30))),
]
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[0] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
onExpiration(items)
waitFor(subject.state.loadingState == .loading(nil))
}
/// `refreshTOTPCodes(for:)` logs when refreshing throws.
@MainActor
func test_refreshTOTPCodes_forItemsThrows() {
let items = [ItemListItem]()
let resultSection = ItemListSection(id: "", items: items, name: "Items")
subject.state.loadingState = .data([resultSection])
authItemRepository.refreshTotpCodesResult = .failure(BitwardenTestError.example)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[0] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
onExpiration(items)
waitFor(errorReporter.errors.last as? BitwardenTestError == BitwardenTestError.example)
}
/// `refreshTOTPCodes(searchItems:)` logs when refreshing throws.
@MainActor
func test_refreshTOTPCodes_searchItemsThrows() {
let items = [
ItemListItem.fixture(
totp: .fixture(
totpCode: TOTPCodeModel(code: "123456",
codeGenerationDate: Date(year: 2023, month: 12, day: 31),
period: 30))),
ItemListItem.fixtureShared(
totp: .fixture(
totpCode: TOTPCodeModel(code: "654321",
codeGenerationDate: Date(year: 2023, month: 12, day: 31),
period: 30))),
]
authItemRepository.refreshTotpCodesResult = .failure(BitwardenTestError.example)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[1] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
onExpiration(items)
waitFor(errorReporter.errors.last as? BitwardenTestError == BitwardenTestError.example)
}
/// `setupForegroundNotification()` is called as part of `init()` and subscribes to any
/// foreground notification, performing `.refresh` when it receives a notification.
@MainActor

View File

@ -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)
}
}

View File

@ -14,6 +14,7 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
typealias Services = HasTimeProvider
& ItemListProcessor.Services
& HasTOTPExpirationManagerFactory
// MARK: - Private Properties

View File

@ -0,0 +1,121 @@
import BitwardenKit
import Combine
import Foundation
/// A protocol to manage TOTP code expirations for the ItemListProcessor and batch refresh calls.
///
protocol TOTPExpirationManager {
// MARK: Properties
/// A closure to call on expiration
///
var onExpiration: (([ItemListItem]) -> Void)? { get set }
// MARK: Methods
/// Removes any outstanding timers
///
func cleanup()
/// Configures TOTP code refresh scheduling
///
/// - Parameter items: The vault list items that may require code expiration tracking.
///
func configureTOTPRefreshScheduling(for items: [ItemListItem])
}
/// A class to manage TOTP code expirations for the ItemListProcessor and batch refresh calls.
///
class DefaultTOTPExpirationManager: TOTPExpirationManager {
// MARK: Private Properties
/// A cancellable object used to manage the publisher subscription.
private var cancellable: AnyCancellable?
/// All items managed by the object, grouped by TOTP period.
///
private(set) var itemsByInterval = [UInt32: [ItemListItem]]()
/// A closure to call on expiration
///
var onExpiration: (([ItemListItem]) -> Void)?
/// 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
/// - itemPublisher: A publisher that emits the current list of vault sections whenever they change.
/// - 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(
itemPublisher: AnyPublisher<[ItemListSection]?, Never>,
onExpiration: (([ItemListItem]) -> Void)?,
timeProvider: any TimeProvider,
) {
self.timeProvider = timeProvider
self.onExpiration = onExpiration
updateTimer = Timer.scheduledTimer(
withTimeInterval: 0.25,
repeats: true,
block: { _ in
self.checkForExpirations()
},
)
cancellable = itemPublisher.sink { [weak self] sections in
self?.configureTOTPRefreshScheduling(for: sections?.flatMap(\.items) ?? [])
}
}
/// 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)
}
}

View File

@ -0,0 +1,32 @@
import BitwardenKit
import Combine
/// Protocol to create `TOTPExpirationManager`.
protocol TOTPExpirationManagerFactory {
/// Creates a `TOTPExpirationManager` passing the `onExpiration` closure.
/// - Parameters
/// - itemPublisher: A publisher that emits the current list of vault sections whenever they change.
/// - onExpiration: Closure to execute on expiration.
/// - Returns: A `TOTPExpirationManager` configured with the given closure.
func create(itemPublisher: AnyPublisher<[ItemListSection]?, Never>,
onExpiration: (([ItemListItem]) -> Void)?) -> TOTPExpirationManager
}
/// The default implementation of `TOTPExpirationManagerFactory`.
class DefaultTOTPExpirationManagerFactory: TOTPExpirationManagerFactory {
/// The service used to get the present time.
var timeProvider: TimeProvider
/// Initializes a `DefaultTOTPExpirationManagerFactory`.
/// - Parameter timeProvider: The service used to get the present time.
init(timeProvider: TimeProvider) {
self.timeProvider = timeProvider
}
func create(itemPublisher: AnyPublisher<[ItemListSection]?, Never>,
onExpiration: (([ItemListItem]) -> Void)?) -> TOTPExpirationManager {
DefaultTOTPExpirationManager(itemPublisher: itemPublisher,
onExpiration: onExpiration,
timeProvider: timeProvider)
}
}

View File

@ -0,0 +1,47 @@
@testable import AuthenticatorShared
import BitwardenKitMocks
import Combine
import XCTest
class TOTPExpirationManagerFactoryTests: BitwardenTestCase {
// MARK: Properties
var timeProvider: MockTimeProvider!
var subject: DefaultTOTPExpirationManagerFactory!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
timeProvider = MockTimeProvider(.currentTime)
subject = DefaultTOTPExpirationManagerFactory(
timeProvider: timeProvider,
)
}
override func tearDown() {
super.tearDown()
timeProvider = nil
subject = nil
}
// MARK: Tests
/// `create(onExpiration:)` creates a `DefaultTOTPExpirationManager` with the
/// given expiration closure.
func test_create() {
var called = false
let expirationClosure: ([ItemListItem]) -> Void = { _ in
called = true
}
let itemPublisher = CurrentValueSubject<[ItemListSection]?, Never>([]).eraseToAnyPublisher()
let result = subject.create(itemPublisher: itemPublisher, onExpiration: expirationClosure)
XCTAssertNotNil(result as? DefaultTOTPExpirationManager)
if let onExpiration = result.onExpiration {
onExpiration([])
}
XCTAssertTrue(called)
}
}

View File

@ -0,0 +1,15 @@
@testable import AuthenticatorShared
class MockTOTPExpirationManager: TOTPExpirationManager {
var cleanupCalled = false
var configuredTOTPRefreshSchedulingItems: [ItemListItem]?
var onExpiration: (([ItemListItem]) -> Void)?
func cleanup() {
cleanupCalled = true
}
func configureTOTPRefreshScheduling(for items: [ItemListItem]) {
configuredTOTPRefreshSchedulingItems = items
}
}

View File

@ -0,0 +1,28 @@
@testable import AuthenticatorShared
import BitwardenKitMocks
import Combine
class MockTOTPExpirationManagerFactory: TOTPExpirationManagerFactory {
var cancellables: Set<AnyCancellable> = []
var createTimesCalled: Int = 0
var createResults: [TOTPExpirationManager] = []
var onExpirationClosures: [(([ItemListItem]) -> Void)?] = []
func create(
itemPublisher: AnyPublisher<[ItemListSection]?, Never>,
onExpiration: (([ItemListItem]) -> Void)?,
) -> TOTPExpirationManager {
defer { createTimesCalled += 1 }
onExpirationClosures.append(onExpiration)
let manager = createResults[createTimesCalled]
itemPublisher
.sink { [manager] sections in
let items = sections?.flatMap(\.items) ?? []
manager.configureTOTPRefreshScheduling(for: items)
}
.store(in: &cancellables)
return manager
}
}

View File

@ -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]
}

View File

@ -119,9 +119,7 @@ final class VaultGroupProcessor: StateProcessor<
case .refresh:
await refreshVaultGroup()
case let .search(text):
let results = await searchGroup(for: text)
state.searchResults = results
searchTotpExpirationManager?.configureTOTPRefreshScheduling(for: results)
await searchGroup(for: text)
case .streamOrganizations:
await streamOrganizations()
case .streamShowWebIcons:
@ -252,14 +250,14 @@ final class VaultGroupProcessor: StateProcessor<
}
}
/// 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 searchGroup(for searchText: String) async -> [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(
@ -268,12 +266,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.

View File

@ -250,12 +250,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")),
@ -264,6 +266,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.

View File

@ -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.

View File

@ -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.