Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable
func test_refreshTotpCodes_errorSharedTotp() async throws {
let item = ItemListItem.fixtureShared(totp: .fixture(itemView: .fixture(totpKey: nil)))

let result = try await subject.refreshTotpCodes(on: [item])
let result = try await subject.refreshTotpCodes(for: [item])
let actual = try XCTUnwrap(result[0])
let error = try XCTUnwrap(errorReporter.errors[0] as? TOTPServiceError)
XCTAssertEqual(
Expand All @@ -166,7 +166,7 @@ class AuthenticatorItemRepositoryTests: BitwardenTestCase { // swiftlint:disable
func test_refreshTotpCodes_errorTotp() async throws {
let item = ItemListItem.fixture(totp: .fixture(itemView: .fixture(totpKey: nil)))

let result = try await subject.refreshTotpCodes(on: [item])
let result = try await subject.refreshTotpCodes(for: [item])
let actual = try XCTUnwrap(result[0])
let error = try XCTUnwrap(errorReporter.errors[0] as? TOTPServiceError)
XCTAssertEqual(
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository {
pmSyncEnabled
}

func refreshTotpCodes(on items: [ItemListItem]) async throws -> [ItemListItem] {
func refreshTotpCodes(for items: [ItemListItem]) async throws -> [ItemListItem] {
refreshedTotpTime = timeProvider.presentTime
refreshedTotpCodes = items
return try refreshTotpCodesResult.get()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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`.
Expand All @@ -56,14 +59,28 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
self.services = services

super.init(state: state)
groupTotpExpirationManager = TOTPExpirationManager(
groupTotpExpirationManager = NewTOTPExpirationManager(
timeProvider: services.timeProvider,
onExpiration: { [weak self] expiredItems in
guard let self else { return }
Task {
await self.refreshTOTPCodes(for: expiredItems)
}
}
},
itemPublisher: statePublisher.map(\.loadingState.data).eraseToAnyPublisher()
)
searchTotpExpirationManager = NewTOTPExpirationManager(
timeProvider: services.timeProvider,
onExpiration: { [weak self] expiredSearchItems in
guard let self else { return }
Task {
await self.refreshTOTPCodes(searchItems: expiredSearchItems)
}
},
itemPublisher: statePublisher
.map { state in [ItemListSection(id: "", items: state.searchResults, name: "")]
}
.eraseToAnyPublisher()
)
setupForegroundNotification()
}
Expand Down Expand Up @@ -107,7 +124,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()
}
Expand Down Expand Up @@ -154,9 +171,6 @@ 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(text: Localizations.itemDeleted)
} catch {
services.errorReporter.log(error: error)
Expand Down Expand Up @@ -201,16 +215,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
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)
}
Expand Down Expand Up @@ -246,29 +276,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.
Expand Down Expand Up @@ -319,7 +347,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)
}
Expand All @@ -329,9 +357,6 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
if showToast {
state.toast = Toast(text: Localizations.accountsSyncedFromBitwardenApp)
}
if !state.searchText.isEmpty {
state.searchResults = await searchItems(for: state.searchText)
}
}
} catch {
services.errorReporter.log(error: error)
Expand Down Expand Up @@ -361,96 +386,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>,
Expand Down Expand Up @@ -675,3 +610,20 @@ extension ItemListProcessor: AuthenticatorItemOperationDelegate {
state.toast = Toast(text: Localizations.itemDeleted)
}
}

// MARK: - HasTOTPCodesSection

extension ItemListProcessor: HasTOTPCodesSections {
// MARK: Types

/// The type of item in the list.
typealias Item = ItemListItem
/// The type of section in the list.
typealias Section = ItemListSection
/// The type of repository that contains item to be refreshed.
typealias Repository = AnyTOTPRefreshingRepository

var repository: AnyTOTPRefreshingRepository {
AnyTOTPRefreshingRepository(services.authenticatorItemRepository)
}
}
Loading