From 0c05855e6bebfe46f3b270d3d36346b4481e5411 Mon Sep 17 00:00:00 2001 From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com> Date: Tue, 2 Jan 2024 14:09:58 -0600 Subject: [PATCH] BIT-514: View identity item UI (#461) --- .../ui/platform/base/util/ListExtensions.kt | 12 + .../ui/platform/base/util/StringExtensions.kt | 9 + .../additem/util/CipherViewExtensions.kt | 8 + .../feature/item/VaultItemCustomField.kt | 101 ++++++ .../feature/item/VaultItemIdentityContent.kt | 227 ++++++++++++ .../feature/item/VaultItemLoginContent.kt | 113 +----- .../ui/vault/feature/item/VaultItemScreen.kt | 9 +- .../vault/feature/item/VaultItemUpdateText.kt | 35 ++ .../vault/feature/item/VaultItemViewModel.kt | 22 +- .../feature/item/util/CipherViewExtensions.kt | 41 ++- .../platform/base/util/ListExtensionsTest.kt | 29 ++ .../base/util/StringExtensionsTest.kt | 12 + .../vault/feature/item/VaultItemScreenTest.kt | 256 +++++++++++--- .../feature/item/VaultItemViewModelTest.kt | 10 +- .../item/util/CipherViewExtensionsTest.kt | 332 +++++++----------- .../feature/item/util/VaultItemTestUtil.kt | 219 ++++++++++++ 16 files changed, 1066 insertions(+), 369 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCustomField.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemUpdateText.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensionsTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensions.kt new file mode 100644 index 000000000..33d0722e9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensions.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +/** + * Returns null if all entries in a given list are equal to a provided [value], otherwise + * the original list is returned. + */ +fun List.nullIfAllEqual(value: T): List? = + if (all { it == value }) { + null + } else { + this + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt index 0c423de00..d037320a1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.ui.platform.base.util import androidx.compose.runtime.Composable @@ -154,3 +156,10 @@ fun String.toHexColorRepresentation(): String { val blue = ((hash and 0xFF0000) shr 16).toTwoDigitHexString() return "#ff$red$green$blue" } + +/** + * Returns a copy of this string having its first letter titlecased using the rules of the specified + * [locale], or the original string if it's empty or already starts with a title case letter. + */ +fun String.capitalize(locale: Locale = Locale.getDefault()): String = + replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt index 9b30022ac..920decbde 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/util/CipherViewExtensions.kt @@ -28,6 +28,7 @@ fun CipherView.toViewState(): VaultAddItemState.ViewState = CipherType.SECURE_NOTE -> VaultAddItemState.ViewState.Content.ItemType.SecureNotes CipherType.CARD -> VaultAddItemState.ViewState.Content.ItemType.Card CipherType.IDENTITY -> VaultAddItemState.ViewState.Content.ItemType.Identity( + selectedTitle = identity?.title.toTitleOrDefault(), firstName = identity?.firstName.orEmpty(), middleName = identity?.middleName.orEmpty(), lastName = identity?.lastName.orEmpty(), @@ -89,3 +90,10 @@ private fun FieldView.toCustomField() = vaultLinkedFieldType = fromId(requireNotNull(this.linkedId)), ) } + +@Suppress("MaxLineLength") +private fun String?.toTitleOrDefault(): VaultAddItemState.ViewState.Content.ItemType.Identity.Title = + VaultAddItemState.ViewState.Content.ItemType.Identity.Title + .entries + .find { it.name == this } + ?: VaultAddItemState.ViewState.Content.ItemType.Identity.Title.MR diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCustomField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCustomField.kt new file mode 100644 index 000000000..d5459e5bc --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCustomField.kt @@ -0,0 +1,101 @@ +package com.x8bit.bitwarden.ui.vault.feature.item + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource +import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordFieldWithActions +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions +import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch +import com.x8bit.bitwarden.ui.platform.components.model.IconResource + +/** + * Custom Field UI common for all item types. + */ +@Suppress("LongMethod", "MaxLineLength") +@Composable +fun CustomField( + customField: VaultItemState.ViewState.Content.Common.Custom, + onCopyCustomHiddenField: (String) -> Unit, + onCopyCustomTextField: (String) -> Unit, + onShowHiddenFieldClick: (VaultItemState.ViewState.Content.Common.Custom.HiddenField, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + when (customField) { + is VaultItemState.ViewState.Content.Common.Custom.BooleanField -> { + BitwardenWideSwitch( + label = customField.name, + isChecked = customField.value, + readOnly = true, + onCheckedChange = { }, + modifier = modifier, + ) + } + + is VaultItemState.ViewState.Content.Common.Custom.HiddenField -> { + BitwardenPasswordFieldWithActions( + label = customField.name, + value = customField.value, + showPasswordChange = { onShowHiddenFieldClick(customField, it) }, + showPassword = customField.isVisible, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = modifier, + actions = { + if (customField.isCopyable) { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource(id = R.string.copy), + ), + onClick = { + onCopyCustomHiddenField(customField.value) + }, + ) + } + }, + ) + } + + is VaultItemState.ViewState.Content.Common.Custom.LinkedField -> { + BitwardenTextField( + label = customField.name, + value = customField.vaultLinkedFieldType.label.invoke(), + leadingIconResource = IconResource( + iconPainter = painterResource(id = R.drawable.ic_linked), + contentDescription = stringResource(id = R.string.field_type_linked), + ), + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = modifier, + ) + } + + is VaultItemState.ViewState.Content.Common.Custom.TextField -> { + BitwardenTextFieldWithActions( + label = customField.name, + value = customField.value, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = modifier, + actions = { + if (customField.isCopyable) { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource(id = R.string.copy), + ), + onClick = { onCopyCustomTextField(customField.value) }, + ) + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt new file mode 100644 index 000000000..8c1832d49 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt @@ -0,0 +1,227 @@ +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.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers + +/** + * The top level content UI state for the [VaultItemScreen] when viewing a Identity cipher. + */ +@Suppress("LongMethod") +@Composable +fun VaultItemIdentityContent( + identityState: VaultItemState.ViewState.Content.ItemType.Identity, + commonState: VaultItemState.ViewState.Content.Common, + vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, + 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 + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + identityState.identityName?.let { identityName -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.identity_name), + value = identityName, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.username?.let { username -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.username), + value = username, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.company?.let { company -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.company), + value = company, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.ssn?.let { ssn -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.ssn), + value = ssn, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.passportNumber?.let { passportNumber -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.passport_number), + value = passportNumber, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.licenseNumber?.let { licenseNumber -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.license_number), + value = licenseNumber, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.email?.let { email -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.email), + value = email, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.phone?.let { phone -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.phone), + value = phone, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + identityState.address?.let { address -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.address), + value = address, + onValueChange = { }, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + commonState.customFields.takeUnless { it.isEmpty() }?.let { customFields -> + item { + Spacer(modifier = Modifier.height(4.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.custom_fields), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + items(customFields) { customField -> + Spacer(modifier = Modifier.height(8.dp)) + CustomField( + customField = customField, + onCopyCustomHiddenField = vaultCommonItemTypeHandlers.onCopyCustomHiddenField, + onCopyCustomTextField = vaultCommonItemTypeHandlers.onCopyCustomTextField, + onShowHiddenFieldClick = vaultCommonItemTypeHandlers.onShowHiddenFieldClick, + modifier = Modifier + .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), + ) + } + 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/VaultItemLoginContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index f59acc1e1..0bed74395 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -23,7 +23,6 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordFieldWithActions import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.BitwardenTextFieldWithActions -import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.model.IconResource import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers @@ -172,7 +171,7 @@ fun VaultItemLoginContent( item { Spacer(modifier = Modifier.height(24.dp)) - UpdateText( + VaultItemUpdateText( header = "${stringResource(id = R.string.date_updated)}: ", text = commonState.lastUpdated, modifier = Modifier @@ -183,7 +182,7 @@ fun VaultItemLoginContent( loginItemState.passwordRevisionDate?.let { revisionDate -> item { - UpdateText( + VaultItemUpdateText( header = "${stringResource(id = R.string.date_password_updated)}: ", text = revisionDate, modifier = Modifier @@ -212,91 +211,6 @@ fun VaultItemLoginContent( } } -@Suppress("LongMethod", "MaxLineLength") -@Composable -private fun CustomField( - customField: VaultItemState.ViewState.Content.Common.Custom, - onCopyCustomHiddenField: (String) -> Unit, - onCopyCustomTextField: (String) -> Unit, - onShowHiddenFieldClick: (VaultItemState.ViewState.Content.Common.Custom.HiddenField, Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - when (customField) { - is VaultItemState.ViewState.Content.Common.Custom.BooleanField -> { - BitwardenWideSwitch( - label = customField.name, - isChecked = customField.value, - readOnly = true, - onCheckedChange = { }, - modifier = modifier, - ) - } - - is VaultItemState.ViewState.Content.Common.Custom.HiddenField -> { - BitwardenPasswordFieldWithActions( - label = customField.name, - value = customField.value, - showPasswordChange = { onShowHiddenFieldClick(customField, it) }, - showPassword = customField.isVisible, - onValueChange = { }, - readOnly = true, - singleLine = false, - modifier = modifier, - actions = { - if (customField.isCopyable) { - BitwardenIconButtonWithResource( - iconRes = IconResource( - iconPainter = painterResource(id = R.drawable.ic_copy), - contentDescription = stringResource(id = R.string.copy), - ), - onClick = { - onCopyCustomHiddenField(customField.value) - }, - ) - } - }, - ) - } - - is VaultItemState.ViewState.Content.Common.Custom.LinkedField -> { - BitwardenTextField( - label = customField.name, - value = customField.vaultLinkedFieldType.label.invoke(), - leadingIconResource = IconResource( - iconPainter = painterResource(id = R.drawable.ic_linked), - contentDescription = stringResource(id = R.string.field_type_linked), - ), - onValueChange = { }, - readOnly = true, - singleLine = false, - modifier = modifier, - ) - } - - is VaultItemState.ViewState.Content.Common.Custom.TextField -> { - BitwardenTextFieldWithActions( - label = customField.name, - value = customField.value, - onValueChange = { }, - readOnly = true, - singleLine = false, - modifier = modifier, - actions = { - if (customField.isCopyable) { - BitwardenIconButtonWithResource( - iconRes = IconResource( - iconPainter = painterResource(id = R.drawable.ic_copy), - contentDescription = stringResource(id = R.string.copy), - ), - onClick = { onCopyCustomTextField(customField.value) }, - ) - } - }, - ) - } - } -} - @Composable private fun NotesField( notes: String, @@ -393,29 +307,6 @@ private fun TotpField( } } -@Composable -private fun UpdateText( - header: String, - text: String, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .semantics(mergeDescendants = true) { }, - ) { - Text( - text = header, - style = LocalNonMaterialTypography.current.labelMediumProminent, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - @Composable private fun UriField( uriData: VaultItemState.ViewState.Content.ItemType.Login.UriData, 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 a3e03547b..d7245553a 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 @@ -197,9 +197,9 @@ private fun VaultItemContent( VaultItemLoginContent( commonState = viewState.common, loginItemState = viewState.type, - modifier = modifier, vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, vaultLoginItemTypeHandlers = vaultLoginItemTypeHandlers, + modifier = modifier, ) } @@ -208,7 +208,12 @@ private fun VaultItemContent( } is VaultItemState.ViewState.Content.ItemType.Identity -> { - // TODO UI for viewing Identity BIT-514 + VaultItemIdentityContent( + commonState = viewState.common, + identityState = viewState.type, + vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + modifier = modifier, + ) } is VaultItemState.ViewState.Content.ItemType.SecureNote -> { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemUpdateText.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemUpdateText.kt new file mode 100644 index 000000000..372df097c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemUpdateText.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.ui.vault.feature.item + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography + +/** + * Update Text UI common for all item types. + */ +@Composable +fun VaultItemUpdateText( + header: String, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .semantics(mergeDescendants = true) { }, + ) { + Text( + text = header, + style = LocalNonMaterialTypography.current.labelMediumProminent, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} 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 30c74dddf..aac8a4c88 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 @@ -612,8 +612,28 @@ data class VaultItemState( /** * Represents the `Identity` item type. + * + * @property identityName The name for the identity. + * @property username The username for the identity. + * @property company The company associated with the identity. + * @property ssn The SSN for the identity. + * @property passportNumber The passport number for the identity. + * @property licenseNumber The license number for the identity. + * @property email The email for the identity. + * @property phone The phone number for the identity. + * @property address The address for the identity. */ - data object Identity : ItemType() + data class Identity( + val identityName: String?, + val username: String?, + val company: String?, + val ssn: String?, + val passportNumber: String?, + val licenseNumber: String?, + val email: String?, + val phone: String?, + val address: String?, + ) : ItemType() /** * Represents the `Card` item type. 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 bc09ea3cc..89ad06265 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 @@ -5,8 +5,12 @@ import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView import com.bitwarden.core.FieldType import com.bitwarden.core.FieldView +import com.bitwarden.core.IdentityView import com.bitwarden.core.LoginUriView import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.ui.platform.base.util.capitalize +import com.x8bit.bitwarden.ui.platform.base.util.nullIfAllEqual +import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.ui.platform.base.util.orZeroWidthSpace import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState @@ -63,7 +67,17 @@ fun CipherView.toViewState( } CipherType.IDENTITY -> { - VaultItemState.ViewState.Content.ItemType.Identity + VaultItemState.ViewState.Content.ItemType.Identity( + username = identity?.username, + identityName = identity?.identityName, + company = identity?.company, + ssn = identity?.ssn, + passportNumber = identity?.passportNumber, + licenseNumber = identity?.licenseNumber, + email = identity?.email, + phone = identity?.phone, + address = identity?.identityAddress, + ) } }, ) @@ -100,3 +114,28 @@ private fun LoginUriView.toUriData() = isCopyable = !uri.isNullOrBlank(), isLaunchable = !uri.isNullOrBlank(), ) + +private val IdentityView.identityAddress: String? + get() = listOfNotNull( + address1, + address2, + address3, + listOf(city ?: "-", state ?: "-", postalCode ?: "-") + .nullIfAllEqual("-") + ?.joinToString(", "), + country, + ) + .joinToString("\n") + .orNullIfBlank() + +private val IdentityView.identityName: String? + get() = listOfNotNull( + title + ?.lowercase() + ?.capitalize(), + firstName, + middleName, + lastName, + ) + .joinToString(" ") + .orNullIfBlank() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensionsTest.kt new file mode 100644 index 000000000..cebd18213 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/ListExtensionsTest.kt @@ -0,0 +1,29 @@ +package com.x8bit.bitwarden.ui.platform.base.util + +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull + +class ListExtensionsTest { + + @Test + fun `nullIfAllEqual should return null for lists with identical values`() { + val initialList = listOf("-", "-", "-", "-", "-") + + val result = initialList.nullIfAllEqual("-") + + assertNull(result) + } + + @Test + fun `nullIfAllEqual should return the initial list for lists with non-identical values`() { + val initialList = listOf("-", "-", "-", "-", "1") + + val result = initialList.nullIfAllEqual("-") + + assertEquals( + initialList, + result, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt index 1dc298437..45f6450fc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensionsTest.kt @@ -86,4 +86,16 @@ class StringExtensionsTest { ) } } + + @Test + fun `capitalize should return a capitalized string`() { + val initialString = "lowercase" + + val result = initialString.capitalize() + + assertEquals( + "Lowercase", + 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 5cd5cf0ef..b28a8288f 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 @@ -752,6 +752,123 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.assertScrollableNodeDoesNotExist("Password history: ") composeTestRule.assertScrollableNodeDoesNotExist("1") } + + @Test + fun `in identity state, identityName should be displayed according to state`() { + val identityName = "the identity name" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(identityName = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, username should be displayed according to state`() { + val identityName = "the username" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(username = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, company should be displayed according to state`() { + val identityName = "the company name" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(company = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, ssn should be displayed according to state`() { + val identityName = "the SSN" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(ssn = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, passportNumber should be displayed according to state`() { + val identityName = "the passport number" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(passportNumber = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, licenseNumber should be displayed according to state`() { + val identityName = "the license number" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(licenseNumber = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, email should be displayed according to state`() { + val identityName = "the email address" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(email = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, phone should be displayed according to state`() { + val identityName = "the phone number" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(phone = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } + + @Test + fun `in identity state, address should be displayed according to state`() { + val identityName = "the address" + mutableStateFlow.update { it.copy(viewState = DEFAULT_IDENTITY_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(identityName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateIdentityType(currentState) { copy(address = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(identityName) + } } //region Helper functions @@ -780,6 +897,28 @@ private fun updateLoginType( return currentState.copy(viewState = updatedType) } +@Suppress("MaxLineLength") +private fun updateIdentityType( + currentState: VaultItemState, + transform: VaultItemState.ViewState.Content.ItemType.Identity.() -> + VaultItemState.ViewState.Content.ItemType.Identity, +): VaultItemState { + val updatedType = when (val viewState = currentState.viewState) { + is VaultItemState.ViewState.Content -> { + when (val type = viewState.type) { + is VaultItemState.ViewState.Content.ItemType.Identity -> { + viewState.copy( + type = type.transform(), + ) + } + else -> viewState + } + } + else -> viewState + } + return currentState.copy(viewState = updatedType) +} + @Suppress("MaxLineLength") private fun updateCommonContent( currentState: VaultItemState, @@ -805,57 +944,70 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState( dialog = null, ) -private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = - VaultItemState.ViewState.Content( - type = VaultItemState.ViewState.Content.ItemType.Login( - passwordHistoryCount = 1, - username = "the username", - passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "the password", +private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = + VaultItemState.ViewState.Content.Common( + lastUpdated = "12/31/69 06:16 PM", + name = "login cipher", + notes = "Lots of notes", + isPremiumUser = true, + customFields = listOf( + VaultItemState.ViewState.Content.Common.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ), + VaultItemState.ViewState.Content.Common.Custom.HiddenField( + name = "hidden", + value = "hidden password", + isCopyable = true, isVisible = false, ), - uris = listOf( - VaultItemState.ViewState.Content.ItemType.Login.UriData( - uri = "www.example.com", - isCopyable = true, - isLaunchable = true, - ), + VaultItemState.ViewState.Content.Common.Custom.BooleanField( + name = "boolean", + value = true, ), - passwordRevisionDate = "4/14/83 3:56 PM", - totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - ), - common = VaultItemState.ViewState.Content.Common( - lastUpdated = "12/31/69 06:16 PM", - name = "login cipher", - notes = "Lots of notes", - isPremiumUser = true, - customFields = listOf( - VaultItemState.ViewState.Content.Common.Custom.TextField( - name = "text", - value = "value", - isCopyable = true, - ), - VaultItemState.ViewState.Content.Common.Custom.HiddenField( - name = "hidden", - value = "hidden password", - isCopyable = true, - isVisible = false, - ), - VaultItemState.ViewState.Content.Common.Custom.BooleanField( - name = "boolean", - value = true, - ), - VaultItemState.ViewState.Content.Common.Custom.LinkedField( - name = "linked username", - vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, - ), - VaultItemState.ViewState.Content.Common.Custom.LinkedField( - name = "linked password", - vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, - ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked username", + vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked password", + vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, ), - requiresReprompt = true, ), + requiresReprompt = true, + ) + +private val DEFAULT_LOGIN: VaultItemState.ViewState.Content.ItemType.Login = + VaultItemState.ViewState.Content.ItemType.Login( + passwordHistoryCount = 1, + username = "the username", + passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( + password = "the password", + isVisible = false, + ), + uris = listOf( + VaultItemState.ViewState.Content.ItemType.Login.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ), + ), + passwordRevisionDate = "4/14/83 3:56 PM", + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + ) + +private val DEFAULT_IDENTITY: VaultItemState.ViewState.Content.ItemType.Identity = + VaultItemState.ViewState.Content.ItemType.Identity( + username = "the username", + identityName = "the identity name", + company = "the company name", + ssn = "the SSN", + passportNumber = "the passport number", + licenseNumber = "the license number", + email = "the email address", + phone = "the phone number", + address = "the address", ) private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common = @@ -883,3 +1035,15 @@ private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = common = EMPTY_COMMON, type = EMPTY_LOGIN_TYPE, ) + +private val DEFAULT_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + type = DEFAULT_LOGIN, + common = DEFAULT_COMMON, + ) + +private val DEFAULT_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + type = DEFAULT_IDENTITY, + common = DEFAULT_COMMON, + ) 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 0c4998c11..0971c463d 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 @@ -12,7 +12,8 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText -import com.x8bit.bitwarden.ui.vault.feature.item.util.DEFAULT_EMPTY_LOGIN_VIEW_STATE +import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent +import com.x8bit.bitwarden.ui.vault.feature.item.util.createLoginContent import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import io.mockk.coEvery @@ -294,11 +295,12 @@ class VaultItemViewModelTest : BaseViewModelTest() { isCopyable = true, isVisible = false, ) - val loginViewState = DEFAULT_EMPTY_LOGIN_VIEW_STATE.copy( - common = DEFAULT_EMPTY_LOGIN_VIEW_STATE.common.copy( + val loginViewState = VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = true).copy( requiresReprompt = false, customFields = listOf(hiddenField), ), + type = createLoginContent(isEmpty = true), ) val loginState = DEFAULT_STATE.copy(viewState = loginViewState) val mockCipherView = mockk { @@ -316,7 +318,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { assertEquals( loginState.copy( viewState = loginViewState.copy( - common = DEFAULT_EMPTY_LOGIN_VIEW_STATE.common.copy( + common = createCommonContent(isEmpty = true).copy( requiresReprompt = false, customFields = listOf(hiddenField.copy(isVisible = true)), ), 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 23605eaa7..f0eef65fe 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 @@ -1,20 +1,11 @@ package com.x8bit.bitwarden.ui.vault.feature.item.util -import com.bitwarden.core.CipherRepromptType import com.bitwarden.core.CipherType -import com.bitwarden.core.CipherView -import com.bitwarden.core.FieldType -import com.bitwarden.core.FieldView -import com.bitwarden.core.LoginUriView -import com.bitwarden.core.LoginView -import com.bitwarden.core.PasswordHistoryView import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState -import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import java.time.Instant import java.util.TimeZone class CipherViewExtensionsTest { @@ -33,20 +24,29 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform full CipherView into ViewState Login Content with premium`() { - val viewState = DEFAULT_FULL_LOGIN_CIPHER_VIEW.toViewState(isPremiumUser = true) + val viewState = createCipherView(type = CipherType.LOGIN, isEmpty = false) + .toViewState(isPremiumUser = true) - assertEquals(DEFAULT_FULL_LOGIN_VIEW_STATE, viewState) + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = false), + type = createLoginContent(isEmpty = false), + ), + viewState, + ) } @Suppress("MaxLineLength") @Test fun `toViewState should transform full CipherView into ViewState Login Content without premium`() { val isPremiumUser = false - val viewState = DEFAULT_FULL_LOGIN_CIPHER_VIEW.toViewState(isPremiumUser = isPremiumUser) + val viewState = createCipherView(type = CipherType.LOGIN, isEmpty = false) + .toViewState(isPremiumUser = isPremiumUser) assertEquals( - DEFAULT_FULL_LOGIN_VIEW_STATE.copy( - common = DEFAULT_FULL_LOGIN_VIEW_STATE.common.copy(isPremiumUser = isPremiumUser), + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = false).copy(isPremiumUser = isPremiumUser), + type = createLoginContent(isEmpty = false), ), viewState, ) @@ -54,196 +54,120 @@ class CipherViewExtensionsTest { @Test fun `toViewState should transform empty CipherView into ViewState Login Content`() { - val viewState = DEFAULT_EMPTY_LOGIN_CIPHER_VIEW.toViewState(isPremiumUser = true) + val viewState = createCipherView(type = CipherType.LOGIN, isEmpty = true) + .toViewState(isPremiumUser = true) - assertEquals(DEFAULT_EMPTY_LOGIN_VIEW_STATE, viewState) + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = true), + type = createLoginContent(isEmpty = true), + ), + viewState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should transform full CipherView into ViewState Identity Content with premium`() { + val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = false) + .toViewState(isPremiumUser = true) + + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = false), + type = createIdentityContent(isEmpty = false), + ), + viewState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should transform full CipherView into ViewState Identity Content without premium`() { + val isPremiumUser = false + val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = false) + .toViewState(isPremiumUser = isPremiumUser) + + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = false).copy(isPremiumUser = isPremiumUser), + type = createIdentityContent(isEmpty = false), + ), + viewState, + ) + } + + @Test + fun `toViewState should transform empty CipherView into ViewState Identity Content`() { + val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = true) + .toViewState(isPremiumUser = true) + + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = true), + type = createIdentityContent(isEmpty = true), + ), + viewState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should transform CipherView with odd naming into ViewState Identity Content`() { + val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = false) + val result = viewState + .copy( + identity = viewState.identity?.copy( + title = "MX", + firstName = null, + middleName = "middleName", + lastName = null, + ), + ) + .toViewState(isPremiumUser = true) + + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = false), + type = createIdentityContent( + isEmpty = false, + identityName = "Mx middleName", + ), + ), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState should transform CipherView with odd address into ViewState Identity Content`() { + val viewState = createCipherView(type = CipherType.IDENTITY, isEmpty = false) + val result = viewState + .copy( + identity = viewState.identity?.copy( + address1 = null, + address2 = null, + address3 = "address3", + city = null, + state = "state", + postalCode = null, + country = null, + ), + ) + .toViewState(isPremiumUser = true) + + assertEquals( + VaultItemState.ViewState.Content( + common = createCommonContent(isEmpty = false), + type = createIdentityContent( + isEmpty = false, + address = """ + address3 + -, state, - + """.trimIndent(), + ), + ), + result, + ) } } - -val DEFAULT_FULL_LOGIN_VIEW: LoginView = LoginView( - username = "username", - password = "password", - passwordRevisionDate = Instant.ofEpochSecond(1_000L), - uris = listOf( - LoginUriView( - uri = "www.example.com", - match = null, - ), - ), - totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - autofillOnPageLoad = false, -) - -val DEFAULT_EMPTY_LOGIN_VIEW: LoginView = LoginView( - username = null, - password = null, - passwordRevisionDate = null, - uris = emptyList(), - totp = null, - autofillOnPageLoad = false, -) - -val DEFAULT_FULL_LOGIN_CIPHER_VIEW: CipherView = CipherView( - id = null, - organizationId = null, - folderId = null, - collectionIds = emptyList(), - key = null, - name = "login cipher", - notes = "Lots of notes", - type = CipherType.LOGIN, - login = DEFAULT_FULL_LOGIN_VIEW, - identity = null, - card = null, - secureNote = null, - favorite = false, - reprompt = CipherRepromptType.PASSWORD, - organizationUseTotp = false, - edit = false, - viewPassword = false, - localData = null, - attachments = null, - fields = listOf( - FieldView( - name = "text", - value = "value", - type = FieldType.TEXT, - linkedId = null, - ), - FieldView( - name = "hidden", - value = "value", - type = FieldType.HIDDEN, - linkedId = null, - ), - FieldView( - name = "boolean", - value = "true", - type = FieldType.BOOLEAN, - linkedId = null, - ), - FieldView( - name = "linked username", - value = null, - type = FieldType.LINKED, - linkedId = 100U, - ), - FieldView( - name = "linked password", - value = null, - type = FieldType.LINKED, - linkedId = 101U, - ), - ), - passwordHistory = listOf( - PasswordHistoryView( - password = "old_password", - lastUsedDate = Instant.ofEpochSecond(1_000L), - ), - ), - creationDate = Instant.ofEpochSecond(1_000L), - deletedDate = null, - revisionDate = Instant.ofEpochSecond(1_000L), -) - -val DEFAULT_EMPTY_LOGIN_CIPHER_VIEW: CipherView = CipherView( - id = null, - organizationId = null, - folderId = null, - collectionIds = emptyList(), - key = null, - name = "login cipher", - notes = null, - type = CipherType.LOGIN, - login = DEFAULT_EMPTY_LOGIN_VIEW, - identity = null, - card = null, - secureNote = null, - favorite = false, - reprompt = CipherRepromptType.PASSWORD, - organizationUseTotp = false, - edit = false, - viewPassword = false, - localData = null, - attachments = null, - fields = null, - passwordHistory = null, - creationDate = Instant.ofEpochSecond(1_000L), - deletedDate = null, - revisionDate = Instant.ofEpochSecond(1_000L), -) - -val DEFAULT_FULL_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = - VaultItemState.ViewState.Content( - common = VaultItemState.ViewState.Content.Common( - name = "login cipher", - lastUpdated = "1/1/70 12:16 AM", - notes = "Lots of notes", - isPremiumUser = true, - customFields = listOf( - VaultItemState.ViewState.Content.Common.Custom.TextField( - name = "text", - value = "value", - isCopyable = true, - ), - VaultItemState.ViewState.Content.Common.Custom.HiddenField( - name = "hidden", - value = "value", - isCopyable = true, - isVisible = false, - ), - VaultItemState.ViewState.Content.Common.Custom.BooleanField( - name = "boolean", - value = true, - ), - VaultItemState.ViewState.Content.Common.Custom.LinkedField( - name = "linked username", - vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, - ), - VaultItemState.ViewState.Content.Common.Custom.LinkedField( - name = "linked password", - vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, - ), - ), - requiresReprompt = true, - ), - type = VaultItemState.ViewState.Content.ItemType.Login( - passwordHistoryCount = 1, - username = "username", - passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "password", - isVisible = false, - ), - uris = listOf( - VaultItemState.ViewState.Content.ItemType.Login.UriData( - uri = "www.example.com", - isCopyable = true, - isLaunchable = true, - ), - ), - passwordRevisionDate = "1/1/70 12:16 AM", - totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - ), - ) - -val DEFAULT_EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = - VaultItemState.ViewState.Content( - common = VaultItemState.ViewState.Content.Common( - name = "login cipher", - lastUpdated = "1/1/70 12:16 AM", - - notes = null, - isPremiumUser = true, - customFields = emptyList(), - requiresReprompt = true, - - ), - type = VaultItemState.ViewState.Content.ItemType.Login( - passwordHistoryCount = null, - username = null, - passwordData = null, - uris = emptyList(), - passwordRevisionDate = null, - totp = null, - ), - ) 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 new file mode 100644 index 000000000..8ccbbd8c0 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt @@ -0,0 +1,219 @@ +package com.x8bit.bitwarden.ui.vault.feature.item.util + +import com.bitwarden.core.CipherRepromptType +import com.bitwarden.core.CipherType +import com.bitwarden.core.CipherView +import com.bitwarden.core.FieldType +import com.bitwarden.core.FieldView +import com.bitwarden.core.IdentityView +import com.bitwarden.core.LoginUriView +import com.bitwarden.core.LoginView +import com.bitwarden.core.PasswordHistoryView +import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState +import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType +import java.time.Instant + +const val DEFAULT_IDENTITY_NAME: String = "Mr firstName middleName lastName" + +val DEFAULT_ADDRESS: String = + """ + address1 + address2 + address3 + city, state, postalCode + country + """ + .trimIndent() + +fun createLoginView(isEmpty: Boolean): LoginView = + LoginView( + username = "username".takeUnless { isEmpty }, + password = "password".takeUnless { isEmpty }, + passwordRevisionDate = Instant.ofEpochSecond(1_000L).takeUnless { isEmpty }, + uris = listOf( + LoginUriView( + uri = "www.example.com", + match = null, + ), + ) + .takeUnless { isEmpty }, + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example" + .takeUnless { isEmpty }, + autofillOnPageLoad = false, + ) + +@Suppress("CyclomaticComplexMethod") +fun createIdentityView(isEmpty: Boolean): IdentityView = + IdentityView( + title = "MR".takeUnless { isEmpty }, + firstName = "firstName".takeUnless { isEmpty }, + lastName = "lastName".takeUnless { isEmpty }, + middleName = "middleName".takeUnless { isEmpty }, + address1 = "address1".takeUnless { isEmpty }, + address2 = "address2".takeUnless { isEmpty }, + address3 = "address3".takeUnless { isEmpty }, + city = "city".takeUnless { isEmpty }, + state = "state".takeUnless { isEmpty }, + postalCode = "postalCode".takeUnless { isEmpty }, + country = "country".takeUnless { isEmpty }, + company = "company".takeUnless { isEmpty }, + email = "email".takeUnless { isEmpty }, + phone = "phone".takeUnless { isEmpty }, + ssn = "ssn".takeUnless { isEmpty }, + username = "username".takeUnless { isEmpty }, + passportNumber = "passportNumber".takeUnless { isEmpty }, + licenseNumber = "licenseNumber".takeUnless { isEmpty }, + ) + +fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView = + CipherView( + id = null, + organizationId = null, + folderId = null, + collectionIds = emptyList(), + key = null, + name = "mockName", + notes = "Lots of notes".takeUnless { isEmpty }, + type = type, + login = createLoginView(isEmpty = isEmpty), + identity = createIdentityView(isEmpty = isEmpty), + card = null, + secureNote = null, + favorite = false, + reprompt = CipherRepromptType.PASSWORD, + organizationUseTotp = false, + edit = false, + viewPassword = false, + localData = null, + attachments = null, + fields = listOf( + FieldView( + name = "text", + value = "value", + type = FieldType.TEXT, + linkedId = null, + ), + FieldView( + name = "hidden", + value = "value", + type = FieldType.HIDDEN, + linkedId = null, + ), + FieldView( + name = "boolean", + value = "true", + type = FieldType.BOOLEAN, + linkedId = null, + ), + FieldView( + name = "linked username", + value = null, + type = FieldType.LINKED, + linkedId = 100U, + ), + FieldView( + name = "linked password", + value = null, + type = FieldType.LINKED, + linkedId = 101U, + ), + ) + .takeUnless { isEmpty }, + passwordHistory = listOf( + PasswordHistoryView( + password = "old_password", + lastUsedDate = Instant.ofEpochSecond(1_000L), + ), + ) + .takeUnless { isEmpty }, + creationDate = Instant.ofEpochSecond(1_000L), + deletedDate = null, + revisionDate = Instant.ofEpochSecond(1_000L), + ) + +fun createCommonContent(isEmpty: Boolean): VaultItemState.ViewState.Content.Common = + if (isEmpty) { + VaultItemState.ViewState.Content.Common( + name = "mockName", + lastUpdated = "1/1/70 12:16 AM", + notes = null, + isPremiumUser = true, + customFields = emptyList(), + requiresReprompt = true, + ) + } else { + VaultItemState.ViewState.Content.Common( + name = "mockName", + lastUpdated = "1/1/70 12:16 AM", + notes = "Lots of notes", + isPremiumUser = true, + customFields = listOf( + VaultItemState.ViewState.Content.Common.Custom.TextField( + name = "text", + value = "value", + isCopyable = true, + ), + VaultItemState.ViewState.Content.Common.Custom.HiddenField( + name = "hidden", + value = "value", + isCopyable = true, + isVisible = false, + ), + VaultItemState.ViewState.Content.Common.Custom.BooleanField( + name = "boolean", + value = true, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked username", + vaultLinkedFieldType = VaultLinkedFieldType.USERNAME, + ), + VaultItemState.ViewState.Content.Common.Custom.LinkedField( + name = "linked password", + vaultLinkedFieldType = VaultLinkedFieldType.PASSWORD, + ), + ), + requiresReprompt = true, + ) + } + +fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemType.Login = + VaultItemState.ViewState.Content.ItemType.Login( + passwordHistoryCount = 1.takeUnless { isEmpty }, + username = "username".takeUnless { isEmpty }, + passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( + password = "password", + isVisible = false, + ) + .takeUnless { isEmpty }, + uris = if (isEmpty) { + emptyList() + } else { + listOf( + VaultItemState.ViewState.Content.ItemType.Login.UriData( + uri = "www.example.com", + isCopyable = true, + isLaunchable = true, + ), + ) + }, + passwordRevisionDate = "1/1/70 12:16 AM".takeUnless { isEmpty }, + totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example" + .takeUnless { isEmpty }, + ) + +fun createIdentityContent( + isEmpty: Boolean, + address: String = DEFAULT_ADDRESS, + identityName: String = DEFAULT_IDENTITY_NAME, +): VaultItemState.ViewState.Content.ItemType.Identity = + VaultItemState.ViewState.Content.ItemType.Identity( + username = "username".takeUnless { isEmpty }, + identityName = identityName.takeUnless { isEmpty }, + company = "company".takeUnless { isEmpty }, + ssn = "ssn".takeUnless { isEmpty }, + passportNumber = "passportNumber".takeUnless { isEmpty }, + licenseNumber = "licenseNumber".takeUnless { isEmpty }, + email = "email".takeUnless { isEmpty }, + phone = "phone".takeUnless { isEmpty }, + address = address.takeUnless { isEmpty }, + )