mirror of
https://github.com/bitwarden/android.git
synced 2026-08-02 02:32:04 -05:00
[PM-39564] feat: Fill Assist support email field key for email-based login forms (#7107)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: aj-rosado <aj-rosado@users.noreply.github.com>
This commit is contained in:
co-authored by
claude[bot] <41898282+claude[bot]@users.noreply.github.com>
aj-rosado
parent
bd9c761823
commit
0a57b9bcb5
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.autofill.builder
|
||||
|
||||
import android.widget.inline.InlinePresentationSpec
|
||||
import com.bitwarden.ui.platform.base.util.isValidEmail
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
|
||||
@@ -152,6 +153,13 @@ class FilledDataBuilderImpl(
|
||||
buildUri(packageName.orEmpty(), "androidapp") == autofillCipher.website
|
||||
) {
|
||||
val value = when (autofillView) {
|
||||
is AutofillView.Login.Email -> {
|
||||
if (!autofillCipher.username.isValidEmail()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
autofillCipher.username
|
||||
}
|
||||
|
||||
is AutofillView.Login.Username -> autofillCipher.username
|
||||
is AutofillView.Login.Password -> autofillCipher.password
|
||||
}
|
||||
|
||||
@@ -118,6 +118,14 @@ sealed class AutofillView {
|
||||
data class Username(
|
||||
override val data: Data,
|
||||
) : Login()
|
||||
|
||||
/**
|
||||
* The email [AutofillView] for the [Login] data partition. Filled only when the cipher's
|
||||
* username is a valid email address.
|
||||
*/
|
||||
data class Email(
|
||||
override val data: Data,
|
||||
) : Login()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -445,6 +445,7 @@ private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillVie
|
||||
is AutofillView.Card.ExpirationYear -> this.copy(data = this.data.copy(website = site))
|
||||
is AutofillView.Card.Number -> this.copy(data = this.data.copy(website = site))
|
||||
is AutofillView.Card.SecurityCode -> this.copy(data = this.data.copy(website = site))
|
||||
is AutofillView.Login.Email -> this.copy(data = this.data.copy(website = site))
|
||||
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))
|
||||
|
||||
+3
-1
@@ -67,7 +67,9 @@ val AutofillPartition.Login.passwordSaveValue: String?
|
||||
*/
|
||||
val AutofillPartition.Login.usernameSaveValue: String?
|
||||
get() = this
|
||||
.extractNonNullTextValueOrNull { it is AutofillView.Login.Username }
|
||||
.extractNonNullTextValueOrNull {
|
||||
it is AutofillView.Login.Username || it is AutofillView.Login.Email
|
||||
}
|
||||
|
||||
/**
|
||||
* Search [AutofillPartition.views] for an [AutofillView] that matches [condition] and has a
|
||||
|
||||
@@ -90,6 +90,7 @@ private fun AutofillView.buildListAutofillValueOrNull(
|
||||
is AutofillView.Card.ExpirationDate,
|
||||
is AutofillView.Card.Number,
|
||||
is AutofillView.Card.SecurityCode,
|
||||
is AutofillView.Login.Email,
|
||||
is AutofillView.Login.Password,
|
||||
is AutofillView.Login.Username,
|
||||
is AutofillView.Unused,
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillView
|
||||
import com.x8bit.bitwarden.data.autofill.model.FillAssistRules
|
||||
|
||||
private const val FIELD_KEY_USERNAME = "username"
|
||||
private const val FIELD_KEY_EMAIL = "email"
|
||||
private const val FIELD_KEY_PASSWORD = "password"
|
||||
private const val FIELD_KEY_NEW_PASSWORD = "newPassword"
|
||||
private const val FIELD_KEY_CARD_NUMBER = "cardNumber"
|
||||
@@ -64,6 +65,7 @@ private fun AssistStructure.ViewNode.traverseForFillAssist(
|
||||
|
||||
private fun String.toAutofillViewForFieldKey(data: AutofillView.Data): AutofillView? = when (this) {
|
||||
FIELD_KEY_USERNAME -> AutofillView.Login.Username(data = data)
|
||||
FIELD_KEY_EMAIL -> AutofillView.Login.Email(data = data)
|
||||
FIELD_KEY_PASSWORD, FIELD_KEY_NEW_PASSWORD -> AutofillView.Login.Password(data = data)
|
||||
FIELD_KEY_CARD_NUMBER -> AutofillView.Card.Number(data = data)
|
||||
FIELD_KEY_CARDHOLDER_NAME -> AutofillView.Card.CardholderName(data = data)
|
||||
|
||||
+102
@@ -136,6 +136,108 @@ class FilledDataBuilderTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build should fill email field when cipher username is a valid email address`() =
|
||||
runTest {
|
||||
// Setup
|
||||
val emailUsername = "user@example.com"
|
||||
val autofillCipher = AutofillCipher.Login(
|
||||
cipherId = null,
|
||||
name = "Cipher One",
|
||||
isTotpEnabled = false,
|
||||
password = "Password",
|
||||
username = emailUsername,
|
||||
subtitle = "Subtitle",
|
||||
website = URI,
|
||||
)
|
||||
val filledItemEmail: FilledItem = mockk()
|
||||
val autofillViewEmail: AutofillView.Login.Email = mockk {
|
||||
every { data } returns mockk { every { website } returns URI }
|
||||
every { buildFilledItemOrNull(emailUsername) } returns filledItemEmail
|
||||
}
|
||||
val autofillPartition = AutofillPartition.Login(views = listOf(autofillViewEmail))
|
||||
val ignoreAutofillIds: List<AutofillId> = mockk()
|
||||
val autofillRequest = AutofillRequest.Fillable(
|
||||
ignoreAutofillIds = ignoreAutofillIds,
|
||||
inlinePresentationSpecs = emptyList(),
|
||||
maxInlineSuggestionsCount = 0,
|
||||
packageName = null,
|
||||
partition = autofillPartition,
|
||||
uri = URI,
|
||||
)
|
||||
val expected = FilledData(
|
||||
filledPartitions = listOf(
|
||||
FilledPartition(
|
||||
autofillCipher = autofillCipher,
|
||||
filledItems = listOf(filledItemEmail),
|
||||
inlinePresentationSpec = null,
|
||||
),
|
||||
),
|
||||
ignoreAutofillIds = ignoreAutofillIds,
|
||||
originalPartition = autofillPartition,
|
||||
uri = URI,
|
||||
vaultItemInlinePresentationSpec = null,
|
||||
isVaultLocked = false,
|
||||
)
|
||||
coEvery {
|
||||
autofillCipherProvider.getLoginAutofillCiphers(uri = URI)
|
||||
} returns listOf(autofillCipher)
|
||||
|
||||
// Test
|
||||
val actual = filledDataBuilder.build(autofillRequest = autofillRequest)
|
||||
|
||||
// Verify
|
||||
assertEquals(expected, actual)
|
||||
verify(exactly = 1) { autofillViewEmail.buildFilledItemOrNull(emailUsername) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build should not fill email field when cipher username is not a valid email address`() =
|
||||
runTest {
|
||||
// Setup
|
||||
val nonEmailUsername = "johndoe"
|
||||
val autofillCipher = AutofillCipher.Login(
|
||||
cipherId = null,
|
||||
name = "Cipher One",
|
||||
isTotpEnabled = false,
|
||||
password = "Password",
|
||||
username = nonEmailUsername,
|
||||
subtitle = "Subtitle",
|
||||
website = URI,
|
||||
)
|
||||
val autofillViewEmail: AutofillView.Login.Email = mockk {
|
||||
every { data } returns mockk { every { website } returns URI }
|
||||
}
|
||||
val autofillPartition = AutofillPartition.Login(views = listOf(autofillViewEmail))
|
||||
val ignoreAutofillIds: List<AutofillId> = mockk()
|
||||
val autofillRequest = AutofillRequest.Fillable(
|
||||
ignoreAutofillIds = ignoreAutofillIds,
|
||||
inlinePresentationSpecs = emptyList(),
|
||||
maxInlineSuggestionsCount = 0,
|
||||
packageName = null,
|
||||
partition = autofillPartition,
|
||||
uri = URI,
|
||||
)
|
||||
|
||||
val expected = FilledData(
|
||||
filledPartitions = emptyList(),
|
||||
ignoreAutofillIds = ignoreAutofillIds,
|
||||
originalPartition = autofillPartition,
|
||||
uri = URI,
|
||||
vaultItemInlinePresentationSpec = null,
|
||||
isVaultLocked = false,
|
||||
)
|
||||
coEvery { autofillCipherProvider.getLoginAutofillCiphers(uri = URI)
|
||||
} returns listOf(autofillCipher)
|
||||
|
||||
// Test
|
||||
val actual = filledDataBuilder.build(autofillRequest = autofillRequest)
|
||||
|
||||
// Verify
|
||||
assertEquals(expected, actual)
|
||||
verify(exactly = 0) { autofillViewEmail.buildFilledItemOrNull(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build should return no partitions and ignored AutofillIds when Login and no URI`() =
|
||||
runTest {
|
||||
|
||||
+36
@@ -482,6 +482,42 @@ class AutofillPartitionExtensionsTest {
|
||||
// Verify
|
||||
assertEquals(TEXT_VALUE, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `usernameSaveValue should return text value when has email view with textValue`() {
|
||||
// Setup
|
||||
val autofillPartition = AutofillPartition.Login(
|
||||
views = listOf(
|
||||
AutofillView.Login.Email(
|
||||
data = autofillDataValidText,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Test
|
||||
val actual = autofillPartition.usernameSaveValue
|
||||
|
||||
// Verify
|
||||
assertEquals(TEXT_VALUE, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `usernameSaveValue should return null when has email view but no textValue`() {
|
||||
// Setup
|
||||
val autofillPartition = AutofillPartition.Login(
|
||||
views = listOf(
|
||||
AutofillView.Login.Email(
|
||||
data = autofillDataEmptyText,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Test
|
||||
val actual = autofillPartition.usernameSaveValue
|
||||
|
||||
// Verify
|
||||
assertNull(actual)
|
||||
}
|
||||
//endregion Login tests
|
||||
}
|
||||
|
||||
|
||||
+32
-5
@@ -77,6 +77,34 @@ class FillAssistViewNodeExtensionsTest {
|
||||
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildFillAssistViews should return Login Email when htmlInfo matches email 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(
|
||||
"email" to listOf(selectorClause(tag = "input", id = "email")),
|
||||
),
|
||||
)
|
||||
|
||||
val actual = assistStructure.buildFillAssistViews(
|
||||
hostRules = listOf(hostRule),
|
||||
urlBarWebsite = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf(AutofillView.Login.Email(data = data)), actual)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `buildFillAssistViews should return Login Password when htmlInfo matches password clause`() {
|
||||
@@ -199,10 +227,9 @@ class FillAssistViewNodeExtensionsTest {
|
||||
@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.
|
||||
// Two field keys ("email" 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 listed first, it wins and produces a Login.Email view.
|
||||
val htmlInfo = createHtmlInfo()
|
||||
val viewNode = createViewNode(htmlInfo = htmlInfo)
|
||||
val assistStructure = createAssistStructure(viewNode)
|
||||
@@ -222,7 +249,7 @@ class FillAssistViewNodeExtensionsTest {
|
||||
urlBarWebsite = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf(AutofillView.Login.Username(data = data)), actual)
|
||||
assertEquals(listOf(AutofillView.Login.Email(data = data)), actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user