From c6463722f2bc7b721d034ae7584d1e02fe4e5247 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 29 May 2026 12:03:19 +0100 Subject: [PATCH 01/15] Add fill assist rules network data --- .../network/di/PlatformNetworkModule.kt | 7 + .../auth/repository/AuthRepositoryTest.kt | 67 ++-- ...ServerCommunicationConfigRepositoryTest.kt | 1 + .../manager/FeatureFlagManagerTest.kt | 1 + .../util/FakeServerConfigRepository.kt | 1 + .../manager/FeatureFlagManagerTest.kt | 1 + .../util/FakeServerConfigRepository.kt | 1 + .../datasource/disk/ConfigDiskSourceTest.kt | 1 + .../repository/ServerConfigRepositoryTest.kt | 2 + .../network/BitwardenServiceClient.kt | 6 + .../network/BitwardenServiceClientImpl.kt | 8 + .../bitwarden/network/api/FillAssistApi.kt | 29 ++ .../network/model/ConfigResponseJson.kt | 3 + .../network/model/FillAssistFormsJson.kt | 68 ++++ .../network/model/FillAssistManifestJson.kt | 64 ++++ .../network/service/FillAssistService.kt | 22 ++ .../network/service/FillAssistServiceImpl.kt | 20 ++ .../network/model/FillAssistFormsJsonTest.kt | 305 ++++++++++++++++++ .../model/FillAssistManifestJsonTest.kt | 165 ++++++++++ .../network/service/ConfigServiceTest.kt | 1 + .../network/service/FillAssistServiceTest.kt | 115 +++++++ 21 files changed, 854 insertions(+), 34 deletions(-) create mode 100644 network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt create mode 100644 network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt create mode 100644 network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt create mode 100644 network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt index ed54531db2c..406fb7de784 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index d0b7dd2f488..8f8b908d14f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -46,6 +46,7 @@ import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson import com.bitwarden.network.model.OrganizationKeysResponseJson import com.bitwarden.network.model.OrganizationType import com.bitwarden.network.model.PasswordHintResponseJson +import com.bitwarden.network.model.PolicyTypeJson import com.bitwarden.network.model.PreLoginResponseJson import com.bitwarden.network.model.PrevalidateSsoResponseJson import com.bitwarden.network.model.RefreshTokenResponseJson @@ -56,6 +57,7 @@ import com.bitwarden.network.model.ResetPasswordRequestJson import com.bitwarden.network.model.SendVerificationEmailRequestJson import com.bitwarden.network.model.SendVerificationEmailResponseJson import com.bitwarden.network.model.SetPasswordRequestJson +import com.bitwarden.network.model.SyncResponseJson import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson import com.bitwarden.network.model.TwoFactorAuthMethod import com.bitwarden.network.model.TwoFactorDataModel @@ -67,13 +69,12 @@ import com.bitwarden.network.model.VerifyEmailTokenResponseJson import com.bitwarden.network.model.createMockAccountKeysJson import com.bitwarden.network.model.createMockAccountKeysJsonWithNullFields import com.bitwarden.network.model.createMockOrganizationNetwork +import com.bitwarden.network.model.createMockPolicy import com.bitwarden.network.service.AccountsService import com.bitwarden.network.service.DevicesService import com.bitwarden.network.service.HaveIBeenPwnedService import com.bitwarden.network.service.IdentityService import com.bitwarden.network.service.OrganizationService -import com.bitwarden.policies.PolicyType -import com.bitwarden.policies.PolicyView import com.bitwarden.ui.platform.resource.BitwardenString import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson @@ -147,7 +148,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource -import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -168,6 +168,8 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -264,14 +266,14 @@ class AuthRepositoryTest { private val mutableLogoutFlow = bufferedMutableSharedFlow() private val mutableSyncOrgKeysFlow = bufferedMutableSharedFlow() - private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() + private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() private val pushManager: PushManager = mockk { every { logoutFlow } returns mutableLogoutFlow every { syncOrgKeysFlow } returns mutableSyncOrgKeysFlow } private val policyManager: PolicyManager = mockk { every { - getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD) + getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD) } returns mutableActivePolicyFlow } private val logsManager: LogsManager = mockk { @@ -468,20 +470,18 @@ class AuthRepositoryTest { // Set policies that will fail the password. mutableActivePolicyFlow.emit( listOf( - createMockPolicyView( - type = PolicyType.MASTER_PASSWORD, - enabled = true, - data = """ - { - "minLength":100, - "minComplexity":null, - "requireUpper":null, - "requireLower":null, - "requireNumbers":null, - "requireSpecial":null, - "enforceOnLogin":true - } - """, + createMockPolicy( + type = PolicyTypeJson.MASTER_PASSWORD, + isEnabled = true, + data = buildJsonObject { + put(key = "minLength", value = 100) + put(key = "minComplexity", value = null) + put(key = "requireUpper", value = null) + put(key = "requireLower", value = null) + put(key = "requireNumbers", value = null) + put(key = "requireSpecial", value = null) + put(key = "enforceOnLogin", value = true) + }, ), ), ) @@ -7344,22 +7344,20 @@ class AuthRepositoryTest { requireSpecial: Boolean = false, ) { every { - policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD) + policyManager.getActivePolicies(type = PolicyTypeJson.MASTER_PASSWORD) } returns listOf( - createMockPolicyView( - type = PolicyType.MASTER_PASSWORD, - enabled = true, - data = """ - { - "minLength":$minLength, - "minComplexity":$minComplexity, - "requireUpper":$requireUpper, - "requireLower":$requireLower, - "requireNumbers":$requireNumbers, - "requireSpecial":$requireSpecial, - "enforceOnLogin":true - } - """, + createMockPolicy( + type = PolicyTypeJson.MASTER_PASSWORD, + isEnabled = true, + data = buildJsonObject { + put(key = "minLength", value = minLength) + put(key = "minComplexity", value = minComplexity) + put(key = "requireUpper", value = requireUpper) + put(key = "requireLower", value = requireLower) + put(key = "requireNumbers", value = requireNumbers) + put(key = "requireSpecial", value = requireSpecial) + put(key = "enforceOnLogin", value = true) + }, ), ) } @@ -8106,6 +8104,7 @@ class AuthRepositoryTest { identityUrl = "mockIdentityUrl", notificationsUrl = "mockNotificationsUrl", ssoUrl = "mockSsoUrl", + fillAssistRulesUrl = null, ), featureStates = emptyMap(), communication = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt index 8b2c98eff0f..82368ce2fc2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/sdk/ServerCommunicationConfigRepositoryTest.kt @@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest { identityUrl = null, notificationsUrl = null, ssoUrl = null, + fillAssistRulesUrl = null, ), featureStates = null, communication = ConfigResponseJson.CommunicationJson( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt index 2228c33b6a8..aeceb2ef29f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerTest.kt @@ -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), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt index 07055353b56..5ce969386af 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/util/FakeServerConfigRepository.kt @@ -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), diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt index b8e29a72510..ef0545cee72 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/manager/FeatureFlagManagerTest.kt @@ -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), diff --git a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt index aaf0ffa2c1b..e7e5ad94f60 100644 --- a/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt +++ b/authenticator/src/test/kotlin/com/bitwarden/authenticator/data/platform/repository/util/FakeServerConfigRepository.kt @@ -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), diff --git a/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt b/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt index 6093171fbe7..062d57b5624 100644 --- a/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt +++ b/data/src/test/kotlin/com/bitwarden/data/datasource/disk/ConfigDiskSourceTest.kt @@ -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), diff --git a/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt b/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt index 4e32741fb5e..abc7c4963fe 100644 --- a/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt +++ b/data/src/test/kotlin/com/bitwarden/data/repository/ServerConfigRepositoryTest.kt @@ -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), diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt index 1824ea01a28..e1b0203a055 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt @@ -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. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index f054cb3e010..7afab54ab64 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -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,12 @@ internal class BitwardenServiceClientImpl( ) } + override val fillAssistService: FillAssistService by lazy { + FillAssistServiceImpl( + api = retrofits.createStaticRetrofit().create(), + ) + } + override val haveIBeenPwnedService: HaveIBeenPwnedService by lazy { HaveIBeenPwnedServiceImpl( api = retrofits diff --git a/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt new file mode 100644 index 00000000000..0ca1c93d239 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt @@ -0,0 +1,29 @@ +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.Url + +/** + * Defines endpoints for retrieving fill-assist targeting rules from the fill-assist service. + * Uses [Url] to support the dynamic base URL provided by server config at runtime. + */ +internal interface FillAssistApi { + /** + * Fetches the fill-assist manifest from the given [url]. + */ + @GET + suspend fun getManifest( + @Url url: String, + ): NetworkResult + + /** + * Fetches and decodes the forms rules file from [url]. + */ + @GET + suspend fun getForms( + @Url url: String, + ): NetworkResult +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt index d0cb32cf9a2..834ab78bf32 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt @@ -82,6 +82,9 @@ data class ConfigResponseJson( @SerialName("sso") val ssoUrl: String?, + + @SerialName("fillAssistRules") + val fillAssistRulesUrl: String?, ) /** diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt new file mode 100644 index 00000000000..9f56cca659a --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt @@ -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? = null, + + @SerialName("hosts") + val hosts: Map? = null, +) { + /** + * 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? = null, + + @SerialName("pathnames") + val pathnames: Map? = null, + ) + + /** + * Form descriptions for a specific pathname. + * + * @property forms The form descriptions for this path. + */ + @Serializable + data class PathnameEntryJson( + @SerialName("forms") + val forms: List? = null, + ) + + /** + * 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? = null, + + @SerialName("container") + val container: List? = null, + + @SerialName("fields") + val fields: Map? = null, + ) +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt new file mode 100644 index 00000000000..460039ff032 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt @@ -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? = null, + + @SerialName("timestamp") + val timestamp: String? = null, + + @SerialName("gitSha") + val gitSha: String? = null, + + @SerialName("maps") + val maps: MapsJson? = null, +) { + /** + * 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?, + ) + + /** + * Metadata for a single versioned file in a map. + * + * @property filename The filename to fetch (e.g. "forms.v0.json"). + * @property cid The SHA-256 content hash in "sha256:" 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? = null, + + @SerialName("cid") + val cid: String? = null, + + @SerialName("schema") + val schema: String? = null, + + @SerialName("deprecated") + val deprecated: Boolean? = null, + ) +} diff --git a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt new file mode 100644 index 00000000000..007a25f13df --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt @@ -0,0 +1,22 @@ +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 from [url]. + */ + suspend fun getManifest(url: String): Result + + /** + * Downloads and parses the forms rules file from [formsUrl]. + * + * Returns [Result.failure] if the network request fails or parsing fails. + * Version-agnostic: any forms file URL can be passed regardless of schema version. + */ + suspend fun getForms(formsUrl: String): Result +} diff --git a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt new file mode 100644 index 00000000000..7668e4b346b --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt @@ -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(url: String): Result = + api.getManifest(url = url).toResult() + + override suspend fun getForms(formsUrl: String): Result = + api.getForms(url = formsUrl).toResult() +} diff --git a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt new file mode 100644 index 00000000000..532154fbb98 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt @@ -0,0 +1,305 @@ +package com.bitwarden.network.model + +import kotlinx.serialization.json.Json +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.Test + +class FillAssistFormsJsonTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `deserialize simple login form`() { + assertEquals( + SIMPLE_LOGIN_FORMS, + json.decodeFromString(SIMPLE_LOGIN_JSON), + ) + } + + @Test + fun `deserialize host with null value is excluded host`() { + val result = json.decodeFromString(NULL_HOST_JSON) + assertNull(result.hosts?.get("excluded.com")) + } + + @Test + fun `deserialize null pathname is excluded path`() { + val result = json.decodeFromString(NULL_PATHNAME_JSON) + assertNull(result.hosts?.get("example.com")?.pathnames?.get("/excluded")) + } + + @Test + fun `deserialize composite OTP field`() { + assertEquals( + OTP_FORMS, + json.decodeFromString(OTP_JSON), + ) + } + + @Test + fun `deserialize compound selector with multiple alternatives`() { + assertEquals( + HONEYPOT_FORMS, + json.decodeFromString(HONEYPOT_JSON), + ) + } + + @Test + fun `deserialize form with container`() { + assertEquals( + CONTAINER_FORMS, + json.decodeFromString(CONTAINER_JSON), + ) + } + + @Test + fun `deserialize form with actions field is graceful`() { + // actions is intentionally excluded from FormJson — handled by ignoreUnknownKeys. + assertEquals( + SIMPLE_LOGIN_FORMS, + json.decodeFromString(SIMPLE_LOGIN_WITH_ACTIONS_JSON), + ) + } +} + +private val SIMPLE_LOGIN_FORMS = 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#username"))), + "password" to JsonArray(listOf(JsonPrimitive("input#password"))), + ), + ), + ), + pathnames = null, + ), + ), +) + +private val OTP_FORMS = 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( + "oneTimeCode" to JsonArray( + listOf( + JsonArray( + listOf( + JsonPrimitive("input[name='otp-0']"), + JsonPrimitive("input[name='otp-1']"), + JsonPrimitive("input[name='otp-2']"), + JsonPrimitive("input[name='otp-3']"), + JsonPrimitive("input[name='otp-4']"), + JsonPrimitive("input[name='otp-5']"), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), +) + +private val HONEYPOT_FORMS = 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#password[name='password']"), + JsonPrimitive("input[name='password']"), + ), + ), + "password" to JsonArray( + listOf( + JsonPrimitive("input#username[name='username']"), + JsonPrimitive("input[name='username']"), + ), + ), + ), + ), + ), + pathnames = null, + ), + ), +) + +private val CONTAINER_FORMS = 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 = listOf("div#login-container"), + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + "password" to JsonArray(listOf(JsonPrimitive("input#pass"))), + ), + ), + ), + ), + ), + ), + ), +) + +private const val SIMPLE_LOGIN_JSON = """ +{ + "schemaVersion": "1.0.0", + "hosts": { + "example.com": { + "forms": [ + { + "category": "account-login", + "fields": { + "username": ["input#username"], + "password": ["input#password"] + } + } + ] + } + } +} +""" + +private const val NULL_HOST_JSON = """ +{ + "schemaVersion": "1.0.0", + "hosts": { + "excluded.com": null + } +} +""" + +private const val NULL_PATHNAME_JSON = """ +{ + "schemaVersion": "1.0.0", + "hosts": { + "example.com": { + "pathnames": { + "/excluded": null + } + } + } +} +""" + +private const val OTP_JSON = """ +{ + "schemaVersion": "1.0.0", + "hosts": { + "example.com": { + "pathnames": { + "/login": { + "forms": [ + { + "category": "account-login", + "fields": { + "oneTimeCode": [ + ["input[name='otp-0']","input[name='otp-1']","input[name='otp-2']", + "input[name='otp-3']","input[name='otp-4']","input[name='otp-5']"] + ] + } + } + ] + } + } + } + } +} +""" + +private const val HONEYPOT_JSON = """ +{ + "schemaVersion": "1.0.0", + "hosts": { + "example.com": { + "forms": [ + { + "category": "account-login", + "fields": { + "username": ["input#password[name='password']", "input[name='password']"], + "password": ["input#username[name='username']", "input[name='username']"] + } + } + ] + } + } +} +""" + +private const val CONTAINER_JSON = """ +{ + "schemaVersion": "1.0.0", + "hosts": { + "example.com": { + "pathnames": { + "/login": { + "forms": [ + { + "category": "account-login", + "container": ["div#login-container"], + "fields": { + "username": ["input#user"], + "password": ["input#pass"] + } + } + ] + } + } + } + } +} +""" + +// Same structure as SIMPLE_LOGIN_JSON with the actions field present — verifies that +// actions (intentionally omitted from FormJson) is handled by ignoreUnknownKeys = true. +private const val SIMPLE_LOGIN_WITH_ACTIONS_JSON = """ +{ + "schemaVersion": "1.0.0", + "hosts": { + "example.com": { + "forms": [ + { + "category": "account-login", + "fields": { + "username": ["input#username"], + "password": ["input#password"] + }, + "actions": { + "submit": ["button#submit"] + } + } + ] + } + } +} +""" diff --git a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt new file mode 100644 index 00000000000..4aac50c46ad --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt @@ -0,0 +1,165 @@ +package com.bitwarden.network.model + +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 FillAssistManifestJsonTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `deserialize full manifest with v0 entry`() { + assertEquals( + FULL_MANIFEST, + json.decodeFromString(MANIFEST_JSON), + ) + } + + @Test + fun `deserialize manifest with multiple version entries`() { + assertEquals( + MULTI_VERSION_MANIFEST, + json.decodeFromString(MANIFEST_MULTI_VERSION_JSON), + ) + } + + @Test + fun `deserialize manifest with unknown top-level fields is graceful`() { + assertEquals( + FULL_MANIFEST, + json.decodeFromString(MANIFEST_EXTRA_FIELDS_JSON), + ) + } + + @Test + fun `deserialize minimal manifest with null fields`() { + val result = json.decodeFromString("{}") + assertNull(result.buildId) + assertNull(result.maps) + } + + @Test + fun `deserialize manifest with deprecated version entry`() { + assertEquals( + DEPRECATED_MANIFEST, + json.decodeFromString(MANIFEST_DEPRECATED_JSON), + ) + } +} + +private val FULL_MANIFEST = FillAssistManifestJson( + buildId = "local-build", + timestamp = "2026-05-20T15:01:02.956Z", + gitSha = "abc123", + maps = FillAssistManifestJson.MapsJson( + forms = mapOf( + "v0" to FillAssistManifestJson.FileEntryJson( + filename = "forms.v0.json", + cid = "sha256:abc123def456", + schema = "forms.v0.schema.json", + ), + ), + ), +) + +private val MULTI_VERSION_MANIFEST = FillAssistManifestJson( + buildId = "local-build", + timestamp = null, + gitSha = null, + maps = FillAssistManifestJson.MapsJson( + forms = mapOf( + "v0" to FillAssistManifestJson.FileEntryJson( + filename = "forms.v0.json", + cid = "sha256:aaa", + schema = "forms.v0.schema.json", + ), + "v1" to FillAssistManifestJson.FileEntryJson( + filename = "forms.v1.json", + cid = "sha256:bbb", + schema = "forms.v1.schema.json", + ), + ), + ), +) + +private const val MANIFEST_JSON = """ +{ + "buildId": "local-build", + "timestamp": "2026-05-20T15:01:02.956Z", + "gitSha": "abc123", + "maps": { + "forms": { + "v0": { + "filename": "forms.v0.json", + "cid": "sha256:abc123def456", + "schema": "forms.v0.schema.json" + } + } + } +} +""" + +private const val MANIFEST_MULTI_VERSION_JSON = """ +{ + "buildId": "local-build", + "maps": { + "forms": { + "v0": { "filename": "forms.v0.json", "cid": "sha256:aaa", "schema": "forms.v0.schema.json" }, + "v1": { "filename": "forms.v1.json", "cid": "sha256:bbb", "schema": "forms.v1.schema.json" } + } + } +} +""" + +private val DEPRECATED_MANIFEST = FillAssistManifestJson( + buildId = "local-build", + timestamp = null, + gitSha = null, + maps = FillAssistManifestJson.MapsJson( + forms = mapOf( + "v0" to FillAssistManifestJson.FileEntryJson( + filename = "forms.v0.json", + cid = "sha256:abc123def456", + schema = "forms.v0.schema.json", + deprecated = true, + ), + ), + ), +) + +private const val MANIFEST_DEPRECATED_JSON = """ +{ + "buildId": "local-build", + "maps": { + "forms": { + "v0": { + "filename": "forms.v0.json", + "cid": "sha256:abc123def456", + "schema": "forms.v0.schema.json", + "deprecated": true + } + } + } +} +""" + +// Same structure as MANIFEST_JSON with an extra unknown key — verifies ignoreUnknownKeys = true. +private const val MANIFEST_EXTRA_FIELDS_JSON = """ +{ + "buildId": "local-build", + "timestamp": "2026-05-20T15:01:02.956Z", + "gitSha": "abc123", + "checksums": "ignored", + "maps": { + "forms": { + "v0": { + "filename": "forms.v0.json", + "cid": "sha256:abc123def456", + "schema": "forms.v0.schema.json" + } + } + } +} +""" diff --git a/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt index f6d3b757ab9..0a0380c3776 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/ConfigServiceTest.kt @@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson( notificationsUrl = "notificationsUrl", identityUrl = "identityUrl", ssoUrl = "ssoUrl", + fillAssistRulesUrl = null, ), featureStates = mapOf( "feature one" to JsonPrimitive(false), diff --git a/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt new file mode 100644 index 00000000000..80e625716d6 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt @@ -0,0 +1,115 @@ +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(url = "$urlPrefix/manifest.json")) + } + + @Test + fun `getManifest should return failure on server error`() = runTest { + server.enqueue(MockResponse().setResponseCode(500)) + assertTrue(service.getManifest(url = "$urlPrefix/manifest.json").isFailure) + } + + @Test + fun `getForms should parse and return forms`() = runTest { + server.enqueue(MockResponse().setBody(FORMS_V1_JSON)) + assertEquals(FORMS_V1.asSuccess(), service.getForms(formsUrl = "$urlPrefix/forms.v1.json")) + } + + @Test + fun `getForms should return failure on server error`() = runTest { + server.enqueue(MockResponse().setResponseCode(404)) + assertTrue(service.getForms(formsUrl = "$urlPrefix/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", + ), + ), + ), +) + +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"] + } + } + ] + } + } +} +""" From f57a7d09a26060a54efd32fe6f32bb8372785e69 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 29 May 2026 13:24:13 +0100 Subject: [PATCH 02/15] reverted unwanted changes on AuthRepositoryTest --- .../auth/repository/AuthRepositoryTest.kt | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 8f8b908d14f..908fdbe3e70 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -46,7 +46,6 @@ import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson import com.bitwarden.network.model.OrganizationKeysResponseJson import com.bitwarden.network.model.OrganizationType import com.bitwarden.network.model.PasswordHintResponseJson -import com.bitwarden.network.model.PolicyTypeJson import com.bitwarden.network.model.PreLoginResponseJson import com.bitwarden.network.model.PrevalidateSsoResponseJson import com.bitwarden.network.model.RefreshTokenResponseJson @@ -57,7 +56,6 @@ import com.bitwarden.network.model.ResetPasswordRequestJson import com.bitwarden.network.model.SendVerificationEmailRequestJson import com.bitwarden.network.model.SendVerificationEmailResponseJson import com.bitwarden.network.model.SetPasswordRequestJson -import com.bitwarden.network.model.SyncResponseJson import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson import com.bitwarden.network.model.TwoFactorAuthMethod import com.bitwarden.network.model.TwoFactorDataModel @@ -69,12 +67,13 @@ import com.bitwarden.network.model.VerifyEmailTokenResponseJson import com.bitwarden.network.model.createMockAccountKeysJson import com.bitwarden.network.model.createMockAccountKeysJsonWithNullFields import com.bitwarden.network.model.createMockOrganizationNetwork -import com.bitwarden.network.model.createMockPolicy import com.bitwarden.network.service.AccountsService import com.bitwarden.network.service.DevicesService import com.bitwarden.network.service.HaveIBeenPwnedService import com.bitwarden.network.service.IdentityService import com.bitwarden.network.service.OrganizationService +import com.bitwarden.policies.PolicyType +import com.bitwarden.policies.PolicyView import com.bitwarden.ui.platform.resource.BitwardenString import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson @@ -148,6 +147,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -168,8 +168,6 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -266,14 +264,14 @@ class AuthRepositoryTest { private val mutableLogoutFlow = bufferedMutableSharedFlow() private val mutableSyncOrgKeysFlow = bufferedMutableSharedFlow() - private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() + private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() private val pushManager: PushManager = mockk { every { logoutFlow } returns mutableLogoutFlow every { syncOrgKeysFlow } returns mutableSyncOrgKeysFlow } private val policyManager: PolicyManager = mockk { every { - getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD) + getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD) } returns mutableActivePolicyFlow } private val logsManager: LogsManager = mockk { @@ -470,18 +468,20 @@ class AuthRepositoryTest { // Set policies that will fail the password. mutableActivePolicyFlow.emit( listOf( - createMockPolicy( - type = PolicyTypeJson.MASTER_PASSWORD, - isEnabled = true, - data = buildJsonObject { - put(key = "minLength", value = 100) - put(key = "minComplexity", value = null) - put(key = "requireUpper", value = null) - put(key = "requireLower", value = null) - put(key = "requireNumbers", value = null) - put(key = "requireSpecial", value = null) - put(key = "enforceOnLogin", value = true) - }, + createMockPolicyView( + type = PolicyType.MASTER_PASSWORD, + enabled = true, + data = """ + { + "minLength":100, + "minComplexity":null, + "requireUpper":null, + "requireLower":null, + "requireNumbers":null, + "requireSpecial":null, + "enforceOnLogin":true + } + """, ), ), ) @@ -7344,20 +7344,22 @@ class AuthRepositoryTest { requireSpecial: Boolean = false, ) { every { - policyManager.getActivePolicies(type = PolicyTypeJson.MASTER_PASSWORD) + policyManager.getActivePolicies(type = PolicyType.MASTER_PASSWORD) } returns listOf( - createMockPolicy( - type = PolicyTypeJson.MASTER_PASSWORD, - isEnabled = true, - data = buildJsonObject { - put(key = "minLength", value = minLength) - put(key = "minComplexity", value = minComplexity) - put(key = "requireUpper", value = requireUpper) - put(key = "requireLower", value = requireLower) - put(key = "requireNumbers", value = requireNumbers) - put(key = "requireSpecial", value = requireSpecial) - put(key = "enforceOnLogin", value = true) - }, + createMockPolicyView( + type = PolicyType.MASTER_PASSWORD, + enabled = true, + data = """ + { + "minLength":$minLength, + "minComplexity":$minComplexity, + "requireUpper":$requireUpper, + "requireLower":$requireLower, + "requireNumbers":$requireNumbers, + "requireSpecial":$requireSpecial, + "enforceOnLogin":true + } + """, ), ) } From 8b6ce8bce99d2a8e20992c3ba9390f046a293308 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 29 May 2026 13:40:03 +0100 Subject: [PATCH 03/15] Add fill assist data layer --- .../datasource/disk/FillAssistDiskSource.kt | 44 ++ .../disk/FillAssistDiskSourceImpl.kt | 72 ++ .../data/autofill/di/FillAssistModule.kt | 57 ++ .../autofill/manager/FillAssistManager.kt | 23 + .../autofill/manager/FillAssistManagerImpl.kt | 233 ++++++ .../data/autofill/model/FillAssistRules.kt | 47 ++ .../disk/FillAssistDiskSourceTest.kt | 135 ++++ .../autofill/manager/FillAssistManagerTest.kt | 708 ++++++++++++++++++ 8 files changed, 1319 insertions(+) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt new file mode 100644 index 00000000000..e72af9e575d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSource.kt @@ -0,0 +1,44 @@ +package com.x8bit.bitwarden.data.autofill.datasource.disk + +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules + +/** + * Disk source for persisting fill-assist targeting rules per server. + * + * All operations are scoped by [serverUrl] (the fill-assist CDN base URL provided by the server + * config), so multiple accounts on the same server share one cached copy of the rules while + * accounts on different servers remain independent. + */ +interface FillAssistDiskSource { + + /** + * Returns the cached [FillAssistRules] for [serverUrl], or null if none are stored. + */ + fun getFillAssistRules(serverUrl: String): FillAssistRules? + + /** + * Stores [rules] for [serverUrl], or removes the entry when [rules] is null. + */ + fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?) + + /** + * Returns the last known content hash (CID) for [serverUrl], or null if none is stored. + */ + fun getLastKnownCid(serverUrl: String): String? + + /** + * Stores [cid] for [serverUrl], or removes the entry when [cid] is null. + */ + fun storeLastKnownCid(serverUrl: String, cid: String?) + + /** + * Returns the epoch-millisecond timestamp of the last successful fetch for [serverUrl], + * or null if never fetched. + */ + fun getLastFetchTimestamp(serverUrl: String): Long? + + /** + * Stores [timestamp] for [serverUrl], or removes the entry when [timestamp] is null. + */ + fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt new file mode 100644 index 00000000000..89f368d0e45 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt @@ -0,0 +1,72 @@ +package com.x8bit.bitwarden.data.autofill.datasource.disk + +import android.content.SharedPreferences +import com.bitwarden.core.data.util.decodeFromStringOrNull +import com.bitwarden.data.datasource.disk.BaseDiskSource +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +// Bump this constant in two cases: +// 1. The parsing logic changes in a way that invalidates previously cached results. +// 2. EXPECTED_SCHEMA_MAJOR in FillAssistManagerImpl is updated to support a new schema major. +// Without bumping this, the staleness check would skip re-downloading data that was previously +// rejected for an unsupported schema — meaning the app would never pick up the new rules. +// On the next app launch after a bump, all stored fill-assist data is cleared and re-downloaded. +private const val CURRENT_CACHE_VERSION = 0 + +private const val FILL_ASSIST_CACHE_VERSION_KEY = "fillAssistCacheVersion" +private const val FILL_ASSIST_RULES_KEY = "fillAssistRules" +private const val FILL_ASSIST_CID_KEY = "fillAssistLastCid" +private const val FILL_ASSIST_TIMESTAMP_KEY = "fillAssistLastFetchTimestamp" + +/** + * Primary implementation of [FillAssistDiskSource]. + */ +class FillAssistDiskSourceImpl( + sharedPreferences: SharedPreferences, + private val json: Json, +) : BaseDiskSource(sharedPreferences), + FillAssistDiskSource { + + init { + performMigrationIfNeeded() + } + + override fun getFillAssistRules(serverUrl: String): FillAssistRules? = + getString(FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl)) + ?.let { json.decodeFromStringOrNull(it) } + + override fun storeFillAssistRules(serverUrl: String, rules: FillAssistRules?) { + putString( + FILL_ASSIST_RULES_KEY.appendIdentifier(serverUrl), + rules?.let { json.encodeToString(it) }, + ) + } + + override fun getLastKnownCid(serverUrl: String): String? = + getString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl)) + + override fun storeLastKnownCid(serverUrl: String, cid: String?) { + putString(FILL_ASSIST_CID_KEY.appendIdentifier(serverUrl), cid) + } + + override fun getLastFetchTimestamp(serverUrl: String): Long? = + getLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl)) + + override fun storeLastFetchTimestamp(serverUrl: String, timestamp: Long?) { + putLong(FILL_ASSIST_TIMESTAMP_KEY.appendIdentifier(serverUrl), timestamp) + } + + private fun performMigrationIfNeeded() { + if (getInt(FILL_ASSIST_CACHE_VERSION_KEY) == CURRENT_CACHE_VERSION) return + clearAllData() + } + + private fun clearAllData() { + removeWithPrefix(FILL_ASSIST_RULES_KEY) + removeWithPrefix(FILL_ASSIST_CID_KEY) + removeWithPrefix(FILL_ASSIST_TIMESTAMP_KEY) + putInt(FILL_ASSIST_CACHE_VERSION_KEY, CURRENT_CACHE_VERSION) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt new file mode 100644 index 00000000000..7bd765a8d4d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt @@ -0,0 +1,57 @@ +package com.x8bit.bitwarden.data.autofill.di + +import android.content.SharedPreferences +import com.bitwarden.core.data.manager.dispatcher.DispatcherManager +import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences +import com.bitwarden.data.repository.ServerConfigRepository +import com.bitwarden.network.service.FillAssistService +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSourceImpl +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManagerImpl +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import kotlinx.serialization.json.Json +import javax.inject.Singleton + +/** + * Provides fill-assist dependencies. + */ +@Module +@InstallIn(SingletonComponent::class) +object FillAssistModule { + + @Provides + @Singleton + fun providesFillAssistDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + json: Json, + ): FillAssistDiskSource = + FillAssistDiskSourceImpl( + sharedPreferences = sharedPreferences, + json = json, + ) + + @Provides + @Singleton + fun providesFillAssistManager( + fillAssistService: FillAssistService, + fillAssistDiskSource: FillAssistDiskSource, + featureFlagManager: FeatureFlagManager, + serverConfigRepository: ServerConfigRepository, + clock: Clock, + dispatcherManager: DispatcherManager, + ): FillAssistManager = + FillAssistManagerImpl( + fillAssistService = fillAssistService, + fillAssistDiskSource = fillAssistDiskSource, + featureFlagManager = featureFlagManager, + serverConfigRepository = serverConfigRepository, + clock = clock, + dispatcherManager = dispatcherManager, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt new file mode 100644 index 00000000000..9d33d1d6b40 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManager.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules + +/** + * Manages fetching and caching fill-assist targeting rules. + * + * Rules are scoped per server (the fill-assist CDN URL from server config), so multiple accounts + * on the same server share one cached copy. + */ +interface FillAssistManager { + /** + * Triggers a background sync if no sync is currently running. The sync fetches and persists + * fill-assist rules when the feature flag is enabled and cached data is stale. + */ + fun syncIfNecessary() + + /** + * Returns the last successfully cached [FillAssistRules] for the active server, or null if + * none exist. + */ + fun getFillAssistRules(): FillAssistRules? +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt new file mode 100644 index 00000000000..7fbdf3d8700 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -0,0 +1,233 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import com.bitwarden.core.data.manager.dispatcher.DispatcherManager +import com.bitwarden.core.data.manager.model.FlagKey +import java.time.Clock +import com.bitwarden.data.repository.ServerConfigRepository +import com.bitwarden.network.model.FillAssistFormsJson +import com.bitwarden.network.service.FillAssistService +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules.SelectorClause +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import timber.log.Timber + +private const val CURRENT_FORMS_VERSION = "v0" +private const val EXPECTED_SCHEMA_MAJOR = "0" + +/** Re-fetch interval in milliseconds (6 hours, matching the browser implementation). */ +private const val UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000L + +// Matches [attr='value'] and [attr="value"] attribute selectors. +private val ATTRIBUTE_REGEX = Regex("""\[([a-zA-Z\-]+)=['"](.*?)['"]]""") + +// Matches the CSS #id shorthand (e.g. "input#oid", "select#state"). +// Used as a fallback when [id='value'] is absent. +private val ID_SHORTHAND_REGEX = Regex("""#([^.\[#\s]+)""") + +// Extracts the leading tag name from a selector (e.g. "input", "select", "form"). +private val TAG_REGEX = Regex("""^([a-zA-Z][a-zA-Z0-9]*)""") + +/** + * Primary implementation of [FillAssistManager]. + */ +@Suppress("LongParameterList") +class FillAssistManagerImpl( + private val fillAssistService: FillAssistService, + private val fillAssistDiskSource: FillAssistDiskSource, + private val featureFlagManager: FeatureFlagManager, + private val serverConfigRepository: ServerConfigRepository, + private val clock: Clock, + dispatcherManager: DispatcherManager, +) : FillAssistManager { + + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + private val ioScope = CoroutineScope(dispatcherManager.io) + private var syncJob: Job = Job().apply { complete() } + + init { + serverConfigRepository.serverConfigStateFlow + .filterNotNull() + .onEach { syncIfNecessary() } + .launchIn(unconfinedScope) + } + + override fun syncIfNecessary() { + if (!featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)) return + val serverUrl = serverConfigRepository.serverConfigStateFlow.value + ?.serverData?.environment?.fillAssistRulesUrl ?: return + val lastFetch = fillAssistDiskSource.getLastFetchTimestamp(serverUrl) ?: 0L + if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS) return + if (!syncJob.isCompleted) return + syncJob = ioScope.launch { sync(serverUrl) } + } + + private suspend fun sync(serverUrl: String) = runCatching { + // Always fetch the manifest — it is the CID staleness check. + val manifest = fillAssistService + .getManifest(url = serverUrl.trimEnd('/') + "/manifest.json") + .getOrThrow() + + val versionEntry = manifest.maps?.forms?.get(CURRENT_FORMS_VERSION) + ?: error("Version $CURRENT_FORMS_VERSION not found in manifest") + val cid = versionEntry.cid + ?: error("No CID for version $CURRENT_FORMS_VERSION in manifest") + + if (versionEntry.deprecated == true) { + Timber.w("Fill-assist forms $CURRENT_FORMS_VERSION is deprecated") + } + + // CID check: data on the server is unchanged — update the timestamp and skip download. + if (cid == fillAssistDiskSource.getLastKnownCid(serverUrl)) { + fillAssistDiskSource.storeLastFetchTimestamp( + serverUrl = serverUrl, + timestamp = clock.millis(), + ) + return@runCatching + } + + val formsUrl = serverUrl.trimEnd('/') + "/" + + (versionEntry.filename ?: "forms.$CURRENT_FORMS_VERSION.json") + + val forms = fillAssistService + .getForms(formsUrl = formsUrl) + .getOrThrow() + + val schemaMajor = forms.schemaVersion?.substringBefore('.') + if (schemaMajor != EXPECTED_SCHEMA_MAJOR) { + Timber.w("Unsupported fill-assist schema version: ${forms.schemaVersion}") + fillAssistDiskSource.storeLastFetchTimestamp( + serverUrl = serverUrl, + timestamp = clock.millis(), + ) + return@runCatching + } + + val rules = parseForms(forms) + fillAssistDiskSource.storeFillAssistRules(serverUrl = serverUrl, rules = rules) + fillAssistDiskSource.storeLastKnownCid(serverUrl = serverUrl, cid = cid) + fillAssistDiskSource.storeLastFetchTimestamp( + serverUrl = serverUrl, + timestamp = clock.millis(), + ) + }.also { result -> + result.onFailure { Timber.w(it, "Fill-assist sync failed") } + } + + override fun getFillAssistRules(): FillAssistRules? { + val environment = + serverConfigRepository.serverConfigStateFlow.value?.serverData?.environment + val serverUrl = environment?.fillAssistRulesUrl ?: return null + return fillAssistDiskSource.getFillAssistRules(serverUrl = serverUrl) + } +} + +// region CSS parser + +private fun parseForms(forms: FillAssistFormsJson): FillAssistRules { + val hostRules = forms.hosts + ?.mapNotNull { (hostname, hostEntry) -> hostEntry?.let { hostname to parseHostEntry(it) } } + ?.filter { (_, rules) -> rules.isNotEmpty() } + ?.toMap() + .orEmpty() + return FillAssistRules(hostRules = hostRules) +} + +private fun parseHostEntry( + hostEntry: FillAssistFormsJson.HostEntryJson, +): List { + val allForms = buildList { + addAll(hostEntry.forms.orEmpty()) + hostEntry.pathnames?.values?.filterNotNull()?.forEach { addAll(it.forms.orEmpty()) } + }.distinct() + + return buildFieldsByCategory(allForms).map { (category, fields) -> + FillAssistRules.HostRule( + category = category, + fields = fields.mapValues { (_, selectors) -> selectors.distinct() }, + ) + } +} + +private fun buildFieldsByCategory( + forms: List, +): Map>> { + val result = mutableMapOf>>() + forms.mapNotNull { form -> form.category?.let { form to it } } + .forEach { (form, category) -> + val parsedFields = form.fields.orEmpty() + .mapValues { (_, elem) -> parseCompositeSelectorArray(elem) } + .filterValues { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } ?: return@forEach + val categoryFields = result.getOrPut(category) { mutableMapOf() } + parsedFields.forEach { (fieldKey, selectors) -> + categoryFields.getOrPut(fieldKey) { mutableListOf() }.addAll(selectors) + } + } + return result +} + +private fun parseCompositeSelectorArray(element: JsonElement): List { + if (element !is JsonArray) return emptyList() + val result = mutableListOf() + for (item in element) { + when (item) { + is JsonPrimitive -> parseSingleSelector(item.content)?.let { result.add(it) } + is JsonArray -> item + .filterIsInstance() + .mapNotNull { parseSingleSelector(it.content) } + .forEach { result.add(it) } + + else -> Unit + } + } + return result +} + +internal fun parseSingleSelector(selector: String): SelectorClause? { + // For shadow DOM / iframe selectors (>>>), extract the last segment — the actual target + // element. Android's autofill framework may expose these elements via htmlInfo when they + // are reachable (e.g. open shadow roots), so we parse their attributes for matching. + val effective = if (selector.contains(">>>")) { + selector.substringAfterLast(">>>").trim() + } else { + selector + } + if (effective.trimStart().startsWith(".")) return null + + val tag = TAG_REGEX.find(effective)?.groupValues?.get(1) + + var id: String? = null + var name: String? = null + var type: String? = null + var role: String? = null + + ATTRIBUTE_REGEX.findAll(effective).forEach { match -> + val attrName = match.groupValues[1] + val attrValue = match.groupValues[2] + when (attrName) { + "id" -> id = attrValue + "name" -> name = attrValue + "type" -> type = attrValue + "role" -> role = attrValue + } + } + + // Fallback: extract id from #shorthand (e.g. input#oid) when not present as [id='...']. + if (id == null) { + id = ID_SHORTHAND_REGEX.find(effective)?.groupValues?.get(1) + } + + return SelectorClause(tag = tag, id = id, name = name, type = type, role = role) +} + +// endregion diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt new file mode 100644 index 00000000000..5ae3bd576f1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/FillAssistRules.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.autofill.model + +import kotlinx.serialization.Serializable + +/** + * Parsed, storage-ready representation of fill-assist targeting rules for all known hosts. + * + * @property hostRules Map of hostname (with optional port) to a list of [HostRule] entries. + * Multiple [HostRule] entries per host are possible when different pages define different forms. + */ +@Serializable +data class FillAssistRules( + val hostRules: Map>, +) { + /** + * Describes one parsed form for a host. Combines host-level and pathname-level forms into a + * single pooled representation so the consumer does not need to know the current URL path. + * + * @property category The form's purpose category (e.g. "account-login", "payment-card"). + * @property fields Map of field key (e.g. "username", "password") to a list of + * [SelectorClause] alternatives. The first clause that matches a view node is used. + */ + @Serializable + data class HostRule( + val category: String, + val fields: Map>, + ) + + /** + * A single parsed CSS selector expressing HTML attribute constraints for matching a view node + * via [android.view.ViewStructure.HtmlInfo]. All non-null fields are AND constraints. + * + * @property tag The HTML tag name (e.g. "input", "select"). + * @property id The value of the element's [id] attribute. + * @property name The value of the element's [name] attribute. + * @property type The value of the element's [type] attribute (e.g. "password", "text"). + * @property role The value of the element's [role] attribute. + */ + @Serializable + data class SelectorClause( + val tag: String?, + val id: String?, + val name: String?, + val type: String?, + val role: String?, + ) +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt new file mode 100644 index 00000000000..92849db2110 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt @@ -0,0 +1,135 @@ +package com.x8bit.bitwarden.data.autofill.datasource.disk + +import com.bitwarden.data.datasource.disk.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class FillAssistDiskSourceTest { + private val fakeSharedPreferences = FakeSharedPreferences() + + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private val diskSource = FillAssistDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + @Test + fun `migration clears all fill-assist data across all servers`() { + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc") + diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = 123L) + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_2, rules = FILL_ASSIST_RULES) + + // Trigger migration by writing a stale version — clears data for all servers. + fakeSharedPreferences.edit() + .putInt("bwPreferencesStorage:fillAssistCacheVersion", -1) + .apply() + val clearedDiskSource = FillAssistDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + assertNull(clearedDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + assertNull(clearedDiskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + assertNull(clearedDiskSource.getFillAssistRules(serverUrl = SERVER_URL_2)) + } + + @Test + fun `storeFillAssistRules and getFillAssistRules round-trip`() { + assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = null) + assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + } + + @Test + fun `data is scoped per server, one server does not affect another`() { + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + + assertNull(diskSource.getFillAssistRules(serverUrl = SERVER_URL_2)) + assertEquals(FILL_ASSIST_RULES, diskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + } + + @Test + fun `storeLastKnownCid and getLastKnownCid round-trip`() { + val cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db" + assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = cid) + assertEquals(cid, diskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = null) + assertNull(diskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + } + + @Test + fun `storeLastFetchTimestamp and getLastFetchTimestamp round-trip`() { + val timestamp = 1716307262956L + assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + + diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = timestamp) + assertEquals(timestamp, diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + + diskSource.storeLastFetchTimestamp(serverUrl = SERVER_URL_1, timestamp = null) + assertNull(diskSource.getLastFetchTimestamp(serverUrl = SERVER_URL_1)) + } + + @Test + fun `migration preserves data when cache version is current`() { + diskSource.storeFillAssistRules(serverUrl = SERVER_URL_1, rules = FILL_ASSIST_RULES) + diskSource.storeLastKnownCid(serverUrl = SERVER_URL_1, cid = "sha256:abc") + + // New instance with the same preferences — version already set to current by first init. + val sameDiskSource = FillAssistDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + json = json, + ) + + assertEquals(FILL_ASSIST_RULES, sameDiskSource.getFillAssistRules(serverUrl = SERVER_URL_1)) + assertEquals("sha256:abc", sameDiskSource.getLastKnownCid(serverUrl = SERVER_URL_1)) + } +} + +private const val SERVER_URL_1 = "https://fill-assist.example.com" +private const val SERVER_URL_2 = "https://fill-assist.other.com" + +private val FILL_ASSIST_RULES = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "email", + name = null, + type = null, + role = null, + ), + ), + "password" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = null, + name = "pass", + type = "password", + role = null, + ), + ), + ), + ), + ), + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt new file mode 100644 index 00000000000..67c03f8dafb --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt @@ -0,0 +1,708 @@ +package com.x8bit.bitwarden.data.autofill.manager + +import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager +import com.bitwarden.core.data.manager.model.FlagKey +import com.bitwarden.data.datasource.disk.model.ServerConfig +import com.bitwarden.data.repository.ServerConfigRepository +import com.bitwarden.network.model.ConfigResponseJson +import com.bitwarden.network.model.FillAssistFormsJson +import com.bitwarden.network.model.FillAssistManifestJson +import com.bitwarden.network.service.FillAssistService +import com.x8bit.bitwarden.data.autofill.datasource.disk.FillAssistDiskSource +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +private const val BASE_URL = "https://fill-assist.example.com" +private const val MANIFEST_URL = "$BASE_URL/manifest.json" +private const val FORMS_URL = "$BASE_URL/forms.v0.json" +private const val CID = "sha256:abc123" + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2026-01-01T12:00:00Z"), + ZoneOffset.UTC, +) + +/** A timestamp far in the past, ensuring the timestamp check never skips network calls. */ +private const val STALE_TIMESTAMP = 0L + +class FillAssistManagerTest { + + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns true + } + + private val serverConfigRepository: ServerConfigRepository = mockk { + every { serverConfigStateFlow } returns MutableStateFlow(SERVER_CONFIG) + } + + private val fillAssistService: FillAssistService = mockk { + coEvery { getManifest(url = MANIFEST_URL) } returns Result.success(MANIFEST) + coEvery { getForms(formsUrl = FORMS_URL) } returns Result.success(FORMS_V1) + } + + private val fillAssistDiskSource: FillAssistDiskSource = mockk { + every { getLastFetchTimestamp(BASE_URL) } returns STALE_TIMESTAMP + every { getLastKnownCid(BASE_URL) } returns null + every { getFillAssistRules(BASE_URL) } returns null + every { storeFillAssistRules(any(), any()) } just runs + every { storeLastKnownCid(any(), any()) } just runs + every { storeLastFetchTimestamp(any(), any()) } just runs + } + + private val manager = FillAssistManagerImpl( + fillAssistService = fillAssistService, + fillAssistDiskSource = fillAssistDiskSource, + featureFlagManager = featureFlagManager, + serverConfigRepository = serverConfigRepository, + clock = FIXED_CLOCK, + dispatcherManager = FakeDispatcherManager(), + ) + + @BeforeEach + fun setUp() { + // serverConfigStateFlow replays its current value on subscription, triggering + // syncIfNecessary() during construction. Clear call counts for a clean test slate. + clearMocks(fillAssistService, fillAssistDiskSource, answers = false) + } + + @Test + fun `sync returns success and does nothing when feature flag is disabled`() = runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) + } returns false + + manager.syncIfNecessary() + + coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + } + + @Test + fun `sync returns success and does nothing when fillAssistRulesUrl is null`() = runTest { + every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null) + + manager.syncIfNecessary() + + coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + } + + @Test + fun `sync skips all network calls when timestamp is fresh`() = runTest { + every { + fillAssistDiskSource.getLastFetchTimestamp(BASE_URL) + } returns FIXED_CLOCK.millis() - (6 * 60 * 60 * 1000L - 1) + + manager.syncIfNecessary() + + coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + coVerify(exactly = 0) { fillAssistService.getForms(any()) } + } + + @Test + fun `sync skips forms download and updates timestamp when CID is unchanged`() = runTest { + every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns CID + + manager.syncIfNecessary() + + coVerify(exactly = 1) { fillAssistService.getManifest(url = MANIFEST_URL) } + coVerify(exactly = 0) { fillAssistService.getForms(any()) } + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync re-fetches forms when CID changes`() = runTest { + every { fillAssistDiskSource.getLastKnownCid(BASE_URL) } returns "sha256:old" + + manager.syncIfNecessary() + + coVerify(exactly = 1) { fillAssistService.getForms(formsUrl = FORMS_URL) } + verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) } + verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync does not store data when manifest fetch fails`() = runTest { + coEvery { + fillAssistService.getManifest(any()) + } returns Result.failure(RuntimeException("network error")) + + manager.syncIfNecessary() + + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) } + verify(exactly = 0) { fillAssistDiskSource.storeLastFetchTimestamp(any(), any()) } + } + + @Test + fun `sync does not store rules or cid when schemaVersion major is unsupported`() = runTest { + coEvery { fillAssistService.getForms(any()) } returns Result.success( + FORMS_V1.copy(schemaVersion = "1.0.0"), + ) + + manager.syncIfNecessary() + + verify(exactly = 0) { fillAssistDiskSource.storeFillAssistRules(any(), any()) } + verify(exactly = 0) { fillAssistDiskSource.storeLastKnownCid(any(), any()) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync happy path stores rules, cid, and timestamp`() = runTest { + manager.syncIfNecessary() + + verify { fillAssistDiskSource.storeFillAssistRules(BASE_URL, any()) } + verify { fillAssistDiskSource.storeLastKnownCid(BASE_URL, CID) } + verify { fillAssistDiskSource.storeLastFetchTimestamp(BASE_URL, FIXED_CLOCK.millis()) } + } + + @Test + fun `sync pools forms from multiple pathnames under the same host`() = runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_MULTI_PATHNAME) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_MULTI_PATHNAME, rulesSlot.captured) + } + + @Test + fun `sync pools host-level forms and pathname forms under the same host`() = runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_HOST_AND_PATHNAME) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_HOST_AND_PATHNAME, rulesSlot.captured) + } + + @Test + fun `sync merges forms with the same category from different pathnames into one HostRule`() = + runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_SAME_CATEGORY_PATHNAMES) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_MERGED_CATEGORY, rulesSlot.captured) + } + + @Test + fun `sync deduplicates selector clauses within a merged category`() = runTest { + coEvery { + fillAssistService.getForms(any()) + } returns Result.success(FORMS_V1_DUPLICATE_SELECTORS) + + val rulesSlot = slot() + every { + fillAssistDiskSource.storeFillAssistRules(any(), capture(rulesSlot)) + } just runs + + manager.syncIfNecessary() + + assertEquals(EXPECTED_RULES_DEDUPLICATED_SELECTORS, rulesSlot.captured) + } + + @Test + fun `getFillAssistRules delegates to disk source`() { + val expected = FillAssistRules(hostRules = emptyMap()) + every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns expected + assertEquals(expected, manager.getFillAssistRules()) + } + + @Test + fun `getFillAssistRules returns null when disk source has no data`() { + every { fillAssistDiskSource.getFillAssistRules(BASE_URL) } returns null + assertNull(manager.getFillAssistRules()) + } + + @Test + fun `getFillAssistRules returns null when server URL is not configured`() { + every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null) + assertNull(manager.getFillAssistRules()) + } + + // region CSS parser + + @Test + fun `parseSingleSelector extracts tag and id shorthand`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = "oid", + name = null, + type = null, + role = null, + ), + parseSingleSelector("input#oid"), + ) + } + + @Test + fun `parseSingleSelector extracts name attribute`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = null, + name = "p", + type = null, + role = null, + ), + parseSingleSelector("input[name='p']"), + ) + } + + @Test + fun `parseSingleSelector extracts compound selector with id shorthand and name`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = "password", + name = "password", + type = null, + role = null, + ), + parseSingleSelector("input#password[name='password']"), + ) + } + + @Test + fun `parseSingleSelector extracts role attribute`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "form", + id = null, + name = null, + type = null, + role = "search", + ), + parseSingleSelector("form[role='search']"), + ) + } + + @Test + fun `parseSingleSelector extracts last segment of shadow DOM selector`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + id = "field", + name = null, + type = null, + role = null, + ), + parseSingleSelector("div#container >>> input#field"), + ) + } + + @Test + fun `parseSingleSelector extracts last segment of multi-level shadow DOM selector`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "input", + name = "password", + id = null, + type = null, + role = null, + ), + parseSingleSelector("div#form-container >>> form > div >>> input[name='password']"), + ) + } + + @Test + fun `parseSingleSelector returns null for pure class selector`() { + assertNull(parseSingleSelector(".loginForm")) + } + + @Test + fun `parseSingleSelector handles select element`() { + assertEquals( + FillAssistRules.SelectorClause( + tag = "select", + id = "state", + name = null, + type = null, + role = null, + ), + parseSingleSelector("select#state"), + ) + } + + // endregion +} + +private val MANIFEST = FillAssistManifestJson( + buildId = "local-build", + timestamp = null, + gitSha = null, + maps = FillAssistManifestJson.MapsJson( + forms = mapOf( + "v0" to FillAssistManifestJson.FileEntryJson( + filename = "forms.v0.json", + cid = CID, + schema = null, + ), + ), + ), +) + +private val FORMS_V1 = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray( + listOf(JsonPrimitive("input#user")), + ), + ), + ), + ), + pathnames = null, + ), + ), +) + +// Host with two pathnames — both forms must appear in the stored rules. +private val FORMS_V1_MULTI_PATHNAME = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = null, + pathnames = mapOf( + "/login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + "password" to JsonArray(listOf(JsonPrimitive("input#pass"))), + ), + ), + ), + ), + "/register" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-creation", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#email"))), + "newPassword" to JsonArray(listOf(JsonPrimitive("input#new-pass"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_MULTI_PATHNAME = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + "password" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "pass", + name = null, + type = null, + role = null, + ), + ), + ), + ), + FillAssistRules.HostRule( + category = "account-creation", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "email", + name = null, + type = null, + role = null, + ), + ), + "newPassword" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "new-pass", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +// Host with both top-level forms and pathname forms — both must appear in the stored rules. +private val FORMS_V1_HOST_AND_PATHNAME = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + pathnames = mapOf( + "/checkout" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "payment-card", + container = null, + fields = mapOf( + "cardNumber" to JsonArray(listOf(JsonPrimitive("input#card-num"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_HOST_AND_PATHNAME = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + "cardNumber" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "card-num", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +// Two pathnames both define account-login — must be merged into one HostRule. +private val FORMS_V1_SAME_CATEGORY_PATHNAMES = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = null, + pathnames = mapOf( + "/login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + ), + "/signin" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#email"))), + "password" to JsonArray(listOf(JsonPrimitive("input#pass"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_MERGED_CATEGORY = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + FillAssistRules.SelectorClause( + tag = "input", + id = "email", + name = null, + type = null, + role = null, + ), + ), + "password" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "pass", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +// Two pathnames define the same selector — the duplicate must be removed. +private val FORMS_V1_DUPLICATE_SELECTORS = FillAssistFormsJson( + schemaVersion = "0.1.0", + hosts = mapOf( + "example.com" to FillAssistFormsJson.HostEntryJson( + forms = null, + pathnames = mapOf( + "/login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + ), + "/other-login" to FillAssistFormsJson.PathnameEntryJson( + forms = listOf( + FillAssistFormsJson.FormJson( + category = "account-login", + container = null, + fields = mapOf( + "username" to JsonArray(listOf(JsonPrimitive("input#user"))), + ), + ), + ), + ), + ), + ), + ), +) + +private val EXPECTED_RULES_DEDUPLICATED_SELECTORS = FillAssistRules( + hostRules = mapOf( + "example.com" to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), +) + +private val SERVER_CONFIG = ServerConfig( + lastSync = 0L, + serverData = ConfigResponseJson( + type = null, + version = null, + gitHash = null, + server = null, + environment = ConfigResponseJson.EnvironmentJson( + cloudRegion = null, + vaultUrl = null, + apiUrl = null, + identityUrl = null, + notificationsUrl = null, + ssoUrl = null, + fillAssistRulesUrl = BASE_URL, + ), + featureStates = null, + communication = null, + ), +) From 09692afff63b2a0bcda137cf926e3354e13bdb30 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 29 May 2026 13:52:18 +0100 Subject: [PATCH 04/15] wire fill-assist manager into vault sync and app startup --- .../main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt | 4 ++++ .../bitwarden/data/vault/manager/VaultSyncManagerImpl.kt | 3 +++ .../bitwarden/data/vault/manager/di/VaultManagerModule.kt | 3 +++ .../bitwarden/data/vault/manager/VaultSyncManagerTest.kt | 6 ++++++ 4 files changed, 16 insertions(+) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt b/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt index cce69972ab2..83df6cde99c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/BitwardenApplication.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden import android.app.Application import com.bitwarden.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager @@ -20,6 +21,9 @@ import javax.inject.Inject class BitwardenApplication : Application() { // Inject classes here that must be triggered on startup but are not otherwise consumed by // other callers. + @Inject + lateinit var fillAssistManager: FillAssistManager + @Inject lateinit var logsManager: LogsManager diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt index afe80b503a3..001b38bb3a7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt @@ -15,6 +15,7 @@ import com.bitwarden.vault.DecryptCipherListResult import com.bitwarden.vault.FolderView import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.auth.manager.UserStateManager import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson @@ -79,6 +80,7 @@ class VaultSyncManagerImpl( private val authDiskSource: AuthDiskSource, private val vaultDiskSource: VaultDiskSource, private val vaultSdkSource: VaultSdkSource, + private val fillAssistManager: FillAssistManager, private val userLogoutManager: UserLogoutManager, private val userStateManager: UserStateManager, private val vaultLockManager: VaultLockManager, @@ -341,6 +343,7 @@ class VaultSyncManagerImpl( lastSyncTime = clock.instant(), ) vaultDiskSource.replaceVaultData(userId = userId, vault = syncResponse) + fillAssistManager.syncIfNecessary() val itemsAvailable = syncResponse.ciphers?.isNotEmpty() == true SyncVaultDataResult.Success(itemsAvailable = itemsAvailable) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 31ef577c1e7..11ece6832fa 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -44,6 +44,7 @@ import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -224,6 +225,7 @@ object VaultManagerModule { @Provides @Singleton fun provideVaultSyncManager( + fillAssistManager: FillAssistManager, syncService: SyncService, settingsDiskSource: SettingsDiskSource, authDiskSource: AuthDiskSource, @@ -237,6 +239,7 @@ object VaultManagerModule { pushManager: PushManager, dispatcherManager: DispatcherManager, ): VaultSyncManager = VaultSyncManagerImpl( + fillAssistManager = fillAssistManager, syncService = syncService, settingsDiskSource = settingsDiskSource, authDiskSource = authDiskSource, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt index 0a754f4b7c3..6550a4df151 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt @@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.UserStateManager +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.error.NoActiveUserException @@ -143,7 +144,12 @@ class VaultSyncManagerTest { every { databaseSchemeChangeFlow } returns mutableDatabaseSchemeChangeFlow } + private val fillAssistManager: FillAssistManager = mockk { + every { syncIfNecessary() } just runs + } + private val vaultSyncManager: VaultSyncManager = VaultSyncManagerImpl( + fillAssistManager = fillAssistManager, syncService = syncService, settingsDiskSource = settingsDiskSource, authDiskSource = fakeAuthDiskSource, From 00b2479808ddb0395e35c6c4fd29f048dd8dfd88 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Fri, 29 May 2026 13:56:30 +0100 Subject: [PATCH 05/15] chained code on FillAssistManager --- .../data/autofill/manager/FillAssistManagerImpl.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index 7fbdf3d8700..3ddac24d8b9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -63,8 +63,13 @@ class FillAssistManagerImpl( override fun syncIfNecessary() { if (!featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules)) return - val serverUrl = serverConfigRepository.serverConfigStateFlow.value - ?.serverData?.environment?.fillAssistRulesUrl ?: return + val serverUrl = serverConfigRepository + .serverConfigStateFlow + .value + ?.serverData + ?.environment + ?.fillAssistRulesUrl + ?: return val lastFetch = fillAssistDiskSource.getLastFetchTimestamp(serverUrl) ?: 0L if (clock.millis() - lastFetch < UPDATE_INTERVAL_MS) return if (!syncJob.isCompleted) return From f66485facdd4c8f4510c9c20ea58c2726664d505 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Mon, 8 Jun 2026 17:20:27 +0100 Subject: [PATCH 06/15] Removed nulls and sets on non nullable fields by schema definition. Removed unnecessary deserialization tests --- .../network/model/FillAssistFormsJson.kt | 8 +- .../network/model/FillAssistManifestJson.kt | 18 +- .../network/model/FillAssistFormsJsonTest.kt | 305 ------------------ .../model/FillAssistManifestJsonTest.kt | 165 ---------- 4 files changed, 13 insertions(+), 483 deletions(-) delete mode 100644 network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt delete mode 100644 network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt index 9f56cca659a..9fd837dd484 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt @@ -42,7 +42,7 @@ data class FillAssistFormsJson( @Serializable data class PathnameEntryJson( @SerialName("forms") - val forms: List? = null, + val forms: List, ) /** @@ -57,12 +57,12 @@ data class FillAssistFormsJson( @Serializable data class FormJson( @SerialName("category") - val category: String? = null, + val category: String, @SerialName("container") - val container: List? = null, + val container: List?, @SerialName("fields") - val fields: Map? = null, + val fields: Map, ) } diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt index 460039ff032..47b9ffcd065 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt @@ -14,16 +14,16 @@ import kotlinx.serialization.Serializable @Serializable data class FillAssistManifestJson( @SerialName("buildId") - val buildId: String? = null, + val buildId: String, @SerialName("timestamp") - val timestamp: String? = null, + val timestamp: String, @SerialName("gitSha") - val gitSha: String? = null, + val gitSha: String, @SerialName("maps") - val maps: MapsJson? = null, + val maps: MapsJson, ) { /** * Container for all available maps. @@ -34,13 +34,13 @@ data class FillAssistManifestJson( @Serializable data class MapsJson( @SerialName("forms") - val forms: Map?, + val forms: Map, ) /** * Metadata for a single versioned file in a map. * - * @property filename The filename to fetch (e.g. "forms.v0.json"). + * @property filename The filename to fetch (e.g. "forms.v1.json"). * @property cid The SHA-256 content hash in "sha256:" 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. @@ -50,13 +50,13 @@ data class FillAssistManifestJson( @Serializable data class FileEntryJson( @SerialName("filename") - val filename: String? = null, + val filename: String, @SerialName("cid") - val cid: String? = null, + val cid: String, @SerialName("schema") - val schema: String? = null, + val schema: String, @SerialName("deprecated") val deprecated: Boolean? = null, diff --git a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt deleted file mode 100644 index 532154fbb98..00000000000 --- a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistFormsJsonTest.kt +++ /dev/null @@ -1,305 +0,0 @@ -package com.bitwarden.network.model - -import kotlinx.serialization.json.Json -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.Test - -class FillAssistFormsJsonTest { - - private val json = Json { ignoreUnknownKeys = true } - - @Test - fun `deserialize simple login form`() { - assertEquals( - SIMPLE_LOGIN_FORMS, - json.decodeFromString(SIMPLE_LOGIN_JSON), - ) - } - - @Test - fun `deserialize host with null value is excluded host`() { - val result = json.decodeFromString(NULL_HOST_JSON) - assertNull(result.hosts?.get("excluded.com")) - } - - @Test - fun `deserialize null pathname is excluded path`() { - val result = json.decodeFromString(NULL_PATHNAME_JSON) - assertNull(result.hosts?.get("example.com")?.pathnames?.get("/excluded")) - } - - @Test - fun `deserialize composite OTP field`() { - assertEquals( - OTP_FORMS, - json.decodeFromString(OTP_JSON), - ) - } - - @Test - fun `deserialize compound selector with multiple alternatives`() { - assertEquals( - HONEYPOT_FORMS, - json.decodeFromString(HONEYPOT_JSON), - ) - } - - @Test - fun `deserialize form with container`() { - assertEquals( - CONTAINER_FORMS, - json.decodeFromString(CONTAINER_JSON), - ) - } - - @Test - fun `deserialize form with actions field is graceful`() { - // actions is intentionally excluded from FormJson — handled by ignoreUnknownKeys. - assertEquals( - SIMPLE_LOGIN_FORMS, - json.decodeFromString(SIMPLE_LOGIN_WITH_ACTIONS_JSON), - ) - } -} - -private val SIMPLE_LOGIN_FORMS = 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#username"))), - "password" to JsonArray(listOf(JsonPrimitive("input#password"))), - ), - ), - ), - pathnames = null, - ), - ), -) - -private val OTP_FORMS = 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( - "oneTimeCode" to JsonArray( - listOf( - JsonArray( - listOf( - JsonPrimitive("input[name='otp-0']"), - JsonPrimitive("input[name='otp-1']"), - JsonPrimitive("input[name='otp-2']"), - JsonPrimitive("input[name='otp-3']"), - JsonPrimitive("input[name='otp-4']"), - JsonPrimitive("input[name='otp-5']"), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), -) - -private val HONEYPOT_FORMS = 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#password[name='password']"), - JsonPrimitive("input[name='password']"), - ), - ), - "password" to JsonArray( - listOf( - JsonPrimitive("input#username[name='username']"), - JsonPrimitive("input[name='username']"), - ), - ), - ), - ), - ), - pathnames = null, - ), - ), -) - -private val CONTAINER_FORMS = 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 = listOf("div#login-container"), - fields = mapOf( - "username" to JsonArray(listOf(JsonPrimitive("input#user"))), - "password" to JsonArray(listOf(JsonPrimitive("input#pass"))), - ), - ), - ), - ), - ), - ), - ), -) - -private const val SIMPLE_LOGIN_JSON = """ -{ - "schemaVersion": "1.0.0", - "hosts": { - "example.com": { - "forms": [ - { - "category": "account-login", - "fields": { - "username": ["input#username"], - "password": ["input#password"] - } - } - ] - } - } -} -""" - -private const val NULL_HOST_JSON = """ -{ - "schemaVersion": "1.0.0", - "hosts": { - "excluded.com": null - } -} -""" - -private const val NULL_PATHNAME_JSON = """ -{ - "schemaVersion": "1.0.0", - "hosts": { - "example.com": { - "pathnames": { - "/excluded": null - } - } - } -} -""" - -private const val OTP_JSON = """ -{ - "schemaVersion": "1.0.0", - "hosts": { - "example.com": { - "pathnames": { - "/login": { - "forms": [ - { - "category": "account-login", - "fields": { - "oneTimeCode": [ - ["input[name='otp-0']","input[name='otp-1']","input[name='otp-2']", - "input[name='otp-3']","input[name='otp-4']","input[name='otp-5']"] - ] - } - } - ] - } - } - } - } -} -""" - -private const val HONEYPOT_JSON = """ -{ - "schemaVersion": "1.0.0", - "hosts": { - "example.com": { - "forms": [ - { - "category": "account-login", - "fields": { - "username": ["input#password[name='password']", "input[name='password']"], - "password": ["input#username[name='username']", "input[name='username']"] - } - } - ] - } - } -} -""" - -private const val CONTAINER_JSON = """ -{ - "schemaVersion": "1.0.0", - "hosts": { - "example.com": { - "pathnames": { - "/login": { - "forms": [ - { - "category": "account-login", - "container": ["div#login-container"], - "fields": { - "username": ["input#user"], - "password": ["input#pass"] - } - } - ] - } - } - } - } -} -""" - -// Same structure as SIMPLE_LOGIN_JSON with the actions field present — verifies that -// actions (intentionally omitted from FormJson) is handled by ignoreUnknownKeys = true. -private const val SIMPLE_LOGIN_WITH_ACTIONS_JSON = """ -{ - "schemaVersion": "1.0.0", - "hosts": { - "example.com": { - "forms": [ - { - "category": "account-login", - "fields": { - "username": ["input#username"], - "password": ["input#password"] - }, - "actions": { - "submit": ["button#submit"] - } - } - ] - } - } -} -""" diff --git a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt b/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt deleted file mode 100644 index 4aac50c46ad..00000000000 --- a/network/src/test/kotlin/com/bitwarden/network/model/FillAssistManifestJsonTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.bitwarden.network.model - -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 FillAssistManifestJsonTest { - - private val json = Json { ignoreUnknownKeys = true } - - @Test - fun `deserialize full manifest with v0 entry`() { - assertEquals( - FULL_MANIFEST, - json.decodeFromString(MANIFEST_JSON), - ) - } - - @Test - fun `deserialize manifest with multiple version entries`() { - assertEquals( - MULTI_VERSION_MANIFEST, - json.decodeFromString(MANIFEST_MULTI_VERSION_JSON), - ) - } - - @Test - fun `deserialize manifest with unknown top-level fields is graceful`() { - assertEquals( - FULL_MANIFEST, - json.decodeFromString(MANIFEST_EXTRA_FIELDS_JSON), - ) - } - - @Test - fun `deserialize minimal manifest with null fields`() { - val result = json.decodeFromString("{}") - assertNull(result.buildId) - assertNull(result.maps) - } - - @Test - fun `deserialize manifest with deprecated version entry`() { - assertEquals( - DEPRECATED_MANIFEST, - json.decodeFromString(MANIFEST_DEPRECATED_JSON), - ) - } -} - -private val FULL_MANIFEST = FillAssistManifestJson( - buildId = "local-build", - timestamp = "2026-05-20T15:01:02.956Z", - gitSha = "abc123", - maps = FillAssistManifestJson.MapsJson( - forms = mapOf( - "v0" to FillAssistManifestJson.FileEntryJson( - filename = "forms.v0.json", - cid = "sha256:abc123def456", - schema = "forms.v0.schema.json", - ), - ), - ), -) - -private val MULTI_VERSION_MANIFEST = FillAssistManifestJson( - buildId = "local-build", - timestamp = null, - gitSha = null, - maps = FillAssistManifestJson.MapsJson( - forms = mapOf( - "v0" to FillAssistManifestJson.FileEntryJson( - filename = "forms.v0.json", - cid = "sha256:aaa", - schema = "forms.v0.schema.json", - ), - "v1" to FillAssistManifestJson.FileEntryJson( - filename = "forms.v1.json", - cid = "sha256:bbb", - schema = "forms.v1.schema.json", - ), - ), - ), -) - -private const val MANIFEST_JSON = """ -{ - "buildId": "local-build", - "timestamp": "2026-05-20T15:01:02.956Z", - "gitSha": "abc123", - "maps": { - "forms": { - "v0": { - "filename": "forms.v0.json", - "cid": "sha256:abc123def456", - "schema": "forms.v0.schema.json" - } - } - } -} -""" - -private const val MANIFEST_MULTI_VERSION_JSON = """ -{ - "buildId": "local-build", - "maps": { - "forms": { - "v0": { "filename": "forms.v0.json", "cid": "sha256:aaa", "schema": "forms.v0.schema.json" }, - "v1": { "filename": "forms.v1.json", "cid": "sha256:bbb", "schema": "forms.v1.schema.json" } - } - } -} -""" - -private val DEPRECATED_MANIFEST = FillAssistManifestJson( - buildId = "local-build", - timestamp = null, - gitSha = null, - maps = FillAssistManifestJson.MapsJson( - forms = mapOf( - "v0" to FillAssistManifestJson.FileEntryJson( - filename = "forms.v0.json", - cid = "sha256:abc123def456", - schema = "forms.v0.schema.json", - deprecated = true, - ), - ), - ), -) - -private const val MANIFEST_DEPRECATED_JSON = """ -{ - "buildId": "local-build", - "maps": { - "forms": { - "v0": { - "filename": "forms.v0.json", - "cid": "sha256:abc123def456", - "schema": "forms.v0.schema.json", - "deprecated": true - } - } - } -} -""" - -// Same structure as MANIFEST_JSON with an extra unknown key — verifies ignoreUnknownKeys = true. -private const val MANIFEST_EXTRA_FIELDS_JSON = """ -{ - "buildId": "local-build", - "timestamp": "2026-05-20T15:01:02.956Z", - "gitSha": "abc123", - "checksums": "ignored", - "maps": { - "forms": { - "v0": { - "filename": "forms.v0.json", - "cid": "sha256:abc123def456", - "schema": "forms.v0.schema.json" - } - } - } -} -""" From 0b39ad27313da783a3644f05adc3aa7c14c6f2ee Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Mon, 8 Jun 2026 17:39:47 +0100 Subject: [PATCH 07/15] following autofill assist forms schema --- .../com/bitwarden/network/model/FillAssistFormsJson.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt index 9fd837dd484..a06d2e91362 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt @@ -14,10 +14,10 @@ import kotlinx.serialization.json.JsonElement @Serializable data class FillAssistFormsJson( @SerialName("schemaVersion") - val schemaVersion: String? = null, + val schemaVersion: String, @SerialName("hosts") - val hosts: Map? = null, + val hosts: Map, ) { /** * Form descriptions and pathname-specific overrides for a single host. @@ -60,7 +60,7 @@ data class FillAssistFormsJson( val category: String, @SerialName("container") - val container: List?, + val container: List? = null, @SerialName("fields") val fields: Map, From b25404e1122f38bcc18a6cf5bc658b317390c9e6 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 9 Jun 2026 13:16:20 +0100 Subject: [PATCH 08/15] removed unnecessary null set --- .../com/bitwarden/network/model/FillAssistFormsJson.kt | 6 +++--- .../com/bitwarden/network/model/FillAssistManifestJson.kt | 2 +- .../com/bitwarden/network/service/FillAssistServiceTest.kt | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt index a06d2e91362..a4b468482c0 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistFormsJson.kt @@ -28,10 +28,10 @@ data class FillAssistFormsJson( @Serializable data class HostEntryJson( @SerialName("forms") - val forms: List? = null, + val forms: List?, @SerialName("pathnames") - val pathnames: Map? = null, + val pathnames: Map?, ) /** @@ -60,7 +60,7 @@ data class FillAssistFormsJson( val category: String, @SerialName("container") - val container: List? = null, + val container: List?, @SerialName("fields") val fields: Map, diff --git a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt index 47b9ffcd065..3cc73b1dd32 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/FillAssistManifestJson.kt @@ -59,6 +59,6 @@ data class FillAssistManifestJson( val schema: String, @SerialName("deprecated") - val deprecated: Boolean? = null, + val deprecated: Boolean?, ) } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt index 80e625716d6..e2e52ab5dfd 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt @@ -54,6 +54,7 @@ private val MANIFEST = FillAssistManifestJson( filename = "forms.v1.json", cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db", schema = "forms.v1.schema.json", + deprecated = null, ), ), ), From 31a9cf7c346d66c81c60f5d0b669fd0a89ef5ac8 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Tue, 9 Jun 2026 13:59:15 +0100 Subject: [PATCH 09/15] Added FillAssist to BaseUrlInterceptors --- .../datasource/disk/EnvironmentDiskSource.kt | 6 ++++++ .../disk/EnvironmentDiskSourceImpl.kt | 5 +++++ .../platform/provider/BaseUrlsProviderImpl.kt | 2 ++ .../disk/EnvironmentDiskSourceTest.kt | 15 ++++++++++++++ .../disk/FakeEnvironmentDiskSource.kt | 2 ++ .../manager/provider/BaseUrlsProviderTest.kt | 13 ++++++++++++ .../manager/sdk/SdkRepositoryFactoryTests.kt | 1 + .../platform/provider/BaseUrlsProviderImpl.kt | 2 ++ .../network/BitwardenServiceClientImpl.kt | 4 +--- .../bitwarden/network/api/FillAssistApi.kt | 20 +++++++++---------- .../interceptor/BaseUrlInterceptors.kt | 7 +++++++ .../network/interceptor/BaseUrlsProvider.kt | 6 ++++++ .../network/model/ConfigResponseJson.kt | 2 ++ .../bitwarden/network/retrofit/Retrofits.kt | 6 ++++++ .../network/retrofit/RetrofitsImpl.kt | 10 ++++++++++ .../network/service/FillAssistService.kt | 9 ++++----- .../network/service/FillAssistServiceImpl.kt | 8 ++++---- .../network/service/FillAssistServiceTest.kt | 8 ++++---- 18 files changed, 99 insertions(+), 27 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSource.kt index 85f0fb8dcf9..af8bf6f8636 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSource.kt @@ -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? } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceImpl.kt index 7a5a5313442..c3c8b435859 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceImpl.kt @@ -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) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/provider/BaseUrlsProviderImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/provider/BaseUrlsProviderImpl.kt index 763279524b7..28fc40d443e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/provider/BaseUrlsProviderImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/provider/BaseUrlsProviderImpl.kt @@ -31,4 +31,6 @@ class BaseUrlsProviderImpl( .toEnvironmentUrlsOrDefault() .environmentUrlData .baseEventsUrl + + override fun getBaseFillAssistUrl(): String? = environmentDiskSource.fillAssistRulesUrl } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceTest.kt index 9aab10046a8..333b4405436 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/EnvironmentDiskSourceTest.kt @@ -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 = """ { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/FakeEnvironmentDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/FakeEnvironmentDiskSource.kt index f3eda792ea2..c37f934d5d0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/FakeEnvironmentDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/FakeEnvironmentDiskSource.kt @@ -29,6 +29,8 @@ class FakeEnvironmentDiskSource : EnvironmentDiskSource { storedEmailVerificationUrls[userEmail] = urls } + override var fillAssistRulesUrl: String? = null + private val mutablePreAuthEnvironmentUrlDataFlow = bufferedMutableSharedFlow(replay = 1) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/provider/BaseUrlsProviderTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/provider/BaseUrlsProviderTest.kt index 811236105bc..598ddbc549b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/provider/BaseUrlsProviderTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/provider/BaseUrlsProviderTest.kt @@ -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()) + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt index a577cdadf4b..62b2cf10279 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/SdkRepositoryFactoryTests.kt @@ -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(), diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/provider/BaseUrlsProviderImpl.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/provider/BaseUrlsProviderImpl.kt index 5105f380030..aaf3fb706f5 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/provider/BaseUrlsProviderImpl.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/provider/BaseUrlsProviderImpl.kt @@ -20,4 +20,6 @@ object BaseUrlsProviderImpl : BaseUrlsProvider { override fun getBaseEventsUrl(): String = Environment.Us.environmentUrlData.baseEventsUrl + + override fun getBaseFillAssistUrl(): String? = null } diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index 7afab54ab64..dd138ed848e 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -158,9 +158,7 @@ internal class BitwardenServiceClientImpl( } override val fillAssistService: FillAssistService by lazy { - FillAssistServiceImpl( - api = retrofits.createStaticRetrofit().create(), - ) + FillAssistServiceImpl(api = retrofits.fillAssistRetrofit.create()) } override val haveIBeenPwnedService: HaveIBeenPwnedService by lazy { diff --git a/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt index 0ca1c93d239..280f0afc902 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/FillAssistApi.kt @@ -4,26 +4,24 @@ 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.Url +import retrofit2.http.Path /** - * Defines endpoints for retrieving fill-assist targeting rules from the fill-assist service. - * Uses [Url] to support the dynamic base URL provided by server config at runtime. + * 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 from the given [url]. + * Fetches the fill-assist manifest. */ - @GET - suspend fun getManifest( - @Url url: String, - ): NetworkResult + @GET("manifest.json") + suspend fun getManifest(): NetworkResult /** - * Fetches and decodes the forms rules file from [url]. + * Fetches the forms rules file by [filename] (e.g. "forms.v1.json"). */ - @GET + @GET("{filename}") suspend fun getForms( - @Url url: String, + @Path("filename") filename: String, ): NetworkResult } diff --git a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt index dceaf9f5e54..5413395b983 100644 --- a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt +++ b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlInterceptors.kt @@ -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() + } } diff --git a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlsProvider.kt b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlsProvider.kt index 9cbca4d38de..4dd0e6f80b5 100644 --- a/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlsProvider.kt +++ b/network/src/main/kotlin/com/bitwarden/network/interceptor/BaseUrlsProvider.kt @@ -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? } diff --git a/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt index 834ab78bf32..eb2d7dbea74 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/ConfigResponseJson.kt @@ -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( diff --git a/network/src/main/kotlin/com/bitwarden/network/retrofit/Retrofits.kt b/network/src/main/kotlin/com/bitwarden/network/retrofit/Retrofits.kt index 002ad3e4a6b..e0ba3d43734 100644 --- a/network/src/main/kotlin/com/bitwarden/network/retrofit/Retrofits.kt +++ b/network/src/main/kotlin/com/bitwarden/network/retrofit/Retrofits.kt @@ -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). * diff --git a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt index 227e7695e74..525cad6b96f 100644 --- a/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/retrofit/RetrofitsImpl.kt @@ -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 { diff --git a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt index 007a25f13df..59c07a4dc6d 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistService.kt @@ -8,15 +8,14 @@ import com.bitwarden.network.model.FillAssistManifestJson */ interface FillAssistService { /** - * Fetches and parses the fill-assist manifest from [url]. + * Fetches and parses the fill-assist manifest. */ - suspend fun getManifest(url: String): Result + suspend fun getManifest(): Result /** - * Downloads and parses the forms rules file from [formsUrl]. + * 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. - * Version-agnostic: any forms file URL can be passed regardless of schema version. */ - suspend fun getForms(formsUrl: String): Result + suspend fun getForms(filename: String): Result } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt index 7668e4b346b..aa4b848662c 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/FillAssistServiceImpl.kt @@ -12,9 +12,9 @@ internal class FillAssistServiceImpl( private val api: FillAssistApi, ) : FillAssistService { - override suspend fun getManifest(url: String): Result = - api.getManifest(url = url).toResult() + override suspend fun getManifest(): Result = + api.getManifest().toResult() - override suspend fun getForms(formsUrl: String): Result = - api.getForms(url = formsUrl).toResult() + override suspend fun getForms(filename: String): Result = + api.getForms(filename = filename).toResult() } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt index e2e52ab5dfd..5126c3a5883 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/FillAssistServiceTest.kt @@ -22,25 +22,25 @@ class FillAssistServiceTest : BaseServiceTest() { @Test fun `getManifest should parse manifest response`() = runTest { server.enqueue(MockResponse().setBody(MANIFEST_JSON)) - assertEquals(MANIFEST.asSuccess(), service.getManifest(url = "$urlPrefix/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(url = "$urlPrefix/manifest.json").isFailure) + 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(formsUrl = "$urlPrefix/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(formsUrl = "$urlPrefix/forms.v1.json").isFailure) + assertTrue(service.getForms(filename = "forms.v1.json").isFailure) } } From ad2aada6b105ca238016b29867c3a0d054bd556f Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Wed, 10 Jun 2026 10:50:33 +0100 Subject: [PATCH 10/15] updating environmentDiskSource.fillAssistUrl when serverConfigStateFlow updates Updating code to schema with required values --- .../disk/FillAssistDiskSourceImpl.kt | 7 +- .../data/autofill/di/FillAssistModule.kt | 3 + .../autofill/manager/FillAssistManagerImpl.kt | 71 +++++++++---------- .../disk/FillAssistDiskSourceTest.kt | 21 ++++++ .../autofill/manager/FillAssistManagerTest.kt | 70 +++++++++++------- 5 files changed, 106 insertions(+), 66 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt index 89f368d0e45..f25df9c0ec1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceImpl.kt @@ -4,7 +4,6 @@ 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: @@ -64,9 +63,9 @@ class FillAssistDiskSourceImpl( } private fun clearAllData() { - removeWithPrefix(FILL_ASSIST_RULES_KEY) - removeWithPrefix(FILL_ASSIST_CID_KEY) - removeWithPrefix(FILL_ASSIST_TIMESTAMP_KEY) + removeWithPrefix("${FILL_ASSIST_RULES_KEY}_") + removeWithPrefix("${FILL_ASSIST_CID_KEY}_") + removeWithPrefix("${FILL_ASSIST_TIMESTAMP_KEY}_") putInt(FILL_ASSIST_CACHE_VERSION_KEY, CURRENT_CACHE_VERSION) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt index 7bd765a8d4d..e8480610b20 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/FillAssistModule.kt @@ -9,6 +9,7 @@ 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 @@ -43,6 +44,7 @@ object FillAssistModule { fillAssistDiskSource: FillAssistDiskSource, featureFlagManager: FeatureFlagManager, serverConfigRepository: ServerConfigRepository, + environmentDiskSource: EnvironmentDiskSource, clock: Clock, dispatcherManager: DispatcherManager, ): FillAssistManager = @@ -51,6 +53,7 @@ object FillAssistModule { fillAssistDiskSource = fillAssistDiskSource, featureFlagManager = featureFlagManager, serverConfigRepository = serverConfigRepository, + environmentDiskSource = environmentDiskSource, clock = clock, dispatcherManager = dispatcherManager, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index 3ddac24d8b9..7a3e6961a65 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -2,13 +2,13 @@ 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.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -20,9 +20,10 @@ 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 = "v0" -private const val EXPECTED_SCHEMA_MAJOR = "0" +private const val CURRENT_FORMS_VERSION = "v1" +private const val EXPECTED_SCHEMA_MAJOR = "1" /** Re-fetch interval in milliseconds (6 hours, matching the browser implementation). */ private const val UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000L @@ -46,6 +47,7 @@ class FillAssistManagerImpl( private val fillAssistDiskSource: FillAssistDiskSource, private val featureFlagManager: FeatureFlagManager, private val serverConfigRepository: ServerConfigRepository, + private val environmentDiskSource: EnvironmentDiskSource, private val clock: Clock, dispatcherManager: DispatcherManager, ) : FillAssistManager { @@ -56,6 +58,10 @@ class FillAssistManagerImpl( init { serverConfigRepository.serverConfigStateFlow + .onEach { config -> + environmentDiskSource.fillAssistRulesUrl = + config?.serverData?.environment?.fillAssistRulesUrl + } .filterNotNull() .onEach { syncIfNecessary() } .launchIn(unconfinedScope) @@ -77,22 +83,16 @@ class FillAssistManagerImpl( } 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 manifest = fillAssistService.getManifest().getOrThrow() - val versionEntry = manifest.maps?.forms?.get(CURRENT_FORMS_VERSION) + val versionEntry = manifest.maps.forms[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)) { + if (versionEntry.cid == fillAssistDiskSource.getLastKnownCid(serverUrl)) { fillAssistDiskSource.storeLastFetchTimestamp( serverUrl = serverUrl, timestamp = clock.millis(), @@ -100,14 +100,11 @@ class FillAssistManagerImpl( return@runCatching } - val formsUrl = serverUrl.trimEnd('/') + "/" + - (versionEntry.filename ?: "forms.$CURRENT_FORMS_VERSION.json") - val forms = fillAssistService - .getForms(formsUrl = formsUrl) + .getForms(filename = versionEntry.filename) .getOrThrow() - val schemaMajor = forms.schemaVersion?.substringBefore('.') + val schemaMajor = forms.schemaVersion.substringBefore('.') if (schemaMajor != EXPECTED_SCHEMA_MAJOR) { Timber.w("Unsupported fill-assist schema version: ${forms.schemaVersion}") fillAssistDiskSource.storeLastFetchTimestamp( @@ -119,7 +116,7 @@ class FillAssistManagerImpl( val rules = parseForms(forms) fillAssistDiskSource.storeFillAssistRules(serverUrl = serverUrl, rules = rules) - fillAssistDiskSource.storeLastKnownCid(serverUrl = serverUrl, cid = cid) + fillAssistDiskSource.storeLastKnownCid(serverUrl = serverUrl, cid = versionEntry.cid) fillAssistDiskSource.storeLastFetchTimestamp( serverUrl = serverUrl, timestamp = clock.millis(), @@ -129,9 +126,13 @@ class FillAssistManagerImpl( } override fun getFillAssistRules(): FillAssistRules? { - val environment = - serverConfigRepository.serverConfigStateFlow.value?.serverData?.environment - val serverUrl = environment?.fillAssistRulesUrl ?: return null + val serverUrl = serverConfigRepository + .serverConfigStateFlow + .value + ?.serverData + ?.environment + ?.fillAssistRulesUrl + ?: return null return fillAssistDiskSource.getFillAssistRules(serverUrl = serverUrl) } } @@ -140,10 +141,9 @@ class FillAssistManagerImpl( 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() + .mapNotNull { (hostname, hostEntry) -> hostEntry?.let { hostname to parseHostEntry(it) } } + .filter { (_, rules) -> rules.isNotEmpty() } + .toMap() return FillAssistRules(hostRules = hostRules) } @@ -152,7 +152,7 @@ private fun parseHostEntry( ): List { val allForms = buildList { addAll(hostEntry.forms.orEmpty()) - hostEntry.pathnames?.values?.filterNotNull()?.forEach { addAll(it.forms.orEmpty()) } + hostEntry.pathnames?.values?.filterNotNull()?.forEach { addAll(it.forms) } }.distinct() return buildFieldsByCategory(allForms).map { (category, fields) -> @@ -167,17 +167,16 @@ private fun buildFieldsByCategory( forms: List, ): Map>> { val result = mutableMapOf>>() - forms.mapNotNull { form -> form.category?.let { form to it } } - .forEach { (form, category) -> - val parsedFields = form.fields.orEmpty() - .mapValues { (_, elem) -> parseCompositeSelectorArray(elem) } - .filterValues { it.isNotEmpty() } - .takeIf { it.isNotEmpty() } ?: return@forEach - val categoryFields = result.getOrPut(category) { mutableMapOf() } - parsedFields.forEach { (fieldKey, selectors) -> - categoryFields.getOrPut(fieldKey) { mutableListOf() }.addAll(selectors) - } + forms.forEach { form -> + val parsedFields = form.fields + .mapValues { (_, elem) -> parseCompositeSelectorArray(elem) } + .filterValues { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } ?: return@forEach + val categoryFields = result.getOrPut(form.category) { mutableMapOf() } + parsedFields.forEach { (fieldKey, selectors) -> + categoryFields.getOrPut(fieldKey) { mutableListOf() }.addAll(selectors) } + } return result } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt index 92849db2110..f916ed345db 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/datasource/disk/FillAssistDiskSourceTest.kt @@ -85,6 +85,27 @@ class FillAssistDiskSourceTest { 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) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt index 67c03f8dafb..f530a1044b4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerTest.kt @@ -10,6 +10,7 @@ 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 @@ -33,8 +34,7 @@ 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 FORMS_FILENAME = "forms.v1.json" private const val CID = "sha256:abc123" private val FIXED_CLOCK: Clock = Clock.fixed( @@ -51,13 +51,15 @@ class FillAssistManagerTest { every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns true } + private val serverConfigFlow = MutableStateFlow(SERVER_CONFIG) + private val serverConfigRepository: ServerConfigRepository = mockk { - every { serverConfigStateFlow } returns MutableStateFlow(SERVER_CONFIG) + every { serverConfigStateFlow } returns serverConfigFlow } private val fillAssistService: FillAssistService = mockk { - coEvery { getManifest(url = MANIFEST_URL) } returns Result.success(MANIFEST) - coEvery { getForms(formsUrl = FORMS_URL) } returns Result.success(FORMS_V1) + coEvery { getManifest() } returns Result.success(MANIFEST) + coEvery { getForms(any()) } returns Result.success(FORMS_V1) } private val fillAssistDiskSource: FillAssistDiskSource = mockk { @@ -69,11 +71,16 @@ class FillAssistManagerTest { 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(), ) @@ -81,8 +88,18 @@ class FillAssistManagerTest { @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) + // 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 @@ -93,17 +110,17 @@ class FillAssistManagerTest { manager.syncIfNecessary() - coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + 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 { - every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null) + serverConfigFlow.value = null manager.syncIfNecessary() - coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + coVerify(exactly = 0) { fillAssistService.getManifest() } } @Test @@ -114,7 +131,7 @@ class FillAssistManagerTest { manager.syncIfNecessary() - coVerify(exactly = 0) { fillAssistService.getManifest(any()) } + coVerify(exactly = 0) { fillAssistService.getManifest() } coVerify(exactly = 0) { fillAssistService.getForms(any()) } } @@ -124,7 +141,7 @@ class FillAssistManagerTest { manager.syncIfNecessary() - coVerify(exactly = 1) { fillAssistService.getManifest(url = MANIFEST_URL) } + 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()) } @@ -136,7 +153,7 @@ class FillAssistManagerTest { manager.syncIfNecessary() - coVerify(exactly = 1) { fillAssistService.getForms(formsUrl = FORMS_URL) } + 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()) } @@ -145,7 +162,7 @@ class FillAssistManagerTest { @Test fun `sync does not store data when manifest fetch fails`() = runTest { coEvery { - fillAssistService.getManifest(any()) + fillAssistService.getManifest() } returns Result.failure(RuntimeException("network error")) manager.syncIfNecessary() @@ -158,7 +175,7 @@ class FillAssistManagerTest { @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"), + FORMS_V1.copy(schemaVersion = "2.0.0"), ) manager.syncIfNecessary() @@ -257,7 +274,7 @@ class FillAssistManagerTest { @Test fun `getFillAssistRules returns null when server URL is not configured`() { - every { serverConfigRepository.serverConfigStateFlow } returns MutableStateFlow(null) + serverConfigFlow.value = null assertNull(manager.getFillAssistRules()) } @@ -371,21 +388,22 @@ class FillAssistManagerTest { private val MANIFEST = FillAssistManifestJson( buildId = "local-build", - timestamp = null, - gitSha = null, + timestamp = "2026-01-01T12:00:00Z", + gitSha = "abc123", maps = FillAssistManifestJson.MapsJson( forms = mapOf( - "v0" to FillAssistManifestJson.FileEntryJson( - filename = "forms.v0.json", + "v1" to FillAssistManifestJson.FileEntryJson( + filename = FORMS_FILENAME, cid = CID, - schema = null, + schema = "forms.v1.schema.json", + deprecated = null, ), ), ), ) private val FORMS_V1 = FillAssistFormsJson( - schemaVersion = "0.1.0", + schemaVersion = "1.0.0", hosts = mapOf( "example.com" to FillAssistFormsJson.HostEntryJson( forms = listOf( @@ -406,7 +424,7 @@ private val FORMS_V1 = FillAssistFormsJson( // Host with two pathnames — both forms must appear in the stored rules. private val FORMS_V1_MULTI_PATHNAME = FillAssistFormsJson( - schemaVersion = "0.1.0", + schemaVersion = "1.0.0", hosts = mapOf( "example.com" to FillAssistFormsJson.HostEntryJson( forms = null, @@ -495,7 +513,7 @@ private val EXPECTED_RULES_MULTI_PATHNAME = FillAssistRules( // 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", + schemaVersion = "1.0.0", hosts = mapOf( "example.com" to FillAssistFormsJson.HostEntryJson( forms = listOf( @@ -561,7 +579,7 @@ private val EXPECTED_RULES_HOST_AND_PATHNAME = FillAssistRules( // Two pathnames both define account-login — must be merged into one HostRule. private val FORMS_V1_SAME_CATEGORY_PATHNAMES = FillAssistFormsJson( - schemaVersion = "0.1.0", + schemaVersion = "1.0.0", hosts = mapOf( "example.com" to FillAssistFormsJson.HostEntryJson( forms = null, @@ -633,7 +651,7 @@ private val EXPECTED_RULES_MERGED_CATEGORY = FillAssistRules( // Two pathnames define the same selector — the duplicate must be removed. private val FORMS_V1_DUPLICATE_SELECTORS = FillAssistFormsJson( - schemaVersion = "0.1.0", + schemaVersion = "1.0.0", hosts = mapOf( "example.com" to FillAssistFormsJson.HostEntryJson( forms = null, From 6c0bfa5ba5ec0a702566e69c2fa362b67e3ba6a1 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 11 Jun 2026 14:58:32 +0100 Subject: [PATCH 11/15] Improved code readability --- .../autofill/manager/FillAssistManagerImpl.kt | 67 ++++++++++--------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index 7a3e6961a65..2d96e097229 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -25,6 +25,11 @@ 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 @@ -121,9 +126,8 @@ class FillAssistManagerImpl( serverUrl = serverUrl, timestamp = clock.millis(), ) - }.also { result -> - result.onFailure { Timber.w(it, "Fill-assist sync failed") } } + .onFailure { Timber.w(it, "Fill-assist sync failed") } override fun getFillAssistRules(): FillAssistRules? { val serverUrl = serverConfigRepository @@ -152,8 +156,9 @@ private fun parseHostEntry( ): List { val allForms = buildList { addAll(hostEntry.forms.orEmpty()) - hostEntry.pathnames?.values?.filterNotNull()?.forEach { addAll(it.forms) } - }.distinct() + addAll(hostEntry.pathnames?.values?.filterNotNull()?.flatMap { it.forms }.orEmpty()) + } + .distinct() return buildFieldsByCategory(allForms).map { (category, fields) -> FillAssistRules.HostRule( @@ -165,36 +170,37 @@ private fun parseHostEntry( private fun buildFieldsByCategory( forms: List, -): Map>> { - val result = mutableMapOf>>() - forms.forEach { form -> - val parsedFields = form.fields - .mapValues { (_, elem) -> parseCompositeSelectorArray(elem) } - .filterValues { it.isNotEmpty() } - .takeIf { it.isNotEmpty() } ?: return@forEach - val categoryFields = result.getOrPut(form.category) { mutableMapOf() } - parsedFields.forEach { (fieldKey, selectors) -> - categoryFields.getOrPut(fieldKey) { mutableListOf() }.addAll(selectors) +): Map>> = + 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() } } - } - return result -} private fun parseCompositeSelectorArray(element: JsonElement): List { if (element !is JsonArray) return emptyList() - val result = mutableListOf() - for (item in element) { + return element.flatMap { item -> when (item) { - is JsonPrimitive -> parseSingleSelector(item.content)?.let { result.add(it) } - is JsonArray -> item - .filterIsInstance() - .mapNotNull { parseSingleSelector(it.content) } - .forEach { result.add(it) } + is JsonPrimitive -> listOfNotNull(parseSingleSelector(item.content)) + is JsonArray -> { + item + .filterIsInstance() + .mapNotNull { parseSingleSelector(it.content) } + } - else -> Unit + else -> emptyList() } } - return result } internal fun parseSingleSelector(selector: String): SelectorClause? { @@ -215,14 +221,15 @@ internal fun parseSingleSelector(selector: String): SelectorClause? { 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 + ID -> id = attrValue + NAME -> name = attrValue + TYPE -> type = attrValue + ROLE -> role = attrValue } } From 8b05d40cc98f313fdfcf6164d5ed1508ba81d6ad Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 11 Jun 2026 16:49:16 +0100 Subject: [PATCH 12/15] Addressing pr comments --- .../data/autofill/manager/FillAssistManagerImpl.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index 2d96e097229..382cb2945f4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -88,7 +88,10 @@ class FillAssistManagerImpl( } private suspend fun sync(serverUrl: String) = runCatching { - val manifest = fillAssistService.getManifest().getOrThrow() + val manifest = fillAssistService + .getManifest() + .getOrNull() + ?: return@runCatching val versionEntry = manifest.maps.forms[CURRENT_FORMS_VERSION] ?: error("Version $CURRENT_FORMS_VERSION not found in manifest") @@ -107,7 +110,8 @@ class FillAssistManagerImpl( val forms = fillAssistService .getForms(filename = versionEntry.filename) - .getOrThrow() + .getOrNull() + ?: return@runCatching val schemaMajor = forms.schemaVersion.substringBefore('.') if (schemaMajor != EXPECTED_SCHEMA_MAJOR) { @@ -176,7 +180,8 @@ private fun buildFieldsByCategory( val parsedFields = form.fields .mapValues { (_, elem) -> parseCompositeSelectorArray(elem) } .filterValues { it.isNotEmpty() } - .takeIf { it.isNotEmpty() } ?: return@mapNotNull null + .takeIf { it.isNotEmpty() } + ?: return@mapNotNull null form.category to parsedFields } .groupBy({ it.first }, { it.second }) From 66b7f36567c4acc1e4a50b8b140058b9ccd37ae4 Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Mon, 15 Jun 2026 20:12:29 +0100 Subject: [PATCH 13/15] Add fill assist logic to Autofill --- .../data/autofill/di/AutofillModule.kt | 6 + .../autofill/manager/FillAssistManagerImpl.kt | 19 +- .../autofill/parser/AutofillParserImpl.kt | 36 +- .../util/FillAssistViewNodeExtensions.kt | 65 ++ .../data/autofill/util/HtmlInfoExtensions.kt | 24 + .../data/autofill/util/ViewNodeExtensions.kt | 18 + .../autofill/parser/AutofillParserTests.kt | 9 + .../util/FillAssistViewNodeExtensionsTest.kt | 611 ++++++++++++++++++ 8 files changed, 778 insertions(+), 10 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt index b9937312231..cc06d2097ed 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/di/AutofillModule.kt @@ -20,6 +20,7 @@ import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogMa import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManagerImpl import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManagerImpl +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.parser.AutofillParser import com.x8bit.bitwarden.data.autofill.parser.AutofillParserImpl import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor @@ -27,6 +28,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager @@ -100,9 +102,13 @@ object AutofillModule { @Provides fun providesAutofillParser( settingsRepository: SettingsRepository, + fillAssistManager: FillAssistManager, + featureFlagManager: FeatureFlagManager, ): AutofillParser = AutofillParserImpl( settingsRepository = settingsRepository, + fillAssistManager = fillAssistManager, + featureFlagManager = featureFlagManager, ) @Singleton diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt index 382cb2945f4..24ed13b8a63 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/manager/FillAssistManagerImpl.kt @@ -43,6 +43,11 @@ 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]*)""") +// Splits on whitespace that is outside [...] attribute brackets, so that descendant selectors +// like "div#container input#field" are split correctly while attribute values containing spaces +// (e.g. [placeholder='Email address']) are preserved intact. +private val DESCENDANT_SEPARATOR_REGEX = Regex("""\s+(?![^\[]*])""") + /** * Primary implementation of [FillAssistManager]. */ @@ -209,13 +214,13 @@ private fun parseCompositeSelectorArray(element: JsonElement): List>>), 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 + // For descendant selectors, only the last segment describes the target element — earlier + // parts describe ancestors that are not represented as view nodes by the autofill framework. + // Shadow DOM (>>>) and space-separated CSS descendant selectors are both handled this way. + // Whitespace inside [...] is part of an attribute value and must not be treated as a separator. + val effective = when { + selector.contains(">>>") -> selector.substringAfterLast(">>>").trim() + else -> selector.split(DESCENDANT_SEPARATOR_REGEX).last().trim() } if (effective.trimStart().startsWith(".")) return null diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index 61f54b71e6a..60b33f11df4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -3,17 +3,22 @@ package com.x8bit.bitwarden.data.autofill.parser import android.app.assist.AssistStructure import android.service.autofill.FillRequest import android.view.autofill.AutofillId +import androidx.core.net.toUri +import com.bitwarden.core.data.manager.model.FlagKey +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillView import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData +import com.x8bit.bitwarden.data.autofill.util.buildFillAssistViews import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount import com.x8bit.bitwarden.data.autofill.util.toAutofillView import com.x8bit.bitwarden.data.autofill.util.website +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import timber.log.Timber @@ -56,6 +61,8 @@ private val URL_BARS: Map = mapOf( */ class AutofillParserImpl( private val settingsRepository: SettingsRepository, + private val fillAssistManager: FillAssistManager, + private val featureFlagManager: FeatureFlagManager, ) : AutofillParser { override fun parse( autofillAppInfo: AutofillAppInfo, @@ -139,17 +146,40 @@ class AutofillParserImpl( return AutofillRequest.Unfillable } + // Apply fill-assist targeting rules when the feature flag is enabled. Rules take priority + // over heuristics; unmatched view nodes are excluded entirely (no heuristic fallback). + val fillAssistHostRules = uri + ?.takeUnless { it.startsWith("androidapp://") }?.toUri()?.host + ?.takeIf { featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) } + ?.let { host -> + fillAssistManager.getFillAssistRules()?.hostRules?.get(host.removePrefix("www.")) + } + + val effectiveViews = fillAssistHostRules + ?.let { rules -> + assistStructure.buildFillAssistViews( + hostRules = rules, + urlBarWebsite = urlBarWebsite, + ) + } + ?: autofillViews + + val effectiveFocusedView = effectiveViews + .firstOrNull { it.data.isFocused } + ?: effectiveViews.firstOrNull() + ?: return AutofillRequest.Unfillable + // Choose the first focused partition of data for fulfillment. - val partition = when (focusedView) { + val partition = when (effectiveFocusedView) { is AutofillView.Card -> { AutofillPartition.Card( - views = autofillViews.filterIsInstance(), + views = effectiveViews.filterIsInstance(), ) } is AutofillView.Login -> { AutofillPartition.Login( - views = autofillViews.filterIsInstance(), + views = effectiveViews.filterIsInstance(), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt new file mode 100644 index 00000000000..109c676f3c8 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensions.kt @@ -0,0 +1,65 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.app.assist.AssistStructure +import com.x8bit.bitwarden.data.autofill.model.AutofillView +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules + +/** + * Traverses the [AssistStructure] and returns a list of [AutofillView]s classified by the + * provided [hostRules]. Only view nodes whose [android.view.ViewStructure.HtmlInfo] attributes + * match a [FillAssistRules.SelectorClause] are included; unmatched nodes are omitted (no + * heuristic fallback). + */ +internal fun AssistStructure.buildFillAssistViews( + hostRules: List, + urlBarWebsite: String?, +): List = + (0 until windowNodeCount) + .mapNotNull { getWindowNodeAt(it).rootViewNode } + .flatMap { it.traverseForFillAssist(hostRules = hostRules, parentWebsite = urlBarWebsite) } + +private fun AssistStructure.ViewNode.traverseForFillAssist( + hostRules: List, + parentWebsite: String?, +): List { + val website = this.website ?: parentWebsite + val ownView = autofillId?.let { id -> + hostRules + .flatMap { it.fields.entries } + .filter { (_, alternatives) -> + alternatives.any { + htmlInfo?.matchesSelectorClause(it) ?: false + } + } + .takeIf { it.isNotEmpty() } + ?.let { matchingEntries -> + val data = toAutofillViewData(autofillId = id, website = website) + matchingEntries.firstNotNullOfOrNull { (key, _) -> + key.toAutofillViewForFieldKey( + data, + ) + } + } + } + val childViews = (0 until childCount) + .flatMap { index -> + getChildAt(index).traverseForFillAssist( + hostRules = hostRules, + parentWebsite = website, + ) + } + return listOfNotNull(ownView) + childViews +} + +private fun String.toAutofillViewForFieldKey(data: AutofillView.Data): AutofillView? = when (this) { + "username" -> AutofillView.Login.Username(data = data) + "password", "newPassword" -> AutofillView.Login.Password(data = data) + "cardNumber" -> AutofillView.Card.Number(data = data) + "cardholderName" -> AutofillView.Card.CardholderName(data = data) + "cardExpirationDate" -> AutofillView.Card.ExpirationDate(data = data) + "cardExpirationMonth" -> AutofillView.Card.ExpirationMonth(data = data, monthValue = null) + "cardExpirationYear" -> AutofillView.Card.ExpirationYear(data = data, yearValue = null) + "cardCvv" -> AutofillView.Card.SecurityCode(data = data) + "cardType" -> AutofillView.Card.Brand(data = data, brandValue = null) + else -> null +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt index 918d96bb63e..35275259270 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/HtmlInfoExtensions.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.autofill.util import android.view.ViewStructure.HtmlInfo +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules /** * Whether this [HtmlInfo] represents a password field. @@ -93,6 +94,29 @@ fun HtmlInfo?.hints(): List = this */ val HtmlInfo?.isInputField: Boolean get() = this?.tag == "input" +/** + * Whether this [HtmlInfo] matches the given [SelectorClause]. + * + * This function is untestable as [HtmlInfo] contains [android.util.Pair] which requires + * instrumentation testing. + */ +internal fun HtmlInfo.matchesSelectorClause(clause: FillAssistRules.SelectorClause): Boolean { + if (clause.tag != null && clause.tag != tag) return false + val attrs = attributes + ?: return clause.id == null && + clause.name == null && + clause.type == null && + clause.role == null + fun hasAttr(key: String, value: String) = attrs.any { it.first == key && it.second == value } + return listOf( + clause.id to "id", + clause.name to "name", + clause.type to "type", + clause.role to "role", + ) + .all { (value, key) -> value == null || hasAttr(key, value) } +} + /** * Checks if the list of strings contains any of the specified patterns. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt index 5d80e86fc8f..127d92db025 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/ViewNodeExtensions.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.autofill.util import android.app.assist.AssistStructure import android.view.View +import android.view.autofill.AutofillId import android.widget.EditText import androidx.annotation.VisibleForTesting import com.bitwarden.ui.platform.base.util.orNullIfBlank @@ -74,6 +75,23 @@ fun AssistStructure.ViewNode.toAutofillView( ) } +/** + * Builds an [AutofillView.Data] for this [AssistStructure.ViewNode] using the given [autofillId] + * and [website]. + */ +internal fun AssistStructure.ViewNode.toAutofillViewData( + autofillId: AutofillId, + website: String?, +): AutofillView.Data = AutofillView.Data( + autofillId = autofillId, + autofillOptions = autofillOptions?.map { it.toString() }.orEmpty(), + autofillType = autofillType, + isFocused = isFocused, + textValue = autofillValue?.extractTextValue(), + hasPasswordTerms = hasPasswordTerms(), + website = website, +) + /** * The first supported autofill hint for this view node, or null if none are found. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index e23a4faa0b0..3048ae1286d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -6,6 +6,8 @@ import android.service.autofill.FillRequest import android.view.View import android.view.autofill.AutofillId import android.widget.inline.InlinePresentationSpec +import com.bitwarden.core.data.manager.model.FlagKey +import com.x8bit.bitwarden.data.autofill.manager.FillAssistManager import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest @@ -17,6 +19,7 @@ import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount import com.x8bit.bitwarden.data.autofill.util.toAutofillView import com.x8bit.bitwarden.data.autofill.util.website +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import io.mockk.every import io.mockk.mockk @@ -70,6 +73,10 @@ class AutofillParserTests { every { isInlineAutofillEnabled } answers { mockIsInlineAutofillEnabled } every { blockedAutofillUris } returns emptyList() } + private val fillAssistManager: FillAssistManager = mockk() + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns false + } private var mockIsInlineAutofillEnabled = true @@ -127,6 +134,8 @@ class AutofillParserTests { every { any().buildUriOrNull(PACKAGE_NAME) } returns URI parser = AutofillParserImpl( settingsRepository = settingsRepository, + fillAssistManager = fillAssistManager, + featureFlagManager = featureFlagManager, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt new file mode 100644 index 00000000000..2a11495eae3 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt @@ -0,0 +1,611 @@ +package com.x8bit.bitwarden.data.autofill.util + +import android.app.assist.AssistStructure +import android.view.View +import android.view.ViewStructure.HtmlInfo +import android.view.autofill.AutofillId +import com.x8bit.bitwarden.data.autofill.model.AutofillView +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@Suppress("LargeClass") +class FillAssistViewNodeExtensionsTest { + + private val autofillId: AutofillId = mockk() + + @BeforeEach + fun setup() { + mockkStatic(AssistStructure.ViewNode::website) + mockkStatic(AssistStructure.ViewNode::toAutofillViewData) + mockkStatic(HtmlInfo::matchesSelectorClause) + } + + @AfterEach + fun teardown() { + unmockkStatic(AssistStructure.ViewNode::website) + unmockkStatic(AssistStructure.ViewNode::toAutofillViewData) + unmockkStatic(HtmlInfo::matchesSelectorClause) + } + + @Test + fun `buildFillAssistViews should return empty list when there are no window nodes`() { + val assistStructure: AssistStructure = mockk { + every { windowNodeCount } returns 0 + } + + val actual = assistStructure.buildFillAssistViews( + hostRules = emptyList(), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should exclude node with null htmlInfo`() { + val viewNode = createViewNode(htmlInfo = null) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should return Login Username when htmlInfo matches username clause`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should return Login Password when htmlInfo matches password clause`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "password" to listOf(selectorClause(tag = "input", id = "pass")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should return Login Password when htmlInfo matches newPassword clause`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-registration", + fields = mapOf( + "newPassword" to listOf(selectorClause(tag = "input", id = "new-pass")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Password(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should map all card field keys to correct AutofillView subtypes`() { + val cardFieldExpectations: List> = listOf( + "cardNumber" to AutofillView.Card.Number(data = autofillData()), + "cardholderName" to AutofillView.Card.CardholderName(data = autofillData()), + "cardExpirationDate" to AutofillView.Card.ExpirationDate(data = autofillData()), + "cardExpirationMonth" to AutofillView.Card.ExpirationMonth( + data = autofillData(), + monthValue = null, + ), + "cardExpirationYear" to AutofillView.Card.ExpirationYear( + data = autofillData(), + yearValue = null, + ), + "cardCvv" to AutofillView.Card.SecurityCode(data = autofillData()), + "cardType" to AutofillView.Card.Brand(data = autofillData(), brandValue = null), + ) + + cardFieldExpectations.forEach { (fieldKey, expectedView) -> + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns data + + val hostRule = FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + fieldKey to listOf(selectorClause(tag = "input", id = "field-$fieldKey")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals( + listOf(expectedView), + actual, + "Failed for field key: $fieldKey", + ) + } + } + + @Test + fun `buildFillAssistViews should exclude node whose matched field key is unknown`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns autofillData() + + val hostRule = FillAssistRules.HostRule( + category = "unknown", + fields = mapOf( + "unknownFieldKey" to listOf(selectorClause(tag = "input", id = "mystery")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should pick first mapped key when multiple keys match the same node`() { + // Two field keys ("email" - unknown - and "username") both match the same node. The + // implementation iterates in insertion order and selects the first key whose mapping is + // non-null. Since "email" is unknown, "username" wins; this also demonstrates that an + // earlier known key wins over a later one. + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = linkedMapOf( + "email" to listOf(selectorClause(tag = "input", id = "shared")), + "username" to listOf(selectorClause(tag = "input", id = "shared")), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should traverse child nodes recursively`() { + val childHtmlInfo = createHtmlInfo() + val childViewNode = createViewNode(htmlInfo = childHtmlInfo) + val childData = autofillData() + every { + childViewNode.toAutofillViewData(autofillId = autofillId, website = null) + } returns childData + + val rootHtmlInfo = createHtmlInfo(matches = false) + val rootViewNode = createViewNode( + htmlInfo = rootHtmlInfo, + children = listOf(childViewNode), + ) + + val assistStructure = createAssistStructure(rootViewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = childData)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should propagate urlBarWebsite as website when node has no website`() { + val urlBarWebsite = "https://example.com" + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo, website = null) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData(website = urlBarWebsite) + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = urlBarWebsite) + } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = urlBarWebsite, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should prefer node's own website over urlBarWebsite`() { + val nodeWebsite = "https://node.example.com" + val urlBarWebsite = "https://urlbar.example.com" + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo, website = nodeWebsite) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData(website = nodeWebsite) + every { + viewNode.toAutofillViewData(autofillId = autofillId, website = nodeWebsite) + } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = urlBarWebsite, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should exclude node with no autofillId`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo, autofillId = null) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should not match when htmlInfo tag differs from clause tag`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should match when htmlInfo has no attributes and all clause attrs are null`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = null, + name = null, + type = null, + role = null, + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should not match when htmlInfo has no attributes but clause requires id`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should match when htmlInfo has correct id attribute`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should not match when htmlInfo id attribute is wrong`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should require all non-null clause attributes to match (AND logic)`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = "username", + type = "text", + role = "textbox", + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Suppress("MaxLineLength") + @Test + fun `buildFillAssistViews should not match when one of multiple required attributes is wrong`() { + val htmlInfo = createHtmlInfo(matches = false) + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = "username", + type = "text", + role = "textbox", + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(emptyList(), actual) + } + + @Test + fun `buildFillAssistViews should skip null clause attributes (not required)`() { + // Clause only requires tag + id; node has extra attributes which should be ignored. + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should match when clause has null tag (tag check skipped)`() { + val htmlInfo = createHtmlInfo() + val viewNode = createViewNode(htmlInfo = htmlInfo) + val assistStructure = createAssistStructure(viewNode) + val data = autofillData() + every { viewNode.toAutofillViewData(autofillId = autofillId, website = null) } returns data + + val hostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = null, + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(hostRule), + urlBarWebsite = null, + ) + + assertEquals(listOf(AutofillView.Login.Username(data = data)), actual) + } + + @Test + fun `buildFillAssistViews should return views from all children when only some match`() { + val matchingChildHtmlInfo = createHtmlInfo() + val matchingChild = createViewNode(htmlInfo = matchingChildHtmlInfo) + val matchingChildData = autofillData() + every { + matchingChild.toAutofillViewData(autofillId = autofillId, website = null) + } returns matchingChildData + + val nonMatchingChildHtmlInfo = createHtmlInfo(matches = false) + val nonMatchingChild = createViewNode(htmlInfo = nonMatchingChildHtmlInfo) + + val rootHtmlInfo = createHtmlInfo(matches = false) + val rootViewNode = createViewNode( + htmlInfo = rootHtmlInfo, + children = listOf(matchingChild, nonMatchingChild), + ) + + val assistStructure = createAssistStructure(rootViewNode) + + val actual = assistStructure.buildFillAssistViews( + hostRules = listOf(usernameHostRule(tag = "input", id = "user")), + urlBarWebsite = null, + ) + + assertEquals( + listOf( + AutofillView.Login.Username( + data = matchingChildData, + ), + ), + actual, + ) + // Sanity check that traversal visited multiple children. + assertTrue(actual.size == 1) + } + + private fun autofillData(website: String? = null): AutofillView.Data = AutofillView.Data( + autofillId = autofillId, + autofillOptions = emptyList(), + autofillType = View.AUTOFILL_TYPE_TEXT, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = website, + ) + + private fun usernameHostRule( + tag: String?, + id: String?, + ): FillAssistRules.HostRule = FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf(selectorClause(tag = tag, id = id)), + ), + ) + + private fun selectorClause( + tag: String? = null, + id: String? = null, + name: String? = null, + type: String? = null, + role: String? = null, + ): FillAssistRules.SelectorClause = FillAssistRules.SelectorClause( + tag = tag, + id = id, + name = name, + type = type, + role = role, + ) + + private fun createHtmlInfo(matches: Boolean = true): HtmlInfo = mockk().also { + every { it.matchesSelectorClause(any()) } returns matches + } + + private fun createViewNode( + htmlInfo: HtmlInfo?, + autofillId: AutofillId? = this.autofillId, + website: String? = null, + children: List = emptyList(), + ): AssistStructure.ViewNode = mockk { + every { this@mockk.htmlInfo } returns htmlInfo + every { this@mockk.autofillId } returns autofillId + every { this@mockk.website } returns website + every { this@mockk.childCount } returns children.size + children.forEachIndexed { index, child -> + every { this@mockk.getChildAt(index) } returns child + } + } + + private fun createAssistStructure( + rootViewNode: AssistStructure.ViewNode, + ): AssistStructure { + val windowNode: AssistStructure.WindowNode = mockk { + every { this@mockk.rootViewNode } returns rootViewNode + } + return mockk { + every { windowNodeCount } returns 1 + every { getWindowNodeAt(0) } returns windowNode + } + } +} From 3a2fe1e011cc1c754e55b96e8dd594d7d28632bc Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Wed, 24 Jun 2026 16:45:34 +0100 Subject: [PATCH 14/15] Using heuristics autofill if fill assist does not have rules for that type --- .../autofill/parser/AutofillParserImpl.kt | 139 +++++--- .../autofill/parser/AutofillParserTests.kt | 318 +++++++++++++++++- .../util/FillAssistViewNodeExtensionsTest.kt | 2 - 3 files changed, 403 insertions(+), 56 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt index 60b33f11df4..7fa53c03b93 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserImpl.kt @@ -55,6 +55,22 @@ private val URL_BARS: Map = mapOf( "com.brave.browser_nightly" to "url_bar", ) +/** + * A list of categories from Fill Assist that are used for [AutofillView.Login] + */ +private val LOGIN_FILL_ASSIST_CATEGORIES: List = listOf( + "account-login", + "account-creation", + "account-update", +) + +/** + * A list of categories from Fill Assist that are used for [AutofillView.Card] + */ +private val CARD_FILL_ASSIST_CATEGORIES: List = listOf( + "payment-card", +) + /** * The default [AutofillParser] implementation for the app. This is a tool for parsing autofill data * from the OS into domain models. @@ -107,62 +123,27 @@ class AutofillParserImpl( val urlBarWebsite = traversalDataList .flatMap { it.urlBarWebsites } .firstOrNull() + val autofillViews = traversalDataList.toAutofillViews(urlBarWebsite = urlBarWebsite) - // Take only the autofill views from the node that currently has focus. - // Then remove all the fields that cannot be filled with data. - // We fallback to taking all the fillable views if nothing has focus. - val autofillViewsList = traversalDataList.map { it.autofillViews } - val autofillViews = (autofillViewsList - .filter { views -> views.any { it.data.isFocused } } - .flatten() - .filter { it !is AutofillView.Unused } - .takeUnless { it.isEmpty() } - ?: autofillViewsList - .flatten() - .filter { it !is AutofillView.Unused }) - .map { it.updateWebsiteIfNecessary(website = urlBarWebsite) } - - // Find the focused view, or fallback to the first fillable item on the screen (so - // we at least have something to hook into) - val focusedView = autofillViews - .firstOrNull { it.data.isFocused } + val focusedView = autofillViews.firstOrNull { it.data.isFocused } ?: autofillViews.firstOrNull() + ?: return AutofillRequest.Unfillable + + val packageName = + traversalDataList.buildPackageNameOrNull(assistStructure = assistStructure) + val uri = focusedView.buildUriOrNull(packageName = packageName) - if (focusedView == null) { - // The view is unfillable if there are no focused views. + if ((settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS).contains(uri)) { return AutofillRequest.Unfillable } - val packageName = traversalDataList.buildPackageNameOrNull( + val effectiveViews = resolveEffectiveViews( assistStructure = assistStructure, + autofillViews = autofillViews, + uri = uri, + focusedView = focusedView, + urlBarWebsite = urlBarWebsite, ) - val uri = focusedView.buildUriOrNull( - packageName = packageName, - ) - - val blockListedURIs = settingsRepository.blockedAutofillUris + BLOCK_LISTED_URIS - if (blockListedURIs.contains(uri)) { - // The view is unfillable if the URI is block listed. - return AutofillRequest.Unfillable - } - - // Apply fill-assist targeting rules when the feature flag is enabled. Rules take priority - // over heuristics; unmatched view nodes are excluded entirely (no heuristic fallback). - val fillAssistHostRules = uri - ?.takeUnless { it.startsWith("androidapp://") }?.toUri()?.host - ?.takeIf { featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) } - ?.let { host -> - fillAssistManager.getFillAssistRules()?.hostRules?.get(host.removePrefix("www.")) - } - - val effectiveViews = fillAssistHostRules - ?.let { rules -> - assistStructure.buildFillAssistViews( - hostRules = rules, - urlBarWebsite = urlBarWebsite, - ) - } - ?: autofillViews val effectiveFocusedView = effectiveViews .firstOrNull { it.data.isFocused } @@ -196,7 +177,6 @@ class AutofillParserImpl( // Get inline information if available val isInlineAutofillEnabled = settingsRepository.isInlineAutofillEnabled - Timber.d("Autofill request isInlineEnabled=$isInlineAutofillEnabled -- ${fillRequest?.id}") val maxInlineSuggestionsCount = fillRequest.getMaxInlineSuggestionsCount( autofillAppInfo = autofillAppInfo, isInlineAutofillEnabled = isInlineAutofillEnabled, @@ -215,6 +195,46 @@ class AutofillParserImpl( uri = uri, ) } + + /** + * Returns the effective [AutofillView] list for filling. Applies fill-assist targeting rules + * when the feature flag is enabled and the host rules cover the current partition type; + * otherwise returns the heuristic [autofillViews]. + */ + private fun resolveEffectiveViews( + assistStructure: AssistStructure, + autofillViews: List, + uri: String?, + focusedView: AutofillView, + urlBarWebsite: String?, + ): List { + val hostRules = uri + ?.takeUnless { it.startsWith("androidapp://") } + ?.toUri() + ?.host + ?.takeIf { featureFlagManager.getFeatureFlag(FlagKey.FillAssistTargetingRules) } + ?.let { host -> + fillAssistManager.getFillAssistRules()?.hostRules?.get(host.removePrefix("www.")) + } + ?: return autofillViews + + val coversCurrentPartition = hostRules.any { rule -> + when (focusedView) { + is AutofillView.Card -> rule.category in CARD_FILL_ASSIST_CATEGORIES + is AutofillView.Login -> rule.category in LOGIN_FILL_ASSIST_CATEGORIES + is AutofillView.Unused -> false + } + } + + return if (coversCurrentPartition) { + assistStructure.buildFillAssistViews( + hostRules = hostRules, + urlBarWebsite = urlBarWebsite, + ) + } else { + autofillViews + } + } } /** @@ -231,6 +251,27 @@ private fun AssistStructure.traverse(): List = ?.updateForMissingUsernameFields() } +/** + * Assembles the [AutofillView] list from this [ViewNodeTraversalData] list. + * Take only the autofill views from the node that currently has focus. + * Then remove all the fields that cannot be filled with data. + * We fall back to taking all the fillable views if nothing has focus. + */ +private fun List.toAutofillViews( + urlBarWebsite: String?, +): List { + val viewsLists = map { it.autofillViews } + return (viewsLists + .filter { views -> views.any { it.data.isFocused } } + .flatten() + .filter { it !is AutofillView.Unused } + .takeUnless { it.isEmpty() } + ?: viewsLists + .flatten() + .filter { it !is AutofillView.Unused }) + .map { it.updateWebsiteIfNecessary(website = urlBarWebsite) } +} + /** * This helper function updates the [ViewNodeTraversalData] if necessary for missing password * fields that were marked invalid because they contained a specific `hint` or `idEntry`. If the diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt index 3048ae1286d..e0bd36448d2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserTests.kt @@ -1,9 +1,12 @@ package com.x8bit.bitwarden.data.autofill.parser import android.app.assist.AssistStructure +import android.net.Uri +import android.net.Uri.parse import android.service.autofill.FillContext import android.service.autofill.FillRequest import android.view.View +import android.view.ViewStructure.HtmlInfo import android.view.autofill.AutofillId import android.widget.inline.InlinePresentationSpec import com.bitwarden.core.data.manager.model.FlagKey @@ -12,12 +15,15 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo import com.x8bit.bitwarden.data.autofill.model.AutofillPartition import com.x8bit.bitwarden.data.autofill.model.AutofillRequest import com.x8bit.bitwarden.data.autofill.model.AutofillView +import com.x8bit.bitwarden.data.autofill.model.FillAssistRules import com.x8bit.bitwarden.data.autofill.model.ViewNodeTraversalData import com.x8bit.bitwarden.data.autofill.util.buildPackageNameOrNull import com.x8bit.bitwarden.data.autofill.util.buildUriOrNull import com.x8bit.bitwarden.data.autofill.util.getInlinePresentationSpecs import com.x8bit.bitwarden.data.autofill.util.getMaxInlineSuggestionsCount +import com.x8bit.bitwarden.data.autofill.util.matchesSelectorClause import com.x8bit.bitwarden.data.autofill.util.toAutofillView +import com.x8bit.bitwarden.data.autofill.util.toAutofillViewData import com.x8bit.bitwarden.data.autofill.util.website import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -26,6 +32,7 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach @@ -43,6 +50,7 @@ class AutofillParserTests { every { this@mockk.autofillHints } returns arrayOf(cardAutofillHint) every { this@mockk.autofillId } returns cardAutofillId every { this@mockk.childCount } returns 0 + every { this@mockk.htmlInfo } returns mockk(relaxed = true) every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.idEntry } returns null } @@ -52,6 +60,7 @@ class AutofillParserTests { every { this@mockk.autofillHints } returns arrayOf(loginAutofillHint) every { this@mockk.autofillId } returns loginAutofillId every { this@mockk.childCount } returns 0 + every { this@mockk.htmlInfo } returns mockk(relaxed = true) every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.idEntry } returns null } @@ -74,16 +83,39 @@ class AutofillParserTests { every { blockedAutofillUris } returns emptyList() } private val fillAssistManager: FillAssistManager = mockk() + private val mutableFillAssistFlagFlow = MutableStateFlow(false) private val featureFlagManager: FeatureFlagManager = mockk { - every { getFeatureFlag(FlagKey.FillAssistTargetingRules) } returns false + every { + getFeatureFlag(FlagKey.FillAssistTargetingRules) + } answers { + mutableFillAssistFlagFlow.value + } + every { getFeatureFlagFlow(FlagKey.ManageDevices) } returns mutableFillAssistFlagFlow } private var mockIsInlineAutofillEnabled = true + private var mockIsFillAssistEnabled = false @BeforeEach fun setup() { + mockIsFillAssistEnabled = false + // toAutofillView, website, and toAutofillViewData all compile into the same + // ViewNodeExtensionsKt class, so one mockkStatic call covers all three. mockkStatic(AssistStructure.ViewNode::toAutofillView) - mockkStatic(AssistStructure.ViewNode::website) + // Default stub for toAutofillViewData (same mocked class — no separate mockkStatic needed). + every { + any().toAutofillViewData(autofillId = any(), website = any()) + } answers { + AutofillView.Data( + autofillId = firstArg(), + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = false, + textValue = null, + hasPasswordTerms = false, + website = secondArg(), + ) + } mockkStatic( FillRequest::getMaxInlineSuggestionsCount, FillRequest::getInlinePresentationSpecs, @@ -137,12 +169,20 @@ class AutofillParserTests { fillAssistManager = fillAssistManager, featureFlagManager = featureFlagManager, ) + + mockkStatic(Uri::parse) + every { parse(any()) } returns mockk { + every { host } returns FILL_ASSIST_URI + } + mockkStatic(HtmlInfo::matchesSelectorClause) + every { any().matchesSelectorClause(any()) } returns false } @AfterEach fun teardown() { unmockkStatic(AssistStructure.ViewNode::toAutofillView) - unmockkStatic(AssistStructure.ViewNode::website) + unmockkStatic(Uri::parse) + unmockkStatic(HtmlInfo::matchesSelectorClause) unmockkStatic( FillRequest::getMaxInlineSuggestionsCount, FillRequest::getInlinePresentationSpecs, @@ -554,10 +594,9 @@ class AutofillParserTests { val rootViewNode: AssistStructure.ViewNode = mockk { every { this@mockk.autofillHints } returns emptyArray() every { this@mockk.autofillId } returns rootAutofillId - every { this@mockk.childCount } returns 0 + every { this@mockk.childCount } returns 2 every { this@mockk.idPackage } returns ID_PACKAGE every { this@mockk.website } returns WEBSITE - every { this@mockk.childCount } returns 2 every { this@mockk.getChildAt(0) } returns hiddenUserNameViewNode every { this@mockk.getChildAt(1) } returns passwordViewNode } @@ -956,6 +995,273 @@ class AutofillParserTests { } } + @Suppress("MaxLineLength") + @Test + fun `parse should fall back to heuristics when fill-assist rules exist but only cover login and a card view is focused`() { + // Setup: fill-assist enabled with login-only rules, but a card view is focused. + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { + any().buildUriOrNull(PACKAGE_NAME) + } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode + val cardAutofillView = AutofillView.Card.ExpirationYear( + data = AutofillView.Data( + autofillId = cardAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + yearValue = null, + ) + every { cardViewNode.toAutofillView(parentWebsite = any()) } returns cardAutofillView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: heuristic card view used + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Card(views = listOf(cardAutofillView)), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should fall back to heuristics when fill-assist rules exist but only cover payment-card and a login view is focused`() { + // Setup: fill-assist enabled with card-only rules, but a login view is focused. + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { any().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + "cardNumber" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "card-number", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode + val loginAutofillView = AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + ) + every { loginViewNode.toAutofillView(parentWebsite = any()) } returns loginAutofillView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: heuristic login view used + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Login(views = listOf(loginAutofillView)), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should use fill-assist views when rules cover login and a login view is focused`() { + // Setup: fill-assist with login rules, login view focused. + // The heuristic and fill-assist paths produce views with DIFFERENT autofillIds so the + // assertion proves which path was actually taken. If heuristics are used the partition + // contains loginAutofillId; if fill-assist is used it contains fillAssistAutofillId. + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { any().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "account-login", + fields = mapOf( + "username" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "user", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + val heuristicLoginView = AutofillView.Login.Username( + data = AutofillView.Data( + autofillId = loginAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + ) + val fillAssistAutofillId: AutofillId = mockk() + val fillAssistLoginData = AutofillView.Data( + autofillId = fillAssistAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = WEBSITE, + ) + every { any().matchesSelectorClause(any()) } returns true + every { + loginViewNode.toAutofillViewData(autofillId = loginAutofillId, website = WEBSITE) + } returns fillAssistLoginData + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns loginWindowNode + every { loginViewNode.toAutofillView(parentWebsite = any()) } returns heuristicLoginView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: fill-assist views used — partition contains fillAssistAutofillId. + // Heuristics would have produced loginAutofillId + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Login( + views = listOf(AutofillView.Login.Username(data = fillAssistLoginData)), + ), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + + @Suppress("MaxLineLength") + @Test + fun `parse should use fill-assist when rules cover payment-card and a card view is focused`() { + mutableFillAssistFlagFlow.value = true + mockIsFillAssistEnabled = true + every { any().buildUriOrNull(PACKAGE_NAME) } returns FILL_ASSIST_URI + every { fillAssistManager.getFillAssistRules() } returns FillAssistRules( + hostRules = mapOf( + FILL_ASSIST_URI to listOf( + FillAssistRules.HostRule( + category = "payment-card", + fields = mapOf( + "cardNumber" to listOf( + FillAssistRules.SelectorClause( + tag = "input", + id = "card-number", + name = null, + type = null, + role = null, + ), + ), + ), + ), + ), + ), + ) + val heuristicCardView = AutofillView.Card.ExpirationYear( + data = AutofillView.Data( + autofillId = cardAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = FILL_ASSIST_URI, + ), + yearValue = null, + ) + val fillAssistAutofillId: AutofillId = mockk() + val fillAssistCardData = AutofillView.Data( + autofillId = fillAssistAutofillId, + autofillOptions = emptyList(), + autofillType = AUTOFILL_TYPE, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = WEBSITE, + ) + every { any().matchesSelectorClause(any()) } returns true + every { + cardViewNode.toAutofillViewData(autofillId = cardAutofillId, website = WEBSITE) + } returns fillAssistCardData + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns cardWindowNode + every { cardViewNode.toAutofillView(parentWebsite = any()) } returns heuristicCardView + + // Test + val actual = parser.parse(autofillAppInfo = autofillAppInfo, fillRequest = fillRequest) + + // Verify: fill-assist views used — partition contains fillAssistAutofillId. + val expected = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = inlinePresentationSpecs, + maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT, + packageName = PACKAGE_NAME, + partition = AutofillPartition.Card( + views = listOf(AutofillView.Card.Number(data = fillAssistCardData)), + ), + uri = FILL_ASSIST_URI, + ) + assertEquals(expected, actual) + } + /** * Setup [assistStructure] to return window nodes with each [AutofillView] type (card and login) * so we can test how different window node configurations produce different partitions. @@ -967,6 +1273,8 @@ class AutofillParserTests { } } +private const val FILL_ASSIST_URI: String = "https://example.com" + private val BLOCK_LISTED_URIS: List = listOf( "androidapp://android", "androidapp://com.android.settings", diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt index 2a11495eae3..ed4a08c74c2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/util/FillAssistViewNodeExtensionsTest.kt @@ -23,14 +23,12 @@ class FillAssistViewNodeExtensionsTest { @BeforeEach fun setup() { - mockkStatic(AssistStructure.ViewNode::website) mockkStatic(AssistStructure.ViewNode::toAutofillViewData) mockkStatic(HtmlInfo::matchesSelectorClause) } @AfterEach fun teardown() { - unmockkStatic(AssistStructure.ViewNode::website) unmockkStatic(AssistStructure.ViewNode::toAutofillViewData) unmockkStatic(HtmlInfo::matchesSelectorClause) } From 1a2ea61425c4b961fc77f01651cc8c7a03cbad9a Mon Sep 17 00:00:00 2001 From: Andre Rosado Date: Thu, 25 Jun 2026 12:23:40 +0100 Subject: [PATCH 15/15] added verify for FillAssistManager syncIfNecessary --- .../bitwarden/data/vault/manager/VaultSyncManagerTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt index 3c7b0a9d160..0321c4f65ae 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt @@ -783,6 +783,7 @@ class VaultSyncManagerTest { ), ) } + verify(exactly = 1) { fillAssistManager.syncIfNecessary() } } @Suppress("MaxLineLength") @@ -823,6 +824,7 @@ class VaultSyncManagerTest { ), ) } + verify(exactly = 0) { fillAssistManager.syncIfNecessary() } } @Test @@ -1189,6 +1191,7 @@ class VaultSyncManagerTest { val syncResult = vaultSyncManager.syncForResult() assertEquals(SyncVaultDataResult.Success(itemsAvailable = true), syncResult) + verify(exactly = 1) { fillAssistManager.syncIfNecessary() } } @Suppress("MaxLineLength") @@ -1220,6 +1223,7 @@ class VaultSyncManagerTest { val syncResult = vaultSyncManager.syncForResult() assertEquals(SyncVaultDataResult.Success(itemsAvailable = false), syncResult) + verify(exactly = 1) { fillAssistManager.syncIfNecessary() } } @Test @@ -1269,6 +1273,7 @@ class VaultSyncManagerTest { ) } coVerify(exactly = 0) { syncService.sync() } + verify(exactly = 0) { fillAssistManager.syncIfNecessary() } } //region Helper functions