From 8b6ce8bce99d2a8e20992c3ba9390f046a293308 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 29 May 2026 13:40:03 +0100 Subject: [PATCH 1/2] Add fill assist data layer --- .../datasource/disk/FillAssistDiskSource.kt | 44 ++ .../disk/FillAssistDiskSourceImpl.kt | 72 ++ .../data/autofill/di/FillAssistModule.kt | 57 ++ .../autofill/manager/FillAssistManager.kt | 23 + .../autofill/manager/FillAssistManagerImpl.kt | 233 ++++++ .../data/autofill/model/FillAssistRules.kt | 47 ++ .../disk/FillAssistDiskSourceTest.kt | 135 ++++ .../autofill/manager/FillAssistManagerTest.kt | 708 ++++++++++++++++++ 8 files changed, 1319 insertions(+) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt new file mode 100644 index 00000000000..e72af9e575d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt @@ -0,0 +1,44 @@ +package com.x8bit.bitwarden.data.autofill.datasource.disk + +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules + +/** + * Disk source for persisting fill-assist targeting rules per server. + * + * All operations are scoped by [serverUrl] (the fill-assist CDN base URL provided by the server + * config), so multiple accounts on the same server share one cached copy of the rules while + * accounts on different servers remain independent. + */ +interface FillAssistDiskSource { + + /** + * Returns the cached [FillAssistRules] for [serverUrl], or null if none are stored. + */ + fun getFillAssistRules(serverUrl: String): FillAssistRules? + + /** + * Stores [rules] for [serverUrl], or removes the entry when [rules] is null. + */ + fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?) + + /** + * Returns the last known content hash (CID) for [serverUrl], or null if none is stored. + */ + fun getLastKnownCid(serverUrl: String): String? + + /** + * Stores [cid] for [serverUrl], or removes the entry when [cid] is null. + */ + fun storeLastKnownCid(serverUrl: String, cid: String?) + + /** + * Returns the epoch-millisecond timestamp of the last successful fetch for [serverUrl], + * or null if never fetched. + */ + fun getLastFetchTimestamp(serverUrl: String): Long? + + /** + * Stores [timestamp] for [serverUrl], or removes the entry when [timestamp] is null. + */ + fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt new file mode 100644 index 00000000000..89f368d0e45 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt @@ -0,0 +1,72 @@ +package com.x8bit.bitwarden.data.autofill.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.core.data.util.decodeFromStringOrNull +import com.bitwarden.data.datasource.disk.BaseDiskSource +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +// Bump this constant in two cases: +// 1. The parsing logic changes in a way that invalidates previously cached results. +// 2. EXPECTED_SCHEMA_MAJOR in FillAssistManagerImpl is updated to support a new schema major. +// Without bumping this, the staleness check would skip re-downloading data that was previously +// rejected for an unsupported schema — meaning the app would never pick up the new rules. +// On the next app launch after a bump, all stored fill-assist data is cleared and re-downloaded. +private const val CURRENT_CACHE_VERSION = 0 + +private const val FILL_ASSIST_CACHE_VERSION_KEY = "fillAssistCacheVersion" +private const val FILL_ASSIST_RULES_KEY = "fillAssistRules" +private const val FILL_ASSIST_CID_KEY = "fillAssistLastCid" +private const val FILL_ASSIST_TIMESTAMP_KEY = "fillAssistLastFetchTimestamp" + +/** + * Primary implementation of [FillAssistDiskSource]. + */ +class FillAssistDiskSourceImpl( + sharedPreferences: SharedPreferences, + private val json: Json, +) : BaseDiskSource(sharedPreferences), + FillAssistDiskSource { + + init { + performMigrationIfNeeded() + } + + override fun getFillAssistRules(serverUrl: String): FillAssistRules? = + getString(FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl)) + ?.let { json.decodeFromStringOrNull(it) } + + override fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?) { + putString( + FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl), + rules?.let { json.encodeToString(it) }, + ) + } + + override fun getLastKnownCid(serverUrl: String): String? = + getString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl)) + + override fun storeLastKnownCid(serverUrl: String, cid: String?) { + putString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl), cid) + } + + override fun getLastFetchTimestamp(serverUrl: String): Long? = + getLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl)) + + override fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?) { + putLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl), timestamp) + } + + private fun performMigrationIfNeeded() { + if (getInt(FILL_ASSIST_CACHE_VERSION_KEY) == CURRENT_CACHE_VERSION) return + clearAllData() + } + + private fun clearAllData() { + removeWithPrefix(FILL_ASSIST_RULES_KEY) + removeWithPrefix(FILL_ASSIST_CID_KEY) + removeWithPrefix(FILL_ASSIST_TIMESTAMP_KEY) + putInt(FILL_ASSIST_CACHE_VERSION_KEY, CURRENT_CACHE_VERSION) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt new file mode 100644 index 00000000000..7bd765a8d4d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt @@ -0,0 +1,57 @@ +package com.x8bit.bitwarden.data.autofill.di + +import android.content.SharedPreferences +import com.bitwarden.core.data.manager.dispatcher.DispatcherManager +import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences +import com.bitwarden.data.repository.ServerConfigRepository +import com.bitwarden.network.service.FillAssistService +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSourceImpl +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManagerImpl +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import kotlinx.serialization.json.Json +import javax.inject.Singleton + +/** + * Provides fill-assist dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +object FillAssistModule { + + @Provides + @Singleton + fun providesFillAssistDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + json: Json, + ): FillAssistDiskSource = + FillAssistDiskSourceImpl( + sharedPreferences = sharedPreferences, + json = json, + ) + + @Provides + @Singleton + fun providesFillAssistManager( + fillAssistService: FillAssistService, + fillAssistDiskSource: FillAssistDiskSource, + featureFlagManager: FeatureFlagManager, + serverConfigRepository: ServerConfigRepository, + clock: Clock, + dispatcherManager: DispatcherManager, + ): FillAssistManager = + FillAssistManagerImpl( + fillAssistService = fillAssistService, + fillAssistDiskSource = fillAssistDiskSource, + featureFlagManager = featureFlagManager, + serverConfigRepository = serverConfigRepository, + clock = clock, + dispatcherManager = dispatcherManager, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt new file mode 100644 index 00000000000..9d33d1d6b40 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules + +/** + * Manages fetching and caching fill-assist targeting rules. + * + * Rules are scoped per server (the fill-assist CDN URL from server config), so multiple accounts + * on the same server share one cached copy. + */ +interface FillAssistManager { + /** + * Triggers a background sync if no sync is currently running. The sync fetches and persists + * fill-assist rules when the feature flag is enabled and cached data is stale. + */ + fun syncIfNecessary() + + /** + * Returns the last successfully cached [FillAssistRules] for the active server, or null if + * none exist. + */ + fun getFillAssistRules(): FillAssistRules? +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt new file mode 100644 index 00000000000..7fbdf3d8700 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -0,0 +1,233 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import com.bitwarden.core.data.manager.dispatcher.DispatcherManager +import com.bitwarden.core.data.manager.model.FlagKey +import java.time.Clock +import com.bitwarden.data.repository.ServerConfigRepository +import com.bitwarden.network.model.FillAssistFormsJson +import com.bitwarden.network.service.FillAssistService +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules.SelectorClause +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import timber.log.Timber + +private const val CURRENT_FORMS_VERSION = "v0" +private const val EXPECTED_SCHEMA_MAJOR = "0" + +/** Re-fetch interval in milliseconds (6 hours, matching the browser implementation). */ +private const val UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000L + +// Matches [attr='value'] and [attr="value"] attribute selectors. +private val ATTRIBUTE_REGEX = Regex("""\[([a-zA-Z\-]+)=['"](.*?)['"]]""") + +// Matches the CSS #id shorthand (e.g. "input#oid", "select#state"). +// Used as a fallback when [id='value'] is absent. +private val ID_SHORTHAND_REGEX = Regex("""#([^.\[#\s]+)""") + +// Extracts the leading tag name from a selector (e.g. "input", "select", "form"). +private val TAG_REGEX = Regex("""^([a-zA-Z][a-zA-Z0-9]*)""") + +/** + * Primary implementation of [FillAssistManager]. + */ +@Suppress("LongParameterList") +class FillAssistManagerImpl( + private val fillAssistService: FillAssistService, + private val fillAssistDiskSource: FillAssistDiskSource, + private val featureFlagManager: FeatureFlagManager, + private val serverConfigRepository: ServerConfigRepository, + private val clock: Clock, + dispatcherManager: DispatcherManager, +) : FillAssistManager { + + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + private val ioScope = CoroutineScope(dispatcherManager.io) + private var syncJob: Job = Job().apply { complete() } + + init { + serverConfigRepository.serverConfigStateFlow + .filterNotNull() + .onEach { syncIfNecessary() } + .launchIn(unconfinedScope) + } + + override fun syncIfNecessary() { + if (!featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)) return + val serverUrl = serverConfigRepository.serverConfigStateFlow.value + ?.serverData?.environment?.fillAssistRulesUrl ?: return + val lastFetch = fillAssistDiskSource.getLastFetchTimestamp(serverUrl) ?: 0L + if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS) return + if (!syncJob.isCompleted) return + syncJob = ioScope.launch { sync(serverUrl) } + } + + private suspend fun sync(serverUrl: String) = runCatching { + // Always fetch the manifest — it is the CID staleness check. + val manifest = fillAssistService + .getManifest(url = serverUrl.trimEnd('/') + "/manifest.json") + .getOrThrow() + + val versionEntry = manifest.maps?.forms?.get(CURRENT_FORMS_VERSION) + ?: error("Version $CURRENT_FORMS_VERSION not found in manifest") + val cid = versionEntry.cid + ?: error("No CID for version $CURRENT_FORMS_VERSION in manifest") + + if (versionEntry.deprecated == true) { + Timber.w("Fill-assist forms $CURRENT_FORMS_VERSION is deprecated") + } + + // CID check: data on the server is unchanged — update the timestamp and skip download. + if (cid == fillAssistDiskSource.getLastKnownCid(serverUrl)) { + fillAssistDiskSource.storeLastFetchTimestamp( + serverUrl = serverUrl, + timestamp = clock.millis(), + ) + return@runCatching + } + + val formsUrl = serverUrl.trimEnd('/') + "/" + + (versionEntry.filename ?: "forms.$CURRENT_FORMS_VERSION.json") + + val forms = fillAssistService + .getForms(formsUrl = formsUrl) + .getOrThrow() + + val schemaMajor = forms.schemaVersion?.substringBefore('.') + if (schemaMajor != EXPECTED_SCHEMA_MAJOR) { + Timber.w("Unsupported fill-assist schema version: ${forms.schemaVersion}") + fillAssistDiskSource.storeLastFetchTimestamp( + serverUrl = serverUrl, + timestamp = clock.millis(), + ) + return@runCatching + } + + val rules = parseForms(forms) + fillAssistDiskSource.storeFillAssistRules(serverUrl = serverUrl, rules = rules) + fillAssistDiskSource.storeLastKnownCid(serverUrl = serverUrl, cid = cid) + fillAssistDiskSource.storeLastFetchTimestamp( + serverUrl = serverUrl, + timestamp = clock.millis(), + ) + }.also { result -> + result.onFailure { Timber.w(it, "Fill-assist sync failed") } + } + + override fun getFillAssistRules(): FillAssistRules? { + val environment = + serverConfigRepository.serverConfigStateFlow.value?.serverData?.environment + val serverUrl = environment?.fillAssistRulesUrl ?: return null + return fillAssistDiskSource.getFillAssistRules(serverUrl = serverUrl) + } +} + +// region CSS parser + +private fun parseForms(forms: FillAssistFormsJson): FillAssistRules { + val hostRules = forms.hosts + ?.mapNotNull { (hostname, hostEntry) -> hostEntry?.let { hostname to parseHostEntry(it) } } + ?.filter { (_, rules) -> rules.isNotEmpty() } + ?.toMap() + .orEmpty() + return FillAssistRules(hostRules = hostRules) +} + +private fun parseHostEntry( + hostEntry: FillAssistFormsJson.HostEntryJson, +): List { + val allForms = buildList { + addAll(hostEntry.forms.orEmpty()) + hostEntry.pathnames?.values?.filterNotNull()?.forEach { addAll(it.forms.orEmpty()) } + }.distinct() + + return buildFieldsByCategory(allForms).map { (category, fields) -> + FillAssistRules.HostRule( + category = category, + fields = fields.mapValues { (_, selectors) -> selectors.distinct() }, + ) + } +} + +private fun buildFieldsByCategory( + forms: List, +): Map>> { + val result = mutableMapOf>>() + forms.mapNotNull { form -> form.category?.let { form to it } } + .forEach { (form, category) -> + val parsedFields = form.fields.orEmpty() + .mapValues { (_, elem) -> parseCompositeSelectorArray(elem) } + .filterValues { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } ?: return@forEach + val categoryFields = result.getOrPut(category) { mutableMapOf() } + parsedFields.forEach { (fieldKey, selectors) -> + categoryFields.getOrPut(fieldKey) { mutableListOf() }.addAll(selectors) + } + } + return result +} + +private fun parseCompositeSelectorArray(element: JsonElement): List { + if (element !is JsonArray) return emptyList() + val result = mutableListOf() + for (item in element) { + when (item) { + is JsonPrimitive -> parseSingleSelector(item.content)?.let { result.add(it) } + is JsonArray -> item + .filterIsInstance() + .mapNotNull { parseSingleSelector(it.content) } + .forEach { result.add(it) } + + else -> Unit + } + } + return result +} + +internal fun parseSingleSelector(selector: String): SelectorClause? { + // For shadow DOM / iframe selectors (>>>), extract the last segment — the actual target + // element. Android's autofill framework may expose these elements via htmlInfo when they + // are reachable (e.g. open shadow roots), so we parse their attributes for matching. + val effective = if (selector.contains(">>>")) { + selector.substringAfterLast(">>>").trim() + } else { + selector + } + if (effective.trimStart().startsWith(".")) return null + + val tag = TAG_REGEX.find(effective)?.groupValues?.get(1) + + var id: String? = null + var name: String? = null + var type: String? = null + var role: String? = null + + ATTRIBUTE_REGEX.findAll(effective).forEach { match -> + val attrName = match.groupValues[1] + val attrValue = match.groupValues[2] + when (attrName) { + "id" -> id = attrValue + "name" -> name = attrValue + "type" -> type = attrValue + "role" -> role = attrValue + } + } + + // Fallback: extract id from #shorthand (e.g. input#oid) when not present as [id='...']. + if (id == null) { + id = ID_SHORTHAND_REGEX.find(effective)?.groupValues?.get(1) + } + + return SelectorClause(tag = tag, id = id, name = name, type = type, role = role) +} + +// endregion diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt new file mode 100644 index 00000000000..5ae3bd576f1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.autofill.model + +import kotlinx.serialization.Serializable + +/** + * Parsed, storage-ready representation of fill-assist targeting rules for all known hosts. + * + * @property hostRules Map of hostname (with optional port) to a list of [HostRule] entries. + * Multiple [HostRule] entries per host are possible when different pages define different forms. + */ +@Serializable +data class FillAssistRules( + val hostRules: Map>, +) { + /** + * Describes one parsed form for a host. Combines host-level and pathname-level forms into a + * single pooled representation so the consumer does not need to know the current URL path. + * + * @property category The form's purpose category (e.g. "account-login", "payment-card"). + * @property fields Map of field key (e.g. "username", "password") to a list of + * [SelectorClause] alternatives. The first clause that matches a view node is used. + */ + @Serializable + data class HostRule( + val category: String, + val fields: Map>, + ) + + /** + * A single parsed CSS selector expressing HTML attribute constraints for matching a view node + * via [android.view.ViewStructure.HtmlInfo]. All non-null fields are AND constraints. + * + * @property tag The HTML tag name (e.g. "input", "select"). + * @property id The value of the element's [id] attribute. + * @property name The value of the element's [name] attribute. + * @property type The value of the element's [type] attribute (e.g. "password", "text"). + * @property role The value of the element's [role] attribute. + */ + @Serializable + data class SelectorClause( + val tag: String?, + val id: String?, + val name: String?, + val type: String?, + val role: String?, + ) +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt new file mode 100644 index 00000000000..92849db2110 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt @@ -0,0 +1,135 @@ +package com.x8bit.bitwarden.data.autofill.datasource.disk + +import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class FillAssistDiskSourceTest { + private val fakeSharedPreferences = FakeSharedPreferences() + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private val diskSource = FillAssistDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + @Test + fun `migration clears all fill-assist data across all servers`() { + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc") + diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = 123L) + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_2, rules = FILL_ASSIST_RULES) + + // Trigger migration by writing a stale version — clears data for all servers. + fakeSharedPreferences.edit() + .putInt("bwPreferencesStorage:fillAssistCacheVersion", -1) + .apply() + val clearedDiskSource = FillAssistDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + assertNull(clearedDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + assertNull(clearedDiskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_2)) + } + + @Test + fun `storeFillAssistRules and getFillAssistRules round-trip`() { + assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = null) + assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + } + + @Test + fun `data is scoped per server, one server does not affect another`() { + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + + assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_2)) + assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + } + + @Test + fun `storeLastKnownCid and getLastKnownCid round-trip`() { + val cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db" + assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = cid) + assertEquals(cid, diskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = null) + assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + } + + @Test + fun `storeLastFetchTimestamp and getLastFetchTimestamp round-trip`() { + val timestamp = 1716307262956L + assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + + diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = timestamp) + assertEquals(timestamp, diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + + diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = null) + assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + } + + @Test + fun `migration preserves data when cache version is current`() { + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc") + + // New instance with the same preferences — version already set to current by first init. + val sameDiskSource = FillAssistDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + assertEquals(FILL_ASSIST_RULES, sameDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + assertEquals("sha256:abc", sameDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + } +} + +private const val SERVER_URL_1 = "https://fill-assist.example.com" +private const val SERVER_URL_2 = "https://fill-assist.other.com" + +private val FILL_ASSIST_RULES = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "email", + name = null, + type = null, + role = null, + ), + ), + "password" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = null, + name = "pass", + type = "password", + role = null, + ), + ), + ), + ), + ), + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt new file mode 100644 index 00000000000..67c03f8dafb --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt @@ -0,0 +1,708 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager +import com.bitwarden.core.data.manager.model.FlagKey +import com.bitwarden.data.datasource.disk.model.ServerConfig +import com.bitwarden.data.repository.ServerConfigRepository +import com.bitwarden.network.model.ConfigResponseJson +import com.bitwarden.network.model.FillAssistFormsJson +import com.bitwarden.network.model.FillAssistManifestJson +import com.bitwarden.network.service.FillAssistService +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +private const val BASE_URL = "https://fill-assist.example.com" +private const val MANIFEST_URL = "$BASE_URL/manifest.json" +private const val FORMS_URL = "$BASE_URL/forms.v0.json" +private const val CID = "sha256:abc123" + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2026-01-01T12:00:00Z"), + ZoneOffset.UTC, +) + +/** A timestamp far in the past, ensuring the timestamp check never skips network calls. */ +private const val STALE_TIMESTAMP = 0L + +class FillAssistManagerTest { + + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns true + } + + private val serverConfigRepository: ServerConfigRepository = mockk { + every { serverConfigStateFlow } returns MutableStateFlow(SERVER_CONFIG) + } + + private val fillAssistService: FillAssistService = mockk { + coEvery { getManifest(url = MANIFEST_URL) } returns Result.success(MANIFEST) + coEvery { getForms(formsUrl = FORMS_URL) } returns Result.success(FORMS_V1) + } + + private val fillAssistDiskSource: FillAssistDiskSource = mockk { + every { getLastFetchTimestamp(BASE_URL) } returns STALE_TIMESTAMP + every { getLastKnownCid(BASE_URL) } returns null + every { getFillAssistRules(BASE_URL) } returns null + every { storeFillAssistRules(any(), any()) } just runs + every { storeLastKnownCid(any(), any()) } just runs + every { storeLastFetchTimestamp(any(), any()) } just runs + } + + private val manager = FillAssistManagerImpl( + fillAssistService = fillAssistService, + fillAssistDiskSource = fillAssistDiskSource, + featureFlagManager = featureFlagManager, + serverConfigRepository = serverConfigRepository, + clock = FIXED_CLOCK, + dispatcherManager = FakeDispatcherManager(), + ) + + @BeforeEach + fun setUp() { + // serverConfigStateFlow replays its current value on subscription, triggering + // syncIfNecessary() during construction. Clear call counts for a clean test slate. + clearMocks(fillAssistService, fillAssistDiskSource, answers = false) + } + + @Test + fun `sync returns success and does nothing when feature flag is disabled`() = runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) + } returns false + + manager.syncIfNecessary() + + coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + } + + @Test + fun `sync returns success and does nothing when fillAssistRulesUrl is null`() = runTest { + every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null) + + manager.syncIfNecessary() + + coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + } + + @Test + fun `sync skips all network calls when timestamp is fresh`() = runTest { + every { + fillAssistDiskSource.getLastFetchTimestamp(BASE_URL) + } returns FIXED_CLOCK.millis() - (6 * 60 * 60 * 1000L - 1) + + manager.syncIfNecessary() + + coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + coVerify(exactly = 0) { fillAssistService.getForms(any()) } + } + + @Test + fun `sync skips forms download and updates timestamp when CID is unchanged`() = runTest { + every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns CID + + manager.syncIfNecessary() + + coVerify(exactly = 1) { fillAssistService.getManifest(url = MANIFEST_URL) } + coVerify(exactly = 0) { fillAssistService.getForms(any()) } + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync re-fetches forms when CID changes`() = runTest { + every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns "sha256:old" + + manager.syncIfNecessary() + + coVerify(exactly = 1) { fillAssistService.getForms(formsUrl = FORMS_URL) } + verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) } + verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync does not store data when manifest fetch fails`() = runTest { + coEvery { + fillAssistService.getManifest(any()) + } returns Result.failure(RuntimeException("network error")) + + manager.syncIfNecessary() + + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) } + verify(exactly = 0) { fillAssistDiskSource.storeLastFetchTimestamp(any(), any()) } + } + + @Test + fun `sync does not store rules or cid when schemaVersion major is unsupported`() = runTest { + coEvery { fillAssistService.getForms(any()) } returns Result.success( + FORMS_V1.copy(schemaVersion = "1.0.0"), + ) + + manager.syncIfNecessary() + + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync happy path stores rules, cid, and timestamp`() = runTest { + manager.syncIfNecessary() + + verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) } + verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync pools forms from multiple pathnames under the same host`() = runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_MULTI_PATHNAME) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_MULTI_PATHNAME, rulesSlot.captured) + } + + @Test + fun `sync pools host-level forms and pathname forms under the same host`() = runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_HOST_AND_PATHNAME) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_HOST_AND_PATHNAME, rulesSlot.captured) + } + + @Test + fun `sync merges forms with the same category from different pathnames into one HostRule`() = + runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_SAME_CATEGORY_PATHNAMES) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_MERGED_CATEGORY, rulesSlot.captured) + } + + @Test + fun `sync deduplicates selector clauses within a merged category`() = runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_DUPLICATE_SELECTORS) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_DEDUPLICATED_SELECTORS, rulesSlot.captured) + } + + @Test + fun `getFillAssistRules delegates to disk source`() { + val expected = FillAssistRules(hostRules = emptyMap()) + every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns expected + assertEquals(expected, manager.getFillAssistRules()) + } + + @Test + fun `getFillAssistRules returns null when disk source has no data`() { + every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns null + assertNull(manager.getFillAssistRules()) + } + + @Test + fun `getFillAssistRules returns null when server URL is not configured`() { + every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null) + assertNull(manager.getFillAssistRules()) + } + + // region CSS parser + + @Test + fun `parseSingleSelector extracts tag and id shorthand`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = "oid", + name = null, + type = null, + role = null, + ), + parseSingleSelector("input#oid"), + ) + } + + @Test + fun `parseSingleSelector extracts name attribute`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = null, + name = "p", + type = null, + role = null, + ), + parseSingleSelector("input[name='p']"), + ) + } + + @Test + fun `parseSingleSelector extracts compound selector with id shorthand and name`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = "password", + name = "password", + type = null, + role = null, + ), + parseSingleSelector("input#password[name='password']"), + ) + } + + @Test + fun `parseSingleSelector extracts role attribute`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "form", + id = null, + name = null, + type = null, + role = "search", + ), + parseSingleSelector("form[role='search']"), + ) + } + + @Test + fun `parseSingleSelector extracts last segment of shadow DOM selector`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = "field", + name = null, + type = null, + role = null, + ), + parseSingleSelector("div#container >>> input#field"), + ) + } + + @Test + fun `parseSingleSelector extracts last segment of multi-level shadow DOM selector`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + name = "password", + id = null, + type = null, + role = null, + ), + parseSingleSelector("div#form-container >>> form > div >>> input[name='password']"), + ) + } + + @Test + fun `parseSingleSelector returns null for pure class selector`() { + assertNull(parseSingleSelector(".loginForm")) + } + + @Test + fun `parseSingleSelector handles select element`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "select", + id = "state", + name = null, + type = null, + role = null, + ), + parseSingleSelector("select#state"), + ) + } + + // endregion +} + +private val MANIFEST = FillAssistManifestJson( + buildId = "local-build", + timestamp = null, + gitSha = null, + maps = FillAssistManifestJson.MapsJson( + forms = mapOf( + "v0" to FillAssistManifestJson.FileEntryJson( + filename = "forms.v0.json", + cid = CID, + schema = null, + ), + ), + ), +) + +private val FORMS_V1 = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray( + listOf(JsonPrimitive("input#user")), + ), + ), + ), + ), + pathnames = null, + ), + ), +) + +// Host with two pathnames — both forms must appear in the stored rules. +private val FORMS_V1_MULTI_PATHNAME = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = null, + pathnames = mapOf( + "/login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + "password" to JsonArray(listOf(JsonPrimitive("input#pass"))), + ), + ), + ), + ), + "/register" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-creation", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#email"))), + "newPassword" to JsonArray(listOf(JsonPrimitive("input#new-pass"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_MULTI_PATHNAME = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + "password" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "pass", + name = null, + type = null, + role = null, + ), + ), + ), + ), + FillAssistRules.HostRule( + category = "account-creation", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "email", + name = null, + type = null, + role = null, + ), + ), + "newPassword" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "new-pass", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +// Host with both top-level forms and pathname forms — both must appear in the stored rules. +private val FORMS_V1_HOST_AND_PATHNAME = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + pathnames = mapOf( + "/checkout" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "payment-card", + container = null, + fields = mapOf( + "cardNumber" to JsonArray(listOf(JsonPrimitive("input#card-num"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_HOST_AND_PATHNAME = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + "cardNumber" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "card-num", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +// Two pathnames both define account-login — must be merged into one HostRule. +private val FORMS_V1_SAME_CATEGORY_PATHNAMES = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = null, + pathnames = mapOf( + "/login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + ), + "/signin" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#email"))), + "password" to JsonArray(listOf(JsonPrimitive("input#pass"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_MERGED_CATEGORY = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + FillAssistRules.SelectorClause( + tag = "input", + id = "email", + name = null, + type = null, + role = null, + ), + ), + "password" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "pass", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +// Two pathnames define the same selector — the duplicate must be removed. +private val FORMS_V1_DUPLICATE_SELECTORS = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = null, + pathnames = mapOf( + "/login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + ), + "/other-login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_DEDUPLICATED_SELECTORS = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +private val SERVER_CONFIG = ServerConfig( + lastSync = 0L, + serverData = ConfigResponseJson( + type = null, + version = null, + gitHash = null, + server = null, + environment = ConfigResponseJson.EnvironmentJson( + cloudRegion = null, + vaultUrl = null, + apiUrl = null, + identityUrl = null, + notificationsUrl = null, + ssoUrl = null, + fillAssistRulesUrl = BASE_URL, + ), + featureStates = null, + communication = null, + ), +) From 00b2479808ddb0395e35c6c4fd29f048dd8dfd88 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 29 May 2026 13:56:30 +0100 Subject: [PATCH 2/2] chained code on FillAssistManager --- .../data/autofill/manager/FillAssistManagerImpl.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index 7fbdf3d8700..3ddac24d8b9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -63,8 +63,13 @@ class FillAssistManagerImpl( override fun syncIfNecessary() { if (!featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)) return - val serverUrl = serverConfigRepository.serverConfigStateFlow.value - ?.serverData?.environment?.fillAssistRulesUrl ?: return + val serverUrl = serverConfigRepository + .serverConfigStateFlow + .value + ?.serverData + ?.environment + ?.fillAssistRulesUrl + ?: return val lastFetch = fillAssistDiskSource.getLastFetchTimestamp(serverUrl) ?: 0L if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS) return if (!syncJob.isCompleted) return