[PM-41291] feat: Add model and data layer for Identity Autofill (#7232)

This commit is contained in:
aj-rosado authored and GitHub committed 2026-08-26 11:44:54 +00:00
1 parent 36bd55adf1
commit da2ea370dd
25 files changed
+956 -23

No files matched your search

@@ -91,6 +91,12 @@ class FilledDataBuilderImpl(
}
.orEmpty()
}
is AutofillPartition.Identity -> {
// Filling an identity partition is wired up in a later phase; this is a no-op
// today since nothing yet classifies a view as Identity.
emptyList()
}
}
// Use getOrLastOrNull so if the list has run dry take the last spec.
@@ -71,4 +71,43 @@ sealed class AutofillCipher {
override val iconRes: Int
@DrawableRes get() = BitwardenDrawable.ic_globe
}
/**
* The identity [AutofillCipher] model. This contains all the data for building
* an identity partition.
*
* @param fullName The identity's name parts joined for filling a combined full-name field.
* @param fullAddress The identity's address parts joined for filling a combined full-address
* field.
*/
data class Identity(
override val cipherId: String?,
override val name: String,
override val subtitle: String,
val fullName: String,
val fullAddress: String,
val title: String,
val firstName: String,
val middleName: String,
val lastName: String,
val address1: String,
val address2: String,
val address3: String,
val city: String,
val state: String,
val postalCode: String,
val country: String,
val company: String,
val email: String,
val phone: String,
val ssn: String,
val passportNumber: String,
val licenseNumber: String,
) : AutofillCipher() {
override val iconRes: Int
@DrawableRes get() = BitwardenDrawable.ic_id_card
override val isTotpEnabled: Boolean
get() = false
}
}
@@ -13,4 +13,21 @@ enum class AutofillHint {
CARD_BRAND,
PASSWORD,
USERNAME,
IDENTITY_PERSON_NAME_FULL,
IDENTITY_PERSON_NAME_PREFIX,
IDENTITY_PERSON_NAME_GIVEN,
IDENTITY_PERSON_NAME_MIDDLE,
IDENTITY_PERSON_NAME_FAMILY,
IDENTITY_POSTAL_ADDRESS_FULL,
IDENTITY_ADDRESS_STREET,
IDENTITY_ADDRESS_LOCALITY,
IDENTITY_ADDRESS_REGION,
IDENTITY_ADDRESS_COUNTRY,
IDENTITY_POSTAL_CODE,
IDENTITY_PHONE_FULL,
IDENTITY_COMPANY,
IDENTITY_EMAIL,
IDENTITY_SSN,
IDENTITY_PASSPORT_NUMBER,
IDENTITY_LICENSE_NUMBER,
}
@@ -10,7 +10,7 @@ sealed class AutofillPartition {
/**
* [AutofillId]s that are optional for save requests. For example, with cards we require a
* phone number too trigger the save request, other card data is optional.
* phone number to trigger the save request, other card data is optional.
*/
abstract val optionalSaveIds: List<AutofillId>
@@ -72,4 +72,20 @@ sealed class AutofillPartition {
override val saveType: Int
get() = SaveInfo.SAVE_DATA_TYPE_PASSWORD
}
/**
* The identity [AutofillPartition] data. Save requests are never offered for this partition
* (there are no [requiredSaveIds]) since capturing new identity data from a filled form is out
* of scope.
*/
data class Identity(
override val views: List<AutofillView.Identity>,
) : AutofillPartition() {
override val optionalSaveIds: List<AutofillId>
get() = emptyList()
override val requiredSaveIds: List<AutofillId>
get() = emptyList()
override val saveType: Int
get() = SaveInfo.SAVE_DATA_TYPE_GENERIC
}
}
@@ -128,6 +128,135 @@ sealed class AutofillView {
) : Login()
}
/**
* A view that corresponds to the identity data partition for autofill fields.
*/
sealed class Identity : AutofillView() {
/**
* The full name [AutofillView] for the [Identity] data partition, used for forms with a
* single combined name field.
*/
data class PersonNameFull(
override val data: Data,
) : Identity()
/**
* The name prefix (e.g. "Mr.", "Dr.") [AutofillView] for the [Identity] data partition.
*/
data class PersonNamePrefix(
override val data: Data,
) : Identity()
/**
* The given (first) name [AutofillView] for the [Identity] data partition.
*/
data class PersonNameGiven(
override val data: Data,
) : Identity()
/**
* The middle name [AutofillView] for the [Identity] data partition.
*/
data class PersonNameMiddle(
override val data: Data,
) : Identity()
/**
* The family (last) name [AutofillView] for the [Identity] data partition.
*/
data class PersonNameFamily(
override val data: Data,
) : Identity()
/**
* The full postal address [AutofillView] for the [Identity] data partition, used for forms
* with a single combined address field.
*/
data class PostalAddressFull(
override val data: Data,
) : Identity()
/**
* The street address [AutofillView] for the [Identity] data partition.
*/
data class AddressStreet(
override val data: Data,
) : Identity()
/**
* The locality (city) [AutofillView] for the [Identity] data partition.
*/
data class AddressLocality(
override val data: Data,
) : Identity()
/**
* The region (state/province) [AutofillView] for the [Identity] data partition.
*/
data class AddressRegion(
override val data: Data,
) : Identity()
/**
* The country [AutofillView] for the [Identity] data partition.
*/
data class AddressCountry(
override val data: Data,
) : Identity()
/**
* The postal code [AutofillView] for the [Identity] data partition.
*/
data class PostalCode(
override val data: Data,
) : Identity()
/**
* The full phone number [AutofillView] for the [Identity] data partition.
*/
data class PhoneFull(
override val data: Data,
) : Identity()
/**
* The company [AutofillView] for the [Identity] data partition.
*/
data class Company(
override val data: Data,
) : Identity()
/**
* The email [AutofillView] for the [Identity] data partition. This is distinct from
* [Login.Email] and may be offered alongside it when a field is ambiguous between the
* login and identity partitions.
*/
data class Email(
override val data: Data,
) : Identity()
/**
* The social security number [AutofillView] for the [Identity] data partition.
*/
data class Ssn(
override val data: Data,
) : Identity()
/**
* The passport number [AutofillView] for the [Identity] data partition.
*/
data class PassportNumber(
override val data: Data,
) : Identity()
/**
* The license number [AutofillView] for the [Identity] data partition.
*/
data class LicenseNumber(
override val data: Data,
) : Identity()
}
/**
* A view that is an input field but does not correspond to any known autofill field.
*/
@@ -186,6 +186,12 @@ class AutofillParserImpl(
)
}
is AutofillView.Identity -> {
// Identity partition construction is wired up in a later phase; treating it as
// unfillable for now is a no-op since nothing yet classifies a view as Identity.
return AutofillRequest.Unfillable
}
is AutofillView.Unused -> {
// This will never happen: the heuristic path filters out Unused views, and the
// fill-assist path never constructs one (toAutofillViewForFieldKey has no Unused
@@ -248,6 +254,9 @@ class AutofillParserImpl(
rule.category in LOGIN_FILL_ASSIST_CATEGORIES ||
rule.category in CARD_FILL_ASSIST_CATEGORIES
}
// Fill-assist category coverage for Identity is added in a later phase; this is a
// no-op today since nothing yet classifies a view as Identity.
is AutofillView.Identity -> false
}
}
if (!coversCurrentPartition) return this
@@ -458,6 +467,7 @@ private fun AssistStructure.ViewNode.traverse(
* This updates the underlying [AutofillView.data] with the given [website] if it does not already
* have a website associated with it.
*/
@Suppress("CyclomaticComplexMethod")
private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillView {
val site = website ?: return this
if (this.data.website != null) return this
@@ -472,6 +482,59 @@ private fun AutofillView.updateWebsiteIfNecessary(website: String?): AutofillVie
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.Identity.AddressCountry -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.AddressLocality -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.AddressRegion -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.AddressStreet -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.Company -> this.copy(data = this.data.copy(website = site))
is AutofillView.Identity.Email -> this.copy(data = this.data.copy(website = site))
is AutofillView.Identity.LicenseNumber -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PassportNumber -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PersonNameFamily -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PersonNameFull -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PersonNameGiven -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PersonNameMiddle -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PersonNamePrefix -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PhoneFull -> this.copy(data = this.data.copy(website = site))
is AutofillView.Identity.PostalAddressFull -> {
this.copy(data = this.data.copy(website = site))
}
is AutofillView.Identity.PostalCode -> this.copy(data = this.data.copy(website = site))
is AutofillView.Identity.Ssn -> this.copy(data = this.data.copy(website = site))
is AutofillView.Unused -> this.copy(data = this.data.copy(website = site))
}
}
@@ -97,12 +97,16 @@ class AutofillProcessorImpl(
when (autofillRequest) {
is AutofillRequest.Fillable -> {
val intentSender = createAutofillSavedItemIntentSender(
autofillAppInfo = autofillAppInfo,
autofillSaveItem = autofillRequest.toAutofillSaveItem(),
)
saveCallback.onSuccess(intentSender)
autofillRequest
.toAutofillSaveItem()
?.let { autofillSaveItem ->
createAutofillSavedItemIntentSender(
autofillAppInfo = autofillAppInfo,
autofillSaveItem = autofillSaveItem,
)
}
?.let { saveCallback.onSuccess(it) }
?: saveCallback.onSuccess()
}
AutofillRequest.Unfillable -> saveCallback.onSuccess()
@@ -23,4 +23,9 @@ interface AutofillCipherProvider {
suspend fun getLoginAutofillCiphers(
uri: String,
): List<AutofillCipher.Login>
/**
* Get all [AutofillCipher.Identity]s for the current user.
*/
suspend fun getIdentityAutofillCiphers(): List<AutofillCipher.Identity>
}
@@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
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
import com.x8bit.bitwarden.data.platform.util.identityAutofillAddress
import com.x8bit.bitwarden.data.platform.util.identityAutofillName
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
@@ -133,6 +135,54 @@ class AutofillCipherProviderImpl(
}
}
override suspend fun getIdentityAutofillCiphers(): List<AutofillCipher.Identity> {
val cipherListViews = getUnlockedCipherListViewsOrNull() ?: return emptyList()
return cipherListViews
.mapNotNull { cipherListView ->
cipherListView
// We only care about non-deleted identity ciphers.
.takeIf {
// Must be identity type.
it.type is CipherListViewType.Identity &&
// Must still be active.
it.isActive &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE
}
?.let { nonNullCipherListView ->
nonNullCipherListView.id?.let { cipherId ->
decryptCipherOrNull(cipherId = cipherId)?.let { cipherView ->
val identityView = cipherView.identity
AutofillCipher.Identity(
cipherId = cipherView.id,
name = cipherView.name,
subtitle = cipherView.subtitle.orEmpty(),
fullName = identityView?.identityAutofillName.orEmpty(),
fullAddress = identityView?.identityAutofillAddress.orEmpty(),
title = identityView?.title.orEmpty(),
firstName = identityView?.firstName.orEmpty(),
middleName = identityView?.middleName.orEmpty(),
lastName = identityView?.lastName.orEmpty(),
address1 = identityView?.address1.orEmpty(),
address2 = identityView?.address2.orEmpty(),
address3 = identityView?.address3.orEmpty(),
city = identityView?.city.orEmpty(),
state = identityView?.state.orEmpty(),
postalCode = identityView?.postalCode.orEmpty(),
country = identityView?.country.orEmpty(),
company = identityView?.company.orEmpty(),
email = identityView?.email.orEmpty(),
phone = identityView?.phone.orEmpty(),
ssn = identityView?.ssn.orEmpty(),
passportNumber = identityView?.passportNumber.orEmpty(),
licenseNumber = identityView?.licenseNumber.orEmpty(),
)
}
}
}
}
}
/**
* Get available [CipherView]s if possible.
*/
@@ -5,9 +5,10 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
/**
* Convert the [AutofillRequest.Fillable] to an [AutofillSaveItem].
* Convert the [AutofillRequest.Fillable] to an [AutofillSaveItem], or null if this partition does
* not support save requests.
*/
fun AutofillRequest.Fillable.toAutofillSaveItem(): AutofillSaveItem =
fun AutofillRequest.Fillable.toAutofillSaveItem(): AutofillSaveItem? =
when (this.partition) {
is AutofillPartition.Card -> {
AutofillSaveItem.Card(
@@ -33,4 +34,11 @@ fun AutofillRequest.Fillable.toAutofillSaveItem(): AutofillSaveItem =
uri = uri,
)
}
is AutofillPartition.Identity -> {
// Capturing identity data from a filled form is out of scope. This is never actually
// reached because AutofillPartition.Identity.canPerformSaveRequest is always false, so
// SaveInfo (and therefore a save callback) is never built for it.
null
}
}
@@ -51,7 +51,7 @@ fun AutofillView.buildFilledItemOrNull(
/**
* Build a list [AutofillValue] out of [value] or return null if not possible.
*/
@Suppress("MagicNumber")
@Suppress("LongMethod", "MagicNumber")
private fun AutofillView.buildListAutofillValueOrNull(
value: String,
): AutofillValue? =
@@ -93,6 +93,23 @@ private fun AutofillView.buildListAutofillValueOrNull(
is AutofillView.Login.Email,
is AutofillView.Login.Password,
is AutofillView.Login.Username,
is AutofillView.Identity.AddressCountry,
is AutofillView.Identity.AddressLocality,
is AutofillView.Identity.AddressRegion,
is AutofillView.Identity.AddressStreet,
is AutofillView.Identity.Company,
is AutofillView.Identity.Email,
is AutofillView.Identity.LicenseNumber,
is AutofillView.Identity.PassportNumber,
is AutofillView.Identity.PersonNameFamily,
is AutofillView.Identity.PersonNameFull,
is AutofillView.Identity.PersonNameGiven,
is AutofillView.Identity.PersonNameMiddle,
is AutofillView.Identity.PersonNamePrefix,
is AutofillView.Identity.PhoneFull,
is AutofillView.Identity.PostalAddressFull,
is AutofillView.Identity.PostalCode,
is AutofillView.Identity.Ssn,
is AutofillView.Unused,
-> {
this
@@ -3,6 +3,8 @@ 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.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.platform.util.identityAutofillAddress
import com.x8bit.bitwarden.data.platform.util.identityAutofillName
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.platform.util.subtitle
@@ -46,6 +48,36 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
),
)
}
override suspend fun getIdentityAutofillCiphers(): List<AutofillCipher.Identity> {
val identityView = this@toAutofillCipherProvider.identity ?: return emptyList()
return listOf(
AutofillCipher.Identity(
cipherId = id,
name = name,
subtitle = subtitle.orEmpty(),
fullName = identityView.identityAutofillName.orEmpty(),
fullAddress = identityView.identityAutofillAddress.orEmpty(),
title = identityView.title.orEmpty(),
firstName = identityView.firstName.orEmpty(),
middleName = identityView.middleName.orEmpty(),
lastName = identityView.lastName.orEmpty(),
address1 = identityView.address1.orEmpty(),
address2 = identityView.address2.orEmpty(),
address3 = identityView.address3.orEmpty(),
city = identityView.city.orEmpty(),
state = identityView.state.orEmpty(),
postalCode = identityView.postalCode.orEmpty(),
country = identityView.country.orEmpty(),
company = identityView.company.orEmpty(),
email = identityView.email.orEmpty(),
phone = identityView.phone.orEmpty(),
ssn = identityView.ssn.orEmpty(),
passportNumber = identityView.passportNumber.orEmpty(),
licenseNumber = identityView.licenseNumber.orEmpty(),
),
)
}
}
/**
@@ -37,6 +37,10 @@ fun FilledData.buildVaultItemDataset(
type = when (this.originalPartition) {
is AutofillPartition.Card -> AutofillSelectionData.Type.CARD
is AutofillPartition.Login -> AutofillSelectionData.Type.LOGIN
// AutofillSelectionData.Type has no IDENTITY value yet (deliberately deferred -- it
// cascades into the vault item listing/add-edit UI flows). This is a no-op placeholder
// today since nothing yet produces an Identity-only partition.
is AutofillPartition.Identity -> AutofillSelectionData.Type.LOGIN
},
uri = this.uri,
)
@@ -252,6 +252,31 @@ private fun AssistStructure.ViewNode.buildAutofillView(
data = autofillViewData,
)
}
// Identity hint detection/dispatch is wired up in a later phase; treating these as Unused for
// now is a no-op since nothing yet produces an IDENTITY_* hint.
AutofillHint.IDENTITY_PERSON_NAME_FULL,
AutofillHint.IDENTITY_PERSON_NAME_PREFIX,
AutofillHint.IDENTITY_PERSON_NAME_GIVEN,
AutofillHint.IDENTITY_PERSON_NAME_MIDDLE,
AutofillHint.IDENTITY_PERSON_NAME_FAMILY,
AutofillHint.IDENTITY_POSTAL_ADDRESS_FULL,
AutofillHint.IDENTITY_ADDRESS_STREET,
AutofillHint.IDENTITY_ADDRESS_LOCALITY,
AutofillHint.IDENTITY_ADDRESS_REGION,
AutofillHint.IDENTITY_ADDRESS_COUNTRY,
AutofillHint.IDENTITY_POSTAL_CODE,
AutofillHint.IDENTITY_PHONE_FULL,
AutofillHint.IDENTITY_COMPANY,
AutofillHint.IDENTITY_EMAIL,
AutofillHint.IDENTITY_SSN,
AutofillHint.IDENTITY_PASSPORT_NUMBER,
AutofillHint.IDENTITY_LICENSE_NUMBER,
-> {
AutofillView.Unused(
data = autofillViewData,
)
}
}
/**
@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.bitwarden.vault.CardView
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.IdentityView
/**
* If someone has multiple AMEX cards, they tend to have the same last 4 digits. So we provide a
@@ -80,3 +82,33 @@ private val CardView.subtitleCardNumber: String?
*/
private val String?.isAmEx: Boolean
get() = this?.startsWith("34") == true || this?.startsWith("37") == true
/**
* The full postal address for an [IdentityView] formatted for filling a combined full-address
* autofill field. Missing parts are omitted entirely rather than padded with a placeholder, and
* parts are joined with a single space -- unlike a display-oriented formatter, this string is
* filled verbatim into a form field, so it can't rely on visual affordances like line breaks,
* commas, or "-" placeholders that only read sensibly in a UI layout.
*/
internal val IdentityView.identityAutofillAddress: String?
get() = listOfNotNull(
address1,
address2,
address3,
city,
state,
postalCode,
country,
)
.joinToString(" ")
.orNullIfBlank()
/**
* The full name for an [IdentityView] formatted for filling a combined full-name autofill field.
* Excludes [IdentityView.title] -- a title prefix (Mr/Mrs/Dr) is its own separate autofill field
* (`AutofillView.Identity.PersonNamePrefix`).
*/
internal val IdentityView.identityAutofillName: String?
get() = listOfNotNull(firstName, middleName, lastName)
.joinToString(" ")
.orNullIfBlank()
@@ -38,4 +38,9 @@ private fun getAutofillSuggestionCipherType(
autofillAppInfo.context,
BitwardenString.type_login,
)
is AutofillCipher.Identity -> ContextCompat.getString(
autofillAppInfo.context,
BitwardenString.type_identity,
)
}
@@ -383,6 +383,22 @@ private val CardView.paymentCardBrandIconRes: Int?
-> null
}
private val CardView.cardBrand: VaultCardBrand?
get() = brand
?.findVaultCardBrandWithNameOrNull()
.takeUnless { it == VaultCardBrand.SELECT }
private val CardView.expiration: String?
get() = listOfNotNull(
expMonth?.padStart(length = 2, padChar = '0'),
expYear,
)
.joinToString("/")
.orNullIfBlank()
/**
* The full postal address for an [IdentityView], joining its address parts.
*/
private val IdentityView.identityAddress: String?
get() = listOfNotNull(
address1,
@@ -396,6 +412,9 @@ private val IdentityView.identityAddress: String?
.joinToString("\n")
.orNullIfBlank()
/**
* The full name for an [IdentityView], joining its name parts.
*/
private val IdentityView.identityName: String?
get() = listOfNotNull(
title
@@ -409,16 +428,3 @@ private val IdentityView.identityName: String?
)
.joinToString(" ")
.orNullIfBlank()
private val CardView.cardBrand: VaultCardBrand?
get() = brand
?.findVaultCardBrandWithNameOrNull()
.takeUnless { it == VaultCardBrand.SELECT }
private val CardView.expiration: String?
get() = listOfNotNull(
expMonth?.padStart(length = 2, padChar = '0'),
expYear,
)
.joinToString("/")
.orNullIfBlank()
@@ -99,6 +99,30 @@ class SaveInfoBuilderTest {
assertNull(actual)
}
@Suppress("MaxLineLength")
@Test
fun `build should return null for an identity partition since it never supports save requests`() {
// Setup
every { settingsRepository.isAutofillSavePromptDisabled } returns false
val autofillPartitionIdentity = AutofillPartition.Identity(
views = listOf(
AutofillView.Identity.Email(
data = autofillViewDataValid,
),
),
)
// Test
val actual = saveInfoBuilder.build(
autofillPartition = autofillPartitionIdentity,
fillRequest = fillRequest,
packageName = PACKAGE_NAME,
)
// Verify
assertNull(actual)
}
@Test
fun `build should return null if autofill enabled and can't perform autofill`() {
// Setup
@@ -10,6 +10,7 @@ import com.bitwarden.vault.CipherRepromptType
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.IdentityView
import com.bitwarden.vault.LoginListView
import com.bitwarden.vault.LoginView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@@ -20,6 +21,8 @@ import com.x8bit.bitwarden.data.autofill.util.card
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.identityAutofillAddress
import com.x8bit.bitwarden.data.platform.util.identityAutofillName
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPolicyView
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
@@ -132,6 +135,42 @@ class AutofillCipherProviderTest {
every { reprompt } returns CipherRepromptType.NONE
every { type } returns CipherType.LOGIN
}
private val identityCipherListView: CipherListView = mockk {
every { archivedDate } returns null
every { deletedDate } returns null
every { id } returns IDENTITY_CIPHER_ID
every { name } returns IDENTITY_NAME
every { reprompt } returns CipherRepromptType.NONE
every { type } returns CipherListViewType.Identity
}
private val identityView: IdentityView = mockk {
every { title } returns IDENTITY_TITLE
every { firstName } returns IDENTITY_FIRST_NAME
every { middleName } returns IDENTITY_MIDDLE_NAME
every { lastName } returns IDENTITY_LAST_NAME
every { address1 } returns IDENTITY_ADDRESS_1
every { address2 } returns IDENTITY_ADDRESS_2
every { address3 } returns IDENTITY_ADDRESS_3
every { city } returns IDENTITY_CITY
every { state } returns IDENTITY_STATE
every { postalCode } returns IDENTITY_POSTAL_CODE
every { country } returns IDENTITY_COUNTRY
every { company } returns IDENTITY_COMPANY
every { email } returns IDENTITY_EMAIL
every { phone } returns IDENTITY_PHONE
every { ssn } returns IDENTITY_SSN
every { passportNumber } returns IDENTITY_PASSPORT_NUMBER
every { licenseNumber } returns IDENTITY_LICENSE_NUMBER
}
private val identityCipherView: CipherView = mockk {
every { identity } returns identityView
every { archivedDate } returns null
every { deletedDate } returns null
every { id } returns IDENTITY_CIPHER_ID
every { name } returns IDENTITY_NAME
every { reprompt } returns CipherRepromptType.NONE
every { type } returns CipherType.IDENTITY
}
private val authRepository: AuthRepository = mockk {
every { activeUserId } returns ACTIVE_USER_ID
}
@@ -536,6 +575,97 @@ class AutofillCipherProviderTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `getIdentityAutofillCiphers when unlocked should return empty list when retrieving ciphers times out`() =
runTest {
coEvery { vaultRepository.isVaultUnlocked(ACTIVE_USER_ID) } returns true
mutableCipherListViewsWithFailuresStateFlow.value = DataState.Loading
// Test
val actual = async {
autofillCipherProvider.getIdentityAutofillCiphers()
}
testScheduler.runCurrent()
assertFalse(actual.isCompleted)
testScheduler.advanceTimeBy(delayTimeMillis = 2_000L)
testScheduler.runCurrent()
// Verify
assertTrue(actual.isCompleted)
assertEquals(emptyList<AutofillCipher.Identity>(), actual.await())
}
@Suppress("MaxLineLength")
@Test
fun `getIdentityAutofillCiphers when unlocked should decrypt then return non-null, non-deleted, and non-reprompt identity ciphers`() =
runTest {
val archivedIdentityCipherView: CipherListView = mockk {
every { archivedDate } returns mockk()
every { deletedDate } returns null
every { type } returns CipherListViewType.Identity
}
val deletedIdentityCipherView: CipherListView = mockk {
every { archivedDate } returns null
every { deletedDate } returns mockk()
every { type } returns CipherListViewType.Identity
}
val repromptIdentityCipherView: CipherListView = mockk {
every { archivedDate } returns null
every { deletedDate } returns null
every { reprompt } returns CipherRepromptType.PASSWORD
every { type } returns CipherListViewType.Identity
}
val decryptCipherListViewsResult = DecryptCipherListResult(
successes = listOf(
identityCipherListView,
archivedIdentityCipherView,
deletedIdentityCipherView,
repromptIdentityCipherView,
loginCipherListViewWithTotp,
loginCipherListViewWithoutTotp,
),
failures = emptyList(),
)
coEvery {
vaultRepository.getCipher(IDENTITY_CIPHER_ID)
} returns GetCipherResult.Success(
cipherView = identityCipherView,
)
mutableCipherListViewsWithFailuresStateFlow.value = DataState.Loaded(
data = decryptCipherListViewsResult,
)
mutableVaultStateFlow.value = listOf(
VaultUnlockData(
userId = ACTIVE_USER_ID,
status = VaultUnlockData.Status.UNLOCKED,
),
)
every { identityCipherView.subtitle } returns IDENTITY_SUBTITLE
every { identityView.identityAutofillName } returns IDENTITY_FULL_NAME
every { identityView.identityAutofillAddress } returns IDENTITY_FULL_ADDRESS
val expected = listOf(IDENTITY_AUTOFILL_CIPHER)
// Test & Verify
val actual = autofillCipherProvider.getIdentityAutofillCiphers()
coVerify(exactly = 1) { vaultRepository.getCipher(IDENTITY_CIPHER_ID) }
assertEquals(expected, actual)
}
@Test
fun `getIdentityAutofillCiphers when locked should return an empty list`() = runTest {
mutableVaultStateFlow.value = emptyList()
// Test & Verify
val actual = autofillCipherProvider.getIdentityAutofillCiphers()
assertEquals(emptyList<AutofillCipher.Identity>(), actual)
}
@Suppress("MaxLineLength")
@Test
fun `getLoginAutofillCiphers when decryption result is CipherNotFound should log the error and omit cipher from results`() =
@@ -644,3 +774,49 @@ private val LOGIN_AUTOFILL_CIPHER_WITHOUT_TOTP = AutofillCipher.Login(
username = LOGIN_USERNAME,
website = URI,
)
private const val IDENTITY_CIPHER_ID = "IDENTITY_CIPHER_ID"
private const val IDENTITY_NAME = "John's Identity"
private const val IDENTITY_TITLE = "IDENTITY_TITLE"
private const val IDENTITY_FIRST_NAME = "IDENTITY_FIRST_NAME"
private const val IDENTITY_MIDDLE_NAME = "IDENTITY_MIDDLE_NAME"
private const val IDENTITY_LAST_NAME = "IDENTITY_LAST_NAME"
private const val IDENTITY_ADDRESS_1 = "IDENTITY_ADDRESS_1"
private const val IDENTITY_ADDRESS_2 = "IDENTITY_ADDRESS_2"
private const val IDENTITY_ADDRESS_3 = "IDENTITY_ADDRESS_3"
private const val IDENTITY_CITY = "IDENTITY_CITY"
private const val IDENTITY_STATE = "IDENTITY_STATE"
private const val IDENTITY_POSTAL_CODE = "IDENTITY_POSTAL_CODE"
private const val IDENTITY_COUNTRY = "IDENTITY_COUNTRY"
private const val IDENTITY_COMPANY = "IDENTITY_COMPANY"
private const val IDENTITY_EMAIL = "IDENTITY_EMAIL"
private const val IDENTITY_PHONE = "IDENTITY_PHONE"
private const val IDENTITY_SSN = "IDENTITY_SSN"
private const val IDENTITY_PASSPORT_NUMBER = "IDENTITY_PASSPORT_NUMBER"
private const val IDENTITY_LICENSE_NUMBER = "IDENTITY_LICENSE_NUMBER"
private const val IDENTITY_SUBTITLE = "IDENTITY_SUBTITLE"
private const val IDENTITY_FULL_NAME = "IDENTITY_FULL_NAME"
private const val IDENTITY_FULL_ADDRESS = "IDENTITY_FULL_ADDRESS"
private val IDENTITY_AUTOFILL_CIPHER = AutofillCipher.Identity(
cipherId = IDENTITY_CIPHER_ID,
name = IDENTITY_NAME,
subtitle = IDENTITY_SUBTITLE,
fullName = IDENTITY_FULL_NAME,
fullAddress = IDENTITY_FULL_ADDRESS,
title = IDENTITY_TITLE,
firstName = IDENTITY_FIRST_NAME,
middleName = IDENTITY_MIDDLE_NAME,
lastName = IDENTITY_LAST_NAME,
address1 = IDENTITY_ADDRESS_1,
address2 = IDENTITY_ADDRESS_2,
address3 = IDENTITY_ADDRESS_3,
city = IDENTITY_CITY,
state = IDENTITY_STATE,
postalCode = IDENTITY_POSTAL_CODE,
country = IDENTITY_COUNTRY,
company = IDENTITY_COMPANY,
email = IDENTITY_EMAIL,
phone = IDENTITY_PHONE,
ssn = IDENTITY_SSN,
passportNumber = IDENTITY_PASSPORT_NUMBER,
licenseNumber = IDENTITY_LICENSE_NUMBER,
)
@@ -418,6 +418,59 @@ class AutofillProcessorTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `processSaveRequest should invoke empty callback when autofill enabled, has fill contexts, and toAutofillSaveItem returns null`() {
// Setup
val saveCallback: SaveCallback = mockk {
every { onSuccess() } just runs
}
val assistStructure: AssistStructure = mockk()
val fillContext: FillContext = mockk {
every { structure } returns assistStructure
}
val saveRequest: SaveRequest = mockk {
every { fillContexts } returns listOf(fillContext)
}
val autofillPartition: AutofillPartition = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk {
every { packageName } returns PACKAGE_NAME
every { partition } returns autofillPartition
every { toAutofillSaveItem() } returns null
}
every { settingsRepository.isAutofillSavePromptDisabled } returns false
every {
policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP)
} returns emptyList()
every {
parser.parse(
autofillAppInfo = appInfo,
assistStructure = assistStructure,
)
} returns autofillRequest
// Test
autofillProcessor.processSaveRequest(
autofillAppInfo = appInfo,
request = saveRequest,
saveCallback = saveCallback,
)
// Verify
verify(exactly = 1) {
settingsRepository.isAutofillSavePromptDisabled
policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP)
parser.parse(
autofillAppInfo = appInfo,
assistStructure = assistStructure,
)
saveCallback.onSuccess()
}
verify(exactly = 0) {
createAutofillSavedItemIntentSender(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `processSaveRequest should invoke empty callback when autofill enabled, has fill contexts, and parser returns Unfillable`() {
@@ -9,6 +9,7 @@ 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.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -103,6 +104,21 @@ class AutofillRequestExtensionsTest {
// Verify
assertEquals(expected, actual)
}
@Test
fun `toAutofillSaveItem should return null when identity partition`() {
// Setup
val autofillPartition: AutofillPartition.Identity = mockk()
val autofillRequest: AutofillRequest.Fillable = mockk {
every { partition } returns autofillPartition
}
// Test
val actual = autofillRequest.toAutofillSaveItem()
// Verify
assertNull(actual)
}
}
private const val AUTOFILL_REQUEST_EXTENSIONS_PATH =
@@ -234,6 +234,36 @@ class AutofillViewExtensionsTest {
assertNull(actual)
}
@Suppress("MaxLineLength")
@Test
fun `buildFilledItemOrNull should return index list value when list type and identity view type`() {
// Setup
val value = "Acme Corp"
val autofillViewData = autofillViewData.copy(
autofillType = View.AUTOFILL_TYPE_LIST,
autofillOptions = listOf("Other Corp", value, "Third Corp"),
)
val autofillView = AutofillView.Identity.Company(
data = autofillViewData,
)
val expected = FilledItem(
autofillId = autofillId,
value = autofillValue,
)
every { AutofillValue.forList(1) } returns autofillValue
// Test
val actual = autofillView.buildFilledItemOrNull(
value = value,
)
// Verify
assertEquals(expected, actual)
verify(exactly = 1) {
AutofillValue.forList(1)
}
}
@Test
fun `buildFilledItemOrNull should return text value when text type`() {
// Setup
@@ -113,6 +113,58 @@ class CipherViewExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toAutofillCipherProvider should return a provider with the correct data for an Identity type`() =
runTest {
val cipherView = createMockCipherView(
number = 1,
cipherType = CipherType.IDENTITY,
)
val autofillCipherProvider = cipherView.toAutofillCipherProvider()
assertFalse(autofillCipherProvider.isVaultLocked())
assertEquals(
emptyList<AutofillCipher.Card>(),
autofillCipherProvider.getCardAutofillCiphers(),
)
assertEquals(
emptyList<AutofillCipher.Login>(),
autofillCipherProvider.getLoginAutofillCiphers(uri = "uri"),
)
assertEquals(
listOf(
AutofillCipher.Identity(
cipherId = "mockId-1",
name = "mockName-1",
subtitle = "mockFirstName-1 mockLastName-1",
fullName = "mockFirstName-1 mockMiddleName-1 mockLastName-1",
fullAddress = "mockAddress1-1 mockAddress2-1 mockAddress3-1 " +
"mockCity-1 mockState-1 mockPostalCode-1 mockCountry-1",
title = "mockTitle-1",
firstName = "mockFirstName-1",
middleName = "mockMiddleName-1",
lastName = "mockLastName-1",
address1 = "mockAddress1-1",
address2 = "mockAddress2-1",
address3 = "mockAddress3-1",
city = "mockCity-1",
state = "mockState-1",
postalCode = "mockPostalCode-1",
country = "mockCountry-1",
company = "mockCompany-1",
email = "mockEmail-1",
phone = "mockPhone-1",
ssn = "mockSsn-1",
passportNumber = "mockPassportNumber-1",
licenseNumber = "mockLicenseNumber-1",
),
),
autofillCipherProvider.getIdentityAutofillCiphers(),
)
}
@Suppress("MaxLineLength")
@Test
fun `toAutofillCipherProvider should return a provider with the correct data for any other type`() =
@@ -121,6 +173,7 @@ class CipherViewExtensionsTest {
.entries
.filterNot { it == CipherType.CARD }
.filterNot { it == CipherType.LOGIN }
.filterNot { it == CipherType.IDENTITY }
.forEach { cipherType ->
val autofillCipherProvider = createMockCipherView(
number = 1,
@@ -137,6 +190,10 @@ class CipherViewExtensionsTest {
emptyList<AutofillCipher.Login>(),
autofillCipherProvider.getLoginAutofillCiphers(uri = "uri"),
)
assertEquals(
emptyList<AutofillCipher.Identity>(),
autofillCipherProvider.getIdentityAutofillCiphers(),
)
}
}
@@ -1,9 +1,12 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.IdentityView
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Clock
@@ -12,6 +15,27 @@ import java.time.ZoneOffset
class CipherViewExtensionsTest {
private val emptyIdentityView = IdentityView(
title = null,
firstName = null,
middleName = null,
lastName = null,
address1 = null,
address2 = null,
address3 = null,
city = null,
state = null,
postalCode = null,
country = null,
company = null,
email = null,
phone = null,
ssn = null,
username = null,
passportNumber = null,
licenseNumber = null,
)
private val clock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
@@ -56,4 +80,64 @@ class CipherViewExtensionsTest {
assertFalse(cipherListView.isActive)
}
@Test
fun `identityAutofillName should return null when all name parts are null`() {
assertNull(emptyIdentityView.identityAutofillName)
}
@Test
fun `identityAutofillName should exclude the title and join the remaining name parts`() {
val identityView = emptyIdentityView.copy(
title = "mr",
firstName = "firstName",
middleName = "middleName",
lastName = "lastName",
)
assertEquals(
"firstName middleName lastName",
identityView.identityAutofillName,
)
}
@Test
fun `identityAutofillAddress should return null when all address parts are null`() {
assertNull(emptyIdentityView.identityAutofillAddress)
}
@Suppress("MaxLineLength")
@Test
fun `identityAutofillAddress should omit missing parts entirely rather than using a placeholder`() {
val identityView = emptyIdentityView.copy(
address1 = null,
address2 = null,
address3 = "address3",
city = null,
state = "state",
postalCode = null,
country = null,
)
assertEquals("address3 state", identityView.identityAutofillAddress)
}
@Suppress("MaxLineLength")
@Test
fun `identityAutofillAddress should join all address parts with a single space when fully populated`() {
val identityView = emptyIdentityView.copy(
address1 = "address1",
address2 = "address2",
address3 = "address3",
city = "city",
state = "state",
postalCode = "postalCode",
country = "country",
)
assertEquals(
"address1 address2 address3 city state postalCode country",
identityView.identityAutofillAddress,
)
}
}
@@ -29,6 +29,9 @@ class AutofillUtilsTest {
every {
ContextCompat.getString(context, BitwardenString.type_login)
} returns "Login"
every {
ContextCompat.getString(context, BitwardenString.type_identity)
} returns "Identity"
}
@AfterEach
@@ -113,6 +116,38 @@ class AutofillUtilsTest {
),
third = "Autofill suggestion, Login, Amazon, AmazonSubtitle",
),
Triple(
first = AutofillCipher.Identity(
cipherId = null,
name = "Cipher One",
subtitle = "Subtitle",
fullName = "fullName",
fullAddress = "fullAddress",
title = "title",
firstName = "firstName",
middleName = "middleName",
lastName = "lastName",
address1 = "address1",
address2 = "address2",
address3 = "address3",
city = "city",
state = "state",
postalCode = "postalCode",
country = "country",
company = "company",
email = "email",
phone = "phone",
ssn = "ssn",
passportNumber = "passportNumber",
licenseNumber = "licenseNumber",
),
second = AutofillAppInfo(
context = context,
packageName = "com.x8bit.bitwarden",
sdkInt = 34,
),
third = "Autofill suggestion, Identity, Cipher One, Subtitle",
),
)
.forEach {
val result = getAutofillSuggestionContentDescription(