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