Compare commits

...

15 Commits

Author SHA1 Message Date
Andre Rosado
1db11d17cd Merge branch 'PM-37255/fill-assist-network-layer' into PM-37255/fill-assist-data-layer 2026-06-12 15:10:47 +01:00
Andre Rosado
8b05d40cc9 Addressing pr comments 2026-06-11 16:49:16 +01:00
Andre Rosado
6c0bfa5ba5 Improved code readability 2026-06-11 14:58:32 +01:00
Andre Rosado
d6f0a47d77 Merge branch 'main' into PM-37255/fill-assist-network-layer 2026-06-10 11:10:26 +01:00
Andre Rosado
ad2aada6b1 updating environmentDiskSource.fillAssistUrl when serverConfigStateFlow updates
Updating code to schema with required values
2026-06-10 10:50:33 +01:00
Andre Rosado
bbdc2f10da Merge branch 'PM-37255/fill-assist-network-layer' into PM-37255/fill-assist-data-layer 2026-06-09 14:46:07 +01:00
Andre Rosado
31a9cf7c34 Added FillAssist to BaseUrlInterceptors 2026-06-09 13:59:15 +01:00
Andre Rosado
b25404e112 removed unnecessary null set 2026-06-09 13:16:20 +01:00
Andre Rosado
0b39ad2731 following autofill assist forms schema 2026-06-08 17:39:47 +01:00
Andre Rosado
f66485facd Removed nulls and sets on non nullable fields by schema definition. Removed unnecessary deserialization tests 2026-06-08 17:20:27 +01:00
Andre Rosado
61db0cd4c4 Merge branch 'PM-37255/fill-assist-network-layer' into PM-37255/fill-assist-data-layer 2026-05-29 14:07:33 +01:00
Andre Rosado
00b2479808 chained code on FillAssistManager 2026-05-29 13:56:30 +01:00
Andre Rosado
8b6ce8bce9 Add fill assist data layer 2026-05-29 13:40:03 +01:00
Andre Rosado
f57a7d09a2 reverted unwanted changes on AuthRepositoryTest 2026-05-29 13:24:13 +01:00
Andre Rosado
c6463722f2 Add fill assist rules network data 2026-05-29 12:03:19 +01:00
39 changed files with 1801 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,71 @@
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.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,60 @@
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.datasource.disk.EnvironmentDiskSource
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,
environmentDiskSource: EnvironmentDiskSource,
clock: Clock,
dispatcherManager: DispatcherManager,
): FillAssistManager =
FillAssistManagerImpl(
fillAssistService = fillAssistService,
fillAssistDiskSource = fillAssistDiskSource,
featureFlagManager = featureFlagManager,
serverConfigRepository = serverConfigRepository,
environmentDiskSource = environmentDiskSource,
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,249 @@
package com.x8bit.bitwarden.data.autofill.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
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.datasource.disk.EnvironmentDiskSource
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
import java.time.Clock
private const val CURRENT_FORMS_VERSION = "v1"
private const val EXPECTED_SCHEMA_MAJOR = "1"
private const val ID = "id"
private const val NAME = "name"
private const val TYPE = "type"
private const val ROLE = "role"
/** 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 environmentDiskSource: EnvironmentDiskSource,
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
.onEach { config ->
environmentDiskSource.fillAssistRulesUrl =
config?.serverData?.environment?.fillAssistRulesUrl
}
.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 {
val manifest = fillAssistService
.getManifest()
.getOrNull()
?: return@runCatching
val versionEntry = manifest.maps.forms[CURRENT_FORMS_VERSION]
?: error("Version $CURRENT_FORMS_VERSION not found in manifest")
if (versionEntry.deprecated == true) {
Timber.w("Fill-assist forms $CURRENT_FORMS_VERSION is deprecated")
}
if (versionEntry.cid == fillAssistDiskSource.getLastKnownCid(serverUrl)) {
fillAssistDiskSource.storeLastFetchTimestamp(
serverUrl = serverUrl,
timestamp = clock.millis(),
)
return@runCatching
}
val forms = fillAssistService
.getForms(filename = versionEntry.filename)
.getOrNull()
?: return@runCatching
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 = versionEntry.cid)
fillAssistDiskSource.storeLastFetchTimestamp(
serverUrl = serverUrl,
timestamp = clock.millis(),
)
}
.onFailure { Timber.w(it, "Fill-assist sync failed") }
override fun getFillAssistRules(): FillAssistRules? {
val serverUrl = serverConfigRepository
.serverConfigStateFlow
.value
?.serverData
?.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()
return FillAssistRules(hostRules = hostRules)
}
private fun parseHostEntry(
hostEntry: FillAssistFormsJson.HostEntryJson,
): List<FillAssistRules.HostRule> {
val allForms = buildList {
addAll(hostEntry.forms.orEmpty())
addAll(hostEntry.pathnames?.values?.filterNotNull()?.flatMap { 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, Map<String, List<SelectorClause>>> =
forms
.mapNotNull { form ->
val parsedFields = form.fields
.mapValues { (_, elem) -> parseCompositeSelectorArray(elem) }
.filterValues { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
form.category to parsedFields
}
.groupBy({ it.first }, { it.second })
.mapValues { (_, fieldMaps) ->
fieldMaps
.flatMap { it.entries }
.groupBy({ it.key }, { it.value })
.mapValues { (_, lists) -> lists.flatten() }
}
private fun parseCompositeSelectorArray(element: JsonElement): List<SelectorClause> {
if (element !is JsonArray) return emptyList()
return element.flatMap { item ->
when (item) {
is JsonPrimitive -> listOfNotNull(parseSingleSelector(item.content))
is JsonArray -> {
item
.filterIsInstance<JsonPrimitive>()
.mapNotNull { parseSingleSelector(it.content) }
}
else -> emptyList()
}
}
}
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
// For e.g. "[type='password']": groupValues[0]="[type='password']", [1]="type", [2]="password".
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

@@ -27,4 +27,10 @@ interface EnvironmentDiskSource {
* Stores the [urls] for the given [userEmail].
*/
fun storePreAuthEnvironmentUrlDataForEmail(userEmail: String, urls: EnvironmentUrlDataJson)
/**
* The fill-assist URL provided by the server config, or `null` if the server does not
* configure fill-assist targeting rules.
*/
var fillAssistRulesUrl: String?
}

View File

@@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json
private const val PRE_AUTH_URLS_KEY = "preAuthEnvironmentUrls"
private const val EMAIL_VERIFICATION_URLS = "emailVerificationUrls"
private const val FILL_ASSIST_RULES_URL_KEY = "fillAssistRulesUrl"
/**
* Primary implementation of [EnvironmentDiskSource].
@@ -54,4 +55,8 @@ class EnvironmentDiskSourceImpl(
value = json.encodeToString(urls),
)
}
override var fillAssistRulesUrl: String?
get() = getString(key = FILL_ASSIST_RULES_URL_KEY)
set(value) = putString(key = FILL_ASSIST_RULES_URL_KEY, value = value)
}

View File

@@ -7,6 +7,7 @@ import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
@@ -32,6 +33,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformNetworkModule {
@Provides
@Singleton
fun providesFillAssistService(
bitwardenServiceClient: BitwardenServiceClient,
): FillAssistService = bitwardenServiceClient.fillAssistService
@Provides
@Singleton
fun providesConfigService(

View File

@@ -31,4 +31,6 @@ class BaseUrlsProviderImpl(
.toEnvironmentUrlsOrDefault()
.environmentUrlData
.baseEventsUrl
override fun getBaseFillAssistUrl(): String? = environmentDiskSource.fillAssistRulesUrl
}

View File

@@ -7589,6 +7589,7 @@ class AuthRepositoryTest {
identityUrl = "mockIdentityUrl",
notificationsUrl = "mockNotificationsUrl",
ssoUrl = "mockSsoUrl",
fillAssistRulesUrl = null,
),
featureStates = emptyMap(),
communication = null,

View File

@@ -0,0 +1,156 @@
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 does not clear fillAssistRulesUrl from EnvironmentDiskSource`() {
fakeSharedPreferences.edit()
.putString(
"bwPreferencesStorage:fillAssistRulesUrl",
"https://fill-assist.example.com/",
)
.putInt("bwPreferencesStorage:fillAssistCacheVersion", -1)
.apply()
FillAssistDiskSourceImpl(sharedPreferences = fakeSharedPreferences, json = json)
assertEquals(
"https://fill-assist.example.com/",
fakeSharedPreferences.getString(
key = "bwPreferencesStorage:fillAssistRulesUrl",
defaultValue = null,
),
)
}
@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,726 @@
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.datasource.disk.EnvironmentDiskSource
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 FORMS_FILENAME = "forms.v1.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 serverConfigFlow = MutableStateFlow<ServerConfig?>(SERVER_CONFIG)
private val serverConfigRepository: ServerConfigRepository = mockk {
every { serverConfigStateFlow } returns serverConfigFlow
}
private val fillAssistService: FillAssistService = mockk {
coEvery { getManifest() } returns Result.success(MANIFEST)
coEvery { getForms(any()) } 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 environmentDiskSource: EnvironmentDiskSource = mockk {
every { fillAssistRulesUrl = any() } just runs
}
private val manager = FillAssistManagerImpl(
fillAssistService = fillAssistService,
fillAssistDiskSource = fillAssistDiskSource,
featureFlagManager = featureFlagManager,
serverConfigRepository = serverConfigRepository,
environmentDiskSource = environmentDiskSource,
clock = FIXED_CLOCK,
dispatcherManager = FakeDispatcherManager(),
)
@BeforeEach
fun setUp() {
// serverConfigStateFlow replays its current value on subscription, triggering
// syncIfNecessary() and the URL write during construction. Clear call counts for a clean
// test slate.
clearMocks(fillAssistService, fillAssistDiskSource, environmentDiskSource, answers = false)
}
@Test
fun `server config change writes fillAssistRulesUrl to environment disk source`() = runTest {
serverConfigFlow.value = null
verify { environmentDiskSource.fillAssistRulesUrl = null }
serverConfigFlow.value = SERVER_CONFIG
verify { environmentDiskSource.fillAssistRulesUrl = BASE_URL }
}
@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() }
verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) }
}
@Test
fun `sync returns success and does nothing when fillAssistRulesUrl is null`() = runTest {
serverConfigFlow.value = null
manager.syncIfNecessary()
coVerify(exactly = 0) { fillAssistService.getManifest() }
}
@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() }
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() }
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(filename = FORMS_FILENAME) }
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()
} 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 = "2.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`() {
serverConfigFlow.value = 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 = "2026-01-01T12:00:00Z",
gitSha = "abc123",
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v1" to FillAssistManifestJson.FileEntryJson(
filename = FORMS_FILENAME,
cid = CID,
schema = "forms.v1.schema.json",
deprecated = null,
),
),
),
)
private val FORMS_V1 = FillAssistFormsJson(
schemaVersion = "1.0.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 = "1.0.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 = "1.0.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 = "1.0.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 = "1.0.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,
),
)

View File

@@ -95,11 +95,26 @@ class EnvironmentDiskSourceTest {
json.parseToJsonElement(requireNotNull(actual)),
)
}
@Test
fun `fillAssistRulesUrl should pull from and update SharedPreferences`() {
assertNull(environmentDiskSource.fillAssistRulesUrl)
assertNull(fakeSharedPreferences.getString(FILL_ASSIST_RULES_URL_KEY, null))
environmentDiskSource.fillAssistRulesUrl = "https://fill-assist.example.com/"
assertEquals(
"https://fill-assist.example.com/",
fakeSharedPreferences.getString(FILL_ASSIST_RULES_URL_KEY, null),
)
environmentDiskSource.fillAssistRulesUrl = null
assertNull(fakeSharedPreferences.getString(FILL_ASSIST_RULES_URL_KEY, null))
}
}
private const val EMAIL = "email@example.com"
private const val EMAIL_VERIFICATION_URLS_KEY = "bwPreferencesStorage:emailVerificationUrls"
private const val PRE_AUTH_URLS_KEY = "bwPreferencesStorage:preAuthEnvironmentUrls"
private const val FILL_ASSIST_RULES_URL_KEY = "bwPreferencesStorage:fillAssistRulesUrl"
private const val ENVIRONMENT_URL_DATA_JSON = """
{

View File

@@ -29,6 +29,8 @@ class FakeEnvironmentDiskSource : EnvironmentDiskSource {
storedEmailVerificationUrls[userEmail] = urls
}
override var fillAssistRulesUrl: String? = null
private val mutablePreAuthEnvironmentUrlDataFlow =
bufferedMutableSharedFlow<EnvironmentUrlDataJson?>(replay = 1)
}

View File

@@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest {
identityUrl = null,
notificationsUrl = null,
ssoUrl = null,
fillAssistRulesUrl = null,
),
featureStates = null,
communication = ConfigResponseJson.CommunicationJson(

View File

@@ -319,6 +319,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"dummy-boolean" to JsonPrimitive(true),

View File

@@ -4,6 +4,7 @@ import com.bitwarden.data.repository.model.Environment
import com.x8bit.bitwarden.data.platform.datasource.disk.FakeEnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.provider.BaseUrlsProviderImpl
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class BaseUrlsProviderTest {
@@ -66,4 +67,16 @@ class BaseUrlsProviderTest {
baseUrlsManager.getBaseEventsUrl(),
)
}
@Test
fun `getBaseFillAssistUrl should return url from disk source when present`() {
fakeEnvironmentDiskSource.fillAssistRulesUrl = "https://example.com/"
assertEquals("https://example.com/", baseUrlsManager.getBaseFillAssistUrl())
}
@Test
fun `getBaseFillAssistUrl should return null when not set`() {
fakeEnvironmentDiskSource.fillAssistRulesUrl = null
assertNull(baseUrlsManager.getBaseFillAssistUrl())
}
}

View File

@@ -36,6 +36,7 @@ class SdkRepositoryFactoryTests {
override fun getBaseApiUrl(): String = BASE_API_URL
override fun getBaseIdentityUrl(): String = BASE_IDENTITY_URL
override fun getBaseEventsUrl(): String = BASE_EVENTS_URL
override fun getBaseFillAssistUrl(): String? = null
},
authTokenProvider = mockk(),
certificateProvider = mockk(),

View File

@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -20,4 +20,6 @@ object BaseUrlsProviderImpl : BaseUrlsProvider {
override fun getBaseEventsUrl(): String =
Environment.Us.environmentUrlData.baseEventsUrl
override fun getBaseFillAssistUrl(): String? = null
}

View File

@@ -262,6 +262,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"dummy-boolean" to JsonPrimitive(true),

View File

@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -107,6 +107,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -161,6 +161,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),
@@ -185,6 +186,7 @@ private val CONFIG_RESPONSE_JSON = ConfigResponseJson(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -16,6 +16,7 @@ import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
@@ -107,6 +108,11 @@ interface BitwardenServiceClient {
*/
val eventService: EventService
/**
* Provides access to the Fill-Assist service.
*/
val fillAssistService: FillAssistService
/**
* Provides access to the Folder service.
*/

View File

@@ -29,6 +29,8 @@ import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.DownloadServiceImpl
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.EventServiceImpl
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.FillAssistServiceImpl
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.FolderServiceImpl
import com.bitwarden.network.service.HaveIBeenPwnedService
@@ -155,6 +157,10 @@ internal class BitwardenServiceClientImpl(
)
}
override val fillAssistService: FillAssistService by lazy {
FillAssistServiceImpl(api = retrofits.fillAssistRetrofit.create())
}
override val haveIBeenPwnedService: HaveIBeenPwnedService by lazy {
HaveIBeenPwnedServiceImpl(
api = retrofits

View File

@@ -0,0 +1,27 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import com.bitwarden.network.model.NetworkResult
import retrofit2.http.GET
import retrofit2.http.Path
/**
* Defines endpoints for retrieving fill-assist targeting rules. The base URL is set dynamically
* at runtime via [com.bitwarden.network.interceptor.BaseUrlInterceptors.fillAssistInterceptor].
*/
internal interface FillAssistApi {
/**
* Fetches the fill-assist manifest.
*/
@GET("manifest.json")
suspend fun getManifest(): NetworkResult<FillAssistManifestJson>
/**
* Fetches the forms rules file by [filename] (e.g. "forms.v1.json").
*/
@GET("{filename}")
suspend fun getForms(
@Path("filename") filename: String,
): NetworkResult<FillAssistFormsJson>
}

View File

@@ -29,4 +29,11 @@ internal class BaseUrlInterceptors(
val eventsInterceptor: BaseUrlInterceptor = BaseUrlInterceptor {
baseUrlsProvider.getBaseEventsUrl()
}
/**
* An interceptor for fill-assist calls.
*/
val fillAssistInterceptor: BaseUrlInterceptor = BaseUrlInterceptor {
baseUrlsProvider.getBaseFillAssistUrl()
}
}

View File

@@ -18,4 +18,10 @@ interface BaseUrlsProvider {
* Gets the base URL for "/events" calls.
*/
fun getBaseEventsUrl(): String
/**
* Gets the base URL for fill-assist calls, or null if the server does not provide
* fill-assist targeting rules.
*/
fun getBaseFillAssistUrl(): String?
}

View File

@@ -62,6 +62,8 @@ data class ConfigResponseJson(
* @param identityUrl The URL of the identity service in the environment.
* @param notificationsUrl The URL of the notifications service in the environment.
* @param ssoUrl The URL of the single sign-on (SSO) service in the environment.
* @param fillAssistRulesUrl The base URL of the fill-assist targeting rules, or null if
* the server does not provide fill-assist rules.
*/
@Serializable
data class EnvironmentJson(
@@ -82,6 +84,9 @@ data class ConfigResponseJson(
@SerialName("sso")
val ssoUrl: String?,
@SerialName("fillAssistRules")
val fillAssistRulesUrl: String?,
)
/**

View File

@@ -0,0 +1,68 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Represents the fill-assist forms rules file.
*
* @property schemaVersion The semantic version string for this file (e.g. "1.0.0").
* @property hosts Map of hostname (optionally with port) to [HostEntryJson], or null if the host
* is explicitly excluded from fill-assist.
*/
@Serializable
data class FillAssistFormsJson(
@SerialName("schemaVersion")
val schemaVersion: String,
@SerialName("hosts")
val hosts: Map<String, HostEntryJson?>,
) {
/**
* Form descriptions and pathname-specific overrides for a single host.
*
* @property forms Site-wide fallback form descriptions.
* @property pathnames Pathname-specific overrides; a null value means that path is excluded.
*/
@Serializable
data class HostEntryJson(
@SerialName("forms")
val forms: List<FormJson>?,
@SerialName("pathnames")
val pathnames: Map<String, PathnameEntryJson?>?,
)
/**
* Form descriptions for a specific pathname.
*
* @property forms The form descriptions for this path.
*/
@Serializable
data class PathnameEntryJson(
@SerialName("forms")
val forms: List<FormJson>,
)
/**
* Describes one logical form on a page.
*
* @property category The categorical purpose of this form (e.g. "account-login").
* @property container Optional CSS selectors identifying the form's container element.
* @property fields Map of field key to [JsonElement] representing a compositeSelectorArray.
* Each array element is either a CSS selector string or an array of strings for composite
* multi-input fields. Unknown fields are gracefully ignored via [ignoreUnknownKeys].
*/
@Serializable
data class FormJson(
@SerialName("category")
val category: String,
@SerialName("container")
val container: List<String>?,
@SerialName("fields")
val fields: Map<String, JsonElement>,
)
}

View File

@@ -0,0 +1,64 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the fill-assist manifest returned by the fill-assist service.
*
* @property buildId The unique identifier for this build.
* @property timestamp The ISO-8601 timestamp when this build was produced.
* @property gitSha The git commit SHA for this build.
* @property maps The map data entries keyed by map type.
*/
@Serializable
data class FillAssistManifestJson(
@SerialName("buildId")
val buildId: String,
@SerialName("timestamp")
val timestamp: String,
@SerialName("gitSha")
val gitSha: String,
@SerialName("maps")
val maps: MapsJson,
) {
/**
* Container for all available maps.
*
* @property forms Map of schema version string (e.g. "v1", "v2") to [FileEntryJson].
* Using a [Map] allows new versions to appear automatically without model changes.
*/
@Serializable
data class MapsJson(
@SerialName("forms")
val forms: Map<String, FileEntryJson>,
)
/**
* Metadata for a single versioned file in a map.
*
* @property filename The filename to fetch (e.g. "forms.v1.json").
* @property cid The SHA-256 content hash in "sha256:<hex>" format. Used as a staleness key
* to detect when the forms file has changed on the server, avoiding unnecessary re-downloads.
* @property schema The schema filename associated with this file version.
* @property deprecated When true, this version has entered its end-of-life support window.
* Consumers should plan migration but may continue using the version until it is removed.
*/
@Serializable
data class FileEntryJson(
@SerialName("filename")
val filename: String,
@SerialName("cid")
val cid: String,
@SerialName("schema")
val schema: String,
@SerialName("deprecated")
val deprecated: Boolean?,
)
}

View File

@@ -36,6 +36,12 @@ internal interface Retrofits {
*/
val unauthenticatedIdentityRetrofit: Retrofit
/**
* Allows access to fill-assist calls. The base URL is determined dynamically via the
* [BaseUrlInterceptors.fillAssistInterceptor].
*/
val fillAssistRetrofit: Retrofit
/**
* Allows access to static API calls (ex: external APIs).
*

View File

@@ -64,6 +64,16 @@ internal class RetrofitsImpl(
//endregion Unauthenticated Retrofits
//region Fill-Assist Retrofit
override val fillAssistRetrofit: Retrofit by lazy {
createUnauthenticatedRetrofit(
baseUrlInterceptor = baseUrlInterceptors.fillAssistInterceptor,
)
}
//endregion Fill-Assist Retrofit
//region Static Retrofit
override fun createStaticRetrofit(isAuthenticated: Boolean, baseUrl: String): Retrofit {

View File

@@ -0,0 +1,21 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
/**
* Provides access to the fill-assist targeting rules service.
*/
interface FillAssistService {
/**
* Fetches and parses the fill-assist manifest.
*/
suspend fun getManifest(): Result<FillAssistManifestJson>
/**
* Downloads and parses the forms rules file identified by [filename] (e.g. "forms.v1.json").
*
* Returns [Result.failure] if the network request fails or parsing fails.
*/
suspend fun getForms(filename: String): Result<FillAssistFormsJson>
}

View File

@@ -0,0 +1,20 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.FillAssistApi
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import com.bitwarden.network.util.toResult
/**
* Default implementation of [FillAssistService].
*/
internal class FillAssistServiceImpl(
private val api: FillAssistApi,
) : FillAssistService {
override suspend fun getManifest(): Result<FillAssistManifestJson> =
api.getManifest().toResult()
override suspend fun getForms(filename: String): Result<FillAssistFormsJson> =
api.getForms(filename = filename).toResult()
}

View File

@@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson(
notificationsUrl = "notificationsUrl",
identityUrl = "identityUrl",
ssoUrl = "ssoUrl",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"feature one" to JsonPrimitive(false),

View File

@@ -0,0 +1,116 @@
package com.bitwarden.network.service
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.api.FillAssistApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class FillAssistServiceTest : BaseServiceTest() {
private val api: FillAssistApi = retrofit.create()
private val service = FillAssistServiceImpl(api = api)
@Test
fun `getManifest should parse manifest response`() = runTest {
server.enqueue(MockResponse().setBody(MANIFEST_JSON))
assertEquals(MANIFEST.asSuccess(), service.getManifest())
}
@Test
fun `getManifest should return failure on server error`() = runTest {
server.enqueue(MockResponse().setResponseCode(500))
assertTrue(service.getManifest().isFailure)
}
@Test
fun `getForms should parse and return forms`() = runTest {
server.enqueue(MockResponse().setBody(FORMS_V1_JSON))
assertEquals(FORMS_V1.asSuccess(), service.getForms(filename = "forms.v1.json"))
}
@Test
fun `getForms should return failure on server error`() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
assertTrue(service.getForms(filename = "forms.v1.json").isFailure)
}
}
private val MANIFEST = FillAssistManifestJson(
buildId = "local-build",
timestamp = "2026-05-20T15:01:02.956Z",
gitSha = "abc123",
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v1" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v1.json",
cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
schema = "forms.v1.schema.json",
deprecated = null,
),
),
),
)
private val FORMS_V1 = FillAssistFormsJson(
schemaVersion = "1.0.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"))),
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
),
),
),
pathnames = null,
),
),
)
private const val MANIFEST_JSON = """
{
"buildId": "local-build",
"timestamp": "2026-05-20T15:01:02.956Z",
"gitSha": "abc123",
"maps": {
"forms": {
"v1": {
"filename": "forms.v1.json",
"cid": "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
"schema": "forms.v1.schema.json"
}
}
}
}
"""
private const val FORMS_V1_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"forms": [
{
"category": "account-login",
"fields": {
"username": ["input#user"],
"password": ["input#pass"]
}
}
]
}
}
}
"""