Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
64b5123
[PM-38443] feat: Add FillAssistRepository and sync integration
andrebispo5 Jun 19, 2026
42e8f20
[PM-38443] fix: Remove SHA-256 integrity check and use URLSession.sha…
andrebispo5 Jun 20, 2026
5d23296
[PM-38443] fix: Add @MainActor to FillAssistRepositoryTests to fix te…
andrebispo5 Jun 23, 2026
333a8cc
[PM-38443] fix: Remove unused Services typealias and add selector poo…
andrebispo5 Jun 25, 2026
1decad0
[PM-38443] fix: Update FillAssistRepository to use Constants.FillAssist
andrebispo5 Jun 26, 2026
993de63
[PM-38443] fix: Rename FillAssistRepository methods, inject TimeProvi…
andrebispo5 Jun 26, 2026
10bd65a
[PM-38443] test: Convert FillAssistRepositoryTests to Swift Testing a…
andrebispo5 Jun 26, 2026
c9fe9f6
[PM-38443] fix: Add @MainActor and extract helpers to fix FillAssistR…
andrebispo5 Jun 29, 2026
4d9842c
[PM-38443] fix: Filter empty field arrays from pooled rules and injec…
andrebispo5 Jun 29, 2026
7738dc2
[PM-38443] refactor: Replace JSON-string test helpers with direct str…
andrebispo5 Jun 29, 2026
67d82cf
[PM-38443] fix: Add missing DocC comments to FormsMapResponseModel in…
andrebispo5 Jul 1, 2026
6e9e6e7
[PM-38444] feat: Use FillAssist cached rules to guide DOM field targe…
andrebispo5 Jul 2, 2026
50b9219
Merge branch 'main' into pm-38443/consume-fill-assist-data-and-cache
andrebispo5 Jul 10, 2026
4a5f77e
[PM-39851] fix: Skip Fill Assist sync only when cache is readable and…
andrebispo5 Jul 3, 2026
124ff13
[PM-39851] fix: Skip Fill Assist sync only when cache is readable and…
andrebispo5 Jul 3, 2026
3511fee
[PM-39851] refactor: Use send(_:) instead of download for Fill Assist…
andrebispo5 Jul 3, 2026
35fcc50
[PM-39851] refactor: Remove unused HTTPService.download(filename:) me…
andrebispo5 Jul 3, 2026
6859eab
[PM-39851] fix: Run Fill Assist sync independently of vault revision …
andrebispo5 Jul 3, 2026
aefc828
[PM-39851] test: Fix FillAssistRepositoryTests for cache-readable syn…
andrebispo5 Jul 10, 2026
72516fc
[PM-38443] test: Add error-path coverage for the Fill Assist toggle
andrebispo5 Jul 10, 2026
a34f5e5
[PM-39851] test: Fix FillAssistAPIServiceTests for send(_:)-based req…
andrebispo5 Jul 10, 2026
a486c51
[PM-38443] fix: Allow autofill reprompt flow when Fill Assist is enabled
andrebispo5 Jul 13, 2026
9f164cb
[PM-38443] refactor: Delegate Fill Assist cache cleanup on logout to …
andrebispo5 Jul 15, 2026
b2286cc
[PM-38443] test: Disable Fill Assist in the autofill-not-supported te…
andrebispo5 Jul 15, 2026
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
Expand Up @@ -21,6 +21,13 @@ struct FormsMapResponseModel: Equatable, JSONResponse {
/// The schema version of the map.
let schemaVersion: String

// MARK: Initialization

init(hosts: [String: FormsMapHostEntry], schemaVersion: String) {
Comment thread
fedemkr marked this conversation as resolved.
self.hosts = hosts
self.schemaVersion = schemaVersion
}

// MARK: Codable

init(from decoder: any Decoder) throws {
Expand All @@ -43,6 +50,18 @@ struct FormsMapHostEntry: Codable, Equatable {

/// Pathname-specific form descriptions. Null-valued entries are excluded during decoding.
@CompactDecodable var pathnames: [String: FormsMapPathnameEntry]?

/// All form descriptions for this host, pooling top-level and pathname-specific entries.
var allForms: [FormsMapContent] {
(forms ?? []) + (pathnames?.values.flatMap(\.forms) ?? [])
}

// MARK: Initialization

init(forms: [FormsMapContent]?, pathnames: [String: FormsMapPathnameEntry]? = nil) {
Comment thread
fedemkr marked this conversation as resolved.
self.forms = forms
_pathnames = CompactDecodable(wrappedValue: pathnames)
}
}

// MARK: - FormsMapPathnameEntry
Expand Down Expand Up @@ -84,6 +103,18 @@ enum FormsMapSelector: Codable, Equatable {
/// An ordered sequence of CSS selectors composing a single field value.
case sequence([String])

// MARK: Computed Properties

/// The `FillAssistFieldAttributes` derived from this selector, empty if the selector is unsupported.
var attributes: [FillAssistFieldAttributes] {
switch self {
case let .single(css):
FillAssistSelectorParser.parse(css).map { [$0] } ?? []
case let .sequence(parts):
parts.compactMap { FillAssistSelectorParser.parse($0) }
}
}

// MARK: Codable

init(from decoder: any Decoder) throws {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import BitwardenKit
import Foundation

// MARK: - FillAssistRepository

/// A protocol for a repository that manages fetching and caching fill-assist targeting rules.
///
protocol FillAssistRepository { // sourcery: AutoMockable
/// Fetches and caches fill-assist rules for the active account.
///
func syncRules() async

/// Returns the cached fill-assist rules for a given hostname, or `nil` if unavailable.
///
/// - Parameter hostname: The hostname to look up.
/// - Returns: The cached `FillAssistHostRules`, or `nil`.
///
func rules(for hostname: String) async -> FillAssistHostRules?

/// Clears all cached fill-assist data for the active account.
///
func clearRules() async throws
}

// MARK: - DefaultFillAssistRepository
Comment thread
andrebispo5 marked this conversation as resolved.

/// The default implementation of `FillAssistRepository`.
///
class DefaultFillAssistRepository: FillAssistRepository {
// MARK: Private Properties

/// The store for persisting fill-assist cached data.
private let appSettingsStore: AppSettingsStore

/// The service for checking feature flags and configuration.
private let configService: ConfigService

/// The service for reporting non-fatal errors.
private let errorReporter: ErrorReporter

/// The service for accessing environment URLs.
private let environmentService: EnvironmentService

/// The API service for fetching fill-assist data.
private let fillAssistAPIService: FillAssistAPIService

/// The service for accessing account state.
private let stateService: StateService

/// The provider of the current time, used for interval checks.
private let timeProvider: TimeProvider

// MARK: Initialization

/// Creates a `DefaultFillAssistRepository`.
///
/// - Parameters:
/// - appSettingsStore: The store for persisting fill-assist cached data.
/// - configService: The service for checking feature flags and configuration.
/// - environmentService: The service for accessing environment URLs.
/// - errorReporter: The service for reporting non-fatal errors.
/// - fillAssistAPIService: The API service for fetching fill-assist data.
/// - stateService: The service for accessing account state.
/// - timeProvider: The provider of the current time.
///
init(
appSettingsStore: AppSettingsStore,
configService: ConfigService,
environmentService: EnvironmentService,
errorReporter: ErrorReporter,
fillAssistAPIService: FillAssistAPIService,
stateService: StateService,
timeProvider: TimeProvider,
) {
self.appSettingsStore = appSettingsStore
self.configService = configService
self.environmentService = environmentService
self.errorReporter = errorReporter
self.fillAssistAPIService = fillAssistAPIService
self.stateService = stateService
self.timeProvider = timeProvider
}

// MARK: FillAssistRepository

func syncRules() async {
do {
try await performSync()
} catch {
// Sync failures are non-fatal β€” existing cache remains available.
errorReporter.log(error: error)
}
}

func rules(for hostname: String) async -> FillAssistHostRules? {
guard let userId = try? await stateService.getActiveAccountId() else { return nil }
return appSettingsStore.fillAssistCachedData(userId: userId)?.rules[hostname]
}

func clearRules() async throws {
let userId = try await stateService.getActiveAccountId()
appSettingsStore.setFillAssistCachedData(nil, userId: userId)
}

// MARK: Private

/// Runs the full sync pipeline if sync conditions are met
///
private func performSync() async throws {
// 1. Feature flag guard.
Comment thread
andrebispo5 marked this conversation as resolved.
Outdated
guard await configService.getFeatureFlag(.fillAssistTargetingRules) else { return }

// 2. Time-interval guard β€” skip if last fetch was less than fillAssistUpdateInterval ago.
let sourceUrl = environmentService.fillAssistRulesURL
let userId = try await stateService.getActiveAccountId()
let lastFetch = appSettingsStore.fillAssistLastFetchTimestamp(userId: userId)
if let lastFetch, timeProvider.presentTime.timeIntervalSince(lastFetch) < Constants.FillAssist.updateInterval {
return
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” I believe we should consider checking forceSync here to bypass this check. Otherwise users will need to wait up to 6 hours when a change is done in map-the-web.
I think it can be done in a different PR so not to keep adding stuff to this one.


// 3. Fetch manifest.
let manifest = try await fillAssistAPIService.getManifest()

// 4. Resolve the non-deprecated entry for the current forms version.
guard let entry = manifest.maps["forms"]?[Constants.FillAssist.formsVersion],
!entry.deprecated
else { return }

// 5. If cid and source URL are unchanged, update timestamp and skip download.
let cached = appSettingsStore.fillAssistCachedData(userId: userId)
if cached?.cid == entry.cid, cached?.sourceUrl == sourceUrl.absoluteString {
appSettingsStore.setFillAssistLastFetchTimestamp(timeProvider.presentTime, userId: userId)
return
}

// 6. Download forms file.
let formsMap = try await fillAssistAPIService.getFormsMap(filename: entry.filename)

// 7. Validate schema major version; update timestamp and skip if unsupported.
let schemaMajor = formsMap.schemaVersion.split(separator: ".").first.map(String.init) ?? ""
guard schemaMajor == Constants.FillAssist.expectedSchemaMajor else {
appSettingsStore.setFillAssistLastFetchTimestamp(timeProvider.presentTime, userId: userId)
return
}

// 8. Parse, cache, and update timestamp.
let rules = buildRules(from: formsMap)
let data = FillAssistCachedData(cid: entry.cid, rules: rules, sourceUrl: sourceUrl.absoluteString)
appSettingsStore.setFillAssistCachedData(data, userId: userId)
appSettingsStore.setFillAssistLastFetchTimestamp(timeProvider.presentTime, userId: userId)
}

/// Builds a `[hostname: FillAssistHostRules]` dictionary by pooling all non-null field
/// definitions across each host's top-level forms and every pathname entry.
///
private func buildRules(from formsMap: FormsMapResponseModel) -> [String: FillAssistHostRules] {
var result = [String: FillAssistHostRules]()
for (hostname, hostEntry) in formsMap.hosts {
Comment thread
fedemkr marked this conversation as resolved.
var pooled = [String: [FillAssistFieldAttributes]]()
let allForms = hostEntry.allForms
for form in allForms {
for (fieldKey, selectors) in form.fields {
let attrs = selectors.flatMap(\.attributes)
pooled[fieldKey, default: []].append(contentsOf: attrs)
}
}
pooled = pooled.filter { !$0.value.isEmpty }
if !pooled.isEmpty {
result[hostname] = FillAssistHostRules(fields: pooled)
}
Comment thread
fedemkr marked this conversation as resolved.
}
return result
}
}
Loading
Loading