[PM-32806] feat: View Passport item type (#6853)

This commit is contained in:
Patrick Honkonen
2026-05-14 19:28:31 +00:00
committed by GitHub
parent 92845c6a4d
commit d390272c0c
17 changed files with 1656 additions and 24 deletions
@@ -278,7 +278,7 @@ private val CipherListViewType.iconRes: Int
CipherListViewType.SshKey -> BitwardenDrawable.ic_ssh_key
CipherListViewType.BankAccount -> BitwardenDrawable.ic_payment_card
CipherListViewType.DriversLicense -> BitwardenDrawable.ic_note
CipherListViewType.Passport -> BitwardenDrawable.ic_note
CipherListViewType.Passport -> BitwardenDrawable.ic_passport
}
/**
@@ -3137,19 +3137,19 @@ data class VaultAddEditState(
*/
@Parcelize
data class Passport(
val surname: String = "",
val givenName: String = "",
val surname: String = "",
val dateOfBirth: String = "",
val birthPlace: String = "",
val sex: String = "",
val birthPlace: String = "",
val nationality: String = "",
val passportNumber: String = "",
val passportType: String = "",
val nationalIdentificationNumber: String = "",
val issuingCountry: String = "",
val issuingAuthority: String = "",
val issueDate: String = "",
val expirationDate: String = "",
val nationalIdentificationNumber: String = "",
) : ItemType() {
override val itemTypeOption: ItemTypeOption
get() = ItemTypeOption.PASSPORT
@@ -127,12 +127,15 @@ fun CipherView.toViewState(
}
CipherType.PASSPORT -> VaultAddEditState.ViewState.Content.ItemType.Passport(
surname = passport?.surname.orEmpty(),
givenName = passport?.givenName.orEmpty(),
surname = passport?.surname.orEmpty(),
dateOfBirth = passport?.dateOfBirth.orEmpty(),
sex = passport?.sex.orEmpty(),
birthPlace = passport?.birthPlace.orEmpty(),
nationality = passport?.nationality.orEmpty(),
passportNumber = passport?.passportNumber.orEmpty(),
passportType = passport?.passportType.orEmpty(),
nationalIdentificationNumber = passport?.nationalIdentificationNumber.orEmpty(),
issuingCountry = passport?.issuingCountry.orEmpty(),
issuingAuthority = passport?.issuingAuthority.orEmpty(),
issueDate = passport?.issueDate.orEmpty(),
@@ -0,0 +1,554 @@
package com.x8bit.bitwarden.ui.vault.feature.item
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.vault.feature.item.component.itemHeader
import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemAttachments
import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemCustomFields
import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemHistory
import com.x8bit.bitwarden.ui.vault.feature.item.component.vaultItemNotes
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultPassportItemTypeHandlers
import kotlinx.collections.immutable.persistentListOf
/**
* The top level content UI state for the [VaultItemScreen] when viewing a passport
* cipher. Each populated field renders as a separate read-only row. The passport
* number and national identification number render with a reveal toggle and an
* inline copy affordance.
*/
@Suppress("LongMethod")
@Composable
fun VaultItemPassportContent(
commonState: VaultItemState.ViewState.Content.Common,
passportState: VaultItemState.ViewState.Content.ItemType.Passport,
vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers,
vaultPassportItemTypeHandlers: VaultPassportItemTypeHandlers,
modifier: Modifier = Modifier,
) {
var isExpanded by rememberSaveable { mutableStateOf(value = false) }
LazyColumn(modifier = modifier.fillMaxWidth()) {
item {
Spacer(Modifier.height(height = 12.dp))
}
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
isArchived = commonState.archived,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "PassportItemNameIcon",
textFieldTestTag = "PassportItemNameEntry",
isExpanded = isExpanded,
onExpandClick = { isExpanded = !isExpanded },
applyIconBackground = commonState.iconData is IconData.Local,
)
item(key = "passportDetailsHeader") {
Spacer(modifier = Modifier.height(height = 16.dp))
BitwardenListHeaderText(
label = stringResource(id = BitwardenString.passport_details),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.padding(horizontal = 16.dp)
.animateItem(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
passportState.givenName?.let { givenName ->
item(key = "givenName") {
PassportCopyField(
label = stringResource(id = BitwardenString.first_name),
value = givenName,
copyContentDescription = stringResource(id = BitwardenString.copy_first_name),
textFieldTestTag = "PassportItemGivenNameEntry",
copyActionTestTag = "PassportCopyGivenNameButton",
onCopyClick = vaultPassportItemTypeHandlers.onCopyGivenNameClick,
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = givenName),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.surname?.let { surname ->
item(key = "surname") {
PassportCopyField(
label = stringResource(id = BitwardenString.last_name),
value = surname,
copyContentDescription = stringResource(id = BitwardenString.copy_last_name),
textFieldTestTag = "PassportItemSurnameEntry",
copyActionTestTag = "PassportCopySurnameButton",
onCopyClick = vaultPassportItemTypeHandlers.onCopySurnameClick,
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = surname),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.dateOfBirth?.let { dateOfBirth ->
item(key = "dateOfBirth") {
BitwardenTextField(
label = stringResource(id = BitwardenString.date_of_birth),
value = dateOfBirth,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemDateOfBirthEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = dateOfBirth),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.sex?.let { sex ->
item(key = "sex") {
BitwardenTextField(
label = stringResource(id = BitwardenString.sex),
value = sex,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemSexEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = sex),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.birthPlace?.let { birthPlace ->
item(key = "birthPlace") {
BitwardenTextField(
label = stringResource(id = BitwardenString.birth_place),
value = birthPlace,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemBirthPlaceEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = birthPlace),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.nationality?.let { nationality ->
item(key = "nationality") {
BitwardenTextField(
label = stringResource(id = BitwardenString.nationality),
value = nationality,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemNationalityEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = nationality),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.passportNumber?.let { passportNumber ->
item(key = "passportNumber") {
BitwardenPasswordField(
label = stringResource(id = BitwardenString.passport_number),
value = passportNumber,
onValueChange = {},
readOnly = true,
supportingContent = null,
actions = {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_copy,
contentDescription = stringResource(
id = BitwardenString.copy_passport_number,
),
onClick = vaultPassportItemTypeHandlers.onCopyPassportNumberClick,
modifier = Modifier.testTag(
tag = "PassportCopyPassportNumberButton",
),
)
},
passwordFieldTestTag = "PassportItemPassportNumberEntry",
showPasswordTestTag = "PassportViewPassportNumberButton",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState
.propertyList
.indexOf(element = passportNumber),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.passportType?.let { passportType ->
item(key = "passportType") {
BitwardenTextField(
label = stringResource(id = BitwardenString.passport_type),
value = passportType,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemPassportTypeEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = passportType),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.nationalIdentificationNumber?.let { nationalIdentificationNumber ->
item(key = "nationalIdentificationNumber") {
BitwardenPasswordField(
label = stringResource(
id = BitwardenString.national_identification_number,
),
value = nationalIdentificationNumber,
onValueChange = {},
readOnly = true,
supportingContent = null,
actions = {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_copy,
contentDescription = stringResource(
id = BitwardenString.copy_national_identification_number,
),
onClick = vaultPassportItemTypeHandlers
.onCopyNationalIdentificationNumberClick,
modifier = Modifier.testTag(
tag = "PassportCopyNationalIdentificationNumberButton",
),
)
},
passwordFieldTestTag = "PassportItemNationalIdentificationNumberEntry",
showPasswordTestTag = "PassportViewNationalIdentificationNumberButton",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState
.propertyList
.indexOf(element = nationalIdentificationNumber),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.issuingCountry?.let { issuingCountry ->
item(key = "issuingCountry") {
BitwardenTextField(
label = stringResource(id = BitwardenString.issuing_country),
value = issuingCountry,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemIssuingCountryEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = issuingCountry),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.issuingAuthority?.let { issuingAuthority ->
item(key = "issuingAuthority") {
BitwardenTextField(
label = stringResource(id = BitwardenString.issuing_authority),
value = issuingAuthority,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemIssuingAuthorityEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState
.propertyList
.indexOf(element = issuingAuthority),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.issueDate?.let { issueDate ->
item(key = "issueDate") {
BitwardenTextField(
label = stringResource(id = BitwardenString.issue_date),
value = issueDate,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemIssueDateEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = issueDate),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
passportState.expirationDate?.let { expirationDate ->
item(key = "expirationDate") {
BitwardenTextField(
label = stringResource(id = BitwardenString.expiration_date),
value = expirationDate,
onValueChange = {},
readOnly = true,
singleLine = false,
textFieldTestTag = "PassportItemExpirationDateEntry",
cardStyle = passportState
.propertyList
.toListItemCardStyle(
index = passportState.propertyList.indexOf(element = expirationDate),
dividerPadding = 0.dp,
),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
}
vaultItemNotes(
notes = commonState.notes,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
)
vaultItemCustomFields(
customFields = commonState.customFields,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
)
vaultItemAttachments(
attachments = commonState.attachments,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
)
vaultItemHistory(
commonState = commonState,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
loginPasswordRevisionDate = null,
)
item {
Spacer(modifier = Modifier.height(88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun PassportCopyField(
label: String,
value: String,
copyContentDescription: String,
textFieldTestTag: String,
copyActionTestTag: String,
onCopyClick: () -> Unit,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
) {
BitwardenTextField(
label = label,
value = value,
onValueChange = {},
readOnly = true,
singleLine = false,
actions = {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_copy,
contentDescription = copyContentDescription,
onClick = onCopyClick,
modifier = Modifier.testTag(tag = copyActionTestTag),
)
},
textFieldTestTag = textFieldTestTag,
cardStyle = cardStyle,
modifier = modifier,
)
}
//region Previews
@Composable
@Preview(showBackground = true, heightDp = 1200)
private fun VaultItemPassportContent_Preview() {
BitwardenTheme {
BitwardenScaffold() {
VaultItemPassportContent(
commonState = PREVIEW_COMMON,
passportState = PREVIEW_PASSPORT,
vaultCommonItemTypeHandlers = PREVIEW_COMMON_HANDLERS,
vaultPassportItemTypeHandlers = PREVIEW_PASSPORT_HANDLERS,
)
}
}
}
private val PREVIEW_COMMON: VaultItemState.ViewState.Content.Common =
VaultItemState.ViewState.Content.Common(
name = "Passport",
created = "May 13, 2026, 12:00 PM".asText(),
lastUpdated = "May 13, 2026, 12:00 PM".asText(),
notes = "Recovery code: 12323234324",
customFields = persistentListOf(),
requiresCloneConfirmation = false,
currentCipher = null,
attachments = persistentListOf(),
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
favorite = false,
archived = false,
passwordHistoryCount = null,
iconData = IconData.Local(iconRes = BitwardenDrawable.ic_passport),
relatedLocations = persistentListOf(),
hasOrganizations = false,
)
private val PREVIEW_PASSPORT: VaultItemState.ViewState.Content.ItemType.Passport =
VaultItemState.ViewState.Content.ItemType.Passport(
givenName = "Mitchell Allen",
surname = "Johnson",
dateOfBirth = "August 10, 1990",
sex = "Male",
birthPlace = "Madison, Wisconsin",
nationality = "United States of America",
passportNumber = "P12345678",
passportType = "P",
nationalIdentificationNumber = "N-987-654-321",
issuingCountry = "United States of America",
issuingAuthority = "Department of State",
issueDate = "August 10, 2021",
expirationDate = "August 10, 2031",
)
private val PREVIEW_COMMON_HANDLERS: VaultCommonItemTypeHandlers =
VaultCommonItemTypeHandlers(
onRefreshClick = {},
onCopyCustomHiddenField = {},
onCopyCustomTextField = {},
onShowHiddenFieldClick = { _, _ -> },
onAttachmentDownloadClick = {},
onAttachmentPreviewClick = {},
onCopyNotesClick = {},
onPasswordHistoryClick = {},
onUpgradeToPremiumClick = {},
)
private val PREVIEW_PASSPORT_HANDLERS: VaultPassportItemTypeHandlers =
VaultPassportItemTypeHandlers(
onCopyGivenNameClick = {},
onCopySurnameClick = {},
onCopyPassportNumberClick = {},
onCopyNationalIdentificationNumberClick = {},
)
//endregion Previews
@@ -47,6 +47,8 @@ import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultBankAccountItemTy
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCardItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultDriversLicenseItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultPassportItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.rememberVaultPassportItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultIdentityItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers
@@ -292,6 +294,9 @@ fun VaultItemScreen(
vaultDriversLicenseItemTypeHandlers = remember(viewModel) {
VaultDriversLicenseItemTypeHandlers.create(viewModel = viewModel)
},
vaultPassportItemTypeHandlers = rememberVaultPassportItemTypeHandlers(
viewModel = viewModel,
),
)
}
}
@@ -378,6 +383,7 @@ private fun VaultItemContent(
vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers,
vaultBankAccountItemTypeHandlers: VaultBankAccountItemTypeHandlers,
vaultDriversLicenseItemTypeHandlers: VaultDriversLicenseItemTypeHandlers,
vaultPassportItemTypeHandlers: VaultPassportItemTypeHandlers,
modifier: Modifier = Modifier,
) {
when (viewState) {
@@ -462,12 +468,11 @@ private fun VaultItemContent(
}
is VaultItemState.ViewState.Content.ItemType.Passport -> {
// TODO(PM-32806): Render dedicated content for Passport once the UI ships
// in its Story slice. Until then this branch is gated behind the
// pm-32009-new-item-types feature flag and cannot be received.
VaultItemSecureNoteContent(
VaultItemPassportContent(
commonState = viewState.common,
passportState = viewState.type,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
vaultPassportItemTypeHandlers = vaultPassportItemTypeHandlers,
modifier = modifier,
)
}
@@ -244,6 +244,7 @@ class VaultItemViewModel @Inject constructor(
handleDriversLicenseTypeActions(action)
}
is VaultItemAction.ItemType.Passport -> handlePassportTypeActions(action)
is VaultItemAction.Common -> handleCommonActions(action)
is VaultItemAction.Internal -> handleInternalAction(action)
}
@@ -1253,6 +1254,83 @@ class VaultItemViewModel @Inject constructor(
//endregion Driver's License Type Handlers
//region Passport Type Handlers
private fun handlePassportTypeActions(action: VaultItemAction.ItemType.Passport) {
when (action) {
VaultItemAction.ItemType.Passport.CopyGivenNameClick -> {
handleCopyPassportGivenNameClick()
}
VaultItemAction.ItemType.Passport.CopySurnameClick -> {
handleCopyPassportSurnameClick()
}
VaultItemAction.ItemType.Passport.CopyPassportNumberClick -> {
handleCopyPassportItemNumberClick()
}
VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick -> {
handleCopyNationalIdentificationNumberClick()
}
}
}
private fun handleCopyPassportGivenNameClick() {
onPassportContent { _, passport ->
passport.givenName
?.takeIf { it.isNotBlank() }
?.let { givenName ->
clipboardManager.setText(
text = givenName,
toastDescriptorOverride = BitwardenString.first_name.asText(),
)
}
}
}
private fun handleCopyPassportSurnameClick() {
onPassportContent { _, passport ->
passport.surname
?.takeIf { it.isNotBlank() }
?.let { surname ->
clipboardManager.setText(
text = surname,
toastDescriptorOverride = BitwardenString.last_name.asText(),
)
}
}
}
private fun handleCopyPassportItemNumberClick() {
onPassportContent { _, passport ->
passport.passportNumber
?.takeIf { it.isNotBlank() }
?.let { passportNumber ->
clipboardManager.setText(
text = passportNumber,
toastDescriptorOverride = BitwardenString.passport_number.asText(),
)
}
}
}
private fun handleCopyNationalIdentificationNumberClick() {
onPassportContent { _, passport ->
passport.nationalIdentificationNumber
?.takeIf { it.isNotBlank() }
?.let { nationalIdentificationNumber ->
clipboardManager.setText(
text = nationalIdentificationNumber,
toastDescriptorOverride =
BitwardenString.national_identification_number.asText(),
)
}
}
}
//endregion Passport Type Handlers
//region Internal Type Handlers
private fun handleInternalAction(action: VaultItemAction.Internal) {
@@ -1665,6 +1743,21 @@ class VaultItemViewModel @Inject constructor(
}
}
}
private inline fun onPassportContent(
crossinline block: (
VaultItemState.ViewState.Content,
VaultItemState.ViewState.Content.ItemType.Passport,
) -> Unit,
) {
state.viewState.asContentOrNull()
?.let { content ->
(content.type as? VaultItemState.ViewState.Content.ItemType.Passport)
?.let { passportContent ->
block(content, passportContent)
}
}
}
}
/**
@@ -2191,17 +2284,41 @@ data class VaultItemState(
* Represents the `Passport` item type.
*/
data class Passport(
val surname: String?,
val givenName: String?,
val surname: String?,
val dateOfBirth: String?,
val sex: String?,
val birthPlace: String?,
val nationality: String?,
val passportNumber: String?,
val passportType: String?,
val nationalIdentificationNumber: String?,
val issuingCountry: String?,
val issuingAuthority: String?,
val issueDate: String?,
val expirationDate: String?,
) : ItemType()
) : ItemType() {
/**
* An ordered list of populated Passport elements.
*/
val propertyList: ImmutableList<String>
get() = persistentListOfNotNull(
givenName,
surname,
dateOfBirth,
sex,
birthPlace,
nationality,
passportNumber,
passportType,
nationalIdentificationNumber,
issuingCountry,
issuingAuthority,
issueDate,
expirationDate,
)
}
}
}
@@ -2741,6 +2858,33 @@ sealed class VaultItemAction {
*/
data object CopyLicenseNumberClick : DriversLicense()
}
/**
* Represents actions specific to the Passport type.
*/
sealed class Passport : ItemType() {
/**
* The user has clicked the copy button for the given name.
*/
data object CopyGivenNameClick : Passport()
/**
* The user has clicked the copy button for the surname.
*/
data object CopySurnameClick : Passport()
/**
* The user has clicked the copy button for the passport number.
*/
data object CopyPassportNumberClick : Passport()
/**
* The user has clicked the copy button for the national identification
* number.
*/
data object CopyNationalIdentificationNumberClick : Passport()
}
}
/**
@@ -0,0 +1,71 @@
package com.x8bit.bitwarden.ui.vault.feature.item.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemAction
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel
/**
* A collection of handler functions for managing actions within the context of viewing
* passport items in a vault.
*
* @property onCopyGivenNameClick Handles the user clicking the copy button next to the
* given name.
* @property onCopySurnameClick Handles the user clicking the copy button next to the
* surname.
* @property onCopyPassportNumberClick Handles the user clicking the copy button next to the
* passport number.
* @property onCopyNationalIdentificationNumberClick Handles the user clicking the copy button
* next to the national identification number.
*/
data class VaultPassportItemTypeHandlers(
val onCopyGivenNameClick: () -> Unit,
val onCopySurnameClick: () -> Unit,
val onCopyPassportNumberClick: () -> Unit,
val onCopyNationalIdentificationNumberClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates the [VaultPassportItemTypeHandlers] using the [viewModel] to send
* desired actions.
*/
fun create(viewModel: VaultItemViewModel): VaultPassportItemTypeHandlers =
VaultPassportItemTypeHandlers(
onCopyGivenNameClick = {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyGivenNameClick,
)
},
onCopySurnameClick = {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopySurnameClick,
)
},
onCopyPassportNumberClick = {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyPassportNumberClick,
)
},
onCopyNationalIdentificationNumberClick = {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport
.CopyNationalIdentificationNumberClick,
)
},
)
}
}
/**
* Helper function to remember a [VaultPassportItemTypeHandlers] instance in a [Composable]
* scope.
*/
@Composable
fun rememberVaultPassportItemTypeHandlers(
viewModel: VaultItemViewModel,
): VaultPassportItemTypeHandlers =
remember(viewModel) {
VaultPassportItemTypeHandlers.create(viewModel = viewModel)
}
@@ -247,16 +247,19 @@ fun CipherView.toViewState(
}
CipherType.PASSPORT -> VaultItemState.ViewState.Content.ItemType.Passport(
surname = passport?.surname.orEmpty(),
givenName = passport?.givenName.orEmpty(),
dateOfBirth = passport?.dateOfBirth.orEmpty(),
nationality = passport?.nationality.orEmpty(),
passportNumber = passport?.passportNumber.orEmpty(),
passportType = passport?.passportType.orEmpty(),
issuingCountry = passport?.issuingCountry.orEmpty(),
issuingAuthority = passport?.issuingAuthority.orEmpty(),
issueDate = passport?.issueDate.orEmpty(),
expirationDate = passport?.expirationDate.orEmpty(),
givenName = passport?.givenName,
surname = passport?.surname,
dateOfBirth = passport?.dateOfBirth,
sex = passport?.sex,
birthPlace = passport?.birthPlace,
nationality = passport?.nationality,
passportNumber = passport?.passportNumber,
passportType = passport?.passportType,
nationalIdentificationNumber = passport?.nationalIdentificationNumber,
issuingCountry = passport?.issuingCountry,
issuingAuthority = passport?.issuingAuthority,
issueDate = passport?.issueDate,
expirationDate = passport?.expirationDate,
)
},
)
@@ -350,7 +353,7 @@ private val CipherType.iconRes: Int
CipherType.LOGIN -> BitwardenDrawable.ic_globe
CipherType.BANK_ACCOUNT -> BitwardenDrawable.ic_payment_card
CipherType.DRIVERS_LICENSE -> BitwardenDrawable.ic_note
CipherType.PASSPORT -> BitwardenDrawable.ic_note
CipherType.PASSPORT -> BitwardenDrawable.ic_passport
}
@get:DrawableRes
@@ -622,7 +622,7 @@ private val CipherListViewType.iconRes: Int
CipherListViewType.SshKey -> BitwardenDrawable.ic_ssh_key
CipherListViewType.BankAccount -> BitwardenDrawable.ic_payment_card
CipherListViewType.DriversLicense -> BitwardenDrawable.ic_note
CipherListViewType.Passport -> BitwardenDrawable.ic_note
CipherListViewType.Passport -> BitwardenDrawable.ic_passport
}
private fun List<CipherListView>.applyFilters(
@@ -144,7 +144,7 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toPassport(): PassportV
surname = it.surname.orNullIfBlank(),
givenName = it.givenName.orNullIfBlank(),
dateOfBirth = it.dateOfBirth.orNullIfBlank(),
birthPlace = it.dateOfBirth.orNullIfBlank(),
birthPlace = it.birthPlace.orNullIfBlank(),
sex = it.sex.orNullIfBlank(),
nationality = it.nationality.orNullIfBlank(),
passportNumber = it.passportNumber.orNullIfBlank(),
@@ -16,6 +16,7 @@ import com.bitwarden.vault.LoginView
import com.bitwarden.vault.PasswordHistoryView
import com.bitwarden.vault.SecureNoteType
import com.bitwarden.vault.SecureNoteView
import com.bitwarden.vault.PassportView
import com.bitwarden.vault.SshKeyView
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.UserState
@@ -376,6 +377,58 @@ class CipherViewExtensionsTest {
)
}
@Test
fun `toViewState should create a Passport ViewState`() {
val cipherView = DEFAULT_PASSPORT_CIPHER_VIEW
val result = cipherView.toViewState(
isClone = false,
isPremium = false,
isIndividualVaultDisabled = false,
totpData = null,
resourceManager = resourceManager,
clock = FIXED_CLOCK,
canDelete = true,
canAssignToCollections = true,
)
assertEquals(
VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(
originalCipher = cipherView,
name = "cipher",
favorite = false,
masterPasswordReprompt = true,
notes = "Lots of notes",
customFieldData = listOf(
VaultAddEditState.Custom.BooleanField(TEST_ID, "TestBoolean", false),
VaultAddEditState.Custom.TextField(TEST_ID, "TestText", "TestText"),
VaultAddEditState.Custom.HiddenField(TEST_ID, "TestHidden", "TestHidden"),
),
availableFolders = emptyList(),
availableOwners = emptyList(),
),
isIndividualVaultDisabled = false,
type = VaultAddEditState.ViewState.Content.ItemType.Passport(
givenName = "the given name",
surname = "the surname",
dateOfBirth = "the date of birth",
sex = "the sex",
birthPlace = "the birth place",
nationality = "the nationality",
passportNumber = "the passport number",
passportType = "the passport type",
nationalIdentificationNumber = "the national identification number",
issuingCountry = "the issuing country",
issuingAuthority = "the issuing authority",
issueDate = "the issue date",
expirationDate = "the expiration date",
),
),
result,
)
}
@Test
fun `toViewState with isClone true should append clone text to the cipher name`() {
val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW
@@ -879,6 +932,45 @@ private val DEFAULT_SSH_KEY_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.c
),
)
private val DEFAULT_PASSPORT_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy(
type = CipherType.PASSPORT,
fields = listOf(
FieldView(
name = "TestBoolean",
value = false.toString(),
type = FieldType.BOOLEAN,
linkedId = null,
),
FieldView(
name = "TestText",
value = "TestText",
type = FieldType.TEXT,
linkedId = null,
),
FieldView(
name = "TestHidden",
value = "TestHidden",
type = FieldType.HIDDEN,
linkedId = null,
),
),
passport = PassportView(
surname = "the surname",
givenName = "the given name",
dateOfBirth = "the date of birth",
birthPlace = "the birth place",
sex = "the sex",
nationality = "the nationality",
passportNumber = "the passport number",
passportType = "the passport type",
issuingCountry = "the issuing country",
issuingAuthority = "the issuing authority",
issueDate = "the issue date",
expirationDate = "the expiration date",
nationalIdentificationNumber = "the national identification number",
),
)
private const val TEST_ID = "testID"
private val NO_FOLDER_ITEM = VaultAddEditState.Folder(
id = null,
@@ -3750,6 +3750,262 @@ class VaultItemScreenTest : BitwardenComposeTest() {
}
//endregion drivers license
//region passport
@Test
fun `in passport state, all fields should be displayed when populated`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll("the given name").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the surname").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the date of birth").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the sex").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the birth place").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the nationality").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the passport type").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the issuing country").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the issuing authority").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the issue date").assertIsDisplayed()
composeTestRule.onNodeWithTextAfterScroll("the expiration date").assertIsDisplayed()
}
@Test
fun `in passport state, on copy given name click should send CopyGivenNameClick`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy first name")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyGivenNameClick,
)
}
}
@Test
fun `in passport state, on copy surname click should send CopySurnameClick`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule
.onNode(hasScrollToNodeAction())
.performScrollToNode(hasTestTag("PassportCopySurnameButton"))
composeTestRule
.onNodeWithTag("PassportCopySurnameButton")
.performSemanticsAction(SemanticsActions.OnClick)
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopySurnameClick,
)
}
}
@Test
fun `in passport state, on copy passport number click should send CopyPassportNumberClick`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy passport number")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyPassportNumberClick,
)
}
}
@Test
fun `in passport state, on copy national id click should send the matching action`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy national identification number")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick,
)
}
}
@Test
fun `in passport state, givenName should be displayed according to state`() {
val givenName = "the given name"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(givenName).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(givenName = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(givenName)
}
@Test
fun `in passport state, surname should be displayed according to state`() {
val surname = "the surname"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(surname).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(surname = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(surname)
}
@Test
fun `in passport state, dateOfBirth should be displayed according to state`() {
val dateOfBirth = "the date of birth"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(dateOfBirth).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(dateOfBirth = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(dateOfBirth)
}
@Test
fun `in passport state, sex should be displayed according to state`() {
val sex = "the sex"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(sex).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(sex = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(sex)
}
@Test
fun `in passport state, birthPlace should be displayed according to state`() {
val birthPlace = "the birth place"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(birthPlace).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(birthPlace = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(birthPlace)
}
@Test
fun `in passport state, nationality should be displayed according to state`() {
val nationality = "the nationality"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(nationality).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(nationality = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(nationality)
}
@Test
fun `in passport state, passportNumber should be displayed according to state`() {
val passportNumberLabel = "Passport number"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(passportNumberLabel).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(passportNumber = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(passportNumberLabel)
}
@Test
fun `in passport state, passportType should be displayed according to state`() {
val passportType = "the passport type"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(passportType).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(passportType = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(passportType)
}
@Test
fun `in passport state, nationalIdentificationNumber should be displayed according to state`() {
val nationalIdLabel = "National identification number"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(nationalIdLabel).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(nationalIdentificationNumber = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(nationalIdLabel)
}
@Test
fun `in passport state, issuingCountry should be displayed according to state`() {
val issuingCountry = "the issuing country"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(issuingCountry).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(issuingCountry = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(issuingCountry)
}
@Test
fun `in passport state, issuingAuthority should be displayed according to state`() {
val issuingAuthority = "the issuing authority"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(issuingAuthority).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(issuingAuthority = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(issuingAuthority)
}
@Test
fun `in passport state, issueDate should be displayed according to state`() {
val issueDate = "the issue date"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(issueDate).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(issueDate = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(issueDate)
}
@Test
fun `in passport state, expirationDate should be displayed according to state`() {
val expirationDate = "the expiration date"
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(expirationDate).assertIsDisplayed()
mutableStateFlow.update { currentState ->
updatePassportType(currentState) { copy(expirationDate = null) }
}
composeTestRule.assertScrollableNodeDoesNotExist(expirationDate)
}
@Test
fun `in passport state, edit fab should be displayed`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PASSPORT_VIEW_STATE) }
composeTestRule.onNodeWithContentDescription("Edit item").assertIsDisplayed()
}
//endregion passport
}
//region Helper functions
@@ -3869,6 +4125,29 @@ private fun updateDriversLicenseType(
return currentState.copy(viewState = updatedType)
}
private fun updatePassportType(
currentState: VaultItemState,
transform: VaultItemState.ViewState.Content.ItemType.Passport.() ->
VaultItemState.ViewState.Content.ItemType.Passport,
): VaultItemState {
val updatedType = when (val viewState = currentState.viewState) {
is VaultItemState.ViewState.Content -> {
when (val type = viewState.type) {
is VaultItemState.ViewState.Content.ItemType.Passport -> {
viewState.copy(
type = type.transform(),
)
}
else -> viewState
}
}
else -> viewState
}
return currentState.copy(viewState = updatedType)
}
private fun updateCommonContent(
currentState: VaultItemState,
transform: VaultItemState.ViewState.Content.Common.()
@@ -4184,6 +4463,29 @@ private val DEFAULT_DRIVERS_LICENSE_VIEW_STATE: VaultItemState.ViewState.Content
type = DEFAULT_DRIVERS_LICENSE,
)
private val DEFAULT_PASSPORT: VaultItemState.ViewState.Content.ItemType.Passport =
VaultItemState.ViewState.Content.ItemType.Passport(
givenName = "the given name",
surname = "the surname",
dateOfBirth = "the date of birth",
sex = "the sex",
birthPlace = "the birth place",
nationality = "the nationality",
passportNumber = "the passport number",
passportType = "the passport type",
nationalIdentificationNumber = "the national identification number",
issuingCountry = "the issuing country",
issuingAuthority = "the issuing authority",
issueDate = "the issue date",
expirationDate = "the expiration date",
)
private val DEFAULT_PASSPORT_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON.copy(iconData = IconData.Local(BitwardenDrawable.ic_globe)),
type = DEFAULT_PASSPORT,
)
private val EMPTY_VIEW_STATES = listOf(
EMPTY_LOGIN_VIEW_STATE,
EMPTY_IDENTITY_VIEW_STATE,
@@ -3131,6 +3131,275 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
}
@Nested
inner class PassportActions {
private lateinit var viewModel: VaultItemViewModel
@BeforeEach
fun setup() {
viewModel = createViewModel(
state = DEFAULT_STATE.copy(viewState = PASSPORT_VIEW_STATE),
)
every {
mockCipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns PASSPORT_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
}
@Test
fun `on CopyGivenNameClick should copy given name to clipboard`() = runTest {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyGivenNameClick,
)
verify(exactly = 1) {
clipboardManager.setText(
text = "Missy",
toastDescriptorOverride = BitwardenString.first_name.asText(),
)
}
}
@Test
fun `on CopyGivenNameClick with null given name should not copy to clipboard`() = runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(givenName = null),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyGivenNameClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
@Test
fun `on CopyGivenNameClick with blank given name should not copy to clipboard`() = runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(givenName = " "),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyGivenNameClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
@Test
fun `on CopySurnameClick should copy surname to clipboard`() = runTest {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopySurnameClick,
)
verify(exactly = 1) {
clipboardManager.setText(
text = "Katner",
toastDescriptorOverride = BitwardenString.last_name.asText(),
)
}
}
@Test
fun `on CopySurnameClick with null surname should not copy to clipboard`() = runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(surname = null),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopySurnameClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
@Test
fun `on CopySurnameClick with blank surname should not copy to clipboard`() = runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(surname = " "),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopySurnameClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
@Test
fun `on CopyPassportNumberClick should copy passport number to clipboard`() = runTest {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyPassportNumberClick,
)
verify(exactly = 1) {
clipboardManager.setText(
text = "P12345678",
toastDescriptorOverride = BitwardenString.passport_number.asText(),
)
}
}
@Test
fun `on CopyPassportNumberClick with null passport number should not copy to clipboard`() =
runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(passportNumber = null),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyPassportNumberClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
@Test
fun `on CopyPassportNumberClick with blank passport number should not copy to clipboard`() =
runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(passportNumber = " "),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyPassportNumberClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
@Test
fun `on CopyNationalIdentificationNumberClick should copy the value to clipboard`() =
runTest {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick,
)
verify(exactly = 1) {
clipboardManager.setText(
text = "N-987-654-321",
toastDescriptorOverride =
BitwardenString.national_identification_number.asText(),
)
}
}
@Test
fun `on CopyNationalIdentificationNumberClick with null value should not copy`() =
runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(nationalIdentificationNumber = null),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
@Test
fun `on CopyNationalIdentificationNumberClick with blank value should not copy`() =
runTest {
val emptyState = PASSPORT_VIEW_STATE.copy(
type = DEFAULT_PASSPORT_TYPE.copy(nationalIdentificationNumber = " "),
)
viewModel = createViewModelWithPassportState(emptyState)
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick,
)
verify(exactly = 0) {
clipboardManager.setText(
text = any<String>(),
toastDescriptorOverride = any<Text>(),
)
}
}
private fun createViewModelWithPassportState(
viewState: VaultItemState.ViewState.Content,
): VaultItemViewModel {
every {
mockCipherView.toViewState(
previousState = any(),
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns viewState
val newViewModel = createViewModel(
state = DEFAULT_STATE.copy(viewState = viewState),
)
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
return newViewModel
}
}
@Nested
inner class VaultItemFlow {
@BeforeEach
@@ -3792,5 +4061,29 @@ class VaultItemViewModelTest : BaseViewModelTest() {
common = DEFAULT_COMMON,
type = DEFAULT_DRIVERS_LICENSE_TYPE,
)
private val DEFAULT_PASSPORT_TYPE:
VaultItemState.ViewState.Content.ItemType.Passport =
VaultItemState.ViewState.Content.ItemType.Passport(
givenName = "Missy",
surname = "Katner",
dateOfBirth = "August 10, 1990",
sex = "Female",
birthPlace = "Madison, WI",
nationality = "USA",
passportNumber = "P12345678",
passportType = "Regular",
nationalIdentificationNumber = "N-987-654-321",
issuingCountry = "USA",
issuingAuthority = "Department of State",
issueDate = "August 10, 2021",
expirationDate = "August 10, 2031",
)
private val PASSPORT_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON,
type = DEFAULT_PASSPORT_TYPE,
)
}
}
@@ -0,0 +1,56 @@
package com.x8bit.bitwarden.ui.vault.feature.item.handlers
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemAction
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
class VaultPassportItemTypeHandlersTest {
private val viewModel = mockk<VaultItemViewModel> {
every { trySendAction(any()) } returns Unit
}
private val handlers = VaultPassportItemTypeHandlers.create(viewModel = viewModel)
@Test
fun `onCopyGivenNameClick should send CopyGivenNameClick action`() {
handlers.onCopyGivenNameClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyGivenNameClick,
)
}
}
@Test
fun `onCopySurnameClick should send CopySurnameClick action`() {
handlers.onCopySurnameClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopySurnameClick,
)
}
}
@Test
fun `onCopyPassportNumberClick should send CopyPassportNumberClick action`() {
handlers.onCopyPassportNumberClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyPassportNumberClick,
)
}
}
@Test
fun `onCopyNationalIdentificationNumberClick should send the matching action`() {
handlers.onCopyNationalIdentificationNumberClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultItemAction.ItemType.Passport.CopyNationalIdentificationNumberClick,
)
}
}
}
@@ -6,6 +6,7 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCardView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockDriversLicenseView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPassportView
import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
@@ -22,6 +23,7 @@ import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
@Suppress("LargeClass")
class CipherViewExtensionsTest {
private val fixedClock: Clock = Clock.fixed(
@@ -541,6 +543,99 @@ class CipherViewExtensionsTest {
)
}
@Test
fun `toViewState should transform full CipherView into ViewState Passport Content`() {
val cipherView = createCipherView(type = CipherType.PASSPORT, isEmpty = false)
.copy(passport = createMockPassportView(number = 1))
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
canRestore = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(
isEmpty = false,
isPremiumUser = true,
iconResId = BitwardenDrawable.ic_passport,
)
.copy(currentCipher = cipherView),
type = VaultItemState.ViewState.Content.ItemType.Passport(
givenName = "mockGivenName-1",
surname = "mockSurname-1",
dateOfBirth = "mockDateOfBirth-1",
sex = "mockSex-1",
birthPlace = "mockBirthPlace-1",
nationality = "mockNationality-1",
passportNumber = "mockPassportNumber-1",
passportType = "mockPassportType-1",
nationalIdentificationNumber = "mockNationalIdentificationNumber-1",
issuingCountry = "mockIssuingCountry-1",
issuingAuthority = "mockIssuingAuthority-1",
issueDate = "mockIssueDate-1",
expirationDate = "mockExpirationDate-1",
),
),
viewState,
)
}
@Test
fun `toViewState should transform empty CipherView into ViewState Passport Content`() {
val cipherView = createCipherView(type = CipherType.PASSPORT, isEmpty = true)
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
canRestore = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
assertEquals(
VaultItemState.ViewState.Content(
common = createCommonContent(
isEmpty = true,
isPremiumUser = true,
iconResId = BitwardenDrawable.ic_passport,
)
.copy(currentCipher = cipherView),
type = VaultItemState.ViewState.Content.ItemType.Passport(
givenName = null,
surname = null,
dateOfBirth = null,
sex = null,
birthPlace = null,
nationality = null,
passportNumber = null,
passportType = null,
nationalIdentificationNumber = null,
issuingCountry = null,
issuingAuthority = null,
issueDate = null,
expirationDate = null,
),
),
viewState,
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform full CipherView into ViewState with iconData based on cipher type`() {
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M5.375,13.2H10.625V12H5.375V13.2ZM8,10.8C9.038,10.8 9.922,10.41 10.653,9.63C11.384,8.85 11.75,7.907 11.75,6.8C11.75,5.693 11.384,4.75 10.653,3.97C9.922,3.19 9.038,2.8 8,2.8C6.963,2.8 6.078,3.19 5.347,3.97C4.616,4.75 4.25,5.693 4.25,6.8C4.25,7.907 4.616,8.85 5.347,9.63C6.078,10.41 6.963,10.8 8,10.8ZM8,9.46C7.9,9.313 7.794,9.07 7.681,8.73C7.569,8.39 7.494,7.947 7.456,7.4H8.544C8.506,7.947 8.431,8.39 8.319,8.73C8.206,9.07 8.1,9.313 8,9.46ZM6.65,9.2C6.35,9 6.094,8.747 5.881,8.44C5.669,8.133 5.525,7.787 5.45,7.4H6.331C6.356,7.733 6.394,8.05 6.444,8.35C6.494,8.65 6.563,8.933 6.65,9.2ZM9.35,9.2C9.438,8.933 9.506,8.65 9.556,8.35C9.606,8.05 9.644,7.733 9.669,7.4H10.55C10.475,7.787 10.331,8.133 10.119,8.44C9.906,8.747 9.65,9 9.35,9.2ZM5.45,6.2C5.525,5.813 5.669,5.467 5.881,5.16C6.094,4.853 6.35,4.6 6.65,4.4C6.563,4.667 6.494,4.95 6.444,5.25C6.394,5.55 6.356,5.867 6.331,6.2H5.45ZM7.456,6.2C7.494,5.653 7.569,5.21 7.681,4.87C7.794,4.53 7.9,4.287 8,4.14C8.1,4.287 8.206,4.53 8.319,4.87C8.431,5.21 8.506,5.653 8.544,6.2H7.456ZM9.669,6.2C9.644,5.867 9.606,5.55 9.556,5.25C9.506,4.95 9.438,4.667 9.35,4.4C9.65,4.6 9.906,4.853 10.119,5.16C10.331,5.467 10.475,5.813 10.55,6.2H9.669ZM2,16V0H12.5C12.913,0 13.266,0.157 13.559,0.47C13.853,0.783 14,1.16 14,1.6V14.4C14,14.84 13.853,15.217 13.559,15.53C13.266,15.843 12.913,16 12.5,16H2ZM3.5,14.4H12.5V1.6H3.5V14.4Z"
android:fillColor="#5A6D91"/>
</vector>
+5
View File
@@ -1360,4 +1360,9 @@ Do you want to switch to this account?</string>
<string name="licenses">Licenses</string>
<string name="no_licenses">There are no licenses in your vault.</string>
<string name="your_request_was_interrupted_because_the_app_needed_to_reauthenticate">Your request was interrupted because the app needed to re-authenticate. Please try again.</string>
<string name="passport_details">Passport details</string>
<string name="sex">Sex</string>
<string name="birth_place">Birth place</string>
<string name="national_identification_number">National identification number</string>
<string name="copy_national_identification_number">Copy national identification number</string>
</resources>