Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,19 @@ sealed class AutofillCipher {
val password: String,
val username: String,
val website: String,
val customFields: List<AutofillField> = 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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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].
*/
Expand Down Expand Up @@ -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()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,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
Expand Down Expand Up @@ -343,5 +345,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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -132,6 +133,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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ private val SUPPORTED_VIEW_HINTS: List<String> = listOf(
*/
private val AssistStructure.ViewNode.isInputField: Boolean
get() {
if (!isEnabled || !isFocusable) return false

val isEditText = className
?.let {
try {
Expand All @@ -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
}

/**
Expand All @@ -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()
Expand All @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<AutofillField>
): 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<AutofillView.Login>
): 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"
}
}
Loading
Loading