diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt index 71abdba5b..9f451a7ab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt @@ -21,7 +21,6 @@ private const val CARD_DIGITS_DISPLAYED: Int = 4 val CipherView.subtitle: String? get() = when (type) { CipherType.LOGIN -> this.login?.username.orEmpty() - CipherType.SECURE_NOTE -> null CipherType.CARD -> { this .card @@ -45,6 +44,10 @@ val CipherView.subtitle: String? } } } + + CipherType.SECURE_NOTE, + CipherType.SSH_KEY, + -> null } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequest.kt index dc7adef69..dbf4266f4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequest.kt @@ -51,6 +51,9 @@ data class CipherJsonRequest( @SerialName("secureNote") val secureNote: SyncResponseJson.Cipher.SecureNote?, + @SerialName("sshKey") + val sshKey: SyncResponseJson.Cipher.SshKey?, + @SerialName("folderId") val folderId: String?, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherTypeJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherTypeJson.kt index 63ddfaec8..1b43aeec7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherTypeJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherTypeJson.kt @@ -33,6 +33,12 @@ enum class CipherTypeJson { */ @SerialName("4") IDENTITY, + + /** + * A SSH key. + */ + @SerialName("5") + SSH_KEY, } @Keep diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt index 5ea91d252..8e47a2624 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseJson.kt @@ -472,6 +472,9 @@ data class SyncResponseJson( @SerialName("identity") val identity: Identity?, + @SerialName("sshKey") + val sshKey: SshKey?, + @SerialName("collectionIds") val collectionIds: List?, @@ -718,6 +721,25 @@ data class SyncResponseJson( ) } + /** + * Represents a SSH key in the vault response. + * + * @property publicKey The public key of the SSH key. + * @property privateKey The private key of the SSH key. + * @property keyFingerprint The key fingerprint of the SSH key. + */ + @Serializable + data class SshKey( + @SerialName("publicKey") + val publicKey: String?, + + @SerialName("privateKey") + val privateKey: String?, + + @SerialName("keyFingerprint") + val keyFingerprint: String?, + ) + /** * Represents password history in the vault response. * diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt index 6c5e8f28d..c31cffaad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt @@ -17,6 +17,7 @@ import com.bitwarden.vault.LoginUri import com.bitwarden.vault.PasswordHistory import com.bitwarden.vault.SecureNote import com.bitwarden.vault.SecureNoteType +import com.bitwarden.vault.SshKey import com.bitwarden.vault.UriMatchType import com.x8bit.bitwarden.data.platform.util.SpecialCharWithPrecedenceComparator import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRequest @@ -55,6 +56,7 @@ fun Cipher.toEncryptedNetworkCipher(): CipherJsonRequest = isFavorite = favorite, card = card?.toEncryptedNetworkCard(), key = key, + sshKey = sshKey?.toEncryptedNetworkSshKey(), ) /** @@ -77,6 +79,7 @@ fun Cipher.toEncryptedNetworkCipherResponse(): SyncResponseJson.Cipher = isFavorite = favorite, card = card?.toEncryptedNetworkCard(), attachments = attachments?.toNetworkAttachmentList(), + sshKey = sshKey?.toEncryptedNetworkSshKey(), shouldOrganizationUseTotp = organizationUseTotp, shouldEdit = edit, revisionDate = ZonedDateTime.ofInstant(revisionDate, ZoneOffset.UTC), @@ -102,6 +105,13 @@ private fun Card.toEncryptedNetworkCard(): SyncResponseJson.Cipher.Card = brand = brand, ) +private fun SshKey.toEncryptedNetworkSshKey(): SyncResponseJson.Cipher.SshKey = + SyncResponseJson.Cipher.SshKey( + publicKey = publicKey, + privateKey = privateKey, + keyFingerprint = fingerprint, + ) + /** * Converts a list of Bitwarden SDK [Field] objects to a corresponding * list of [SyncResponseJson.Cipher.Field] objects. @@ -309,6 +319,7 @@ private fun CipherType.toNetworkCipherType(): CipherTypeJson = CipherType.SECURE_NOTE -> CipherTypeJson.SECURE_NOTE CipherType.CARD -> CipherTypeJson.CARD CipherType.IDENTITY -> CipherTypeJson.IDENTITY + CipherType.SSH_KEY -> CipherTypeJson.SSH_KEY } /** @@ -334,6 +345,7 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher = type = type.toSdkCipherType(), login = login?.toSdkLogin(), identity = identity?.toSdkIdentity(), + sshKey = sshKey?.toSdkSshKey(), card = card?.toSdkCard(), secureNote = secureNote?.toSdkSecureNote(), favorite = isFavorite, @@ -432,6 +444,17 @@ fun SyncResponseJson.Cipher.SecureNote.toSdkSecureNote(): SecureNote = }, ) +/** + * Transforms a [SyncResponseJson.Cipher.SshKey] into + * the corresponding Bitwarden SDK [SshKey]. + */ +fun SyncResponseJson.Cipher.SshKey.toSdkSshKey(): SshKey = + SshKey( + publicKey = publicKey, + privateKey = privateKey, + fingerprint = keyFingerprint, + ) + /** * Transforms a list of [SyncResponseJson.Cipher.Login.Uri] into * a corresponding list of Bitwarden SDK [LoginUri]. @@ -517,6 +540,7 @@ fun CipherTypeJson.toSdkCipherType(): CipherType = CipherTypeJson.SECURE_NOTE -> CipherType.SECURE_NOTE CipherTypeJson.CARD -> CipherType.CARD CipherTypeJson.IDENTITY -> CipherType.IDENTITY + CipherTypeJson.SSH_KEY -> CipherType.SSH_KEY } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt index 40f59b517..93bf3d9a0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt @@ -26,6 +26,7 @@ private const val SEARCH_TYPE_VAULT_TRASH: String = "search_type_vault_trash" private const val SEARCH_TYPE_VAULT_VERIFICATION_CODES: String = "search_type_vault_verification_codes" private const val SEARCH_TYPE_ID: String = "search_type_id" +private const val SEARCH_TYPE_VAULT_SSH_KEYS: String = "search_type_vault_ssh_keys" private const val SEARCH_ROUTE_PREFIX: String = "search" private const val SEARCH_ROUTE: String = "$SEARCH_ROUTE_PREFIX/{$SEARCH_TYPE}/{$SEARCH_TYPE_ID}" @@ -104,6 +105,7 @@ private fun determineSearchType( SEARCH_TYPE_VAULT_FOLDER -> SearchType.Vault.Folder(requireNotNull(id)) SEARCH_TYPE_VAULT_TRASH -> SearchType.Vault.Trash SEARCH_TYPE_VAULT_VERIFICATION_CODES -> SearchType.Vault.VerificationCodes + SEARCH_TYPE_VAULT_SSH_KEYS -> SearchType.Vault.SshKeys else -> throw IllegalArgumentException("Invalid Search Type") } @@ -122,6 +124,7 @@ private fun SearchType.toTypeString(): String = SearchType.Vault.SecureNotes -> SEARCH_TYPE_VAULT_SECURE_NOTES SearchType.Vault.Trash -> SEARCH_TYPE_VAULT_TRASH SearchType.Vault.VerificationCodes -> SEARCH_TYPE_VAULT_VERIFICATION_CODES + SearchType.Vault.SshKeys -> SEARCH_TYPE_VAULT_SSH_KEYS } private fun SearchType.toIdOrNull(): String? = @@ -139,4 +142,5 @@ private fun SearchType.toIdOrNull(): String? = SearchType.Vault.SecureNotes -> null SearchType.Vault.Trash -> null SearchType.Vault.VerificationCodes -> null + SearchType.Vault.SshKeys -> null } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index 1f06d7871..f498b8372 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager @@ -876,6 +877,7 @@ sealed class SearchTypeData : Parcelable { * Indicates that we should be searching vault items. */ @Parcelize + @OmitFromCoverage sealed class Vault : SearchTypeData() { /** * Indicates that we should be searching all vault items. @@ -924,6 +926,16 @@ sealed class SearchTypeData : Parcelable { .concat(R.string.secure_notes.asText()) } + /** + * Indicates that we should be searching only ssh key ciphers. + */ + data object SshKeys : Vault() { + override val title: Text + get() = R.string.search.asText() + .concat(" ".asText()) + .concat(R.string.ssh_keys.asText()) + } + /** * Indicates that we should be searching only ciphers in the given collection. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/model/SearchType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/model/SearchType.kt index 3eb74d887..94331d15a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/model/SearchType.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/model/SearchType.kt @@ -58,6 +58,11 @@ sealed class SearchType : Parcelable { */ data object SecureNotes : Vault() + /** + * Indicates that we should be searching only SSH key ciphers. + */ + data object SshKeys : Vault() + /** * Indicates that we should be searching only ciphers in the given collection. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt index 861eb7c67..650dfd707 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt @@ -62,6 +62,7 @@ fun SearchTypeData.updateWithAdditionalDataIfNecessary( SearchTypeData.Vault.SecureNotes -> this SearchTypeData.Vault.Trash -> this SearchTypeData.Vault.VerificationCodes -> this + SearchTypeData.Vault.SshKeys -> this } /** @@ -114,6 +115,7 @@ private fun CipherView.filterBySearchType( is SearchTypeData.Vault.Identities -> type == CipherType.IDENTITY && deletedDate == null is SearchTypeData.Vault.Logins -> type == CipherType.LOGIN && deletedDate == null is SearchTypeData.Vault.SecureNotes -> type == CipherType.SECURE_NOTE && deletedDate == null + is SearchTypeData.Vault.SshKeys -> type == CipherType.SSH_KEY && deletedDate == null is SearchTypeData.Vault.VerificationCodes -> login?.totp != null && deletedDate == null is SearchTypeData.Vault.Trash -> deletedDate != null } @@ -255,6 +257,7 @@ private val CipherType.iconRes: Int CipherType.SECURE_NOTE -> R.drawable.ic_note CipherType.CARD -> R.drawable.ic_payment_card CipherType.IDENTITY -> R.drawable.ic_id_card + CipherType.SSH_KEY -> R.drawable.ic_ssh_key } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensions.kt index 29b865ec9..c0475acb2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensions.kt @@ -21,4 +21,5 @@ fun SearchType.toSearchTypeData(): SearchTypeData = SearchType.Vault.SecureNotes -> SearchTypeData.Vault.SecureNotes SearchType.Vault.Trash -> SearchTypeData.Vault.Trash SearchType.Vault.VerificationCodes -> SearchTypeData.Vault.VerificationCodes + SearchType.Vault.SshKeys -> SearchTypeData.Vault.SshKeys } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt index 979a021c8..fcd7fc16a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditItemContent.kt @@ -21,21 +21,24 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCardTyp import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCommonHandlers import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditIdentityTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLoginTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditSshKeyTypeHandlers import kotlinx.collections.immutable.toImmutableList /** * The top level content UI state for the [VaultAddEditScreen]. */ @Composable -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") fun VaultAddEditContent( state: VaultAddEditState.ViewState.Content, isAddItemMode: Boolean, + typeOptions: List, onTypeOptionClicked: (VaultAddEditState.ItemTypeOption) -> Unit, commonTypeHandlers: VaultAddEditCommonHandlers, loginItemTypeHandlers: VaultAddEditLoginTypeHandlers, identityItemTypeHandlers: VaultAddEditIdentityTypeHandlers, cardItemTypeHandlers: VaultAddEditCardTypeHandlers, + sshKeyItemTypeHandlers: VaultAddEditSshKeyTypeHandlers, modifier: Modifier = Modifier, permissionsManager: PermissionsManager, ) { @@ -45,6 +48,7 @@ fun VaultAddEditContent( is VaultAddEditState.ViewState.Content.ItemType.SecureNotes -> Unit is VaultAddEditState.ViewState.Content.ItemType.Card -> Unit is VaultAddEditState.ViewState.Content.ItemType.Identity -> Unit + is VaultAddEditState.ViewState.Content.ItemType.SshKey -> Unit is VaultAddEditState.ViewState.Content.ItemType.Login -> { loginItemTypeHandlers.onSetupTotpClick(isGranted) } @@ -77,6 +81,7 @@ fun VaultAddEditContent( item { Spacer(modifier = Modifier.height(8.dp)) TypeOptionsItem( + entries = typeOptions, itemType = state.type, onTypeOptionClicked = onTypeOptionClicked, modifier = Modifier @@ -131,6 +136,15 @@ fun VaultAddEditContent( commonTypeHandlers = commonTypeHandlers, ) } + + is VaultAddEditState.ViewState.Content.ItemType.SshKey -> { + vaultAddEditSshKeyItems( + commonState = state.common, + sshKeyState = state.type, + commonTypeHandlers = commonTypeHandlers, + sshKeyTypeHandlers = sshKeyItemTypeHandlers, + ) + } } item { @@ -141,12 +155,12 @@ fun VaultAddEditContent( @Composable private fun TypeOptionsItem( + entries: List, itemType: VaultAddEditState.ViewState.Content.ItemType, onTypeOptionClicked: (VaultAddEditState.ItemTypeOption) -> Unit, modifier: Modifier = Modifier, ) { - val possibleMainStates = VaultAddEditState.ItemTypeOption.entries.toList() - val optionsWithStrings = possibleMainStates.associateWith { stringResource(id = it.labelRes) } + val optionsWithStrings = entries.associateWith { stringResource(id = it.labelRes) } BitwardenMultiSelectButton( label = stringResource(id = R.string.type), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt index 5a9ee185c..ea2ec0fa6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt @@ -21,6 +21,7 @@ private const val LOGIN: String = "login" private const val CARD: String = "card" private const val IDENTITY: String = "identity" private const val SECURE_NOTE: String = "secure_note" +private const val SSH_KEY: String = "ssh_key" private const val ADD_ITEM_TYPE: String = "vault_add_item_type" private const val ADD_EDIT_ITEM_PREFIX: String = "vault_add_edit_item" @@ -127,6 +128,7 @@ private fun VaultItemCipherType.toTypeString(): String = VaultItemCipherType.CARD -> CARD VaultItemCipherType.IDENTITY -> IDENTITY VaultItemCipherType.SECURE_NOTE -> SECURE_NOTE + VaultItemCipherType.SSH_KEY -> SSH_KEY } private fun String.toVaultItemCipherType(): VaultItemCipherType = @@ -135,6 +137,7 @@ private fun String.toVaultItemCipherType(): VaultItemCipherType = CARD -> VaultItemCipherType.CARD IDENTITY -> VaultItemCipherType.IDENTITY SECURE_NOTE -> VaultItemCipherType.SECURE_NOTE + SSH_KEY -> VaultItemCipherType.SSH_KEY else -> throw IllegalStateException( "Edit Item string arguments for VaultAddEditNavigation must match!", ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index 092806bd2..2bf3d550c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -58,6 +58,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCardTyp import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCommonHandlers import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditIdentityTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLoginTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditSshKeyTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditUserVerificationHandlers /** @@ -155,6 +156,10 @@ fun VaultAddEditScreen( VaultAddEditCardTypeHandlers.create(viewModel = viewModel) } + val sshKeyItemTypeHandlers = remember(viewModel) { + VaultAddEditSshKeyTypeHandlers.create(viewModel = viewModel) + } + val confirmDeleteClickAction = remember(viewModel) { { viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) } } @@ -321,6 +326,7 @@ fun VaultAddEditScreen( VaultAddEditContent( state = viewState, isAddItemMode = state.isAddItemMode, + typeOptions = state.supportedItemTypes, onTypeOptionClicked = remember(viewModel) { { viewModel.trySendAction(VaultAddEditAction.Common.TypeOptionSelect(it)) } }, @@ -329,6 +335,7 @@ fun VaultAddEditScreen( permissionsManager = permissionsManager, identityItemTypeHandlers = identityItemTypeHandlers, cardItemTypeHandlers = cardItemTypeHandlers, + sshKeyItemTypeHandlers = sshKeyItemTypeHandlers, modifier = Modifier .imePadding() .padding(innerPadding) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditSshKeyItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditSshKeyItems.kt new file mode 100644 index 000000000..547ea390d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditSshKeyItems.kt @@ -0,0 +1,127 @@ +package com.x8bit.bitwarden.ui.vault.feature.addedit + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +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.field.BitwardenPasswordField +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme +import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCommonHandlers +import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditSshKeyTypeHandlers + +/** + * The UI for adding and editing a SSH key cipher. + */ +fun LazyListScope.vaultAddEditSshKeyItems( + commonState: VaultAddEditState.ViewState.Content.Common, + sshKeyState: VaultAddEditState.ViewState.Content.ItemType.SshKey, + commonTypeHandlers: VaultAddEditCommonHandlers, + sshKeyTypeHandlers: VaultAddEditSshKeyTypeHandlers, +) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.name), + value = commonState.name, + onValueChange = commonTypeHandlers.onNameTextChange, + modifier = Modifier + .testTag("ItemNameEntry") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.public_key), + value = sshKeyState.publicKey, + onValueChange = sshKeyTypeHandlers.onPublicKeyTextChange, + modifier = Modifier + .testTag("PublicKeyEntry") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenPasswordField( + label = stringResource(id = R.string.private_key), + value = sshKeyState.privateKey, + onValueChange = sshKeyTypeHandlers.onPrivateKeyTextChange, + showPassword = sshKeyState.showPrivateKey, + showPasswordChange = { sshKeyTypeHandlers.onPrivateKeyVisibilityChange(it) }, + showPasswordTestTag = "ViewPrivateKeyButton", + modifier = Modifier + .testTag("PrivateKeyEntry") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.fingerprint), + value = sshKeyState.fingerprint, + onValueChange = sshKeyTypeHandlers.onFingerprintTextChange, + modifier = Modifier + .testTag("FingerprintEntry") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun VaultAddEditSshKeyItems_preview() { + BitwardenTheme { + LazyColumn { + vaultAddEditSshKeyItems( + commonState = VaultAddEditState.ViewState.Content.Common( + name = "SSH Key", + ), + sshKeyState = VaultAddEditState.ViewState.Content.ItemType.SshKey( + publicKey = "public key", + privateKey = "private key", + fingerprint = "fingerprint", + showPublicKey = false, + showPrivateKey = false, + showFingerprint = false, + ), + commonTypeHandlers = VaultAddEditCommonHandlers( + onNameTextChange = { }, + onFolderSelected = { }, + onToggleFavorite = { }, + onToggleMasterPasswordReprompt = { }, + onNotesTextChange = { }, + onOwnerSelected = { }, + onTooltipClick = { }, + onAddNewCustomFieldClick = { _, _ -> }, + onCustomFieldValueChange = { }, + onCustomFieldActionSelect = { _, _ -> }, + onCollectionSelect = { }, + onHiddenFieldVisibilityChange = { }, + ), + sshKeyTypeHandlers = VaultAddEditSshKeyTypeHandlers( + onPublicKeyTextChange = { }, + onPrivateKeyTextChange = { }, + onPrivateKeyVisibilityChange = { }, + onFingerprintTextChange = { }, + ), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 1aa9daa52..3c2e0b6e3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -16,10 +16,12 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull @@ -101,6 +103,7 @@ class VaultAddEditViewModel @Inject constructor( private val resourceManager: ResourceManager, private val clock: Clock, private val organizationEventManager: OrganizationEventManager, + private val featureFlagManager: FeatureFlagManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] @@ -162,6 +165,11 @@ class VaultAddEditViewModel @Inject constructor( // Set special conditions for autofill and fido2 save shouldShowCloseButton = autofillSaveItem == null && fido2AttestationOptions == null, shouldExitOnSave = shouldExitOnSave, + supportedItemTypes = getSupportedItemTypeOptions( + isSshKeyVaultItemSupported = featureFlagManager.getFeatureFlag( + key = FlagKey.SshKeyCipherItems, + ), + ), ) }, ) { @@ -203,6 +211,11 @@ class VaultAddEditViewModel @Inject constructor( } .onEach(::sendAction) .launchIn(viewModelScope) + + featureFlagManager.getFeatureFlagFlow(FlagKey.SshKeyCipherItems) + .map { VaultAddEditAction.Internal.SshKeyCipherItemsFeatureFlagReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: VaultAddEditAction) { @@ -211,6 +224,7 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.ItemType.LoginType -> handleAddLoginTypeAction(action) is VaultAddEditAction.ItemType.IdentityType -> handleIdentityTypeActions(action) is VaultAddEditAction.ItemType.CardType -> handleCardTypeActions(action) + is VaultAddEditAction.ItemType.SshKeyType -> handleSshKeyTypeActions(action) is VaultAddEditAction.Internal -> handleInternalActions(action) } } @@ -320,6 +334,7 @@ class VaultAddEditViewModel @Inject constructor( VaultAddEditState.ItemTypeOption.CARD -> handleSwitchToAddCardItem() VaultAddEditState.ItemTypeOption.IDENTITY -> handleSwitchToAddIdentityItem() VaultAddEditState.ItemTypeOption.SECURE_NOTES -> handleSwitchToAddSecureNotesItem() + VaultAddEditState.ItemTypeOption.SSH_KEYS -> handleSwitchToSshKeyItem() } } @@ -371,6 +386,18 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleSwitchToSshKeyItem() { + updateContent { currentContent -> + currentContent.copy( + common = currentContent.clearNonSharedData(), + type = currentContent.previousItemTypeOrDefault( + itemType = VaultAddEditState.ItemTypeOption.SSH_KEYS, + ), + previousItemTypes = currentContent.toUpdatedPreviousItemTypes(), + ) + } + } + @Suppress("LongMethod") private fun handleSaveClick() = onContent { content -> if (content.common.name.isBlank()) { @@ -1363,6 +1390,54 @@ class VaultAddEditViewModel @Inject constructor( //endregion Card Type Handlers + //region SSH Key Type Handlers + + private fun handleSshKeyTypeActions(action: VaultAddEditAction.ItemType.SshKeyType) { + when (action) { + is VaultAddEditAction.ItemType.SshKeyType.PublicKeyTextChange -> { + handlePublicKeyTextChange(action) + } + + is VaultAddEditAction.ItemType.SshKeyType.PrivateKeyTextChange -> { + handlePrivateKeyTextChange(action) + } + + is VaultAddEditAction.ItemType.SshKeyType.PrivateKeyVisibilityChange -> { + handlePrivateKeyVisibilityChange(action) + } + + is VaultAddEditAction.ItemType.SshKeyType.FingerprintTextChange -> { + handleSshKeyFingerprintTextChange(action) + } + } + } + + private fun handlePublicKeyTextChange( + action: VaultAddEditAction.ItemType.SshKeyType.PublicKeyTextChange, + ) { + updateSshKeyContent { it.copy(publicKey = action.publicKey) } + } + + private fun handlePrivateKeyTextChange( + action: VaultAddEditAction.ItemType.SshKeyType.PrivateKeyTextChange, + ) { + updateSshKeyContent { it.copy(privateKey = action.privateKey) } + } + + private fun handlePrivateKeyVisibilityChange( + action: VaultAddEditAction.ItemType.SshKeyType.PrivateKeyVisibilityChange, + ) { + updateSshKeyContent { it.copy(showPrivateKey = action.isVisible) } + } + + private fun handleSshKeyFingerprintTextChange( + action: VaultAddEditAction.ItemType.SshKeyType.FingerprintTextChange, + ) { + updateSshKeyContent { it.copy(fingerprint = action.fingerprint) } + } + + //endregion SSH Key Type Handlers + //region Internal Type Handlers private fun handleInternalActions(action: VaultAddEditAction.Internal) { @@ -1397,6 +1472,10 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Internal.ValidateFido2PinResultReceive -> { handleValidateFido2PinResultReceive(action) } + + is VaultAddEditAction.Internal.SshKeyCipherItemsFeatureFlagReceive -> { + handleSshKeyCipherItemsFeatureFlagReceive(action) + } } } @@ -1707,6 +1786,18 @@ class VaultAddEditViewModel @Inject constructor( getRequestAndRegisterCredential() } + private fun handleSshKeyCipherItemsFeatureFlagReceive( + action: VaultAddEditAction.Internal.SshKeyCipherItemsFeatureFlagReceive, + ) { + mutableStateFlow.update { + it.copy( + supportedItemTypes = getSupportedItemTypeOptions( + isSshKeyVaultItemSupported = action.enabled, + ), + ) + } + } + //endregion Internal Type Handlers //region Utility Functions @@ -1818,6 +1909,19 @@ class VaultAddEditViewModel @Inject constructor( } } + private inline fun updateSshKeyContent( + crossinline block: (VaultAddEditState.ViewState.Content.ItemType.SshKey) -> + VaultAddEditState.ViewState.Content.ItemType.SshKey, + ) { + updateContent { currentContent -> + (currentContent.type as? VaultAddEditState.ViewState.Content.ItemType.SshKey)?.let { + currentContent.copy( + type = block(it), + ) + } + } + } + private fun VaultAddEditState.ViewState.Content.clearNonSharedData(): VaultAddEditState.ViewState.Content.Common = common.copy( @@ -1852,6 +1956,10 @@ class VaultAddEditViewModel @Inject constructor( VaultAddEditState.ItemTypeOption.SECURE_NOTES -> { VaultAddEditState.ViewState.Content.ItemType.SecureNotes } + + VaultAddEditState.ItemTypeOption.SSH_KEYS -> { + VaultAddEditState.ViewState.Content.ItemType.SshKey() + } }, ) @@ -1911,6 +2019,7 @@ data class VaultAddEditState( val viewState: ViewState, val dialog: DialogState?, val shouldShowCloseButton: Boolean = true, + val supportedItemTypes: List, // Internal val shouldExitOnSave: Boolean = false, val totpData: TotpData? = null, @@ -1957,6 +2066,7 @@ data class VaultAddEditState( CARD(R.string.type_card), IDENTITY(R.string.type_identity), SECURE_NOTES(R.string.type_secure_note), + SSH_KEYS(R.string.type_ssh_key), } /** @@ -2162,6 +2272,25 @@ data class VaultAddEditState( data object SecureNotes : ItemType() { override val itemTypeOption: ItemTypeOption get() = ItemTypeOption.SECURE_NOTES } + + /** + * Represents the `SshKey` item type. + * + * @property publicKey The public key for the SSH key item. + * @property privateKey The private key for the SSH key item. + * @property fingerprint The fingerprint for the SSH key item. + */ + @Parcelize + data class SshKey( + val publicKey: String = "", + val privateKey: String = "", + val fingerprint: String = "", + val showPublicKey: Boolean = false, + val showPrivateKey: Boolean = false, + val showFingerprint: Boolean = false, + ) : ItemType() { + override val itemTypeOption: ItemTypeOption get() = ItemTypeOption.SSH_KEYS + } } /** @@ -2928,6 +3057,31 @@ sealed class VaultAddEditAction { */ data class SecurityCodeVisibilityChange(val isVisible: Boolean) : CardType() } + + /** + * Represents actions specific to the SSH Key type. + */ + sealed class SshKeyType : ItemType() { + /** + * Fired when the public key text input is changed. + */ + data class PublicKeyTextChange(val publicKey: String) : SshKeyType() + + /** + * Fired when the private key text input is changed. + */ + data class PrivateKeyTextChange(val privateKey: String) : SshKeyType() + + /** + * Fired when the private key's visibility has changed. + */ + data class PrivateKeyVisibilityChange(val isVisible: Boolean) : SshKeyType() + + /** + * Fired when the fingerprint text input is changed. + */ + data class FingerprintTextChange(val fingerprint: String) : SshKeyType() + } } /** @@ -2952,6 +3106,13 @@ sealed class VaultAddEditAction { val generatorResult: GeneratorResult, ) : Internal() + /** + * Indicates that the the SSH key vault item feature flag state has been received. + */ + data class SshKeyCipherItemsFeatureFlagReceive( + val enabled: Boolean, + ) : Internal() + /** * Indicates that the vault item data has been received. */ @@ -3005,3 +3166,8 @@ sealed class VaultAddEditAction { ) : Internal() } } + +private fun getSupportedItemTypeOptions( + isSshKeyVaultItemSupported: Boolean, +) = VaultAddEditState.ItemTypeOption.entries + .filter { isSshKeyVaultItemSupported || it != VaultAddEditState.ItemTypeOption.SSH_KEYS } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditSshKeyTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditSshKeyTypeHandlers.kt new file mode 100644 index 000000000..3fb197d9c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/handlers/VaultAddEditSshKeyTypeHandlers.kt @@ -0,0 +1,64 @@ +package com.x8bit.bitwarden.ui.vault.feature.addedit.handlers + +import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditAction +import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditViewModel + +/** + * Provides a set of handlers for interactions related to SSH key types within the vault add/edit + * screen. + * + * These handlers are used to update the ViewModel with user actions such as text changes and + * visibility changes for different SSH key fields (public key, private key, fingerprint). + * + * @property onPublicKeyTextChange Handler for changes in the public key text field. + * @property onPrivateKeyTextChange Handler for changes in the private key text field. + * @property onPrivateKeyVisibilityChange Handler for toggling the visibility of the private key. + * @property onFingerprintTextChange Handler for changes in the fingerprint text field. + */ +data class VaultAddEditSshKeyTypeHandlers( + val onPublicKeyTextChange: (String) -> Unit, + val onPrivateKeyTextChange: (String) -> Unit, + val onPrivateKeyVisibilityChange: (Boolean) -> Unit, + val onFingerprintTextChange: (String) -> Unit, +) { + @Suppress("UndocumentedPublicClass") + companion object { + /** + * Creates an instance of [VaultAddEditSshKeyTypeHandlers] with handlers that dispatch + * actions to the provided ViewModel. + * + * @param viewModel The ViewModel to which actions will be dispatched. + */ + fun create(viewModel: VaultAddEditViewModel): VaultAddEditSshKeyTypeHandlers = + VaultAddEditSshKeyTypeHandlers( + onPublicKeyTextChange = { newPublicKey -> + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.PublicKeyTextChange( + publicKey = newPublicKey, + ), + ) + }, + onPrivateKeyTextChange = { newPrivateKey -> + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.PrivateKeyTextChange( + privateKey = newPrivateKey, + ), + ) + }, + onPrivateKeyVisibilityChange = { + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.PrivateKeyVisibilityChange( + isVisible = it, + ), + ) + }, + onFingerprintTextChange = { newFingerprint -> + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.FingerprintTextChange( + fingerprint = newFingerprint, + ), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/model/CustomFieldType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/model/CustomFieldType.kt index 70306669c..0163a1b7e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/model/CustomFieldType.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/model/CustomFieldType.kt @@ -66,4 +66,5 @@ private val VaultAddEditState.ViewState.Content.ItemType.defaultLinkedFieldTypeO is VaultAddEditState.ViewState.Content.ItemType.Identity -> VaultLinkedFieldType.TITLE is VaultAddEditState.ViewState.Content.ItemType.Login -> VaultLinkedFieldType.USERNAME is VaultAddEditState.ViewState.Content.ItemType.SecureNotes -> null + is VaultAddEditState.ViewState.Content.ItemType.SshKey -> null } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index 62af2170e..749e8e051 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -35,6 +35,7 @@ private const val PASSKEY_CREATION_TIME_PATTERN: String = "hh:mm a" /** * Transforms [CipherView] into [VaultAddEditState.ViewState]. */ +@Suppress("LongMethod") fun CipherView.toViewState( isClone: Boolean, isIndividualVaultDisabled: Boolean, @@ -88,6 +89,12 @@ fun CipherView.toViewState( zip = identity?.postalCode.orEmpty(), country = identity?.country.orEmpty(), ) + + CipherType.SSH_KEY -> VaultAddEditState.ViewState.Content.ItemType.SshKey( + publicKey = sshKey?.publicKey.orEmpty(), + privateKey = sshKey?.privateKey.orEmpty(), + fingerprint = sshKey?.fingerprint.orEmpty(), + ) }, common = VaultAddEditState.ViewState.Content.Common( originalCipher = this, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt index 807c43f22..7a637b6bd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensions.kt @@ -25,4 +25,5 @@ fun VaultItemCipherType.toItemType(): VaultAddEditState.ViewState.Content.ItemTy VaultItemCipherType.CARD -> VaultAddEditState.ViewState.Content.ItemType.Card() VaultItemCipherType.IDENTITY -> VaultAddEditState.ViewState.Content.ItemType.Identity() VaultItemCipherType.SECURE_NOTE -> VaultAddEditState.ViewState.Content.ItemType.SecureNotes + VaultItemCipherType.SSH_KEY -> VaultAddEditState.ViewState.Content.ItemType.SshKey() } 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 38965e384..63011234f 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 @@ -45,6 +45,7 @@ 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.VaultLoginItemTypeHandlers +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers /** * Displays the vault item screen. @@ -265,6 +266,9 @@ fun VaultItemScreen( vaultCardItemTypeHandlers = remember(viewModel) { VaultCardItemTypeHandlers.create(viewModel = viewModel) }, + vaultSshKeyItemTypeHandlers = remember(viewModel) { + VaultSshKeyItemTypeHandlers.create(viewModel = viewModel) + }, ) } } @@ -342,6 +346,7 @@ private fun VaultItemContent( vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers, vaultCardItemTypeHandlers: VaultCardItemTypeHandlers, + vaultSshKeyItemTypeHandlers: VaultSshKeyItemTypeHandlers, modifier: Modifier = Modifier, ) { when (viewState) { @@ -389,6 +394,15 @@ private fun VaultItemContent( modifier = modifier, ) } + + is VaultItemState.ViewState.Content.ItemType.SshKey -> { + VaultItemSshKeyContent( + commonState = viewState.common, + sshKeyItemState = viewState.type, + vaultSshKeyItemTypeHandlers = vaultSshKeyItemTypeHandlers, + modifier = modifier, + ) + } } } 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 new file mode 100644 index 000000000..bac0d17c5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt @@ -0,0 +1,127 @@ +package com.x8bit.bitwarden.ui.vault.feature.item + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.field.BitwardenPasswordField +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers + +/** + * The top level content UI state for the [VaultItemScreen] when viewing a SSH key cipher. + */ +@Suppress("LongMethod") +@Composable +fun VaultItemSshKeyContent( + commonState: VaultItemState.ViewState.Content.Common, + sshKeyItemState: VaultItemState.ViewState.Content.ItemType.SshKey, + vaultSshKeyItemTypeHandlers: VaultSshKeyItemTypeHandlers, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + item { + BitwardenListHeaderText( + label = stringResource(id = R.string.item_information), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.name), + value = commonState.name, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .testTag("SshKeyItemNameEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + sshKeyItemState.publicKey?.let { publicKey -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.public_key), + value = publicKey, + onValueChange = { }, + singleLine = false, + readOnly = true, + modifier = Modifier + .testTag("SshKeyItemPublicKeyEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + sshKeyItemState.privateKey?.let { privateKey -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenPasswordField( + label = stringResource(id = R.string.private_key), + value = privateKey, + onValueChange = { }, + singleLine = false, + readOnly = true, + showPassword = sshKeyItemState.showPrivateKey, + showPasswordTestTag = "ViewPrivateKeyButton", + showPasswordChange = vaultSshKeyItemTypeHandlers.onShowPrivateKeyClick, + modifier = Modifier + .testTag("SshKeyItemPrivateKeyEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + sshKeyItemState.fingerprint?.let { fingerprint -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.fingerprint), + value = fingerprint, + onValueChange = { }, + singleLine = false, + readOnly = true, + modifier = Modifier + .testTag("SshKeyItemFingerprintEntry") + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + item { + Spacer(modifier = Modifier.height(24.dp)) + VaultItemUpdateText( + header = "${stringResource(id = R.string.date_updated)}: ", + text = commonState.lastUpdated, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag("SshKeyItemLastUpdated"), + ) + } + + item { + Spacer(modifier = Modifier.height(88.dp)) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} 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 df774ff66..72063980c 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 @@ -116,6 +116,7 @@ class VaultItemViewModel @Inject constructor( when (action) { is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action) is VaultItemAction.ItemType.Card -> handleCardTypeActions(action) + is VaultItemAction.ItemType.SshKey -> handleSshKeyTypeActions(action) is VaultItemAction.Common -> handleCommonActions(action) is VaultItemAction.Internal -> handleInternalAction(action) } @@ -753,6 +754,32 @@ class VaultItemViewModel @Inject constructor( //endregion Card Type Handlers + //region SSH Key Type Handlers + + private fun handleSshKeyTypeActions(action: VaultItemAction.ItemType.SshKey) { + when (action) { + is VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked -> { + handlePrivateKeyVisibilityClicked(action) + } + } + } + + private fun handlePrivateKeyVisibilityClicked( + action: VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked, + ) { + onSshKeyContent { content, sshKey -> + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = content.copy( + type = sshKey.copy(showPrivateKey = action.isVisible), + ), + ) + } + } + } + + //endregion SSH Key Type Handlers + //region Internal Type Handlers private fun handleInternalAction(action: VaultItemAction.Internal) { @@ -1057,6 +1084,21 @@ class VaultItemViewModel @Inject constructor( } } } + + private inline fun onSshKeyContent( + crossinline block: ( + VaultItemState.ViewState.Content, + VaultItemState.ViewState.Content.ItemType.SshKey, + ) -> Unit, + ) { + state.viewState.asContentOrNull() + ?.let { content -> + (content.type as? VaultItemState.ViewState.Content.ItemType.SshKey) + ?.let { sshKeyContent -> + block(content, sshKeyContent) + } + } + } } /** @@ -1359,6 +1401,20 @@ data class VaultItemState( val isVisible: Boolean, ) : Parcelable } + + /** + * Represents the data for displaying an `SSHKey` item type. + * + * @property name The name of the key. + * @property privateKey The SSH private key. + */ + data class SshKey( + val name: String?, + val publicKey: String?, + val privateKey: String?, + val fingerprint: String?, + val showPrivateKey: Boolean, + ) : ItemType() } } @@ -1697,6 +1753,16 @@ sealed class VaultItemAction { */ data class NumberVisibilityClick(val isVisible: Boolean) : Card() } + + /** + * Represents actions specific to the SshKey type. + */ + sealed class SshKey : ItemType() { + /** + * The user has clicked to display the private key. + */ + data class PrivateKeyVisibilityClicked(val isVisible: Boolean) : 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 new file mode 100644 index 000000000..fc4e1411b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultSshKeyItemTypeHandlers.kt @@ -0,0 +1,32 @@ +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 SSH key + * items in a vault. + */ +data class VaultSshKeyItemTypeHandlers( + val onShowPrivateKeyClick: (isVisible: Boolean) -> Unit, +) { + + @Suppress("UndocumentedPublicClass") + companion object { + + /** + * Creates the [VaultSshKeyItemTypeHandlers] using the [viewModel] to send desired actions. + */ + @Suppress("LongMethod") + fun create(viewModel: VaultItemViewModel): VaultSshKeyItemTypeHandlers = + VaultSshKeyItemTypeHandlers( + onShowPrivateKeyClick = { + viewModel.trySendAction( + VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( + isVisible = it, + ), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index 108e1cb76..e4c46f592 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -156,6 +156,19 @@ fun CipherView.toViewState( address = identity?.identityAddress, ) } + + CipherType.SSH_KEY -> { + val sshKeyValues = requireNotNull(sshKey) + VaultItemState.ViewState.Content.ItemType.SshKey( + name = name, + publicKey = sshKeyValues.publicKey, + privateKey = sshKeyValues.privateKey, + fingerprint = sshKeyValues.fingerprint, + showPrivateKey = (previousState?.type as? + VaultItemState.ViewState.Content.ItemType.SshKey) + ?.showPrivateKey == true, + ) + } }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt index daf653fed..b076c532a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt @@ -18,6 +18,7 @@ private const val COLLECTION: String = "collection" private const val FOLDER: String = "folder" private const val IDENTITY: String = "identity" private const val LOGIN: String = "login" +private const val SSH_KEY: String = "ssh_key" private const val SECURE_NOTE: String = "secure_note" private const val SEND_FILE: String = "send_file" private const val SEND_TEXT: String = "send_text" @@ -234,6 +235,7 @@ private fun VaultItemListingType.toTypeString(): String { is VaultItemListingType.Trash -> TRASH is VaultItemListingType.SendFile -> SEND_FILE is VaultItemListingType.SendText -> SEND_TEXT + is VaultItemListingType.SshKey -> SSH_KEY } } @@ -248,6 +250,7 @@ private fun VaultItemListingType.toIdOrNull(): String? = is VaultItemListingType.Trash -> null is VaultItemListingType.SendFile -> null is VaultItemListingType.SendText -> null + is VaultItemListingType.SshKey -> null } private fun determineVaultItemListingType( @@ -259,6 +262,7 @@ private fun determineVaultItemListingType( CARD -> VaultItemListingType.Card IDENTITY -> VaultItemListingType.Identity SECURE_NOTE -> VaultItemListingType.SecureNote + SSH_KEY -> VaultItemListingType.SshKey TRASH -> VaultItemListingType.Trash FOLDER -> VaultItemListingType.Folder(folderId = id) COLLECTION -> VaultItemListingType.Collection(collectionId = requireNotNull(id)) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index d0db9cb6e..e1e8084ef 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -2058,6 +2058,14 @@ data class VaultItemListingState( override val hasFab: Boolean get() = true } + /** + * A SSH key item listing. + */ + data object SshKey : Vault() { + override val titleText: Text get() = R.string.ssh_keys.asText() + override val hasFab: Boolean get() = true + } + /** * A Secure Trash item listing. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 148dc3cf2..dfc1f4fcf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -70,6 +70,10 @@ fun CipherView.determineListingPredicate( type == CipherType.SECURE_NOTE && deletedDate == null } + is VaultItemListingState.ItemListingType.Vault.SshKey -> { + type == CipherType.SSH_KEY && deletedDate == null + } + is VaultItemListingState.ItemListingType.Vault.Trash -> { deletedDate != null } @@ -272,6 +276,7 @@ fun VaultItemListingState.ItemListingType.updateWithAdditionalDataIfNecessary( is VaultItemListingState.ItemListingType.Vault.Trash -> this is VaultItemListingState.ItemListingType.Send.SendFile -> this is VaultItemListingState.ItemListingType.Send.SendText -> this + is VaultItemListingState.ItemListingType.Vault.SshKey -> this } @Suppress("LongParameterList") @@ -374,6 +379,7 @@ private fun CipherView.toIconTestTag(): String = CipherType.SECURE_NOTE -> "SecureNoteCipherIcon" CipherType.CARD -> "CardCipherIcon" CipherType.IDENTITY -> "IdentityCipherIcon" + CipherType.SSH_KEY -> "SshKeyCipherIcon" } private fun CipherView.toIconData( @@ -431,4 +437,5 @@ private val CipherType.iconRes: Int CipherType.SECURE_NOTE -> R.drawable.ic_note CipherType.CARD -> R.drawable.ic_payment_card CipherType.IDENTITY -> R.drawable.ic_id_card + CipherType.SSH_KEY -> R.drawable.ic_ssh_key } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensions.kt index 3548da9f3..87814f044 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensions.kt @@ -19,6 +19,7 @@ fun VaultItemListingState.ItemListingType.toSearchType(): SearchType = is VaultItemListingState.ItemListingType.Vault.Identity -> SearchType.Vault.Identities is VaultItemListingState.ItemListingType.Vault.Login -> SearchType.Vault.Logins is VaultItemListingState.ItemListingType.Vault.SecureNote -> SearchType.Vault.SecureNotes + is VaultItemListingState.ItemListingType.Vault.SshKey -> SearchType.Vault.SshKeys is VaultItemListingState.ItemListingType.Vault.Trash -> SearchType.Vault.Trash is VaultItemListingState.ItemListingType.Vault.Collection -> { SearchType.Vault.Collection(collectionId = collectionId) @@ -36,6 +37,7 @@ fun VaultItemListingState.ItemListingType.Vault.toVaultItemCipherType(): VaultIt is VaultItemListingState.ItemListingType.Vault.Card -> VaultItemCipherType.CARD is VaultItemListingState.ItemListingType.Vault.Identity -> VaultItemCipherType.IDENTITY is VaultItemListingState.ItemListingType.Vault.SecureNote -> VaultItemCipherType.SECURE_NOTE + is VaultItemListingState.ItemListingType.Vault.SshKey -> VaultItemCipherType.SSH_KEY is VaultItemListingState.ItemListingType.Vault.Login -> VaultItemCipherType.LOGIN is VaultItemListingState.ItemListingType.Vault.Collection -> VaultItemCipherType.LOGIN is VaultItemListingState.ItemListingType.Vault.Trash, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensions.kt index 86a99c31b..dd052de8e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensions.kt @@ -16,6 +16,7 @@ fun VaultItemListingType.toItemListingType(): VaultItemListingState.ItemListingT is VaultItemListingType.Identity -> VaultItemListingState.ItemListingType.Vault.Identity is VaultItemListingType.Login -> VaultItemListingState.ItemListingType.Vault.Login is VaultItemListingType.SecureNote -> VaultItemListingState.ItemListingType.Vault.SecureNote + is VaultItemListingType.SshKey -> VaultItemListingState.ItemListingType.Vault.SshKey is VaultItemListingType.Trash -> VaultItemListingState.ItemListingType.Vault.Trash is VaultItemListingType.Collection -> { VaultItemListingState.ItemListingType.Vault.Collection(collectionId = collectionId) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 5e11a4c53..d40429e9b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -31,6 +31,7 @@ fun VaultContent( state: VaultState.ViewState.Content, vaultHandlers: VaultHandlers, onOverflowOptionClick: (action: ListingItemOverflowAction.VaultAction) -> Unit, + showSshKeys: Boolean, modifier: Modifier = Modifier, ) { LazyColumn( @@ -122,7 +123,7 @@ fun VaultContent( item { BitwardenListHeaderText( label = stringResource(id = R.string.types), - supportingLabel = "4", + supportingLabel = state.itemTypesCount.toString(), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -193,6 +194,23 @@ fun VaultContent( ) } + if (showSshKeys) { + item { + BitwardenGroupItem( + startIcon = rememberVectorPainter(id = R.drawable.ic_ssh_key), + startIconTestTag = "SshKeyCipherIcon", + label = stringResource(id = R.string.type_ssh_key), + supportingLabel = state.sshKeyItemsCount.toString(), + onClick = vaultHandlers.sshKeyGroupClick, + showDivider = false, + modifier = Modifier + .fillMaxWidth() + .testTag("SshKeyFilter") + .padding(horizontal = 16.dp), + ) + } + } + if (state.folderItems.isNotEmpty()) { item { BitwardenHorizontalDivider( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 9d165acc8..0bbded82d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -321,6 +321,7 @@ private fun VaultScreenScaffold( when (val viewState = state.viewState) { is VaultState.ViewState.Content -> VaultContent( state = viewState, + showSshKeys = state.showSshKeys, vaultHandlers = vaultHandlers, onOverflowOptionClick = { masterPasswordRepromptAction = it }, modifier = innerModifier, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 526898b32..37a321337 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -89,6 +89,7 @@ class VaultViewModel @Inject constructor( .any(), ) val appBarTitle = vaultFilterData.toAppBarTitle() + val showSshKeys = featureFlagManager.getFeatureFlag(FlagKey.SshKeyCipherItems) VaultState( appBarTitle = appBarTitle, initials = activeAccountSummary.initials, @@ -104,6 +105,7 @@ class VaultViewModel @Inject constructor( hideNotificationsDialog = isBuildVersionBelow(Build.VERSION_CODES.TIRAMISU) || isFdroid, isRefreshing = false, showImportActionCard = false, + showSshKeys = showSshKeys, ) }, ) { @@ -131,9 +133,16 @@ class VaultViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) - vaultRepository - .vaultDataStateFlow - .onEach { sendAction(VaultAction.Internal.VaultDataReceive(vaultData = it)) } + combine( + vaultRepository.vaultDataStateFlow, + featureFlagManager.getFeatureFlagFlow(FlagKey.SshKeyCipherItems), + ) { vaultData, sshKeyCipherItemsEnabled -> + VaultAction.Internal.VaultDataReceive( + vaultData = vaultData, + showSshKeys = sshKeyCipherItemsEnabled, + ) + } + .onEach(::sendAction) .launchIn(viewModelScope) authRepository @@ -177,6 +186,7 @@ class VaultViewModel @Inject constructor( is VaultAction.ExitConfirmationClick -> handleExitConfirmationClick() is VaultAction.VaultFilterTypeSelect -> handleVaultFilterTypeSelect(action) is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick() + is VaultAction.SshKeyGroupClick -> handleSshKeyClick() is VaultAction.TrashClick -> handleTrashClick() is VaultAction.VaultItemClick -> handleVaultItemClick(action) is VaultAction.TryAgainClick -> handleTryAgainClick() @@ -211,7 +221,10 @@ class VaultViewModel @Inject constructor( it.copy(isIconLoadingDisabled = action.isIconLoadingDisabled) } - updateViewState(vaultRepository.vaultDataStateFlow.value) + updateViewState( + vaultData = vaultRepository.vaultDataStateFlow.value, + showSshKeys = state.showSshKeys, + ) } //region VaultAction Handlers @@ -311,7 +324,10 @@ class VaultViewModel @Inject constructor( } // Re-process the current vault data with the new filter - updateViewState(vaultData = vaultRepository.vaultDataStateFlow.value) + updateViewState( + vaultData = vaultRepository.vaultDataStateFlow.value, + showSshKeys = state.showSshKeys, + ) } private fun handleTrashClick() { @@ -322,6 +338,10 @@ class VaultViewModel @Inject constructor( sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.SecureNote)) } + private fun handleSshKeyClick() { + sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.SshKey)) + } + private fun handleVaultItemClick(action: VaultAction.VaultItemClick) { sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id)) } @@ -517,6 +537,7 @@ class VaultViewModel @Inject constructor( val appBarTitle = vaultFilterData.toAppBarTitle() val shouldShowImportActionCard = action.importLoginsFlowEnabled && firstTimeState.showImportLoginsCard + mutableStateFlow.update { val accountSummaries = userState.toAccountSummaries() val activeAccountSummary = userState.toActiveAccountSummary() @@ -537,13 +558,20 @@ class VaultViewModel @Inject constructor( // navigating. if (state.isSwitchingAccounts) return - updateViewState(vaultData = action.vaultData) + updateViewState( + vaultData = action.vaultData, + showSshKeys = action.showSshKeys, + ) } - private fun updateViewState(vaultData: DataState) { + private fun updateViewState(vaultData: DataState, showSshKeys: Boolean) { when (vaultData) { is DataState.Error -> vaultErrorReceive(vaultData = vaultData) - is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData) + is DataState.Loaded -> vaultLoadedReceive( + vaultData = vaultData, + showSshKeys = showSshKeys, + ) + is DataState.Loading -> vaultLoadingReceive() is DataState.NoNetwork -> vaultNoNetworkReceive(vaultData = vaultData) is DataState.Pending -> vaultPendingReceive(vaultData = vaultData) @@ -564,7 +592,7 @@ class VaultViewModel @Inject constructor( ) } - private fun vaultLoadedReceive(vaultData: DataState.Loaded) { + private fun vaultLoadedReceive(vaultData: DataState.Loaded, showSshKeys: Boolean) { if (state.dialog == VaultState.DialogState.Syncing) { sendEvent( VaultEvent.ShowToast( @@ -580,9 +608,11 @@ class VaultViewModel @Inject constructor( isPremium = state.isPremium, hasMasterPassword = state.hasMasterPassword, vaultFilterType = vaultFilterTypeOrDefault, + showSshKeys = showSshKeys, ), dialog = null, isRefreshing = false, + showSshKeys = showSshKeys, ) } } @@ -614,6 +644,7 @@ class VaultViewModel @Inject constructor( isPremium = state.isPremium, hasMasterPassword = state.hasMasterPassword, vaultFilterType = vaultFilterTypeOrDefault, + showSshKeys = state.showSshKeys, ), ) } @@ -685,6 +716,7 @@ data class VaultState( val hideNotificationsDialog: Boolean, val isRefreshing: Boolean, val showImportActionCard: Boolean, + val showSshKeys: Boolean, ) : Parcelable { /** @@ -767,11 +799,13 @@ data class VaultState( */ @Parcelize data class Content( + val itemTypesCount: Int, val totpItemsCount: Int, val loginItemsCount: Int, val cardItemsCount: Int, val identityItemsCount: Int, val secureNoteItemsCount: Int, + val sshKeyItemsCount: Int, val favoriteItems: List, val folderItems: List, val noFolderItems: List, @@ -944,6 +978,29 @@ data class VaultState( ) : VaultItem() { override val supportingLabel: Text? get() = null } + + /** + * Represents a SSH key item within the vault, designed to store SSH keys. + * + * @property publicKey The public key associated with this SSH key item. + * @property privateKey The private key associated with this SSH key item. + * @property fingerprint The fingerprint associated with this SSH key item. + */ + @Parcelize + data class SshKey( + override val id: String, + override val name: Text, + override val startIcon: IconData = IconData.Local(R.drawable.ic_ssh_key), + override val startIconTestTag: String = "SshKeyCipherIcon", + override val extraIconList: List = emptyList(), + override val overflowOptions: List, + override val shouldShowMasterPasswordReprompt: Boolean, + val publicKey: Text?, + val privateKey: Text?, + val fingerprint: Text?, + ) : VaultItem() { + override val supportingLabel: Text? get() = null + } } } @@ -1154,6 +1211,11 @@ sealed class VaultAction { */ data object SecureNoteGroupClick : VaultAction() + /** + * User clicked the SSH key types button. + */ + data object SshKeyGroupClick : VaultAction() + /** * User clicked the trash button. */ @@ -1232,6 +1294,7 @@ sealed class VaultAction { */ data class VaultDataReceive( val vaultData: DataState, + val showSshKeys: Boolean, ) : Internal() /** @@ -1272,6 +1335,7 @@ private fun MutableStateFlow.updateToErrorStateOrDialog( hasMasterPassword = hasMasterPassword, vaultFilterType = vaultFilterType, isIconLoadingDisabled = isIconLoadingDisabled, + showSshKeys = it.showSshKeys, ), dialog = VaultState.DialogState.Error( title = errorTitle, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index 3e1106d62..0c257640c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -29,6 +29,7 @@ data class VaultHandlers( val cardGroupClick: () -> Unit, val identityGroupClick: () -> Unit, val secureNoteGroupClick: () -> Unit, + val sshKeyGroupClick: () -> Unit, val trashClick: () -> Unit, val tryAgainClick: () -> Unit, val dialogDismiss: () -> Unit, @@ -77,6 +78,7 @@ data class VaultHandlers( secureNoteGroupClick = { viewModel.trySendAction(VaultAction.SecureNoteGroupClick) }, + sshKeyGroupClick = { viewModel.trySendAction(VaultAction.SshKeyGroupClick) }, trashClick = { viewModel.trySendAction(VaultAction.TrashClick) }, tryAgainClick = { viewModel.trySendAction(VaultAction.TryAgainClick) }, dialogDismiss = { viewModel.trySendAction(VaultAction.DialogDismiss) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt index 4c4d64297..78b4ea852 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt @@ -15,6 +15,7 @@ import com.bitwarden.vault.LoginView import com.bitwarden.vault.PasswordHistoryView import com.bitwarden.vault.SecureNoteType import com.bitwarden.vault.SecureNoteView +import com.bitwarden.vault.SshKeyView import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem @@ -49,6 +50,7 @@ fun VaultAddEditState.ViewState.Content.toCipherView(): CipherView = secureNote = type.toSecureNotesView(), login = type.toLoginView(common = common), card = type.toCardView(), + sshKey = type.toSshKeyView(), // Fields we always grab from the UI name = common.name, @@ -66,6 +68,16 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toCipherType(): CipherT is VaultAddEditState.ViewState.Content.ItemType.Identity -> CipherType.IDENTITY is VaultAddEditState.ViewState.Content.ItemType.Login -> CipherType.LOGIN is VaultAddEditState.ViewState.Content.ItemType.SecureNotes -> CipherType.SECURE_NOTE + is VaultAddEditState.ViewState.Content.ItemType.SshKey -> CipherType.SSH_KEY + } + +private fun VaultAddEditState.ViewState.Content.ItemType.toSshKeyView(): SshKeyView? = + (this as? VaultAddEditState.ViewState.Content.ItemType.SshKey)?.let { + SshKeyView( + publicKey = it.publicKey.orNullIfBlank(), + privateKey = it.privateKey.orNullIfBlank(), + fingerprint = it.fingerprint.orNullIfBlank(), + ) } private fun VaultAddEditState.ViewState.Content.ItemType.toCardView(): CardView? = diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 6b087bd18..b4acd8860 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -32,13 +32,14 @@ private const val NO_FOLDER_ITEM_THRESHOLD: Int = 100 /** * Transforms [VaultData] into [VaultState.ViewState] using the given [vaultFilterType]. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "LongParameterList") fun VaultData.toViewState( isPremium: Boolean, hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, baseIconUrl: String, vaultFilterType: VaultFilterType, + showSshKeys: Boolean, ): VaultState.ViewState { val filteredCipherViewListWithDeletedItems = @@ -46,6 +47,7 @@ fun VaultData.toViewState( val filteredCipherViewList = filteredCipherViewListWithDeletedItems .filter { it.deletedDate == null } + .filterSshKeysIfNecessary(showSshKeys) val filteredFolderViewList = folderViewList .toFilteredList( @@ -61,11 +63,19 @@ fun VaultData.toViewState( val noFolderItems = filteredCipherViewList .filter { it.folderId.isNullOrBlank() } + val itemTypesCount: Int = if (showSshKeys) { + CipherType.entries + } else { + CipherType.entries.filterNot { it == CipherType.SSH_KEY } + } + .size + return if (filteredCipherViewListWithDeletedItems.isEmpty()) { VaultState.ViewState.NoItems } else { val totpItems = filteredCipherViewList.filter { it.login?.totp != null } VaultState.ViewState.Content( + itemTypesCount = itemTypesCount, totpItemsCount = if (isPremium) { totpItems.count() } else { @@ -76,6 +86,7 @@ fun VaultData.toViewState( identityItemsCount = filteredCipherViewList.count { it.type == CipherType.IDENTITY }, secureNoteItemsCount = filteredCipherViewList .count { it.type == CipherType.SECURE_NOTE }, + sshKeyItemsCount = filteredCipherViewList.count { it.type == CipherType.SSH_KEY }, favoriteItems = filteredCipherViewList .filter { it.favorite } .mapNotNull { @@ -259,6 +270,26 @@ private fun CipherView.toVaultItemOrNull( extraIconList = toLabelIcons(), shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, ) + + CipherType.SSH_KEY -> VaultState.ViewState.VaultItem.SshKey( + id = id, + name = name.asText(), + publicKey = sshKey + ?.publicKey + ?.asText(), + privateKey = sshKey + ?.privateKey + ?.asText(), + fingerprint = sshKey + ?.fingerprint + ?.asText(), + overflowOptions = toOverflowActions( + hasMasterPassword = hasMasterPassword, + isPremiumUser = isPremiumUser, + ), + extraIconList = toLabelIcons(), + shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, + ) } } @@ -321,3 +352,16 @@ fun List.toFilteredList( } } } + +/** + * Filters out all [CipherView]s that are of type [CipherType.SSH_KEY] if [showSshKeys] is false. + * + * @param showSshKeys Whether to show SSH keys in the vault. + */ +@JvmName("filterSshKeysIfNecessary") +fun List.filterSshKeysIfNecessary(showSshKeys: Boolean): List = + if (showSshKeys) { + this + } else { + filter { it.type != CipherType.SSH_KEY } + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemCipherType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemCipherType.kt index a44c95207..6571a4601 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemCipherType.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemCipherType.kt @@ -24,4 +24,9 @@ enum class VaultItemCipherType { * A secure note cipher. */ SECURE_NOTE, + + /** + * A SSH key cipher. + */ + SSH_KEY, } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemListingType.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemListingType.kt index dc8b56760..2a49826be 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemListingType.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultItemListingType.kt @@ -21,10 +21,15 @@ sealed class VaultItemListingType { data object SecureNote : VaultItemListingType() /** - * A Card listing. + * A Card listing. */ data object Card : VaultItemListingType() + /** + * A SSH key listing. + */ + data object SshKey : VaultItemListingType() + /** * A Trash listing. */ diff --git a/app/src/main/res/drawable/ic_ssh_key.xml b/app/src/main/res/drawable/ic_ssh_key.xml new file mode 100644 index 000000000..227926e9b --- /dev/null +++ b/app/src/main/res/drawable/ic_ssh_key.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 963b2c569..4c1c14a0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1075,4 +1075,8 @@ Do you want to switch to this account? No logins were imported Logins imported Remember to delete your imported password file from your computer + SSH key + Public key + Private key + SSH keys diff --git a/app/src/test/java/com/x8bit/bitwarden/data/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/util/CipherViewExtensionsTest.kt index 66474e3ec..8db5de845 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/util/CipherViewExtensionsTest.kt @@ -310,6 +310,18 @@ class CipherViewExtensionsTest { assertEquals(expected, actual) } + @Test + fun `subtitle should return null when type is IDENTITY and identity is null`() { + val cipherView: CipherView = mockk { + every { identity } returns null + every { type } returns CipherType.IDENTITY + } + + val actual = cipherView.subtitle + + assertNull(actual) + } + @Suppress("MaxLineLength") @Test fun `subtitle should return null when type is IDENTITY and first and last name are null`() { @@ -329,4 +341,15 @@ class CipherViewExtensionsTest { // Verify assertNull(actual) } + + @Test + fun `subtitle should return null when type is SSH_KEY`() { + val cipherView: CipherView = mockk { + every { type } returns CipherType.SSH_KEY + } + + val actual = cipherView.subtitle + + assertNull(actual) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt index 7613fe173..085611f39 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt @@ -407,7 +407,12 @@ private const val CIPHER_JSON = """ "cardholderName": "mockCardholderName-1", "brand": "mockBrand-1" }, - "key": "mockKey-1" + "key": "mockKey-1", + "sshKey": { + "publicKey": "mockPublicKey-1", + "privateKey": "mockPrivateKey-1", + "keyFingerprint": "mockKeyFingerprint-1" + } } """ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequestUtil.kt index fbcaef7cf..dcff50d18 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequestUtil.kt @@ -21,6 +21,7 @@ fun createMockCipherJsonRequest(number: Int, hasNullUri: Boolean = false): Ciphe type = CipherTypeJson.LOGIN, login = createMockLogin(number = number, hasNullUri = hasNullUri), card = createMockCard(number = number), + sshKey = createMockSshKey(number = number), fields = listOf(createMockField(number = number)), identity = createMockIdentity(number = number), isFavorite = false, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseCipherUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseCipherUtil.kt index f6be71879..1ac03e59e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseCipherUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/SyncResponseCipherUtil.kt @@ -38,6 +38,7 @@ fun createMockCipher( card = createMockCard(number = number), fields = listOf(createMockField(number = number)), identity = createMockIdentity(number = number), + sshKey = createMockSshKey(number = number), isFavorite = false, passwordHistory = listOf(createMockPasswordHistory(number = number)), reprompt = CipherRepromptTypeJson.NONE, @@ -148,6 +149,18 @@ fun createMockLogin( fido2Credentials = fido2Credentials, ) +/** + * Create a mock [SyncResponseJson.Cipher.SshKey] with a given [number]. + */ +fun createMockSshKey(number: Int) = SyncResponseJson.Cipher.SshKey( + publicKey = "mockPublicKey-$number", + privateKey = "mockPrivateKey-$number", + keyFingerprint = "mockKeyFingerprint-$number", +) + +/** + * Create a mock [SyncResponseJson.Cipher.Fido2Credential] with a given [number]. + */ fun createMockFido2Credential(number: Int) = SyncResponseJson.Cipher.Fido2Credential( credentialId = "mockCredentialId-$number", keyType = "mockKeyType-$number", diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt index e8134e016..50c6b07a4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt @@ -465,7 +465,12 @@ private const val CREATE_ATTACHMENT_SUCCESS_JSON = """ "cardholderName": "mockCardholderName-1", "brand": "mockBrand-1" }, - "key": "mockKey-1" + "key": "mockKey-1", + "sshKey": { + "publicKey": "mockPublicKey-1", + "privateKey": "mockPrivateKey-1", + "keyFingerprint": "mockKeyFingerprint-1" + } } } """ @@ -576,7 +581,12 @@ private const val CREATE_RESTORE_UPDATE_CIPHER_SUCCESS_JSON = """ "cardholderName": "mockCardholderName-1", "brand": "mockBrand-1" }, - "key": "mockKey-1" + "key": "mockKey-1", + "sshKey": { + "publicKey": "mockPublicKey-1", + "privateKey": "mockPrivateKey-1", + "keyFingerprint": "mockKeyFingerprint-1" + } } """ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt index f2b4a9e4f..8ca29fa96 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/SyncServiceTest.kt @@ -302,7 +302,12 @@ private const val SYNC_SUCCESS_JSON = """ "cardholderName": "mockCardholderName-1", "brand": "mockBrand-1" }, - "key": "mockKey-1" + "key": "mockKey-1", + "sshKey": { + "publicKey": "mockPublicKey-1", + "privateKey": "mockPrivateKey-1", + "keyFingerprint": "mockKeyFingerprint-1" + } } ], "domains": { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index 936e98fcd..3498970ac 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -15,6 +15,7 @@ import com.bitwarden.vault.LoginView import com.bitwarden.vault.PasswordHistoryView import com.bitwarden.vault.SecureNoteType import com.bitwarden.vault.SecureNoteView +import com.bitwarden.vault.SshKeyView import com.bitwarden.vault.UriMatchType import java.time.Clock import java.time.Instant @@ -47,6 +48,7 @@ fun createMockCipherView( folderId: String? = "mockId-$number", clock: Clock = FIXED_CLOCK, fido2Credentials: List? = null, + sshKey: SshKeyView? = createMockSshKeyView(number = number), ): CipherView = CipherView( id = "mockId-$number", @@ -77,6 +79,7 @@ fun createMockCipherView( identity = createMockIdentityView(number = number).takeIf { cipherType == CipherType.IDENTITY }, + sshKey = sshKey.takeIf { cipherType == CipherType.SSH_KEY }, favorite = false, passwordHistory = listOf(createMockPasswordHistoryView(number = number, clock)), reprompt = repromptType, @@ -223,6 +226,16 @@ fun createMockIdentityView(number: Int): IdentityView = username = "mockUsername-$number", ) +/** + * Create a mock [SshKeyView] with a given [number]. + */ +fun createMockSshKeyView(number: Int): SshKeyView = + SshKeyView( + publicKey = "mockPublicKey-$number", + privateKey = "mockPrivateKey-$number", + fingerprint = "mockKeyFingerprint-$number", + ) + /** * Create a mock [PasswordHistoryView] with a given [number]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt index ed3ce0cce..2920e071f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt @@ -13,6 +13,7 @@ import com.bitwarden.vault.LoginUri import com.bitwarden.vault.PasswordHistory import com.bitwarden.vault.SecureNote import com.bitwarden.vault.SecureNoteType +import com.bitwarden.vault.SshKey import com.bitwarden.vault.UriMatchType import java.time.Clock import java.time.Instant @@ -49,6 +50,7 @@ fun createMockSdkCipher(number: Int, clock: Clock = FIXED_CLOCK): Cipher = card = createMockSdkCard(number = number), fields = listOf(createMockSdkField(number = number)), identity = createMockSdkIdentity(number = number), + sshKey = createMockSdkSshKey(number = number), favorite = false, passwordHistory = listOf(createMockSdkPasswordHistory(number = number, clock = clock)), reprompt = CipherRepromptType.NONE, @@ -101,6 +103,16 @@ fun createMockSdkIdentity(number: Int): Identity = username = "mockUsername-$number", ) +/** + * Create a mock [SshKey] with a given [number]. + */ +fun createMockSdkSshKey(number: Int): SshKey = + SshKey( + publicKey = "mockPublicKey-$number", + privateKey = "mockPrivateKey-$number", + fingerprint = "mockKeyFingerprint-$number", + ) + /** * Create a mock [Field] with a given [number]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt index d8c902995..702afa8cc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensionsTest.kt @@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockIdentit import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockLogin import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPasswordHistory import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSecureNote +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockSshKey import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockUri import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkAttachment @@ -28,6 +29,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkIdentity import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkLogin import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkPasswordHistory import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSecureNote +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSshKey import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkUri import org.junit.Assert.assertEquals import org.junit.Test @@ -152,6 +154,16 @@ class VaultSdkCipherExtensionsTest { ) } + @Test + fun `toSdkSshKey should convert a SyncResponseJson Cipher SshKey to a SshKey`() { + val syncSshKey = createMockSshKey(number = 1) + val sdkSshKey = syncSshKey.toSdkSshKey() + assertEquals( + createMockSdkSshKey(number = 1), + sdkSshKey, + ) + } + @Test fun `toSdkLoginUriList should convert list of LoginUri to List of Sdk LoginUri`() { val syncLoginUris = listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index 91fbc0164..734af84e6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -485,6 +485,31 @@ class SearchScreenTest : BaseComposeTest() { } composeTestRule.onNodeWithText(text = "Search Verification codes").assertIsDisplayed() + mutableStateFlow.update { + it.copy(searchType = SearchTypeData.Vault.SshKeys) + } + composeTestRule.onNodeWithText(text = "Search SSH keys").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(searchType = SearchTypeData.Vault.Logins) + } + composeTestRule.onNodeWithText(text = "Search Logins").assertIsDisplayed() + + mutableStateFlow.update { + it.copy(searchType = SearchTypeData.Vault.NoFolder) + } + composeTestRule.onNodeWithText(text = "Search No Folder").assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + searchType = SearchTypeData.Vault.Collection( + collectionId = "mockId", + collectionName = "mockName", + ), + ) + } + composeTestRule.onNodeWithText(text = "Search mockName").assertIsDisplayed() + mutableStateFlow.update { it.copy( searchType = SearchTypeData.Vault.Folder( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index 9936ac3e8..dd528c56f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -1444,6 +1444,7 @@ class SearchViewModelTest : BaseViewModelTest() { SearchTypeData.Sends.Texts -> "search_type_sends_text" SearchTypeData.Vault.All -> "search_type_vault_all" SearchTypeData.Vault.Cards -> "search_type_vault_cards" + SearchTypeData.Vault.SshKeys -> "search_type_vault_ssh_keys" is SearchTypeData.Vault.Collection -> "search_type_vault_collection" is SearchTypeData.Vault.Folder -> "search_type_vault_folder" SearchTypeData.Vault.Identities -> "search_type_vault_identities" @@ -1463,6 +1464,7 @@ class SearchViewModelTest : BaseViewModelTest() { SearchTypeData.Sends.Texts -> null SearchTypeData.Vault.All -> null SearchTypeData.Vault.Cards -> null + SearchTypeData.Vault.SshKeys -> null is SearchTypeData.Vault.Collection -> searchType.collectionId is SearchTypeData.Vault.Folder -> searchType.folderId SearchTypeData.Vault.Identities -> null diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt index 7898c1698..ccecb1bcb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt @@ -203,6 +203,32 @@ class SearchTypeDataExtensionsTest { ) } + @Suppress("MaxLineLength") + @Test + fun `updateWithAdditionalDataIfNecessary should return the searchTypeData unchanged for Vault SshKeys`() { + val searchTypeData = SearchTypeData.Vault.SshKeys + assertEquals( + searchTypeData, + searchTypeData.updateWithAdditionalDataIfNecessary( + folderList = listOf(), + collectionList = emptyList(), + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `updateWithAdditionalDataIfNecessary should return the searchTypeData unchanged for Vault VerificationCodes`() { + val searchTypeData = SearchTypeData.Vault.VerificationCodes + assertEquals( + searchTypeData, + searchTypeData.updateWithAdditionalDataIfNecessary( + folderList = listOf(), + collectionList = emptyList(), + ), + ) + } + @Suppress("MaxLineLength") @Test fun `updateWithAdditionalDataIfNecessary should return the searchTypeData unchanged for Vault Trash`() { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensionsTest.kt index 7077fe0ee..d7757078b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeExtensionsTest.kt @@ -80,4 +80,18 @@ class SearchTypeExtensionsTest { fun `toSearchTypeData should return Vault Trash then SearchType is Vault Trash`() { assertEquals(SearchTypeData.Vault.Trash, SearchType.Vault.Trash.toSearchTypeData()) } + + @Suppress("MaxLineLength") + @Test + fun `toSearchTypeData should return Vault VerificationCodes then SearchType is Vault VerificationCodes`() { + assertEquals( + SearchTypeData.Vault.VerificationCodes, + SearchType.Vault.VerificationCodes.toSearchTypeData(), + ) + } + + @Test + fun `toSearchTypeData should return Vault SshKeys then SearchType is Vault SshKeys`() { + assertEquals(SearchTypeData.Vault.SshKeys, SearchType.Vault.SshKeys.toSearchTypeData()) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt index 80736f1bf..a3e23fe2b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt @@ -186,6 +186,36 @@ fun createMockDisplayItemForCipher( isTotp = false, ) } + + CipherType.SSH_KEY -> { + SearchState.DisplayItem( + id = "mockId-$number", + title = "mockName-$number", + titleTestTag = "CipherNameLabel", + subtitle = "mockPublicKey-$number", + subtitleTestTag = "CipherSubTitleLabel", + iconData = IconData.Local(R.drawable.ic_ssh_key), + extraIconList = listOf( + IconRes( + iconRes = R.drawable.ic_collections, + contentDescription = R.string.collections.asText(), + testTag = "CipherInCollectionIcon", + ), + ), + overflowOptions = listOf( + ListingItemOverflowAction.VaultAction.ViewClick(cipherId = "mockId-$number"), + ListingItemOverflowAction.VaultAction.EditClick( + cipherId = "mockId-$number", + requiresPasswordReprompt = true, + ), + ), + overflowTestTag = "CipherOptionsButton", + totpCode = null, + autofillSelectionOptions = emptyList(), + shouldDisplayMasterPasswordReprompt = false, + isTotp = false, + ) + } } /** diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index db34dd628..1956f980a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -3322,6 +3322,77 @@ class VaultAddEditScreenTest : BaseComposeTest() { } } + @Test + fun `in ItemType_SshKeys changing the public key should trigger PublicKeyTextChange`() { + mutableStateFlow.value = DEFAULT_STATE_SSH_KEYS + + composeTestRule + .onNodeWithTextAfterScroll("Public key") + .performTextInput("TestPublicKey") + + verify { + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.PublicKeyTextChange( + publicKey = "TestPublicKey", + ), + ) + } + } + + @Test + fun `in ItemType_SshKeys changing the private key should trigger PrivateKeyTextChange`() { + mutableStateFlow.value = DEFAULT_STATE_SSH_KEYS + + composeTestRule + .onNodeWithTextAfterScroll("Private key") + .performTextInput("TestPrivateKey") + + verify { + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.PrivateKeyTextChange( + privateKey = "TestPrivateKey", + ), + ) + } + } + + @Test + fun `in ItemType_SshKeys changing the fingerprint should trigger FingerprintTextChange`() { + mutableStateFlow.value = DEFAULT_STATE_SSH_KEYS + + composeTestRule + .onNodeWithTextAfterScroll("Fingerprint") + .performTextInput("TestFingerprint") + + verify { + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.FingerprintTextChange( + fingerprint = "TestFingerprint", + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SshKeys changing the private key visibility should trigger PrivateKeyVisibilityChange`() { + mutableStateFlow.value = DEFAULT_STATE_SSH_KEYS + + composeTestRule + .onNodeWithTextAfterScroll(text = "Private key") + .assertExists() + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "Show") + .assertExists() + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultAddEditAction.ItemType.SshKeyType.PrivateKeyVisibilityChange(true), + ) + } + } + //region Helper functions private fun updateLoginType( @@ -3444,6 +3515,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { ), dialog = VaultAddEditState.DialogState.Generic(message = "test".asText()), vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN), + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, ) private val DEFAULT_STATE_LOGIN = VaultAddEditState( @@ -3454,6 +3526,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { isIndividualVaultDisabled = false, ), dialog = null, + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, ) private val DEFAULT_STATE_IDENTITY = VaultAddEditState( @@ -3464,6 +3537,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { isIndividualVaultDisabled = false, ), dialog = null, + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, ) private val DEFAULT_STATE_CARD = VaultAddEditState( @@ -3474,6 +3548,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { isIndividualVaultDisabled = false, ), dialog = null, + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, ) @Suppress("MaxLineLength") @@ -3495,6 +3570,7 @@ class VaultAddEditScreenTest : BaseComposeTest() { ), dialog = null, vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.SECURE_NOTE), + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, ) private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState( @@ -3505,6 +3581,18 @@ class VaultAddEditScreenTest : BaseComposeTest() { isIndividualVaultDisabled = false, ), dialog = null, + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, + ) + + private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState( + vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.SSH_KEY), + viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + type = VaultAddEditState.ViewState.Content.ItemType.SshKey(), + isIndividualVaultDisabled = false, + ), + dialog = null, + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, ) private val ALTERED_COLLECTIONS = listOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 5169d97d9..4bd49bbd4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -25,12 +25,14 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialRe import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -152,6 +154,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { private val organizationEventManager = mockk { every { trackEvent(event = any()) } just runs } + private val mutableSshVaultItemsFeatureFlagFlow = MutableStateFlow(true) + private val featureFlagManager = mockk { + every { + getFeatureFlagFlow(key = FlagKey.SshKeyCipherItems) + } returns mutableSshVaultItemsFeatureFlagFlow + every { + getFeatureFlag(key = FlagKey.SshKeyCipherItems) + } returns mutableSshVaultItemsFeatureFlagFlow.value + } @BeforeEach fun setup() { @@ -250,6 +261,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { type = VaultAddEditState.ViewState.Content.ItemType.Login(), ), dialog = null, + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries, ), viewModel.stateFlow.value, ) @@ -365,6 +377,56 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Test + fun `initial add state should be correct when SSH key feature flag is enabled`() { + mutableSshVaultItemsFeatureFlagFlow.value = true + val vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN) + val initState = createVaultAddItemState(vaultAddEditType = vaultAddEditType) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = initState, + vaultAddEditType = vaultAddEditType, + ), + ) + assertEquals( + initState, + viewModel.stateFlow.value, + ) + } + + @Test + fun `initial add state should be correct when SSH key feature flag is disabled`() { + mutableSshVaultItemsFeatureFlagFlow.value = false + every { + featureFlagManager.getFeatureFlag(key = FlagKey.SshKeyCipherItems) + } returns false + val vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN) + val expectedState = VaultAddEditState( + vaultAddEditType = vaultAddEditType, + viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common(), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.Login(), + ), + dialog = null, + totpData = null, + shouldShowCloseButton = true, + shouldExitOnSave = false, + supportedItemTypes = VaultAddEditState.ItemTypeOption.entries + .filter { it != VaultAddEditState.ItemTypeOption.SSH_KEYS }, + ) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = null, + vaultAddEditType = vaultAddEditType, + ), + ) + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + @Test fun `initial edit state should be correct`() = runTest { val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) @@ -1792,6 +1854,36 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) } + @Test + fun `TypeOptionSelect SSH_KEYS should switch to SshKeysItem`() = runTest { + mutableVaultDataFlow.value = DataState.Loaded( + createVaultData(cipherView = createMockCipherView(1)), + ) + val viewModel = createAddVaultItemViewModel() + val action = VaultAddEditAction.Common.TypeOptionSelect( + VaultAddEditState.ItemTypeOption.SSH_KEYS, + ) + + viewModel.trySendAction(action) + + val expectedState = loginInitialState.copy( + viewState = VaultAddEditState.ViewState.Content( + common = createCommonContentViewState(), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.SshKey(), + previousItemTypes = mapOf( + VaultAddEditState.ItemTypeOption.LOGIN + to VaultAddEditState.ViewState.Content.ItemType.Login(), + ), + ), + ) + + assertEquals( + expectedState, + viewModel.stateFlow.value, + ) + } + @Nested inner class VaultAddEditLoginTypeItemActions { private lateinit var viewModel: VaultAddEditViewModel @@ -2610,6 +2702,90 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Nested + inner class VaultAddEditSshKeyTypeItemActions { + private lateinit var viewModel: VaultAddEditViewModel + private lateinit var vaultAddItemInitialState: VaultAddEditState + private lateinit var sshKeyInitialSavedStateHandle: SavedStateHandle + + @BeforeEach + fun setup() { + mutableVaultDataFlow.value = DataState.Loaded( + createVaultData(cipherView = createMockCipherView(1)), + ) + vaultAddItemInitialState = createVaultAddItemState( + typeContentViewState = VaultAddEditState.ViewState.Content.ItemType.SshKey(), + ) + sshKeyInitialSavedStateHandle = createSavedStateHandleWithState( + state = vaultAddItemInitialState, + vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.SSH_KEY), + ) + viewModel = createAddVaultItemViewModel( + savedStateHandle = sshKeyInitialSavedStateHandle, + ) + } + + @Test + fun `PublicKeyTextChange should update public key`() = runTest { + val action = VaultAddEditAction.ItemType.SshKeyType.PublicKeyTextChange( + publicKey = "newPublicKey", + ) + val expectedState = createVaultAddItemState( + typeContentViewState = VaultAddEditState.ViewState.Content.ItemType.SshKey( + publicKey = "newPublicKey", + ), + ) + viewModel.trySendAction(action) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `PrivateKeyTextChange should update private key`() = runTest { + val action = VaultAddEditAction.ItemType.SshKeyType.PrivateKeyTextChange( + privateKey = "newPrivateKey", + ) + val expectedState = createVaultAddItemState( + typeContentViewState = VaultAddEditState.ViewState.Content.ItemType.SshKey( + privateKey = "newPrivateKey", + ), + ) + viewModel.trySendAction(action) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `PrivateKeyVisibilityChange should update private key visibility`() = runTest { + val action = VaultAddEditAction.ItemType.SshKeyType.PrivateKeyVisibilityChange( + isVisible = true, + ) + val expectedState = createVaultAddItemState( + typeContentViewState = VaultAddEditState.ViewState.Content.ItemType.SshKey( + showPrivateKey = true, + ), + ) + viewModel.trySendAction(action) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `FingerprintTextChange should update fingerprint`() = runTest { + val action = VaultAddEditAction.ItemType.SshKeyType.FingerprintTextChange( + fingerprint = "newFingerprint", + ) + val expectedState = createVaultAddItemState( + typeContentViewState = VaultAddEditState.ViewState.Content.ItemType.SshKey( + fingerprint = "newFingerprint", + ), + ) + viewModel.trySendAction(action) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + } + @Test fun `NumberVisibilityChange should log an event when in edit mode and password is visible`() = runTest { @@ -2686,6 +2862,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { resourceManager = resourceManager, clock = fixedClock, organizationEventManager = organizationEventManager, + featureFlagManager = featureFlagManager, ) } @@ -3781,6 +3958,30 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) } } + + @Suppress("MaxLineLength") + @Test + fun `SshKeyCipherItemsFeatureFlagReceive should update supportedItemTypes`() = runTest { + // Verify SSH keys is supported when feature flag is enabled. + viewModel.trySendAction( + VaultAddEditAction.Internal.SshKeyCipherItemsFeatureFlagReceive(enabled = true), + ) + assertEquals( + VaultAddEditState.ItemTypeOption.entries, + viewModel.stateFlow.value.supportedItemTypes, + ) + + // Verify SSH keys is not supported when feature flag is disabled. + viewModel.trySendAction( + VaultAddEditAction.Internal.SshKeyCipherItemsFeatureFlagReceive(enabled = false), + ) + assertEquals( + VaultAddEditState.ItemTypeOption.entries.filterNot { + it == VaultAddEditState.ItemTypeOption.SSH_KEYS + }, + viewModel.stateFlow.value.supportedItemTypes, + ) + } } //region Helper functions @@ -3794,6 +3995,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { typeContentViewState: VaultAddEditState.ViewState.Content.ItemType = createLoginTypeContentViewState(), dialogState: VaultAddEditState.DialogState? = null, totpData: TotpData? = null, + supportedItemTypes: List = VaultAddEditState.ItemTypeOption.entries, ): VaultAddEditState = VaultAddEditState( vaultAddEditType = vaultAddEditType, @@ -3805,6 +4007,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { dialog = dialogState, shouldExitOnSave = shouldExitOnSave, totpData = totpData, + supportedItemTypes = supportedItemTypes, ) @Suppress("LongParameterList") @@ -3879,6 +4082,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { VaultItemCipherType.CARD -> "card" VaultItemCipherType.IDENTITY -> "identity" VaultItemCipherType.SECURE_NOTE -> "secure_note" + VaultItemCipherType.SSH_KEY -> "ssh_key" null -> null }, ) @@ -3906,6 +4110,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { resourceManager = bitwardenResourceManager, clock = clock, organizationEventManager = organizationEventManager, + featureFlagManager = featureFlagManager, ) private fun createVaultData( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 58a6cf633..02c551f71 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -12,6 +12,7 @@ import com.bitwarden.vault.LoginView import com.bitwarden.vault.PasswordHistoryView import com.bitwarden.vault.SecureNoteType import com.bitwarden.vault.SecureNoteView +import com.bitwarden.vault.SshKeyView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.model.Organization @@ -313,6 +314,50 @@ class CipherViewExtensionsTest { ) } + @Test + fun `toViewState should create SSH Key ViewState`() { + val cipherView = DEFAULT_SSH_KEY_CIPHER_VIEW + + val result = cipherView.toViewState( + isClone = false, + isIndividualVaultDisabled = false, + totpData = null, + resourceManager = resourceManager, + clock = FIXED_CLOCK, + ) + + assertEquals( + VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + originalCipher = cipherView, + name = "cipher", + favorite = false, + masterPasswordReprompt = true, + notes = "Lots of notes", + customFieldData = listOf( + VaultAddEditState.Custom.BooleanField(TEST_ID, "TestBoolean", false), + VaultAddEditState.Custom.TextField(TEST_ID, "TestText", "TestText"), + VaultAddEditState.Custom.HiddenField(TEST_ID, "TestHidden", "TestHidden"), + VaultAddEditState.Custom.LinkedField( + TEST_ID, + "TestLinked", + VaultLinkedFieldType.USERNAME, + ), + ), + availableFolders = emptyList(), + availableOwners = emptyList(), + ), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.SshKey( + publicKey = "PublicKey", + privateKey = "PrivateKey", + fingerprint = "Fingerprint", + ), + ), + result, + ) + } + @Test fun `toViewState with isClone true should append clone text to the cipher name`() { val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW @@ -578,6 +623,7 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView( creationDate = FIXED_CLOCK.instant(), deletedDate = null, revisionDate = FIXED_CLOCK.instant(), + sshKey = null, ) private val DEFAULT_CARD_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( @@ -660,4 +706,13 @@ private val DEFAULT_SECURE_NOTES_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_V secureNote = SecureNoteView(type = SecureNoteType.GENERIC), ) +private val DEFAULT_SSH_KEY_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( + type = CipherType.SSH_KEY, + sshKey = SshKeyView( + publicKey = "PublicKey", + privateKey = "PrivateKey", + fingerprint = "Fingerprint", + ), +) + private const val TEST_ID = "testID" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt index aa4d49997..483d37337 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/util/VaultAddEditExtensionsTest.kt @@ -31,6 +31,7 @@ class VaultAddEditExtensionsTest { VaultItemCipherType.CARD, VaultItemCipherType.SECURE_NOTE, VaultItemCipherType.IDENTITY, + VaultItemCipherType.SSH_KEY, ) val result = vaultItemCipherTypeList.map { it.toItemType() } @@ -41,6 +42,7 @@ class VaultAddEditExtensionsTest { VaultAddEditState.ViewState.Content.ItemType.Card(), VaultAddEditState.ViewState.Content.ItemType.SecureNotes, VaultAddEditState.ViewState.Content.ItemType.Identity(), + VaultAddEditState.ViewState.Content.ItemType.SshKey(), ), result, ) 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 803811bd2..22d67c1d4 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 @@ -1867,6 +1867,8 @@ class VaultItemScreenTest : BaseComposeTest() { } //endregion identity + //region card + @Test fun `in card state, cardholderName should be displayed according to state`() { val cardholderName = "the cardholder name" @@ -2139,6 +2141,79 @@ class VaultItemScreenTest : BaseComposeTest() { viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) } } + + //endregion card + + //region ssh key + + @Test + fun `in ssh key state, public key should be displayed according to state`() { + val publicKey = "the public key" + mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(publicKey).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateSshKeyType(currentState) { copy(publicKey = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(publicKey) + } + + @Test + fun `in ssh key state, private key should be displayed according to state`() { + val privateKey = "the private key" + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_SSH_KEY_VIEW_STATE + .copy( + type = DEFAULT_SSH_KEY.copy(showPrivateKey = true), + ), + ) + } + composeTestRule + .onNodeWithText(privateKey) + .assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateSshKeyType(currentState) { copy(privateKey = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(privateKey) + } + + @Test + fun `in ssh key state, on show private key click should send ShowPrivateKeyClick`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) } + + composeTestRule + .onNodeWithTextAfterScroll("Private key") + .onChildren() + .filterToOne(hasContentDescription("Show")) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( + isVisible = true, + ), + ) + } + } + + @Test + fun `in ssh key state, fingerprint should be displayed according to state`() { + val fingerprint = "the fingerprint" + mutableStateFlow.update { it.copy(viewState = DEFAULT_SSH_KEY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(fingerprint).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateSshKeyType(currentState) { copy(fingerprint = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(fingerprint) + } + + //endregion ssh key } //region Helper functions @@ -2212,6 +2287,29 @@ private fun updateCardType( return currentState.copy(viewState = updatedType) } +private fun updateSshKeyType( + currentState: VaultItemState, + transform: VaultItemState.ViewState.Content.ItemType.SshKey.() -> + VaultItemState.ViewState.Content.ItemType.SshKey, +): VaultItemState { + val updatedType = when (val viewState = currentState.viewState) { + is VaultItemState.ViewState.Content -> { + when (val type = viewState.type) { + is VaultItemState.ViewState.Content.ItemType.SshKey -> { + viewState.copy( + type = type.transform(), + ) + } + + else -> viewState + } + } + + else -> viewState + } + return currentState.copy(viewState = updatedType) +} + private fun updateCommonContent( currentState: VaultItemState, transform: VaultItemState.ViewState.Content.Common.() @@ -2333,6 +2431,15 @@ private val DEFAULT_CARD: VaultItemState.ViewState.Content.ItemType.Card = ), ) +private val DEFAULT_SSH_KEY: VaultItemState.ViewState.Content.ItemType.SshKey = + VaultItemState.ViewState.Content.ItemType.SshKey( + name = "the ssh key name", + publicKey = "the public key", + privateKey = "the private key", + fingerprint = "the fingerprint", + showPrivateKey = false, + ) + private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common = VaultItemState.ViewState.Content.Common( name = "cipher", @@ -2433,6 +2540,12 @@ private val DEFAULT_SECURE_NOTE_VIEW_STATE: VaultItemState.ViewState.Content = type = VaultItemState.ViewState.Content.ItemType.SecureNote, ) +private val DEFAULT_SSH_KEY_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = DEFAULT_COMMON, + type = DEFAULT_SSH_KEY, + ) + private val EMPTY_VIEW_STATES = listOf( EMPTY_LOGIN_VIEW_STATE, EMPTY_IDENTITY_VIEW_STATE, 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 160e78575..be2bc9512 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 @@ -2336,6 +2336,60 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Nested + inner class SshKeyActions { + private lateinit var viewModel: VaultItemViewModel + + @BeforeEach + fun setup() { + viewModel = createViewModel( + state = DEFAULT_STATE.copy( + viewState = SSH_KEY_VIEW_STATE, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `on PrivateKeyVisibilityClick should show password dialog when re-prompt is required`() = + runTest { + val sshKeyViewState = createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + ) + val sshKeyState = DEFAULT_STATE.copy(viewState = SSH_KEY_VIEW_STATE) + 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) + + assertEquals(sshKeyState, viewModel.stateFlow.value) + viewModel.trySendAction( + VaultItemAction.ItemType.SshKey.PrivateKeyVisibilityClicked( + isVisible = true, + ), + ) + assertEquals( + sshKeyState.copy( + viewState = sshKeyViewState.copy( + common = DEFAULT_COMMON, + type = DEFAULT_SSH_KEY_TYPE.copy( + showPrivateKey = true, + ), + ), + ), + viewModel.stateFlow.value, + ) + } + } + @Nested inner class VaultItemFlow { @BeforeEach @@ -2628,6 +2682,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { ), ) + private val DEFAULT_SSH_KEY_TYPE: VaultItemState.ViewState.Content.ItemType.SshKey = + VaultItemState.ViewState.Content.ItemType.SshKey( + name = "mockName", + publicKey = "mockPublicKey", + privateKey = "mockPrivateKey", + fingerprint = "mockFingerprint", + showPrivateKey = false, + ) + private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = VaultItemState.ViewState.Content.Common( name = "login cipher", @@ -2684,5 +2747,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { common = DEFAULT_COMMON, type = DEFAULT_CARD_TYPE, ) + + private val SSH_KEY_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = DEFAULT_COMMON, + type = DEFAULT_SSH_KEY_TYPE, + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index 3ceefb197..b2ab66549 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -374,4 +374,32 @@ class CipherViewExtensionsTest { assertEquals(expectedState, viewState) } + + @Test + fun `toViewState should transform full CipherView into ViewState SSH Key Content`() { + val cipherView = createCipherView(type = CipherType.SSH_KEY, isEmpty = false) + val viewState = cipherView.toViewState( + previousState = null, + isPremiumUser = true, + hasMasterPassword = true, + totpCodeItemData = null, + clock = fixedClock, + ) + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = false, isPremiumUser = true).copy( + currentCipher = cipherView.copy( + name = "mockName", + sshKey = cipherView.sshKey?.copy( + publicKey = "publicKey", + privateKey = "privateKey", + fingerprint = "fingerprint", + ), + ), + ), + type = createSshKeyContent(isEmpty = false), + ), + viewState, + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt index e8dbc22e1..c07c29879 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt @@ -10,6 +10,7 @@ import com.bitwarden.vault.IdentityView import com.bitwarden.vault.LoginUriView import com.bitwarden.vault.LoginView import com.bitwarden.vault.PasswordHistoryView +import com.bitwarden.vault.SshKeyView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -72,6 +73,13 @@ fun createIdentityView(isEmpty: Boolean): IdentityView = licenseNumber = "licenseNumber".takeUnless { isEmpty }, ) +fun createSshKeyView(isEmpty: Boolean): SshKeyView = + SshKeyView( + privateKey = "privateKey".takeUnless { isEmpty }, + publicKey = "publicKey".takeUnless { isEmpty }, + fingerprint = "fingerprint".takeUnless { isEmpty }, + ) + fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView = CipherView( id = null, @@ -146,6 +154,7 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView = creationDate = Instant.ofEpochSecond(1_000L), deletedDate = null, revisionDate = Instant.ofEpochSecond(1_000L), + sshKey = createSshKeyView(isEmpty = isEmpty), ) fun createCommonContent( @@ -259,3 +268,12 @@ fun createIdentityContent( phone = "phone".takeUnless { isEmpty }, address = address.takeUnless { isEmpty }, ) + +fun createSshKeyContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemType.SshKey = + VaultItemState.ViewState.Content.ItemType.SshKey( + name = "mockName".takeUnless { isEmpty }, + privateKey = "privateKey".takeUnless { isEmpty }, + publicKey = "publicKey".takeUnless { isEmpty }, + fingerprint = "fingerprint".takeUnless { isEmpty }, + showPrivateKey = false, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 1222a9f27..f84921b60 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -1199,6 +1199,13 @@ class VaultItemListingScreenTest : BaseComposeTest() { .onNodeWithText(text = "Identities") .assertIsDisplayed() + mutableStateFlow.update { + it.copy(itemListingType = VaultItemListingState.ItemListingType.Vault.SshKey) + } + composeTestRule + .onNodeWithText(text = "SSH keys") + .assertIsDisplayed() + mutableStateFlow.update { it.copy(itemListingType = VaultItemListingState.ItemListingType.Vault.Trash) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 7eeb19f82..591a0f1e4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -3971,6 +3971,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { is VaultItemListingType.Trash -> "trash" is VaultItemListingType.SendFile -> "send_file" is VaultItemListingType.SendText -> "send_text" + is VaultItemListingType.SshKey -> "ssh_key" }, ) set( @@ -3985,6 +3986,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { is VaultItemListingType.Trash -> null is VaultItemListingType.SendFile -> null is VaultItemListingType.SendText -> null + is VaultItemListingType.SshKey -> null }, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 891e74680..db50ecce2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -254,6 +254,66 @@ class VaultItemListingDataExtensionsTest { } } + @Test + @Suppress("MaxLineLength") + fun `determineListingPredicate should return the correct predicate for a trash SshKey cipherView`() { + val cipherView = createMockCipherView( + number = 1, + isDeleted = true, + cipherType = CipherType.SSH_KEY, + ) + + mapOf( + VaultItemListingState.ItemListingType.Vault.Login to false, + VaultItemListingState.ItemListingType.Vault.Card to false, + VaultItemListingState.ItemListingType.Vault.SecureNote to false, + VaultItemListingState.ItemListingType.Vault.Identity to false, + VaultItemListingState.ItemListingType.Vault.Trash to true, + VaultItemListingState.ItemListingType.Vault.Folder(folderId = "mockId-1") to false, + VaultItemListingState.ItemListingType.Vault.Collection(collectionId = "mockId-1") to false, + VaultItemListingState.ItemListingType.Vault.SshKey to false, + ) + .forEach { (type, expected) -> + val result = cipherView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `determineListingPredicate should return the correct predicate for a non trash SshKey cipherView`() { + val cipherView = createMockCipherView( + number = 1, + isDeleted = false, + cipherType = CipherType.SSH_KEY, + ) + + mapOf( + VaultItemListingState.ItemListingType.Vault.Login to false, + VaultItemListingState.ItemListingType.Vault.Card to false, + VaultItemListingState.ItemListingType.Vault.SecureNote to false, + VaultItemListingState.ItemListingType.Vault.Identity to false, + VaultItemListingState.ItemListingType.Vault.Trash to false, + VaultItemListingState.ItemListingType.Vault.SshKey to true, + VaultItemListingState.ItemListingType.Vault.Folder(folderId = "mockId-1") to true, + VaultItemListingState.ItemListingType.Vault.Collection(collectionId = "mockId-1") to true, + ) + .forEach { (type, expected) -> + val result = cipherView.determineListingPredicate( + itemListingType = type, + ) + assertEquals( + expected, + result, + ) + } + } + @Test @Suppress("MaxLineLength") fun `determineListingPredicate should return the correct predicate for item not in a folder`() { @@ -873,15 +933,76 @@ class VaultItemListingDataExtensionsTest { createMockCollectionView(number = 3), ) - val result = VaultItemListingState.ItemListingType.Vault.Login - .updateWithAdditionalDataIfNecessary( - folderList = folderViewList, - collectionList = collectionViewList, - ) + assertEquals( + VaultItemListingState.ItemListingType.Vault.Identity, + VaultItemListingState.ItemListingType.Vault.Identity + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), + ) assertEquals( VaultItemListingState.ItemListingType.Vault.Login, - result, + VaultItemListingState.ItemListingType.Vault.Login + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), + ) + + assertEquals( + VaultItemListingState.ItemListingType.Vault.SecureNote, + VaultItemListingState.ItemListingType.Vault.SecureNote + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), + ) + + assertEquals( + VaultItemListingState.ItemListingType.Vault.Trash, + VaultItemListingState.ItemListingType.Vault.Trash + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), + ) + + assertEquals( + VaultItemListingState.ItemListingType.Vault.SshKey, + VaultItemListingState.ItemListingType.Vault.SshKey + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), + ) + + assertEquals( + VaultItemListingState.ItemListingType.Vault.Card, + VaultItemListingState.ItemListingType.Vault.Card + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), + ) + + assertEquals( + VaultItemListingState.ItemListingType.Send.SendFile, + VaultItemListingState.ItemListingType.Send.SendFile + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), + ) + + assertEquals( + VaultItemListingState.ItemListingType.Send.SendText, + VaultItemListingState.ItemListingType.Send.SendText + .updateWithAdditionalDataIfNecessary( + folderList = folderViewList, + collectionList = collectionViewList, + ), ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index 61fa4bb8d..454b20800 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -202,6 +202,44 @@ fun createMockDisplayItemForCipher( isTotp = false, ) } + + CipherType.SSH_KEY -> { + VaultItemListingState.DisplayItem( + id = "mockId-$number", + title = "mockName-$number", + titleTestTag = "CipherNameLabel", + secondSubtitle = null, + secondSubtitleTestTag = secondSubtitleTestTag, + subtitle = subtitle, + subtitleTestTag = "CipherSubTitleLabel", + iconData = IconData.Local(R.drawable.ic_ssh_key), + extraIconList = listOf( + IconRes( + iconRes = R.drawable.ic_collections, + contentDescription = R.string.collections.asText(), + testTag = "CipherInCollectionIcon", + ), + IconRes( + iconRes = R.drawable.ic_paperclip, + contentDescription = R.string.attachments.asText(), + testTag = "CipherWithAttachmentsIcon", + ), + ), + overflowOptions = listOf( + ListingItemOverflowAction.VaultAction.ViewClick(cipherId = "mockId-$number"), + ListingItemOverflowAction.VaultAction.EditClick( + cipherId = "mockId-$number", + requiresPasswordReprompt = requiresPasswordReprompt, + ), + ), + optionsTestTag = "CipherOptionsButton", + isAutofill = false, + isFido2Creation = false, + shouldShowMasterPasswordReprompt = false, + iconTestTag = "SshKeyCipherIcon", + isTotp = false, + ) + } } /** diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt index 0485bf6ee..211d6dc58 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingStateExtensionsTest.kt @@ -111,6 +111,16 @@ class VaultItemListingStateExtensionsTest { assertEquals(expected, result) } + @Test + fun `toSearchType should return SshKey when item type is SshKey`() { + val expected = SearchType.Vault.SshKeys + val itemType = VaultItemListingState.ItemListingType.Vault.SshKey + + val result = itemType.toSearchType() + + assertEquals(expected, result) + } + @Test fun `toVaultItemCipherType should return the correct response`() { val itemListingTypes = listOf( @@ -119,6 +129,7 @@ class VaultItemListingStateExtensionsTest { VaultItemListingState.ItemListingType.Vault.SecureNote, VaultItemListingState.ItemListingType.Vault.Login, VaultItemListingState.ItemListingType.Vault.Collection(collectionId = "mockId"), + VaultItemListingState.ItemListingType.Vault.SshKey, ) val result = itemListingTypes.map { it.toVaultItemCipherType() } @@ -130,6 +141,7 @@ class VaultItemListingStateExtensionsTest { VaultItemCipherType.SECURE_NOTE, VaultItemCipherType.LOGIN, VaultItemCipherType.LOGIN, + VaultItemCipherType.SSH_KEY, ), result, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt index c29d7fe74..571c51dc8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingTypeExtensionsTest.kt @@ -14,6 +14,13 @@ class VaultItemListingTypeExtensionsTest { VaultItemListingType.Folder(folderId = "mock"), VaultItemListingType.Trash, VaultItemListingType.Collection(collectionId = "collectionId"), + VaultItemListingType.SshKey, + VaultItemListingType.SendFile, + VaultItemListingType.SendText, + VaultItemListingType.Card, + VaultItemListingType.Identity, + VaultItemListingType.Login, + VaultItemListingType.SecureNote, ) val result = itemListingTypeList.map { it.toItemListingType() } @@ -25,6 +32,13 @@ class VaultItemListingTypeExtensionsTest { VaultItemListingState.ItemListingType.Vault.Collection( collectionId = "collectionId", ), + VaultItemListingState.ItemListingType.Vault.SshKey, + VaultItemListingState.ItemListingType.Send.SendFile, + VaultItemListingState.ItemListingType.Send.SendText, + VaultItemListingState.ItemListingType.Vault.Card, + VaultItemListingState.ItemListingType.Vault.Identity, + VaultItemListingState.ItemListingType.Vault.Login, + VaultItemListingState.ItemListingType.Vault.SecureNote, ), result, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 157d01688..203f9a8fe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasScrollToNodeAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.isNotDisplayed import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription @@ -704,6 +706,13 @@ class VaultScreenTest : BaseComposeTest() { assertEquals(VaultItemListingType.SecureNote, onNavigateToVaultItemListingType) } + @Test + @Suppress("MaxLineLength") + fun `NavigateToItemListing event for SshKey type should call onNavigateToVaultItemListingType with SshKey type`() { + mutableEventFlow.tryEmit(VaultEvent.NavigateToItemListing(VaultItemListingType.SshKey)) + assertEquals(VaultItemListingType.SshKey, onNavigateToVaultItemListingType) + } + @Test @Suppress("MaxLineLength") fun `NavigateToItemListing event for Trash type should call onNavigateToVaultItemListingType with Trash type`() { @@ -1207,6 +1216,62 @@ class VaultScreenTest : BaseComposeTest() { mutableEventFlow.tryEmit(VaultEvent.ShowSnackbar(data)) composeTestRule.onNodeWithText("message").assertIsDisplayed() } + + @Test + fun `SSH key group header should display correctly based on state`() { + val count = 1 + // Verify SSH key group is displayed when showSshKeys is true + mutableStateFlow.update { + it.copy( + showSshKeys = true, + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + sshKeyItemsCount = count, + ), + ) + } + composeTestRule + .onNodeWithText("SSH key") + .assertTextEquals("SSH key", count.toString()) + .assertIsDisplayed() + + // Verify SSH key group is hidden when showSshKeys is false + mutableStateFlow.update { it.copy(showSshKeys = false) } + composeTestRule + .onNodeWithText("SSH key") + .assertIsNotDisplayed() + } + + @Test + fun `SSH key vault items should display correctly based on state`() { + // Verify SSH key vault items are displayed when showSshKeys is true + mutableStateFlow.update { + it.copy( + showSshKeys = true, + viewState = DEFAULT_CONTENT_VIEW_STATE.copy( + noFolderItems = listOf( + VaultState.ViewState.VaultItem.SshKey( + id = "mockId", + name = "mockSshKey".asText(), + publicKey = "mockPublicKey".asText(), + privateKey = "mockPrivateKey".asText(), + fingerprint = "mockFingerprint".asText(), + overflowOptions = emptyList(), + shouldShowMasterPasswordReprompt = false, + ), + ), + ), + ) + } + composeTestRule + .onNodeWithTextAfterScroll("mockSshKey") + .isDisplayed() + + // Verify SSH key vault items are hidden when showSshKeys is false + mutableStateFlow.update { it.copy(showSshKeys = false) } + composeTestRule + .onNodeWithText("mockSshKey") + .isNotDisplayed() + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( @@ -1262,6 +1327,7 @@ private val DEFAULT_STATE: VaultState = VaultState( hideNotificationsDialog = true, isRefreshing = false, showImportActionCard = false, + showSshKeys = false, ) private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content( @@ -1275,4 +1341,6 @@ private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultStat collectionItems = emptyList(), trashItemsCount = 0, totpItemsCount = 0, + itemTypesCount = 4, + sshKeyItemsCount = 0, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 509b3bba5..d3071498d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault import app.cash.turbine.test +import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -124,10 +125,17 @@ class VaultViewModelTest : BaseViewModelTest() { } private val mutableImportLoginsFeatureFlow = MutableStateFlow(true) + private val mutableSshKeyVaultItemsEnabledFlow = MutableStateFlow(false) private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlagFlow(FlagKey.ImportLoginsFlow) } returns mutableImportLoginsFeatureFlow + every { + getFeatureFlagFlow(FlagKey.SshKeyCipherItems) + } returns mutableSshKeyVaultItemsEnabledFlow + every { + getFeatureFlag(FlagKey.SshKeyCipherItems) + } returns mutableSshKeyVaultItemsEnabledFlow.value } @Test @@ -526,6 +534,7 @@ class VaultViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = viewModel.stateFlow.value.isIconLoadingDisabled, baseIconUrl = viewModel.stateFlow.value.baseIconUrl, hasMasterPassword = true, + showSshKeys = false, ), ) .copy( @@ -550,6 +559,7 @@ class VaultViewModelTest : BaseViewModelTest() { isIconLoadingDisabled = viewModel.stateFlow.value.isIconLoadingDisabled, baseIconUrl = viewModel.stateFlow.value.baseIconUrl, hasMasterPassword = true, + showSshKeys = false, ), ), viewModel.stateFlow.value, @@ -559,12 +569,31 @@ class VaultViewModelTest : BaseViewModelTest() { @Test fun `vaultDataStateFlow Loaded with items should update state to Content`() = runTest { + mutableSshKeyVaultItemsEnabledFlow.value = true mutableVaultDataStateFlow.tryEmit( value = DataState.Loaded( data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - folderViewList = listOf(createMockFolderView(number = 1)), + cipherViewList = listOf( + createMockCipherView(number = 1, cipherType = CipherType.LOGIN), + createMockCipherView(number = 2, cipherType = CipherType.CARD), + createMockCipherView(number = 3, cipherType = CipherType.IDENTITY), + createMockCipherView(number = 4, cipherType = CipherType.SECURE_NOTE), + createMockCipherView(number = 5, cipherType = CipherType.SSH_KEY), + ), + collectionViewList = listOf( + createMockCollectionView(number = 1), + createMockCollectionView(number = 2), + createMockCollectionView(number = 3), + createMockCollectionView(number = 4), + createMockCollectionView(number = 5), + ), + folderViewList = listOf( + createMockFolderView(number = 1), + createMockFolderView(number = 2), + createMockFolderView(number = 3), + createMockFolderView(number = 4), + createMockFolderView(number = 5), + ), sendViewList = listOf(createMockSendView(number = 1)), ), ), @@ -576,9 +605,9 @@ class VaultViewModelTest : BaseViewModelTest() { createMockVaultState( viewState = VaultState.ViewState.Content( loginItemsCount = 1, - cardItemsCount = 0, - identityItemsCount = 0, - secureNoteItemsCount = 0, + cardItemsCount = 1, + identityItemsCount = 1, + secureNoteItemsCount = 1, favoriteItems = listOf(), folderItems = listOf( VaultState.ViewState.FolderItem( @@ -586,6 +615,26 @@ class VaultViewModelTest : BaseViewModelTest() { name = "mockName-1".asText(), itemCount = 1, ), + VaultState.ViewState.FolderItem( + id = "mockId-2", + name = "mockName-2".asText(), + itemCount = 1, + ), + VaultState.ViewState.FolderItem( + id = "mockId-3", + name = "mockName-3".asText(), + itemCount = 1, + ), + VaultState.ViewState.FolderItem( + id = "mockId-4", + name = "mockName-4".asText(), + itemCount = 1, + ), + VaultState.ViewState.FolderItem( + id = "mockId-5", + name = "mockName-5".asText(), + itemCount = 1, + ), ), collectionItems = listOf( VaultState.ViewState.CollectionItem( @@ -593,11 +642,34 @@ class VaultViewModelTest : BaseViewModelTest() { name = "mockName-1", itemCount = 1, ), + VaultState.ViewState.CollectionItem( + id = "mockId-2", + name = "mockName-2", + itemCount = 1, + ), + VaultState.ViewState.CollectionItem( + id = "mockId-3", + name = "mockName-3", + itemCount = 1, + ), + VaultState.ViewState.CollectionItem( + id = "mockId-4", + name = "mockName-4", + itemCount = 1, + ), + VaultState.ViewState.CollectionItem( + id = "mockId-5", + name = "mockName-5", + itemCount = 1, + ), ), noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = CipherType.entries.size, + sshKeyItemsCount = 1, ), + showSshKeys = true, ), viewModel.stateFlow.value, ) @@ -619,6 +691,8 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), ) val viewModel = createViewModel() @@ -731,6 +805,8 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), ), viewModel.stateFlow.value, @@ -829,6 +905,8 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), dialog = VaultState.DialogState.Error( title = R.string.an_error_has_occurred.asText(), @@ -927,6 +1005,8 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), dialog = VaultState.DialogState.Error( title = R.string.internet_connection_required_title.asText(), @@ -999,6 +1079,88 @@ class VaultViewModelTest : BaseViewModelTest() { ) } + @Test + fun `vaultDataStateFlow Loaded should exclude SSH key vault items when showSshKeys is false`() = + runTest { + mutableVaultDataStateFlow.tryEmit( + value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 1), + createMockCipherView(number = 1, cipherType = CipherType.SSH_KEY), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + ), + ), + ) + + val viewModel = createViewModel() + + assertEquals( + createMockVaultState( + viewState = VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf(), + collectionItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 1, + itemTypesCount = CipherType.entries.size - 1, + sshKeyItemsCount = 0, + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `vaultDataStateFlow Loaded should include SSH key vault items when showSshKeys is true`() = + runTest { + mutableSshKeyVaultItemsEnabledFlow.value = true + mutableVaultDataStateFlow.tryEmit( + value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 1), + createMockCipherView(number = 1, cipherType = CipherType.SSH_KEY), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + ), + ), + ) + + val viewModel = createViewModel() + + assertEquals( + createMockVaultState( + viewState = VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf(), + collectionItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 1, + itemTypesCount = CipherType.entries.size, + sshKeyItemsCount = 1, + ), + showSshKeys = true, + ), + viewModel.stateFlow.value, + ) + } + @Test fun `VerificationCodesClick should emit NavigateToVerificationCodeScreen`() = runTest { val viewModel = createViewModel() @@ -1112,6 +1274,18 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Test + fun `SshKeyGroupClick should emit NavigateToItemListing event with SshKey type`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.SshKeyGroupClick) + assertEquals( + VaultEvent.NavigateToItemListing(VaultItemListingType.SshKey), + awaitItem(), + ) + } + } + @Test fun `TrashClick should emit NavigateToItemListing event with Trash type`() = runTest { val viewModel = createViewModel() @@ -1720,6 +1894,7 @@ private val DEFAULT_USER_STATE = UserState( private fun createMockVaultState( viewState: VaultState.ViewState, dialog: VaultState.DialogState? = null, + showSshKeys: Boolean = false, ): VaultState = VaultState( appBarTitle = R.string.my_vault.asText(), @@ -1758,4 +1933,5 @@ private fun createMockVaultState( hideNotificationsDialog = true, showImportActionCard = true, isRefreshing = false, + showSshKeys = showSshKeys, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt index 43c4d4be4..6430fc086 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt @@ -12,6 +12,7 @@ import com.bitwarden.vault.LoginView import com.bitwarden.vault.PasswordHistoryView import com.bitwarden.vault.SecureNoteType import com.bitwarden.vault.SecureNoteView +import com.bitwarden.vault.SshKeyView import com.bitwarden.vault.UriMatchType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState @@ -112,6 +113,7 @@ class VaultAddItemStateExtensionsTest { creationDate = Instant.MIN, deletedDate = null, revisionDate = Instant.MIN, + sshKey = null, ), result, ) @@ -295,6 +297,7 @@ class VaultAddItemStateExtensionsTest { creationDate = Instant.MIN, deletedDate = null, revisionDate = Instant.MIN, + sshKey = null, ), result, ) @@ -426,6 +429,7 @@ class VaultAddItemStateExtensionsTest { creationDate = Instant.MIN, deletedDate = null, revisionDate = Instant.MIN, + sshKey = null, ), result, ) @@ -611,6 +615,7 @@ class VaultAddItemStateExtensionsTest { creationDate = Instant.MIN, deletedDate = null, revisionDate = Instant.MIN, + sshKey = null, ), result, ) @@ -703,6 +708,65 @@ class VaultAddItemStateExtensionsTest { ) } + @Test + fun `toCipherView should transform SSH Key ItemType to CipherView`() { + mockkStatic(Instant::class) + every { Instant.now() } returns Instant.MIN + val viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + name = "mockName-1", + selectedFolderId = "mockId-1", + favorite = false, + masterPasswordReprompt = false, + notes = "mockNotes-1", + selectedOwnerId = "mockOwnerId-1", + ), + isIndividualVaultDisabled = false, + type = VaultAddEditState.ViewState.Content.ItemType.SshKey( + publicKey = "mockPublicKey-1", + privateKey = "mockPrivateKey-1", + fingerprint = "mockFingerprint-1", + ), + ) + + val result = viewState.toCipherView() + + assertEquals( + CipherView( + id = null, + organizationId = "mockOwnerId-1", + folderId = "mockId-1", + collectionIds = emptyList(), + key = null, + name = "mockName-1", + notes = "mockNotes-1", + type = CipherType.SSH_KEY, + login = null, + identity = null, + card = null, + secureNote = null, + favorite = false, + reprompt = CipherRepromptType.NONE, + organizationUseTotp = false, + edit = true, + viewPassword = true, + localData = null, + attachments = null, + fields = emptyList(), + passwordHistory = null, + creationDate = Instant.MIN, + deletedDate = null, + revisionDate = Instant.MIN, + sshKey = SshKeyView( + publicKey = "mockPublicKey-1", + privateKey = "mockPrivateKey-1", + fingerprint = "mockFingerprint-1", + ), + ), + result, + ) + } + @Suppress("MaxLineLength") @Test fun `toLoginView should transform Login ItemType to LoginView deleting fido2Credentials with original cipher`() { @@ -911,6 +975,7 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView( creationDate = Instant.MIN, deletedDate = null, revisionDate = Instant.MIN, + sshKey = null, ) private val DEFAULT_LOGIN_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.copy( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 5ea4c6e64..17e257de1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util import android.net.Uri +import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.bitwarden.vault.FolderView import com.bitwarden.vault.LoginUriView @@ -12,9 +13,12 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSshKeyView import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.model.IconRes +import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import io.mockk.every @@ -57,6 +61,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -94,6 +99,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -118,6 +125,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.MyVault, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -138,6 +146,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -171,6 +181,7 @@ class VaultDataExtensionsTest { organizationName = "Mock Organization 1", ), hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -197,6 +208,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -217,6 +230,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -240,6 +254,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -264,6 +279,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -278,6 +294,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -299,6 +317,7 @@ class VaultDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -313,6 +332,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 0, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -334,6 +355,7 @@ class VaultDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -348,6 +370,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -371,6 +395,7 @@ class VaultDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -385,6 +410,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -592,6 +619,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -606,6 +634,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 2, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -629,6 +659,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -643,6 +674,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 2, totpItemsCount = 0, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -669,6 +702,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -689,6 +723,8 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 100, + itemTypesCount = 4, + sshKeyItemsCount = 0, ), actual, ) @@ -722,6 +758,7 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, + showSshKeys = false, ) assertEquals( @@ -764,8 +801,195 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, + ), + actual, + ) + } + + @Test + fun `toViewState should exclude SSH keys if showSshKeys is false`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 1), + createMockCipherView(number = 2, cipherType = CipherType.SSH_KEY), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + fido2CredentialAutofillViewList = null, + ) + + val actual = vaultData.toViewState( + isPremium = true, + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + showSshKeys = false, + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + // Verify SSH key vault items are not counted when showSshKeys is false. + sshKeyItemsCount = 0, + favoriteItems = listOf(), + collectionItems = listOf(), + folderItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 1, + // Verify item types count excludes CipherType.SSH_KEY when showSshKeys is false. + itemTypesCount = 4, + ), + actual, + ) + } + + @Test + fun `toViewState should include SSH key vault items and type count if showSshKeys is true`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 1), + createMockCipherView(number = 2, cipherType = CipherType.SSH_KEY), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + fido2CredentialAutofillViewList = null, + ) + + val actual = vaultData.toViewState( + isPremium = true, + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + showSshKeys = true, + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + // Verify SSH key vault items are counted + sshKeyItemsCount = 1, + favoriteItems = listOf(), + collectionItems = listOf(), + folderItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 1, + // Verify item types count includes all CipherTypes when showSshKeys is true. + itemTypesCount = CipherType.entries.size, + ), + actual, + ) + } + + @Test + fun `toViewState should transform SSH key vault items into correct vault item`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 1, cipherType = CipherType.SSH_KEY, folderId = null), + createMockCipherView( + number = 2, + cipherType = CipherType.SSH_KEY, + repromptType = CipherRepromptType.PASSWORD, + folderId = null, + sshKey = createMockSshKeyView(number = 1) + .copy( + publicKey = null, + privateKey = null, + fingerprint = null, + ), + ), + createMockCipherView( + number = 3, + cipherType = CipherType.SSH_KEY, + folderId = null, + sshKey = null, + ), + ), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + ) + val actual = vaultData.toViewState( + isPremium = true, + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + vaultFilterType = VaultFilterType.AllVaults, + hasMasterPassword = true, + showSshKeys = true, + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 0, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + sshKeyItemsCount = 3, + favoriteItems = listOf(), + collectionItems = listOf(), + folderItems = listOf(), + noFolderItems = listOf( + createMockSshKeyVaultItem(number = 1), + createMockSshKeyVaultItem(number = 2) + .copy( + publicKey = null, + privateKey = null, + fingerprint = null, + shouldShowMasterPasswordReprompt = true, + ), + createMockSshKeyVaultItem(number = 3) + .copy( + publicKey = null, + privateKey = null, + fingerprint = null, + ), + ), + trashItemsCount = 0, + totpItemsCount = 0, + itemTypesCount = CipherType.entries.size, ), actual, ) } } + +private fun createMockSshKeyVaultItem(number: Int): VaultState.ViewState.VaultItem.SshKey = + VaultState.ViewState.VaultItem.SshKey( + id = "mockId-$number", + name = "mockName-$number".asText(), + publicKey = "mockPublicKey-$number".asText(), + privateKey = "mockPrivateKey-$number".asText(), + fingerprint = "mockKeyFingerprint-$number".asText(), + overflowOptions = listOf( + ListingItemOverflowAction.VaultAction.ViewClick("mockId-$number"), + ListingItemOverflowAction.VaultAction.EditClick("mockId-$number", true), + ), + startIcon = IconData.Local(iconRes = R.drawable.ic_ssh_key), + startIconTestTag = "SshKeyCipherIcon", + extraIconList = listOf( + IconRes( + iconRes = R.drawable.ic_collections, + contentDescription = R.string.collections.asText(), + testTag = "CipherInCollectionIcon", + ), + IconRes( + iconRes = R.drawable.ic_paperclip, + contentDescription = R.string.attachments.asText(), + testTag = "CipherWithAttachmentsIcon", + ), + ), + shouldShowMasterPasswordReprompt = false, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt index 6d275b9eb..47ab815fb 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt @@ -83,5 +83,7 @@ class VaultStateExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, + itemTypesCount = 4, + sshKeyItemsCount = 0, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca783f41e..2049224d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ androidxSplash = "1.1.0-rc01" androidXAppCompat = "1.7.0" androdixAutofill = "1.1.0" androidxWork = "2.9.1" -bitwardenSdk = "1.0.0-20240924.112512-21" +bitwardenSdk = "1.0.0-20241021.160919-71" crashlytics = "3.0.2" detekt = "1.23.7" firebaseBom = "33.5.1"