[PM-13831] Add copy button identity and note fields (#4302)

This commit is contained in:
aj-rosado 2024-11-19 16:31:00 +00:00 committed by GitHub
parent 531b003347
commit d418444dc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 798 additions and 58 deletions

View file

@ -16,6 +16,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTonalIconButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTonalIconButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordFieldWithActions import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCardItemTypeHandlers 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.VaultCommonItemTypeHandlers
@ -173,14 +174,22 @@ fun VaultItemCardContent(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( BitwardenTextFieldWithActions(
label = stringResource(id = R.string.notes), label = stringResource(id = R.string.notes),
value = notes, value = notes,
onValueChange = { }, onValueChange = { },
readOnly = true, readOnly = true,
singleLine = false, singleLine = false,
actions = {
BitwardenTonalIconButton(
vectorIconRes = R.drawable.ic_copy,
contentDescription = stringResource(id = R.string.copy_notes),
onClick = vaultCommonItemTypeHandlers.onCopyNotesClick,
modifier = Modifier.testTag(tag = "CipherNotesCopyButton"),
)
},
textFieldTestTag = "CipherNotesLabel",
modifier = Modifier modifier = Modifier
.testTag("CipherNotesLabel")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )

View file

@ -13,19 +13,23 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTonalIconButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultIdentityItemTypeHandlers
/** /**
* The top level content UI state for the [VaultItemScreen] when viewing a Identity cipher. * The top level content UI state for the [VaultItemScreen] when viewing a Identity cipher.
*/ */
@Suppress("LongMethod") @Suppress("LongMethod", "MaxLineLength")
@Composable @Composable
fun VaultItemIdentityContent( fun VaultItemIdentityContent(
identityState: VaultItemState.ViewState.Content.ItemType.Identity, identityState: VaultItemState.ViewState.Content.ItemType.Identity,
commonState: VaultItemState.ViewState.Content.Common, commonState: VaultItemState.ViewState.Content.Common,
vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers,
vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier = modifier) { LazyColumn(modifier = modifier) {
@ -54,14 +58,14 @@ fun VaultItemIdentityContent(
identityState.identityName?.let { identityName -> identityState.identityName?.let { identityName ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.identity_name), label = stringResource(id = R.string.identity_name),
value = identityName, value = identityName,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_identity_name),
readOnly = true, textFieldTestTag = "IdentityNameEntry",
singleLine = false, copyActionTestTag = "IdentityCopyNameButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyIdentityNameClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityNameEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -70,14 +74,14 @@ fun VaultItemIdentityContent(
identityState.username?.let { username -> identityState.username?.let { username ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.username), label = stringResource(id = R.string.username),
value = username, value = username,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_username),
readOnly = true, textFieldTestTag = "IdentityUsernameEntry",
singleLine = false, copyActionTestTag = "IdentityCopyUsernameButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyUsernameClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityUsernameEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -86,14 +90,14 @@ fun VaultItemIdentityContent(
identityState.company?.let { company -> identityState.company?.let { company ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.company), label = stringResource(id = R.string.company),
value = company, value = company,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_company),
readOnly = true, textFieldTestTag = "IdentityCompanyEntry",
singleLine = false, copyActionTestTag = "IdentityCopyCompanyButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyCompanyClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityCompanyEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -102,14 +106,14 @@ fun VaultItemIdentityContent(
identityState.ssn?.let { ssn -> identityState.ssn?.let { ssn ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.ssn), label = stringResource(id = R.string.ssn),
value = ssn, value = ssn,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_ssn),
readOnly = true, textFieldTestTag = "IdentitySsnEntry",
singleLine = false, copyActionTestTag = "IdentityCopySsnButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopySsnClick,
modifier = Modifier modifier = Modifier
.testTag("IdentitySsnEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -118,14 +122,14 @@ fun VaultItemIdentityContent(
identityState.passportNumber?.let { passportNumber -> identityState.passportNumber?.let { passportNumber ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.passport_number), label = stringResource(id = R.string.passport_number),
value = passportNumber, value = passportNumber,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_passport_number),
readOnly = true, textFieldTestTag = "IdentityPassportNumberEntry",
singleLine = false, copyActionTestTag = "IdentityCopyPassportNumberButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyPassportNumberClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityPassportNumberEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -134,14 +138,14 @@ fun VaultItemIdentityContent(
identityState.licenseNumber?.let { licenseNumber -> identityState.licenseNumber?.let { licenseNumber ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.license_number), label = stringResource(id = R.string.license_number),
value = licenseNumber, value = licenseNumber,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_license_number),
readOnly = true, textFieldTestTag = "IdentityLicenseNumberEntry",
singleLine = false, copyActionTestTag = "IdentityCopyLicenseNumberButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyLicenseNumberClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityLicenseNumberEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -150,14 +154,14 @@ fun VaultItemIdentityContent(
identityState.email?.let { email -> identityState.email?.let { email ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.email), label = stringResource(id = R.string.email),
value = email, value = email,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_email),
readOnly = true, textFieldTestTag = "IdentityEmailEntry",
singleLine = false, copyActionTestTag = "IdentityCopyEmailButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyEmailClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityEmailEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -166,14 +170,14 @@ fun VaultItemIdentityContent(
identityState.phone?.let { phone -> identityState.phone?.let { phone ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.phone), label = stringResource(id = R.string.phone),
value = phone, value = phone,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_phone),
readOnly = true, textFieldTestTag = "IdentityPhoneEntry",
singleLine = false, copyActionTestTag = "IdentityCopyPhoneButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyPhoneClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityPhoneEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -182,20 +186,19 @@ fun VaultItemIdentityContent(
identityState.address?.let { address -> identityState.address?.let { address ->
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.address), label = stringResource(id = R.string.address),
value = address, value = address,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_address),
readOnly = true, textFieldTestTag = "IdentityAddressEntry",
singleLine = false, copyActionTestTag = "IdentityCopyAddressButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyAddressClick,
modifier = Modifier modifier = Modifier
.testTag("IdentityAddressEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
} }
} }
commonState.notes?.let { notes -> commonState.notes?.let { notes ->
item { item {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@ -206,14 +209,14 @@ fun VaultItemIdentityContent(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( IdentityCopyField(
label = stringResource(id = R.string.notes), label = stringResource(id = R.string.notes),
value = notes, value = notes,
onValueChange = { }, copyContentDescription = stringResource(id = R.string.copy_notes),
readOnly = true, textFieldTestTag = "CipherNotesLabel",
singleLine = false, copyActionTestTag = "CipherNotesCopyButton",
onCopyClick = vaultCommonItemTypeHandlers.onCopyNotesClick,
modifier = Modifier modifier = Modifier
.testTag("CipherNotesLabel")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -284,3 +287,32 @@ fun VaultItemIdentityContent(
} }
} }
} }
@Composable
private fun IdentityCopyField(
label: String,
value: String,
copyContentDescription: String,
textFieldTestTag: String,
copyActionTestTag: String,
onCopyClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenTextFieldWithActions(
label = label,
value = value,
onValueChange = { },
readOnly = true,
singleLine = false,
actions = {
BitwardenTonalIconButton(
vectorIconRes = R.drawable.ic_copy,
contentDescription = copyContentDescription,
onClick = onCopyClick,
modifier = Modifier.testTag(tag = copyActionTestTag),
)
},
modifier = modifier,
textFieldTestTag = textFieldTestTag,
)
}

View file

@ -158,8 +158,8 @@ fun VaultItemLoginContent(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
NotesField( NotesField(
notes = notes, notes = notes,
onCopyAction = vaultCommonItemTypeHandlers.onCopyNotesClick,
modifier = Modifier modifier = Modifier
.testTag("CipherNotesLabel")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
@ -273,14 +273,24 @@ private fun Fido2CredentialField(
@Composable @Composable
private fun NotesField( private fun NotesField(
notes: String, notes: String,
onCopyAction: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BitwardenTextField( BitwardenTextFieldWithActions(
label = stringResource(id = R.string.notes), label = stringResource(id = R.string.notes),
value = notes, value = notes,
onValueChange = { }, onValueChange = { },
readOnly = true, readOnly = true,
singleLine = false, singleLine = false,
actions = {
BitwardenTonalIconButton(
vectorIconRes = R.drawable.ic_copy,
contentDescription = stringResource(id = R.string.copy_notes),
onClick = onCopyAction,
modifier = Modifier.testTag(tag = "CipherNotesCopyButton"),
)
},
textFieldTestTag = "CipherNotesLabel",
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCardItemTypeHandlers 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.VaultCommonItemTypeHandlers
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.VaultLoginItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers
@ -272,6 +273,9 @@ fun VaultItemScreen(
vaultSshKeyItemTypeHandlers = remember(viewModel) { vaultSshKeyItemTypeHandlers = remember(viewModel) {
VaultSshKeyItemTypeHandlers.create(viewModel = viewModel) VaultSshKeyItemTypeHandlers.create(viewModel = viewModel)
}, },
vaultIdentityItemTypeHandlers = remember(viewModel) {
VaultIdentityItemTypeHandlers.create(viewModel = viewModel)
},
) )
} }
} }
@ -350,6 +354,7 @@ private fun VaultItemContent(
vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers, vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers,
vaultCardItemTypeHandlers: VaultCardItemTypeHandlers, vaultCardItemTypeHandlers: VaultCardItemTypeHandlers,
vaultSshKeyItemTypeHandlers: VaultSshKeyItemTypeHandlers, vaultSshKeyItemTypeHandlers: VaultSshKeyItemTypeHandlers,
vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (viewState) { when (viewState) {
@ -386,6 +391,7 @@ private fun VaultItemContent(
commonState = viewState.common, commonState = viewState.common,
identityState = viewState.type, identityState = viewState.type,
vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers,
vaultIdentityItemTypeHandlers = vaultIdentityItemTypeHandlers,
modifier = modifier, modifier = modifier,
) )
} }

View file

@ -16,7 +16,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTonalIconButton
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextFieldWithActions
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
@ -66,14 +68,22 @@ fun VaultItemSecureNoteContent(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( BitwardenTextFieldWithActions(
label = stringResource(id = R.string.notes), label = stringResource(id = R.string.notes),
value = notes, value = notes,
onValueChange = { }, onValueChange = { },
readOnly = true, readOnly = true,
singleLine = false, singleLine = false,
actions = {
BitwardenTonalIconButton(
vectorIconRes = R.drawable.ic_copy,
contentDescription = stringResource(id = R.string.copy_notes),
onClick = vaultCommonItemTypeHandlers.onCopyNotesClick,
modifier = Modifier.testTag(tag = "CipherNotesCopyButton"),
)
},
textFieldTestTag = "CipherNotesLabel",
modifier = Modifier modifier = Modifier
.testTag("CipherNotesLabel")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) )

View file

@ -133,6 +133,7 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action) is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action)
is VaultItemAction.ItemType.Card -> handleCardTypeActions(action) is VaultItemAction.ItemType.Card -> handleCardTypeActions(action)
is VaultItemAction.ItemType.SshKey -> handleSshKeyTypeActions(action) is VaultItemAction.ItemType.SshKey -> handleSshKeyTypeActions(action)
is VaultItemAction.ItemType.Identity -> handleIdentityTypeActions(action)
is VaultItemAction.Common -> handleCommonActions(action) is VaultItemAction.Common -> handleCommonActions(action)
is VaultItemAction.Internal -> handleInternalAction(action) is VaultItemAction.Internal -> handleInternalAction(action)
} }
@ -184,6 +185,7 @@ class VaultItemViewModel @Inject constructor(
} }
is VaultItemAction.Common.RestoreVaultItemClick -> handleRestoreItemClicked() is VaultItemAction.Common.RestoreVaultItemClick -> handleRestoreItemClicked()
is VaultItemAction.Common.CopyNotesClick -> handleCopyNotesClick()
} }
} }
@ -508,6 +510,13 @@ class VaultItemViewModel @Inject constructor(
} }
} }
private fun handleCopyNotesClick() {
onContent { content ->
val notes = content.common.notes.orEmpty()
clipboardManager.setText(text = notes)
}
}
//endregion Common Handlers //endregion Common Handlers
//region Login Type Handlers //region Login Type Handlers
@ -812,6 +821,99 @@ class VaultItemViewModel @Inject constructor(
//endregion SSH Key Type Handlers //endregion SSH Key Type Handlers
//region Identity Type Handlers
private fun handleIdentityTypeActions(action: VaultItemAction.ItemType.Identity) {
when (action) {
VaultItemAction.ItemType.Identity.CopyIdentityNameClick -> {
handleCopyIdentityNameClick()
}
VaultItemAction.ItemType.Identity.CopyUsernameClick -> {
handleCopyIdentityUsernameClick()
}
VaultItemAction.ItemType.Identity.CopyCompanyClick -> handleCopyCompanyClick()
VaultItemAction.ItemType.Identity.CopySsnClick -> handleCopySsnClick()
VaultItemAction.ItemType.Identity.CopyPassportNumberClick -> {
handleCopyPassportNumberClick()
}
VaultItemAction.ItemType.Identity.CopyLicenseNumberClick -> {
handleCopyLicenseNumberClick()
}
VaultItemAction.ItemType.Identity.CopyEmailClick -> handleCopyEmailClick()
VaultItemAction.ItemType.Identity.CopyPhoneClick -> handleCopyPhoneClick()
VaultItemAction.ItemType.Identity.CopyAddressClick -> handleCopyAddressClick()
}
}
private fun handleCopyIdentityNameClick() {
onIdentityContent { _, identity ->
val identityName = identity.identityName.orEmpty()
clipboardManager.setText(text = identityName)
}
}
private fun handleCopyIdentityUsernameClick() {
onIdentityContent { _, identity ->
val username = identity.username.orEmpty()
clipboardManager.setText(text = username)
}
}
private fun handleCopyCompanyClick() {
onIdentityContent { _, identity ->
val company = identity.company.orEmpty()
clipboardManager.setText(text = company)
}
}
private fun handleCopySsnClick() {
onIdentityContent { _, identity ->
val ssn = identity.ssn.orEmpty()
clipboardManager.setText(text = ssn)
}
}
private fun handleCopyPassportNumberClick() {
onIdentityContent { _, identity ->
val passportNumber = identity.passportNumber.orEmpty()
clipboardManager.setText(text = passportNumber)
}
}
private fun handleCopyLicenseNumberClick() {
onIdentityContent { _, identity ->
val licenseNumber = identity.licenseNumber.orEmpty()
clipboardManager.setText(text = licenseNumber)
}
}
private fun handleCopyEmailClick() {
onIdentityContent { _, identity ->
val email = identity.email.orEmpty()
clipboardManager.setText(text = email)
}
}
private fun handleCopyPhoneClick() {
onIdentityContent { _, identity ->
val phone = identity.phone.orEmpty()
clipboardManager.setText(text = phone)
}
}
private fun handleCopyAddressClick() {
onIdentityContent { _, identity ->
val address = identity.address.orEmpty()
clipboardManager.setText(text = address)
}
}
//endregion Identity Type Handlers
//region Internal Type Handlers //region Internal Type Handlers
private fun handleInternalAction(action: VaultItemAction.Internal) { private fun handleInternalAction(action: VaultItemAction.Internal) {
@ -1133,6 +1235,21 @@ class VaultItemViewModel @Inject constructor(
} }
} }
} }
private inline fun onIdentityContent(
crossinline block: (
VaultItemState.ViewState.Content,
VaultItemState.ViewState.Content.ItemType.Identity,
) -> Unit,
) {
state.viewState.asContentOrNull()
?.let { content ->
(content.type as? VaultItemState.ViewState.Content.ItemType.Identity)
?.let { identityContent ->
block(content, identityContent)
}
}
}
} }
/** /**
@ -1724,6 +1841,11 @@ sealed class VaultItemAction {
* The user confirmed cloning a cipher without its FIDO 2 credentials. * The user confirmed cloning a cipher without its FIDO 2 credentials.
*/ */
data object ConfirmCloneWithoutFido2CredentialClick : Common() data object ConfirmCloneWithoutFido2CredentialClick : Common()
/**
* The user has clicked the copy button for notes text field.
*/
data object CopyNotesClick : Common()
} }
/** /**
@ -1827,6 +1949,56 @@ sealed class VaultItemAction {
*/ */
data object CopyFingerprintClick : SshKey() data object CopyFingerprintClick : SshKey()
} }
/**
* Represents actions specific to the Identity type.
*/
sealed class Identity : VaultItemAction() {
/**
* The user has clicked the copy button for the identity name.
*/
data object CopyIdentityNameClick : Identity()
/**
* The user has clicked the copy button for the username.
*/
data object CopyUsernameClick : Identity()
/**
* The user has clicked the copy button for the company.
*/
data object CopyCompanyClick : Identity()
/**
* The user has clicked the copy button for the SSN.
*/
data object CopySsnClick : Identity()
/**
* The user has clicked the copy button for the passport number.
*/
data object CopyPassportNumberClick : Identity()
/**
* The user has clicked the copy button for the license number.
*/
data object CopyLicenseNumberClick : Identity()
/**
* The user has clicked the copy button for the email.
*/
data object CopyEmailClick : Identity()
/**
* The user has clicked the copy button for the phone number.
*/
data object CopyPhoneClick : Identity()
/**
* The user has clicked the copy button for the address.
*/
data object CopyAddressClick : Identity()
}
} }
/** /**

View file

@ -21,6 +21,7 @@ data class VaultCommonItemTypeHandlers(
Boolean, Boolean,
) -> Unit, ) -> Unit,
val onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit, val onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit,
val onCopyNotesClick: () -> Unit,
) { ) {
@Suppress("UndocumentedPublicClass") @Suppress("UndocumentedPublicClass")
companion object { companion object {
@ -52,6 +53,9 @@ data class VaultCommonItemTypeHandlers(
onAttachmentDownloadClick = { onAttachmentDownloadClick = {
viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(it)) viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(it))
}, },
onCopyNotesClick = {
viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick)
},
) )
} }
} }

View file

@ -0,0 +1,59 @@
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
/**
* A collection of handler functions for managing actions within the context of viewing identity
* items in a vault.
*/
data class VaultIdentityItemTypeHandlers(
val onCopyIdentityNameClick: () -> Unit,
val onCopyUsernameClick: () -> Unit,
val onCopyCompanyClick: () -> Unit,
val onCopySsnClick: () -> Unit,
val onCopyPassportNumberClick: () -> Unit,
val onCopyLicenseNumberClick: () -> Unit,
val onCopyEmailClick: () -> Unit,
val onCopyPhoneClick: () -> Unit,
val onCopyAddressClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass", "MaxLineLength")
companion object {
/**
* Creates the [VaultIdentityItemTypeHandlers] using the [viewModel] to send desired actions.
*/
fun create(
viewModel: VaultItemViewModel,
): VaultIdentityItemTypeHandlers =
VaultIdentityItemTypeHandlers(
onCopyIdentityNameClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyIdentityNameClick)
},
onCopyUsernameClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyUsernameClick)
},
onCopyCompanyClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyCompanyClick)
},
onCopySsnClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopySsnClick)
},
onCopyPassportNumberClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPassportNumberClick)
},
onCopyLicenseNumberClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyLicenseNumberClick)
},
onCopyEmailClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyEmailClick)
},
onCopyPhoneClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPhoneClick)
},
onCopyAddressClick = {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyAddressClick)
},
)
}
}

View file

@ -1087,4 +1087,12 @@ Do you want to switch to this account?</string>
<string name="skip_for_now">Skip for now</string> <string name="skip_for_now">Skip for now</string>
<string name="done_text">Done</string> <string name="done_text">Done</string>
<string name="page_number_x_of_y">%1$s of %2$s</string> <string name="page_number_x_of_y">%1$s of %2$s</string>
<string name="copy_identity_name">Copy identity name</string>
<string name="copy_company">Copy company</string>
<string name="copy_ssn">Copy social security number</string>
<string name="copy_passport_number">Copy passport number</string>
<string name="copy_license_number">Copy license number</string>
<string name="copy_email">Copy email</string>
<string name="copy_phone">Copy phone number</string>
<string name="copy_address">Copy address</string>
</resources> </resources>

View file

@ -19,6 +19,7 @@ import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onSiblings import androidx.compose.ui.test.onSiblings
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@ -1257,6 +1258,114 @@ class VaultItemScreenTest : BaseComposeTest() {
.filterToOne(hasAnyAncestor(isPopup())) .filterToOne(hasAnyAncestor(isPopup()))
.assertDoesNotExist() .assertDoesNotExist()
} }
@Test
fun `on login copy notes field click should send CopyNotesClick`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE,
)
}
composeTestRule.onNodeWithTextAfterScroll("Lots of notes")
composeTestRule
.onNodeWithTag("CipherNotesCopyButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick)
}
}
@Test
fun `on identity copy notes field click should send CopyNotesClick`() {
// Adding a custom field so that we can scroll to it
// So we can see the Copy notes button but not have it covered by the FAB
val textField = VaultItemState.ViewState.Content.Common.Custom.TextField(
name = "text",
value = "value",
isCopyable = true,
)
EMPTY_VIEW_STATES
.forEach { typeState ->
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = typeState.copy(
type = DEFAULT_IDENTITY,
common = EMPTY_COMMON.copy(
notes = "this is a note",
customFields = listOf(textField),
),
),
)
}
composeTestRule.onNodeWithTextAfterScroll(textField.name)
composeTestRule
.onNodeWithTag("CipherNotesCopyButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick)
}
}
}
@Test
fun `on card copy notes field click should send CopyNotesClick`() {
// Adding a custom field so that we can scroll to it
// So we can see the Copy notes button but not have it covered by the FAB
val textField = VaultItemState.ViewState.Content.Common.Custom.TextField(
name = "text",
value = "value",
isCopyable = true,
)
EMPTY_VIEW_STATES
.forEach { typeState ->
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = typeState.copy(
type = DEFAULT_IDENTITY,
common = EMPTY_COMMON.copy(
notes = "this is a note",
customFields = listOf(textField),
),
),
)
}
}
composeTestRule.onNodeWithTextAfterScroll(textField.name)
composeTestRule
.onNodeWithTag("CipherNotesCopyButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick)
}
}
@Test
fun `on secure note copy notes field click should send CopyNotesClick`() {
mutableStateFlow.update { currentState ->
currentState.copy(
viewState = DEFAULT_SECURE_NOTE_VIEW_STATE,
)
}
composeTestRule.onNodeWithTextAfterScroll("Lots of notes")
composeTestRule
.onNodeWithTag("CipherNotesCopyButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick)
}
}
//endregion common //endregion common
//region login //region login
@ -1927,6 +2036,144 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.assertScrollableNodeDoesNotExist(identityName) composeTestRule.assertScrollableNodeDoesNotExist(identityName)
} }
@Test
fun `in identity state, on copy identity name field click should send CopyIdentityNameClick`() {
val identityName = "the identity name"
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(identityName)
composeTestRule
.onNodeWithTag("IdentityCopyNameButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyIdentityNameClick)
}
}
@Test
fun `in identity state, on copy username field click should send CopyUsernameClick`() {
val username = "the username"
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(username)
composeTestRule
.onNodeWithTag("IdentityCopyUsernameButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyUsernameClick)
}
}
@Test
fun `in identity state, on copy company field click should send CopyCompanyClick`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
// Scroll to ssn so we can see the Copy company button but not have it covered by the FAB
composeTestRule.onNodeWithTextAfterScroll("the SSN")
composeTestRule
.onNodeWithTag("IdentityCopyCompanyButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyCompanyClick)
}
}
@Test
fun `in identity state, on copy SSN field click should send CopySsnClick`() {
val ssn = "the SSN"
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(ssn)
composeTestRule
.onNodeWithTag("IdentityCopySsnButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopySsnClick)
}
}
@Suppress("MaxLineLength")
@Test
fun `in identity state, on copy passport number field click should send CopyPassportNumberClick`() {
val passportNumber = "the passport number"
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(passportNumber)
composeTestRule
.onNodeWithTag("IdentityCopyPassportNumberButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPassportNumberClick)
}
}
@Suppress("MaxLineLength")
@Test
fun `in identity state, on copy license number field click should send CopyLicenseNumberClick`() {
val licenseNumber = "the license number"
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(licenseNumber)
composeTestRule
.onNodeWithTag("IdentityCopyLicenseNumberButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyLicenseNumberClick)
}
}
@Test
fun `in identity state, on copy email field click should send CopyEmailClick`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onFirstNodeWithTextAfterScroll("the address")
composeTestRule
.onNodeWithContentDescriptionAfterScroll("Copy email")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyEmailClick)
}
}
@Test
fun `in identity state, on copy phone field click should send CopyPhoneClick`() {
val phone = "the phone number"
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(phone)
composeTestRule
.onNodeWithTag("IdentityCopyPhoneButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPhoneClick)
}
}
@Test
fun `in identity state, on copy address field click should send CopyAddressClick`() {
val address = "the address"
mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) }
composeTestRule.onNodeWithTextAfterScroll(address)
composeTestRule
.onNodeWithTag("IdentityCopyAddressButton")
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyAddressClick)
}
}
//endregion identity //endregion identity
//region card //region card

View file

@ -1670,6 +1670,33 @@ class VaultItemViewModelTest : BaseViewModelTest() {
coVerify { mockFileManager.delete(file) } coVerify { mockFileManager.delete(file) }
} }
@Test
fun `on CopyNotesFieldClick should call setText on ClipboardManager`() {
every {
mockCipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
canDelete = true,
canAssignToCollections = true,
)
} returns DEFAULT_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
val notes = "Lots of notes"
every { clipboardManager.setText(text = notes) } just runs
viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick)
verify(exactly = 1) {
clipboardManager.setText(text = notes)
}
}
} }
@Nested @Nested
@ -2527,6 +2554,143 @@ class VaultItemViewModelTest : BaseViewModelTest() {
} }
} }
@Nested
inner class IdentityActions {
private lateinit var viewModel: VaultItemViewModel
@BeforeEach
fun setup() {
viewModel = createViewModel(
state = DEFAULT_STATE.copy(
viewState = IDENTITY_VIEW_STATE,
),
)
every {
mockCipherView.toViewState(
previousState = null,
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = null,
canDelete = true,
canAssignToCollections = true,
)
} returns IDENTITY_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
}
@Test
fun `on CopyIdentityNameClick should copy fingerprint to clipboard`() =
runTest {
val username = "the username"
every { clipboardManager.setText(text = username) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyUsernameClick)
verify(exactly = 1) {
clipboardManager.setText(text = username)
}
}
@Test
fun `on CopyUsernameClick should copy fingerprint to clipboard`() =
runTest {
val identityName = "the identity name"
every { clipboardManager.setText(text = identityName) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyIdentityNameClick)
verify(exactly = 1) {
clipboardManager.setText(text = identityName)
}
}
@Test
fun `on CopyCompanyClick should copy company to clipboard`() = runTest {
val company = "the company name"
every { clipboardManager.setText(text = company) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyCompanyClick)
verify(exactly = 1) {
clipboardManager.setText(text = company)
}
}
@Test
fun `on CopySsnClick should copy SSN to clipboard`() = runTest {
val ssn = "the SSN"
every { clipboardManager.setText(text = ssn) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopySsnClick)
verify(exactly = 1) {
clipboardManager.setText(text = ssn)
}
}
@Test
fun `on CopyPassportNumberClick should copy passport number to clipboard`() = runTest {
val passportNumber = "the passport number"
every { clipboardManager.setText(text = passportNumber) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPassportNumberClick)
verify(exactly = 1) {
clipboardManager.setText(text = passportNumber)
}
}
@Test
fun `on CopyLicenseNumberClick should copy license number to clipboard`() = runTest {
val licenseNumber = "the license number"
every { clipboardManager.setText(text = licenseNumber) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyLicenseNumberClick)
verify(exactly = 1) {
clipboardManager.setText(text = licenseNumber)
}
}
@Test
fun `on CopyEmailClick should copy email to clipboard`() = runTest {
val email = "the email address"
every { clipboardManager.setText(text = email) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyEmailClick)
verify(exactly = 1) {
clipboardManager.setText(text = email)
}
}
@Test
fun `on CopyPhoneClick should copy phone to clipboard`() = runTest {
val phone = "the phone number"
every { clipboardManager.setText(text = phone) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyPhoneClick)
verify(exactly = 1) {
clipboardManager.setText(text = phone)
}
}
@Test
fun `on CopyAddressClick should copy address to clipboard`() = runTest {
val address = "the address"
every { clipboardManager.setText(text = address) } just runs
viewModel.trySendAction(VaultItemAction.ItemType.Identity.CopyAddressClick)
verify(exactly = 1) {
clipboardManager.setText(text = address)
}
}
}
@Nested @Nested
inner class VaultItemFlow { inner class VaultItemFlow {
@BeforeEach @BeforeEach
@ -2844,6 +3008,19 @@ class VaultItemViewModelTest : BaseViewModelTest() {
showPrivateKey = false, showPrivateKey = false,
) )
private val DEFAULT_IDENTITY_TYPE: VaultItemState.ViewState.Content.ItemType.Identity =
VaultItemState.ViewState.Content.ItemType.Identity(
username = "the username",
identityName = "the identity name",
company = "the company name",
ssn = "the SSN",
passportNumber = "the passport number",
licenseNumber = "the license number",
email = "the email address",
phone = "the phone number",
address = "the address",
)
private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
VaultItemState.ViewState.Content.Common( VaultItemState.ViewState.Content.Common(
name = "login cipher", name = "login cipher",
@ -2908,5 +3085,11 @@ class VaultItemViewModelTest : BaseViewModelTest() {
common = DEFAULT_COMMON, common = DEFAULT_COMMON,
type = DEFAULT_SSH_KEY_TYPE, type = DEFAULT_SSH_KEY_TYPE,
) )
private val IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content =
VaultItemState.ViewState.Content(
common = DEFAULT_COMMON,
type = DEFAULT_IDENTITY_TYPE,
)
} }
} }