PM-14273 Add copy functionality for SSH key public key and fingerprint

This commit adds the ability to copy the public key and fingerprint of an SSH key within the vault item view.

It introduces new buttons to trigger the copy actions and updates the view model and item handlers to handle the copy functionality.
This commit is contained in:
Patrick Honkonen 2024-10-30 15:30:50 -04:00
parent eaa7923d1f
commit 5ab42296ce
No known key found for this signature in database
GPG key ID: B63AF42A5531C877
6 changed files with 133 additions and 4 deletions

View file

@ -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(),
)
}

View file

@ -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()
}
}

View file

@ -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,
)
},
)
}
}

View file

@ -1079,4 +1079,6 @@ Do you want to switch to this account?</string>
<string name="public_key">Public key</string>
<string name="private_key">Private key</string>
<string name="ssh_keys">SSH keys</string>
<string name="copy_public_key">Copy public key</string>
<string name="copy_fingerprint">Copy fingerprint</string>
</resources>

View file

@ -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
}

View file

@ -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<CipherView> {
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<CipherView> {
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