diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt index 710288c925d..7347f458758 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderImpl.kt @@ -154,8 +154,21 @@ class FilledDataBuilderImpl( val value = when (autofillView) { is AutofillView.Login.Username -> autofillCipher.username is AutofillView.Login.Password -> autofillCipher.password + is AutofillView.Login.Custom -> { + // Only fill custom fields if we have a strict match. + if (autofillCipher.isStrictMatch) { + autofillCipher.customFields.firstOrNull { field -> + val name = field.name + // Match against hint or idEntry + autofillView.data.hint?.contains(name, ignoreCase = true) == true || + autofillView.data.idEntry?.contains(name, ignoreCase = true) == true + }?.value + } else { + null + } + } } - autofillView.buildFilledItemOrNull(value = value) + value?.let { autofillView.buildFilledItemOrNull(value = it) } } else { null } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt index 2d535d9ce98..1c3f094849a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillCipher.kt @@ -67,8 +67,19 @@ sealed class AutofillCipher { val password: String, val username: String, val website: String, + val customFields: List = emptyList(), + val isStrictMatch: Boolean = false, ) : AutofillCipher() { override val iconRes: Int @DrawableRes get() = BitwardenDrawable.ic_globe } } + +/** + * A field on a cipher that can be autofilled. + */ +data class AutofillField( + val name: String, + val value: String, + val type: com.bitwarden.vault.FieldType, +) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt index fe7d287215b..846c07cf348 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/model/AutofillView.kt @@ -26,8 +26,11 @@ sealed class AutofillView { val textValue: String?, val hasPasswordTerms: Boolean, val website: String?, + val idEntry: String? = null, + val hint: String? = null, ) + /** * The core data that describes this [AutofillView]. */ @@ -118,6 +121,14 @@ sealed class AutofillView { data class Username( override val data: Data, ) : Login() + + /** + * A custom [AutofillView] for the [Login] data partition. + */ + data class Custom( + override val data: Data, + val inputType: Int, + ) : Login() } /** 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 1a7b8789eb1..88f152b87f3 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 @@ -227,7 +227,9 @@ private fun ViewNodeTraversalData.updateForMissingUsernameFields(): ViewNodeTrav this.autofillViews.none { it is AutofillView.Login.Username } ) { this.copyAndMapAutofillViews { index, autofillView -> - if (autofillView is AutofillView.Unused && passwordPositions.contains(index + 1)) { + if ((autofillView is AutofillView.Unused || autofillView is AutofillView.Login.Custom) && + passwordPositions.contains(index + 1) + ) { AutofillView.Login.Username(data = autofillView.data) } else { autofillView @@ -336,5 +338,6 @@ private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillVie is AutofillView.Login.Password -> this.copy(data = this.data.copy(website = site)) is AutofillView.Login.Username -> this.copy(data = this.data.copy(website = site)) is AutofillView.Unused -> this.copy(data = this.data.copy(website = site)) + is AutofillView.Login.Custom -> this.copy(data = this.data.copy(website = site)) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt index 03138c2a4f8..1e33949de4b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/provider/AutofillCipherProviderImpl.kt @@ -7,6 +7,7 @@ import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.autofill.model.AutofillCipher +import com.x8bit.bitwarden.data.autofill.model.AutofillField import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull @@ -128,6 +129,17 @@ class AutofillCipherProviderImpl( subtitle = cipherView.subtitle.orEmpty(), username = cipherView.login?.username.orEmpty(), website = uri, + customFields = cipherView.fields + .orEmpty() + .filter { it.type != com.bitwarden.vault.FieldType.BOOLEAN } + .map { field -> + AutofillField( + name = field.name.orEmpty(), + value = field.value.orEmpty(), + type = field.type, + ) + }, + isStrictMatch = cipherView.login?.uris?.any { it.uri == uri } == true, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensions.kt index 8271643d9a1..38f6983b359 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/AutofillViewExtensions.kt @@ -92,6 +92,7 @@ private fun AutofillView.buildListAutofillValueOrNull( is AutofillView.Card.SecurityCode, is AutofillView.Login.Password, is AutofillView.Login.Username, + is AutofillView.Login.Custom, is AutofillView.Unused, -> { this diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt index d0d7b3d9a99..cfef70fcea2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/autofill/util/CipherViewExtensions.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.autofill.util import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.autofill.model.AutofillCipher +import com.x8bit.bitwarden.data.autofill.model.AutofillField import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider import com.x8bit.bitwarden.data.platform.util.subtitle @@ -42,6 +43,14 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider = subtitle = subtitle.orEmpty(), username = login.username.orEmpty(), website = uri, + customFields = this@toAutofillCipherProvider.fields.orEmpty().map { field -> + AutofillField( + name = field.name.orEmpty(), + value = field.value.orEmpty(), + type = field.type, + ) + }, + isStrictMatch = login.uris?.any { it.uri == uri } == true, ), ) } 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..0e791b27977 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 @@ -32,6 +32,8 @@ private val SUPPORTED_VIEW_HINTS: List = listOf( */ private val AssistStructure.ViewNode.isInputField: Boolean get() { + if (!isEnabled || !isFocusable) return false + val isEditText = className ?.let { try { @@ -41,7 +43,11 @@ private val AssistStructure.ViewNode.isInputField: Boolean } } ?.let { EditText::class.java.isAssignableFrom(it) } == true - return isEditText || htmlInfo.isInputField + + // Ensure it's not a null input type (0) + val hasValidInputType = inputType != 0 + + return (isEditText || htmlInfo.isInputField) && hasValidInputType } /** @@ -53,7 +59,8 @@ fun AssistStructure.ViewNode.toAutofillView( parentWebsite: String?, ): AutofillView? { val nonNullAutofillId = this.autofillId ?: return null - if (this.supportedAutofillHint == null && !this.isInputField) return null + val isInput = this.isInputField + if (this.supportedAutofillHint == null && !isInput) return null val autofillOptions = this .autofillOptions .orEmpty() @@ -66,11 +73,22 @@ fun AssistStructure.ViewNode.toAutofillView( textValue = this.autofillValue?.extractTextValue(), hasPasswordTerms = this.hasPasswordTerms(), website = this.website ?: parentWebsite, + idEntry = this.idEntry, + hint = this.hint, ) + + val supportedHint = this.supportedAutofillHint + if (supportedHint == null && isInput) { + return AutofillView.Login.Custom( + data = autofillViewData, + inputType = this.inputType, + ) + } + return buildAutofillView( autofillOptions = autofillOptions, autofillViewData = autofillViewData, - autofillHint = this.supportedAutofillHint, + autofillHint = supportedHint, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderCustomFieldTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderCustomFieldTest.kt new file mode 100644 index 00000000000..5c6d8b13bbd --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/builder/FilledDataBuilderCustomFieldTest.kt @@ -0,0 +1,195 @@ +package com.x8bit.bitwarden.data.autofill.builder + +import com.bitwarden.vault.FieldType + +import android.view.autofill.AutofillId +import android.view.autofill.AutofillValue +import com.x8bit.bitwarden.data.autofill.model.AutofillCipher +import com.x8bit.bitwarden.data.autofill.model.AutofillField +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.FilledData +import com.x8bit.bitwarden.data.autofill.model.FilledItem +import com.x8bit.bitwarden.data.autofill.model.FilledPartition +import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider +import com.x8bit.bitwarden.data.autofill.util.buildFilledItemOrNull +import com.x8bit.bitwarden.data.autofill.util.buildUri +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class FilledDataBuilderCustomFieldTest { + private lateinit var filledDataBuilder: FilledDataBuilder + + private val autofillCipherProvider: AutofillCipherProvider = mockk { + coEvery { isVaultLocked() } returns false + } + + @BeforeEach + fun setup() { + mockkStatic(AutofillValue::forText) + mockkStatic(AutofillView::buildFilledItemOrNull) + mockkStatic("com.x8bit.bitwarden.data.autofill.util.AutofillViewExtensionsKt") + filledDataBuilder = FilledDataBuilderImpl( + autofillCipherProvider = autofillCipherProvider, + ) + } + + @AfterEach + fun teardown() { + unmockkStatic(AutofillValue::forText) + unmockkStatic(AutofillView::buildFilledItemOrNull) + unmockkStatic("com.x8bit.bitwarden.data.autofill.util.AutofillViewExtensionsKt") + } + + + + @Test + fun `build should fill custom field when strict match is true and hint matches`() = runTest { + val customValue = "CustomValue" + val customFieldName = "My Field" + val autofillCipher = createAutofillCipher( + isStrictMatch = true, + customFields = listOf(AutofillField(customFieldName, customValue, FieldType.TEXT)) + ) + + val filledItemCustom: FilledItem = mockk() + val autofillViewCustom: AutofillView.Login.Custom = mockk { + every { data } returns mockk { + every { website } returns URI + every { hint } returns customFieldName + every { idEntry } returns null + } + every { buildFilledItemOrNull(customValue) } returns filledItemCustom + } + + val result = buildFilledData(autofillCipher, listOf(autofillViewCustom)) + + assertEquals(1, result.filledPartitions.size) + assertEquals(listOf(filledItemCustom), result.filledPartitions[0].filledItems) + } + + @Test + fun `build should fill custom field when strict match is true and idEntry matches`() = runTest { + val customValue = "CustomValue" + val customFieldName = "My Field" + val autofillCipher = createAutofillCipher( + isStrictMatch = true, + customFields = listOf(AutofillField(customFieldName, customValue, FieldType.TEXT)) + ) + + val filledItemCustom: FilledItem = mockk() + val autofillViewCustom: AutofillView.Login.Custom = mockk { + every { data } returns mockk { + every { website } returns URI + every { hint } returns null + every { idEntry } returns "some_prefix_${customFieldName}_suffix" + } + every { buildFilledItemOrNull(customValue) } returns filledItemCustom + } + + val result = buildFilledData(autofillCipher, listOf(autofillViewCustom)) + + assertEquals(1, result.filledPartitions.size) + assertEquals(listOf(filledItemCustom), result.filledPartitions[0].filledItems) + } + + @Test + fun `build should NOT fill custom field when strict match is false`() = runTest { + val customValue = "CustomValue" + val customFieldName = "My Field" + val autofillCipher = createAutofillCipher( + isStrictMatch = false, + customFields = listOf(AutofillField(customFieldName, customValue, FieldType.TEXT)) + ) + + val autofillViewCustom: AutofillView.Login.Custom = mockk { + every { data } returns mockk { + every { website } returns URI + every { hint } returns customFieldName + every { idEntry } returns null + } + // Should not be called with value, or called with null? + // In implementation: autofillView.buildFilledItemOrNull(value = null) -> returns null + // buildFilledItemOrNull should not be called + } + + val result = buildFilledData(autofillCipher, listOf(autofillViewCustom)) + + assertEquals(0, result.filledPartitions.size) + } + + @Test + fun `build should NOT fill custom field when name does not match`() = runTest { + val customValue = "CustomValue" + val customFieldName = "My Field" + val autofillCipher = createAutofillCipher( + isStrictMatch = true, + customFields = listOf(AutofillField(customFieldName, customValue, FieldType.TEXT)) + ) + + val autofillViewCustom: AutofillView.Login.Custom = mockk { + every { data } returns mockk { + every { website } returns URI + every { hint } returns "Other Field" + every { idEntry } returns "other_id" + } + // buildFilledItemOrNull should not be called + } + + val result = buildFilledData(autofillCipher, listOf(autofillViewCustom)) + + assertEquals(0, result.filledPartitions.size) + } + + private fun createAutofillCipher( + isStrictMatch: Boolean, + customFields: List + ): AutofillCipher.Login { + return AutofillCipher.Login( + cipherId = null, + name = "Cipher One", + isTotpEnabled = false, + password = "password", + username = "username", + subtitle = "Subtitle", + website = URI, + isStrictMatch = isStrictMatch, + customFields = customFields + ) + } + + private suspend fun buildFilledData( + autofillCipher: AutofillCipher.Login, + views: List + ): FilledData { + val autofillPartition = AutofillPartition.Login(views = views) + val autofillRequest = AutofillRequest.Fillable( + ignoreAutofillIds = emptyList(), + inlinePresentationSpecs = emptyList(), + maxInlineSuggestionsCount = 0, + packageName = null, + partition = autofillPartition, + uri = URI, + ) + + coEvery { + autofillCipherProvider.getLoginAutofillCiphers(uri = URI) + } returns listOf(autofillCipher) + + return filledDataBuilder.build(autofillRequest) + } + + companion object { + private const val URI: String = "androidapp://com.x8bit.bitwarden" + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserRegressionTests.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserRegressionTests.kt new file mode 100644 index 00000000000..9b25cb1eb10 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/parser/AutofillParserRegressionTests.kt @@ -0,0 +1,178 @@ +package com.x8bit.bitwarden.data.autofill.parser + +import android.app.assist.AssistStructure +import android.service.autofill.FillContext +import android.service.autofill.FillRequest +import android.view.View +import android.view.autofill.AutofillId +import android.widget.inline.InlinePresentationSpec +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.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.repository.SettingsRepository +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 + +class AutofillParserRegressionTests { + private lateinit var parser: AutofillParser + + private val autofillAppInfo: AutofillAppInfo = mockk() + private val assistStructure: AssistStructure = mockk() + private val fillContext: FillContext = mockk { + every { structure } returns assistStructure + } + private val fillRequest: FillRequest = mockk { + every { id } returns 55 + every { fillContexts } returns listOf(fillContext) + } + private val settingsRepository: SettingsRepository = mockk { + every { isInlineAutofillEnabled } returns true + every { blockedAutofillUris } returns emptyList() + } + + private val packageName = "com.example.app" + private val uri = "https://example.com" + private val inlinePresentationSpecs: List = mockk() + + @BeforeEach + fun setup() { + mockkStatic(AssistStructure.ViewNode::toAutofillView) + mockkStatic(AssistStructure.ViewNode::website) + mockkStatic( + FillRequest::getMaxInlineSuggestionsCount, + FillRequest::getInlinePresentationSpecs, + AutofillView::buildUriOrNull, + List::buildPackageNameOrNull, + ) + + every { + fillRequest.getInlinePresentationSpecs(any(), any()) + } returns inlinePresentationSpecs + every { + fillRequest.getMaxInlineSuggestionsCount(any(), any()) + } returns 5 + every { + any>().buildPackageNameOrNull(assistStructure) + } returns packageName + every { any().buildUriOrNull(packageName) } returns uri + + parser = AutofillParserImpl(settingsRepository) + } + + @AfterEach + fun teardown() { + unmockkStatic(AssistStructure.ViewNode::toAutofillView) + unmockkStatic(AssistStructure.ViewNode::website) + unmockkStatic( + FillRequest::getMaxInlineSuggestionsCount, + FillRequest::getInlinePresentationSpecs, + AutofillView::buildUriOrNull, + List::buildPackageNameOrNull, + ) + } + + @Test + fun `parse should promote Custom view to Username when above Password view`() { + // Setup scenarios: + // 1. A generic input (parsed as Custom) + // 2. A password input (parsed as Password) + // 3. The generic input is directly above the password input in the view hierarchy. + + val customId = mockk() + val passwordId = mockk() + + // Mock Custom View (Generic Input) + val customViewData = AutofillView.Data( + autofillId = customId, + autofillOptions = emptyList(), + autofillType = View.AUTOFILL_TYPE_TEXT, + isFocused = true, + textValue = null, + hasPasswordTerms = false, + website = uri, + hint = "Employee Number", // Generic Hint + idEntry = "employee_id" + ) + val customAutofillView = AutofillView.Login.Custom( + data = customViewData, + inputType = 1 + ) + + // Mock Password View + val passwordViewData = AutofillView.Data( + autofillId = passwordId, + autofillOptions = emptyList(), + autofillType = View.AUTOFILL_TYPE_TEXT, + isFocused = false, + textValue = null, + hasPasswordTerms = true, + website = uri, + hint = "Password" + ) + val passwordAutofillView = AutofillView.Login.Password( + data = passwordViewData + ) + + // Mock ViewNodes with relaxed=true to avoid missing answer exceptions + val customNode = mockk(relaxed = true) { + every { toAutofillView(any()) } returns customAutofillView + every { website } returns uri + every { idPackage } returns packageName + every { autofillId } returns customId + } + val passwordNode = mockk(relaxed = true) { + every { toAutofillView(any()) } returns passwordAutofillView + every { website } returns uri + every { idPackage } returns packageName + every { autofillId } returns passwordId + } + + // Mock Root Node containing children + val rootNode = mockk(relaxed = true) { + every { childCount } returns 2 + every { getChildAt(0) } returns customNode + every { getChildAt(1) } returns passwordNode + every { toAutofillView(any()) } returns null // Root itself is not a view + every { autofillId } returns null + every { website } returns uri + every { idPackage } returns packageName + } + + val windowNode = mockk { + every { rootViewNode } returns rootNode + } + + every { assistStructure.windowNodeCount } returns 1 + every { assistStructure.getWindowNodeAt(0) } returns windowNode + + // Execute + val result = parser.parse(autofillAppInfo, fillRequest) + + // Verify + assertTrue(result is AutofillRequest.Fillable, "Result should be Fillable") + val fillable = result as AutofillRequest.Fillable + assertTrue(fillable.partition is AutofillPartition.Login, "Partition should be Login") + val loginPartition = fillable.partition as AutofillPartition.Login + + // Assert that the first view in the partition is now a Username view, not Custom + val firstView = loginPartition.views[0] + assertTrue(firstView is AutofillView.Login.Username, "Expected Custom view to be promoted to Username. Found: ${firstView::class.java.simpleName}") + + assertEquals(customId, firstView.data.autofillId, "Username ID should match the original Custom view ID") + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/processor/AutofillCipherProviderTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/processor/AutofillCipherProviderTest.kt index 2bf663e7e02..2a1fea0e8e3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/processor/AutofillCipherProviderTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/autofill/processor/AutofillCipherProviderTest.kt @@ -104,6 +104,7 @@ class AutofillCipherProviderTest { every { password } returns LOGIN_PASSWORD every { username } returns LOGIN_USERNAME every { totp } returns null + every { uris } returns listOf(com.bitwarden.vault.LoginUriView(uri = URI, match = null, uriChecksum = null)) } private val loginCipherViewWithoutTotp: CipherView = mockk { every { deletedDate } returns null @@ -112,11 +113,13 @@ class AutofillCipherProviderTest { every { name } returns LOGIN_NAME every { reprompt } returns CipherRepromptType.NONE every { type } returns CipherType.LOGIN + every { fields } returns null } private val loginViewWithTotp: LoginView = mockk { every { password } returns LOGIN_PASSWORD every { username } returns LOGIN_USERNAME every { totp } returns "TOTP-CODE" + every { uris } returns listOf(com.bitwarden.vault.LoginUriView(uri = URI, match = null, uriChecksum = null)) } private val loginCipherViewWithTotp: CipherView = mockk { every { deletedDate } returns null @@ -125,6 +128,7 @@ class AutofillCipherProviderTest { every { name } returns LOGIN_NAME every { reprompt } returns CipherRepromptType.NONE every { type } returns CipherType.LOGIN + every { fields } returns null } private val authRepository: AuthRepository = mockk { every { activeUserId } returns ACTIVE_USER_ID @@ -572,6 +576,56 @@ class AutofillCipherProviderTest { Timber.Forest.e("Cipher not found for autofill.") } } + + @Test + fun `getLoginAutofillCiphers should exclude Boolean fields`() = runTest { + val booleanField = com.bitwarden.vault.FieldView( + name = "Boolean Field", + value = "true", + type = com.bitwarden.vault.FieldType.BOOLEAN, + linkedId = null + ) + val textField = com.bitwarden.vault.FieldView( + name = "Text Field", + value = "Text Value", + type = com.bitwarden.vault.FieldType.TEXT, + linkedId = null + ) + + val cipherViewWithMixedFields: CipherView = mockk { + every { deletedDate } returns null + every { id } returns "mixed_fields_id" + every { login } returns loginViewWithoutTotp + every { name } returns "Mixed Fields Login" + every { reprompt } returns CipherRepromptType.NONE + every { type } returns CipherType.LOGIN + every { fields } returns listOf(booleanField, textField) + every { subtitle } returns "Subtitle" + } + + val cipherListView: CipherListView = mockk { + every { deletedDate } returns null + every { id } returns "mixed_fields_id" + every { login } returns loginListViewWithoutTotp + every { name } returns "Mixed Fields Login" + every { reprompt } returns CipherRepromptType.NONE + every { type } returns CipherListViewType.Login(v1 = loginListViewWithoutTotp) + } + + coEvery { vaultRepository.getCipher("mixed_fields_id") } returns GetCipherResult.Success(cipherViewWithMixedFields) + coEvery { cipherMatchingManager.filterCiphersForMatches(any(), URI) } returns listOf(cipherListView) + mutableCipherListViewsWithFailuresStateFlow.value = DataState.Loaded( + data = DecryptCipherListResult(successes = listOf(cipherListView), failures = emptyList()) + ) + mutableVaultStateFlow.value = listOf(VaultUnlockData(userId = ACTIVE_USER_ID, status = VaultUnlockData.Status.UNLOCKED)) + + val result = autofillCipherProvider.getLoginAutofillCiphers(URI) + + assertEquals(1, result.size) + // Should only contain the Text field, boolean field should be filtered out + assertEquals(1, result[0].customFields.size) + assertEquals("Text Field", result[0].customFields[0].name) + } } private const val ACTIVE_USER_ID = "activeUserId" @@ -613,6 +667,7 @@ private val LOGIN_AUTOFILL_CIPHER_WITH_TOTP = AutofillCipher.Login( subtitle = LOGIN_SUBTITLE, username = LOGIN_USERNAME, website = URI, + isStrictMatch = true, ) private val LOGIN_AUTOFILL_CIPHER_WITHOUT_TOTP = AutofillCipher.Login( cipherId = LOGIN_WITHOUT_TOTP_CIPHER_ID, @@ -622,4 +677,5 @@ private val LOGIN_AUTOFILL_CIPHER_WITHOUT_TOTP = AutofillCipher.Login( subtitle = LOGIN_SUBTITLE, username = LOGIN_USERNAME, website = URI, + isStrictMatch = true, )