Compare commits

...

7 Commits

Author SHA1 Message Date
Andre Rosado
d6f0a47d77 Merge branch 'main' into PM-37255/fill-assist-network-layer 2026-06-10 11:10:26 +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
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
31 changed files with 425 additions and 0 deletions

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

@@ -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"]
}
}
]
}
}
}
"""