Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
Expand Down Expand Up @@ -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))
}
}
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 @@ -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,
)
}
}
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