From d418444dc05f5d57d46385101e436f2c2bbd0ad9 Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:31:00 +0000 Subject: [PATCH] [PM-13831] Add copy button identity and note fields (#4302) --- .../feature/item/VaultItemCardContent.kt | 13 +- .../feature/item/VaultItemIdentityContent.kt | 136 ++++++---- .../feature/item/VaultItemLoginContent.kt | 14 +- .../ui/vault/feature/item/VaultItemScreen.kt | 6 + .../item/VaultItemSecureNoteContent.kt | 14 +- .../vault/feature/item/VaultItemViewModel.kt | 172 ++++++++++++ .../handlers/VaultCommonItemTypeHandlers.kt | 4 + .../handlers/VaultIdentityItemTypeHandlers.kt | 59 +++++ app/src/main/res/values/strings.xml | 8 + .../vault/feature/item/VaultItemScreenTest.kt | 247 ++++++++++++++++++ .../feature/item/VaultItemViewModelTest.kt | 183 +++++++++++++ 11 files changed, 798 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultIdentityItemTypeHandlers.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt index 29d25f365..fd2a182d7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt @@ -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.field.BitwardenPasswordFieldWithActions 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.vault.feature.item.handlers.VaultCardItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers @@ -173,14 +174,22 @@ fun VaultItemCardContent( .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + BitwardenTextFieldWithActions( label = stringResource(id = R.string.notes), value = notes, onValueChange = { }, readOnly = true, 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 - .testTag("CipherNotesLabel") .fillMaxWidth() .padding(horizontal = 16.dp), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt index 6606a4bae..3e82215ff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt @@ -13,19 +13,23 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.BitwardenTextFieldWithActions 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.VaultIdentityItemTypeHandlers /** * The top level content UI state for the [VaultItemScreen] when viewing a Identity cipher. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "MaxLineLength") @Composable fun VaultItemIdentityContent( identityState: VaultItemState.ViewState.Content.ItemType.Identity, commonState: VaultItemState.ViewState.Content.Common, vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, + vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers, modifier: Modifier = Modifier, ) { LazyColumn(modifier = modifier) { @@ -54,14 +58,14 @@ fun VaultItemIdentityContent( identityState.identityName?.let { identityName -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.identity_name), value = identityName, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_identity_name), + textFieldTestTag = "IdentityNameEntry", + copyActionTestTag = "IdentityCopyNameButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyIdentityNameClick, modifier = Modifier - .testTag("IdentityNameEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -70,14 +74,14 @@ fun VaultItemIdentityContent( identityState.username?.let { username -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.username), value = username, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_username), + textFieldTestTag = "IdentityUsernameEntry", + copyActionTestTag = "IdentityCopyUsernameButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyUsernameClick, modifier = Modifier - .testTag("IdentityUsernameEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -86,14 +90,14 @@ fun VaultItemIdentityContent( identityState.company?.let { company -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.company), value = company, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_company), + textFieldTestTag = "IdentityCompanyEntry", + copyActionTestTag = "IdentityCopyCompanyButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyCompanyClick, modifier = Modifier - .testTag("IdentityCompanyEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -102,14 +106,14 @@ fun VaultItemIdentityContent( identityState.ssn?.let { ssn -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.ssn), value = ssn, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_ssn), + textFieldTestTag = "IdentitySsnEntry", + copyActionTestTag = "IdentityCopySsnButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopySsnClick, modifier = Modifier - .testTag("IdentitySsnEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -118,14 +122,14 @@ fun VaultItemIdentityContent( identityState.passportNumber?.let { passportNumber -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.passport_number), value = passportNumber, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_passport_number), + textFieldTestTag = "IdentityPassportNumberEntry", + copyActionTestTag = "IdentityCopyPassportNumberButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyPassportNumberClick, modifier = Modifier - .testTag("IdentityPassportNumberEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -134,14 +138,14 @@ fun VaultItemIdentityContent( identityState.licenseNumber?.let { licenseNumber -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.license_number), value = licenseNumber, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_license_number), + textFieldTestTag = "IdentityLicenseNumberEntry", + copyActionTestTag = "IdentityCopyLicenseNumberButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyLicenseNumberClick, modifier = Modifier - .testTag("IdentityLicenseNumberEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -150,14 +154,14 @@ fun VaultItemIdentityContent( identityState.email?.let { email -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.email), value = email, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_email), + textFieldTestTag = "IdentityEmailEntry", + copyActionTestTag = "IdentityCopyEmailButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyEmailClick, modifier = Modifier - .testTag("IdentityEmailEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -166,14 +170,14 @@ fun VaultItemIdentityContent( identityState.phone?.let { phone -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.phone), value = phone, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_phone), + textFieldTestTag = "IdentityPhoneEntry", + copyActionTestTag = "IdentityCopyPhoneButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyPhoneClick, modifier = Modifier - .testTag("IdentityPhoneEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -182,20 +186,19 @@ fun VaultItemIdentityContent( identityState.address?.let { address -> item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.address), value = address, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_address), + textFieldTestTag = "IdentityAddressEntry", + copyActionTestTag = "IdentityCopyAddressButton", + onCopyClick = vaultIdentityItemTypeHandlers.onCopyAddressClick, modifier = Modifier - .testTag("IdentityAddressEntry") .fillMaxWidth() .padding(horizontal = 16.dp), ) } } - commonState.notes?.let { notes -> item { Spacer(modifier = Modifier.height(4.dp)) @@ -206,14 +209,14 @@ fun VaultItemIdentityContent( .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + IdentityCopyField( label = stringResource(id = R.string.notes), value = notes, - onValueChange = { }, - readOnly = true, - singleLine = false, + copyContentDescription = stringResource(id = R.string.copy_notes), + textFieldTestTag = "CipherNotesLabel", + copyActionTestTag = "CipherNotesCopyButton", + onCopyClick = vaultCommonItemTypeHandlers.onCopyNotesClick, modifier = Modifier - .testTag("CipherNotesLabel") .fillMaxWidth() .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, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index 308741fb3..fce007507 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -158,8 +158,8 @@ fun VaultItemLoginContent( Spacer(modifier = Modifier.height(8.dp)) NotesField( notes = notes, + onCopyAction = vaultCommonItemTypeHandlers.onCopyNotesClick, modifier = Modifier - .testTag("CipherNotesLabel") .fillMaxWidth() .padding(horizontal = 16.dp), ) @@ -273,14 +273,24 @@ private fun Fido2CredentialField( @Composable private fun NotesField( notes: String, + onCopyAction: () -> Unit, modifier: Modifier = Modifier, ) { - BitwardenTextField( + BitwardenTextFieldWithActions( label = stringResource(id = R.string.notes), value = notes, onValueChange = { }, readOnly = true, 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, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 5f8ca044e..268ac415a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -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.vault.feature.item.handlers.VaultCardItemTypeHandlers 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.VaultSshKeyItemTypeHandlers @@ -272,6 +273,9 @@ fun VaultItemScreen( vaultSshKeyItemTypeHandlers = remember(viewModel) { VaultSshKeyItemTypeHandlers.create(viewModel = viewModel) }, + vaultIdentityItemTypeHandlers = remember(viewModel) { + VaultIdentityItemTypeHandlers.create(viewModel = viewModel) + }, ) } } @@ -350,6 +354,7 @@ private fun VaultItemContent( vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers, vaultCardItemTypeHandlers: VaultCardItemTypeHandlers, vaultSshKeyItemTypeHandlers: VaultSshKeyItemTypeHandlers, + vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers, modifier: Modifier = Modifier, ) { when (viewState) { @@ -386,6 +391,7 @@ private fun VaultItemContent( commonState = viewState.common, identityState = viewState.type, vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + vaultIdentityItemTypeHandlers = vaultIdentityItemTypeHandlers, modifier = modifier, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt index 7fbbdfc64..9d68e3dc5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt @@ -16,7 +16,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp 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.BitwardenTextFieldWithActions import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers @@ -66,14 +68,22 @@ fun VaultItemSecureNoteContent( .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + BitwardenTextFieldWithActions( label = stringResource(id = R.string.notes), value = notes, onValueChange = { }, readOnly = true, 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 - .testTag("CipherNotesLabel") .fillMaxWidth() .padding(horizontal = 16.dp), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index f119ce545..021d88624 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -133,6 +133,7 @@ class VaultItemViewModel @Inject constructor( is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action) is VaultItemAction.ItemType.Card -> handleCardTypeActions(action) is VaultItemAction.ItemType.SshKey -> handleSshKeyTypeActions(action) + is VaultItemAction.ItemType.Identity -> handleIdentityTypeActions(action) is VaultItemAction.Common -> handleCommonActions(action) is VaultItemAction.Internal -> handleInternalAction(action) } @@ -184,6 +185,7 @@ class VaultItemViewModel @Inject constructor( } 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 //region Login Type Handlers @@ -812,6 +821,99 @@ class VaultItemViewModel @Inject constructor( //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 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. */ 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() } + + /** + * 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() + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt index ec011346e..7dfa682ca 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt @@ -21,6 +21,7 @@ data class VaultCommonItemTypeHandlers( Boolean, ) -> Unit, val onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit, + val onCopyNotesClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -52,6 +53,9 @@ data class VaultCommonItemTypeHandlers( onAttachmentDownloadClick = { viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(it)) }, + onCopyNotesClick = { + viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultIdentityItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultIdentityItemTypeHandlers.kt new file mode 100644 index 000000000..d44ac6066 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultIdentityItemTypeHandlers.kt @@ -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) + }, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6f12dfda..426bcbcce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1087,4 +1087,12 @@ Do you want to switch to this account? Skip for now Done %1$s of %2$s + Copy identity name + Copy company + Copy social security number + Copy passport number + Copy license number + Copy email + Copy phone number + Copy address diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 0c71c774f..f63b90561 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onSiblings import androidx.compose.ui.test.performClick @@ -1257,6 +1258,114 @@ class VaultItemScreenTest : BaseComposeTest() { .filterToOne(hasAnyAncestor(isPopup())) .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 //region login @@ -1927,6 +2036,144 @@ class VaultItemScreenTest : BaseComposeTest() { 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 //region card diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 89485e7a6..4a82e0c32 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -1670,6 +1670,33 @@ class VaultItemViewModelTest : BaseViewModelTest() { 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 @@ -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 inner class VaultItemFlow { @BeforeEach @@ -2844,6 +3008,19 @@ class VaultItemViewModelTest : BaseViewModelTest() { 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 = VaultItemState.ViewState.Content.Common( name = "login cipher", @@ -2908,5 +3085,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { common = DEFAULT_COMMON, type = DEFAULT_SSH_KEY_TYPE, ) + + private val IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = DEFAULT_COMMON, + type = DEFAULT_IDENTITY_TYPE, + ) } }