Add fill assist data layer

This commit is contained in:
Andre Rosado
2026-05-29 13:40:03 +01:00
parent c6463722f2
commit 8b6ce8bce9
8 changed files with 1319 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<FillAssistRules.HostRule> {
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<FillAssistFormsJson.FormJson>,
): Map<String, MutableMap<String, MutableList<SelectorClause>>> {
val result = mutableMapOf<String, MutableMap<String, MutableList<SelectorClause>>>()
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<SelectorClause> {
if (element !is JsonArray) return emptyList()
val result = mutableListOf<SelectorClause>()
for (item in element) {
when (item) {
is JsonPrimitive -> parseSingleSelector(item.content)?.let { result.add(it) }
is JsonArray -> item
.filterIsInstance<JsonPrimitive>()
.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

View File

@@ -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<String, List<HostRule>>,
) {
/**
* 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<String, List<SelectorClause>>,
)
/**
* 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?,
)
}

View File

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

View File

@@ -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<FillAssistRules>()
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<FillAssistRules>()
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<FillAssistRules>()
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<FillAssistRules>()
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,
),
)