[PM-14273] Add copy functionality for SSH key fields (#4204)

This commit is contained in:
Patrick Honkonen 2024-10-31 16:41:13 -04:00 committed by GitHub
parent 0844939eca
commit 2f6578fd5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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.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.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.BitwardenPasswordField
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.VaultSshKeyItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers
@ -55,16 +58,24 @@ fun VaultItemSshKeyContent(
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( BitwardenTextFieldWithActions(
label = stringResource(id = R.string.public_key), label = stringResource(id = R.string.public_key),
value = sshKeyItemState.publicKey, value = sshKeyItemState.publicKey,
onValueChange = { }, onValueChange = { },
singleLine = false, singleLine = false,
readOnly = true, 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 modifier = Modifier
.testTag("SshKeyItemPublicKeyEntry") .testTag("SshKeyItemPublicKeyEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .standardHorizontalMargin(),
) )
} }
@ -88,16 +99,24 @@ fun VaultItemSshKeyContent(
item { item {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
BitwardenTextField( BitwardenTextFieldWithActions(
label = stringResource(id = R.string.fingerprint), label = stringResource(id = R.string.fingerprint),
value = sshKeyItemState.fingerprint, value = sshKeyItemState.fingerprint,
onValueChange = { }, onValueChange = { },
singleLine = false, singleLine = false,
readOnly = true, 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 modifier = Modifier
.testTag("SshKeyItemFingerprintEntry") .testTag("SshKeyItemFingerprintEntry")
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .standardHorizontalMargin(),
) )
} }

View file

@ -758,9 +758,19 @@ class VaultItemViewModel @Inject constructor(
private fun handleSshKeyTypeActions(action: VaultItemAction.ItemType.SshKey) { private fun handleSshKeyTypeActions(action: VaultItemAction.ItemType.SshKey) {
when (action) { when (action) {
VaultItemAction.ItemType.SshKey.CopyPublicKeyClick -> handleCopyPublicKeyClick()
is VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked -> { is VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked -> {
handlePrivateKeyVisibilityClicked(action) 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 //endregion SSH Key Type Handlers
//region Internal Type Handlers //region Internal Type Handlers
@ -1758,10 +1774,20 @@ sealed class VaultItemAction {
* Represents actions specific to the SshKey type. * Represents actions specific to the SshKey type.
*/ */
sealed class SshKey : ItemType() { 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. * The user has clicked to display the private key.
*/ */
data class PrivateKeyVisibilityClicked(val isVisible: Boolean) : SshKey() 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. * items in a vault.
*/ */
data class VaultSshKeyItemTypeHandlers( data class VaultSshKeyItemTypeHandlers(
val onCopyPublicKeyClick: () -> Unit,
val onShowPrivateKeyClick: (isVisible: Boolean) -> Unit, val onShowPrivateKeyClick: (isVisible: Boolean) -> Unit,
val onCopyFingerprintClick: () -> Unit,
) { ) {
@Suppress("UndocumentedPublicClass") @Suppress("UndocumentedPublicClass")
@ -20,6 +22,11 @@ data class VaultSshKeyItemTypeHandlers(
@Suppress("LongMethod") @Suppress("LongMethod")
fun create(viewModel: VaultItemViewModel): VaultSshKeyItemTypeHandlers = fun create(viewModel: VaultItemViewModel): VaultSshKeyItemTypeHandlers =
VaultSshKeyItemTypeHandlers( VaultSshKeyItemTypeHandlers(
onCopyPublicKeyClick = {
viewModel.trySendAction(
VaultItemAction.ItemType.SshKey.CopyPublicKeyClick,
)
},
onShowPrivateKeyClick = { onShowPrivateKeyClick = {
viewModel.trySendAction( viewModel.trySendAction(
VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( 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="public_key">Public key</string>
<string name="private_key">Private key</string> <string name="private_key">Private key</string>
<string name="ssh_keys">SSH keys</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> </resources>

View file

@ -2153,6 +2153,18 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithTextAfterScroll(publicKey).assertIsDisplayed() 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 @Test
fun `in ssh key state, private key should be displayed according to state`() { fun `in ssh key state, private key should be displayed according to state`() {
val privateKey = "the private key" val privateKey = "the private key"
@ -2195,6 +2207,18 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.onNodeWithTextAfterScroll(fingerprint).assertIsDisplayed() 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 //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") @Suppress("MaxLineLength")
@Test @Test
fun `on PrivateKeyVisibilityClick should show password dialog when re-prompt is required`() = fun `on PrivateKeyVisibilityClick should show password dialog when re-prompt is required`() =
@ -2388,6 +2411,29 @@ class VaultItemViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.value, 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 @Nested