mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
[PM-11707] Fix search results update dynamically (#1966)
This commit is contained in:
parent
3fd6ca6c48
commit
fae5c46d74
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
|
||||
typealias Services = HasTimeProvider
|
||||
& ItemListProcessor.Services
|
||||
& HasTOTPExpirationManagerFactory
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
65
BitwardenKit/UI/Vault/Utilities/HasTOTPCodesSections.swift
Normal file
65
BitwardenKit/UI/Vault/Utilities/HasTOTPCodesSections.swift
Normal 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]
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user