From 2f6578fd5acfb836048d2d20ec7538915fdda4fb Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Thu, 31 Oct 2024 16:41:13 -0400 Subject: [PATCH] [PM-14273] Add copy functionality for SSH key fields (#4204) --- .../feature/item/VaultItemSshKeyContent.kt | 27 +++++++++-- .../vault/feature/item/VaultItemViewModel.kt | 26 +++++++++++ .../handlers/VaultSshKeyItemTypeHandlers.kt | 12 +++++ app/src/main/res/values/strings.xml | 2 + .../vault/feature/item/VaultItemScreenTest.kt | 24 ++++++++++ .../feature/item/VaultItemViewModelTest.kt | 46 +++++++++++++++++++ 6 files changed, 133 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt index 505ccb8e3..fcd744aaa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt @@ -12,8 +12,11 @@ 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.base.util.standardHorizontalMargin +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTonalIconButton import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField 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.VaultSshKeyItemTypeHandlers @@ -55,16 +58,24 @@ fun VaultItemSshKeyContent( item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + BitwardenTextFieldWithActions( label = stringResource(id = R.string.public_key), value = sshKeyItemState.publicKey, onValueChange = { }, singleLine = false, readOnly = true, + actions = { + BitwardenTonalIconButton( + vectorIconRes = R.drawable.ic_copy, + contentDescription = stringResource(id = R.string.copy_public_key), + onClick = vaultSshKeyItemTypeHandlers.onCopyPublicKeyClick, + modifier = Modifier.testTag(tag = "SshKeyCopyPublicKeyButton"), + ) + }, modifier = Modifier .testTag("SshKeyItemPublicKeyEntry") .fillMaxWidth() - .padding(horizontal = 16.dp), + .standardHorizontalMargin(), ) } @@ -88,16 +99,24 @@ fun VaultItemSshKeyContent( item { Spacer(modifier = Modifier.height(8.dp)) - BitwardenTextField( + BitwardenTextFieldWithActions( label = stringResource(id = R.string.fingerprint), value = sshKeyItemState.fingerprint, onValueChange = { }, singleLine = false, readOnly = true, + actions = { + BitwardenTonalIconButton( + vectorIconRes = R.drawable.ic_copy, + contentDescription = stringResource(id = R.string.copy_fingerprint), + onClick = vaultSshKeyItemTypeHandlers.onCopyFingerprintClick, + modifier = Modifier.testTag(tag = "SshKeyCopyFingerprintButton"), + ) + }, modifier = Modifier .testTag("SshKeyItemFingerprintEntry") .fillMaxWidth() - .padding(horizontal = 16.dp), + .standardHorizontalMargin(), ) } 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 0168b289d..b3623dbdc 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 @@ -758,9 +758,19 @@ class VaultItemViewModel @Inject constructor( private fun handleSshKeyTypeActions(action: VaultItemAction.ItemType.SshKey) { when (action) { + VaultItemAction.ItemType.SshKey.CopyPublicKeyClick -> handleCopyPublicKeyClick() + is VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked -> { handlePrivateKeyVisibilityClicked(action) } + + VaultItemAction.ItemType.SshKey.CopyFingerprintClick -> handleCopyFingerprintClick() + } + } + + private fun handleCopyPublicKeyClick() { + onSshKeyContent { _, sshKey -> + clipboardManager.setText(text = sshKey.publicKey) } } @@ -778,6 +788,12 @@ class VaultItemViewModel @Inject constructor( } } + private fun handleCopyFingerprintClick() { + onSshKeyContent { _, sshKey -> + clipboardManager.setText(text = sshKey.fingerprint) + } + } + //endregion SSH Key Type Handlers //region Internal Type Handlers @@ -1758,10 +1774,20 @@ sealed class VaultItemAction { * Represents actions specific to the SshKey type. */ sealed class SshKey : ItemType() { + /** + * The user has clicked the copy button for the public key. + */ + data object CopyPublicKeyClick : SshKey() + /** * The user has clicked to display the private key. */ data class PrivateKeyVisibilityClicked(val isVisible: Boolean) : SshKey() + + /** + * The user has clicked the copy button for the fingerprint. + */ + data object CopyFingerprintClick : SshKey() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt index fc4e1411b..340ba90d1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt @@ -8,7 +8,9 @@ import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemViewModel * items in a vault. */ data class VaultSshKeyItemTypeHandlers( + val onCopyPublicKeyClick: () -> Unit, val onShowPrivateKeyClick: (isVisible: Boolean) -> Unit, + val onCopyFingerprintClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") @@ -20,6 +22,11 @@ data class VaultSshKeyItemTypeHandlers( @Suppress("LongMethod") fun create(viewModel: VaultItemViewModel): VaultSshKeyItemTypeHandlers = VaultSshKeyItemTypeHandlers( + onCopyPublicKeyClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.SshKey.CopyPublicKeyClick, + ) + }, onShowPrivateKeyClick = { viewModel.trySendAction( VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( @@ -27,6 +34,11 @@ data class VaultSshKeyItemTypeHandlers( ), ) }, + onCopyFingerprintClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.SshKey.CopyFingerprintClick, + ) + }, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c1c14a0f..5c817e004 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1079,4 +1079,6 @@ Do you want to switch to this account? Public key Private key SSH keys + Copy public key + Copy fingerprint 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 07f9dd129..dbcd02847 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 @@ -2153,6 +2153,18 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithTextAfterScroll(publicKey).assertIsDisplayed() } + @Test + fun `in ssh key state, on copy public key click should send CopyPublicKeyClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) } + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Copy public key") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPublicKeyClick) + } + } + @Test fun `in ssh key state, private key should be displayed according to state`() { val privateKey = "the private key" @@ -2195,6 +2207,18 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.onNodeWithTextAfterScroll(fingerprint).assertIsDisplayed() } + @Test + fun `in ssh key state, on copy fingerprint click should send CopyFingerprintClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) } + composeTestRule + .onNodeWithContentDescription("Copy fingerprint") + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyFingerprintClick) + } + } + //endregion ssh key } 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 be2bc9512..560de35a1 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 @@ -2349,6 +2349,29 @@ class VaultItemViewModelTest : BaseViewModelTest() { ) } + @Test + fun `on CopyPublicKeyClick should copy public key to clipboard`() = runTest { + every { clipboardManager.setText("mockPublicKey") } just runs + val mockCipherView = mockk { + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + ) + } returns SSH_KEY_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyPublicKeyClick) + + verify(exactly = 1) { + clipboardManager.setText(text = DEFAULT_SSH_KEY_TYPE.publicKey) + } + } + @Suppress("MaxLineLength") @Test fun `on PrivateKeyVisibilityClick should show password dialog when re-prompt is required`() = @@ -2388,6 +2411,29 @@ class VaultItemViewModelTest : BaseViewModelTest() { viewModel.stateFlow.value, ) } + + @Test + fun `on CopyFingerprintClick should copy fingerprint to clipboard`() = runTest { + every { clipboardManager.setText("mockFingerprint") } just runs + val mockCipherView = mockk { + every { + toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + ) + } returns SSH_KEY_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) + + viewModel.trySendAction(VaultItemAction.ItemType.SshKey.CopyFingerprintClick) + + verify(exactly = 1) { + clipboardManager.setText(text = DEFAULT_SSH_KEY_TYPE.fingerprint) + } + } } @Nested