From 7a6088a23d46362ee4b7beba211eb82429b87e22 Mon Sep 17 00:00:00 2001 From: Ramsey Smith <142836716+ramsey-livefront@users.noreply.github.com> Date: Thu, 11 Jan 2024 16:16:55 -0700 Subject: [PATCH] BIT-513: View Card Item (#573) --- .../addedit/util/CipherViewExtensions.kt | 9 +- .../feature/item/VaultItemCardContent.kt | 220 +++++++++++++++++ .../ui/vault/feature/item/VaultItemScreen.kt | 13 +- .../vault/feature/item/VaultItemViewModel.kt | 86 ++++++- .../handlers/VaultCardItemTypeHandlers.kt | 29 +++ .../handlers/VaultLoginItemTypeHandlers.kt | 2 +- .../feature/item/util/CipherViewExtensions.kt | 24 +- .../vault/util/VaultAddItemStateExtensions.kt | 4 +- .../ui/vault/model/VaultCardBrand.kt | 16 ++ .../vault/model/VaultCardExpirationMonth.kt | 66 +++++- .../addedit/util/CipherViewExtensionsTest.kt | 3 + .../vault/feature/item/VaultItemScreenTest.kt | 224 +++++++++++++++++- .../feature/item/VaultItemViewModelTest.kt | 116 +++++++++ .../ui/vault/model/VaultCardBrandTest.kt | 31 +++ 14 files changed, 819 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCardItemTypeHandlers.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrandTest.kt 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 a6bb768732..a02c505a0b 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 @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth import com.x8bit.bitwarden.ui.vault.model.VaultIdentityTitle import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType.Companion.fromId +import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull import java.util.UUID /** @@ -36,6 +37,7 @@ fun CipherView.toViewState(): VaultAddEditState.ViewState = number = card?.number.orEmpty(), brand = card?.brand.toBrandOrDefault(), expirationMonth = card?.expMonth.toExpirationMonthOrDefault(), + expirationYear = card?.expYear.orEmpty(), securityCode = card?.code.orEmpty(), ) CipherType.IDENTITY -> VaultAddEditState.ViewState.Content.ItemType.Identity( @@ -109,13 +111,12 @@ private fun String?.toTitleOrDefault(): VaultIdentityTitle = ?: VaultIdentityTitle.SELECT private fun String?.toBrandOrDefault(): VaultCardBrand = - VaultCardBrand - .entries - .find { it.name == this } + this + ?.findVaultCardBrandWithNameOrNull() ?: VaultCardBrand.SELECT private fun String?.toExpirationMonthOrDefault(): VaultCardExpirationMonth = VaultCardExpirationMonth .entries - .find { it.name == this } + .find { it.number == this } ?: VaultCardExpirationMonth.SELECT diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt new file mode 100644 index 0000000000..4b172a023e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt @@ -0,0 +1,220 @@ +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.BitwardenIconButtonWithResource +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.model.IconResource +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.model.VaultCardBrand + +/** + * The top level content UI state for the [VaultItemScreen] when viewing a Card cipher. + */ +@Suppress("LongMethod") +@Composable +fun VaultItemCardContent( + commonState: VaultItemState.ViewState.Content.Common, + cardState: VaultItemState.ViewState.Content.ItemType.Card, + vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, + vaultCardItemTypeHandlers: VaultCardItemTypeHandlers, + 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), + ) + } + cardState.cardholderName?.let { cardholderName -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.cardholder_name), + value = cardholderName, + onValueChange = {}, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + cardState.number?.let { number -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenPasswordFieldWithActions( + label = stringResource(id = R.string.number), + value = number, + onValueChange = {}, + readOnly = true, + singleLine = false, + actions = { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource(id = R.string.copy_number), + ), + onClick = vaultCardItemTypeHandlers.onCopyNumberClick, + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + if (cardState.brand != null && cardState.brand != VaultCardBrand.SELECT) { + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.brand), + value = cardState.brand.value(), + onValueChange = {}, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + cardState.expiration?.let { expiration -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.expiration), + value = expiration, + onValueChange = {}, + readOnly = true, + singleLine = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + cardState.securityCode?.let { securityCode -> + item { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenPasswordFieldWithActions( + label = stringResource(id = R.string.security_code), + value = securityCode, + onValueChange = {}, + readOnly = true, + singleLine = false, + actions = { + BitwardenIconButtonWithResource( + iconRes = IconResource( + iconPainter = painterResource(id = R.drawable.ic_copy), + contentDescription = stringResource( + id = R.string.copy_security_code, + ), + ), + onClick = vaultCardItemTypeHandlers.onCopySecurityCodeClick, + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + + commonState.notes?.let { notes -> + item { + Spacer(modifier = Modifier.height(4.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.notes), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.notes), + value = notes, + 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/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index e87f9667c2..90f999aace 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 @@ -39,6 +39,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState +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 @@ -137,6 +138,9 @@ fun VaultItemScreen( vaultLoginItemTypeHandlers = remember(viewModel) { VaultLoginItemTypeHandlers.create(viewModel = viewModel) }, + vaultCardItemTypeHandlers = remember(viewModel) { + VaultCardItemTypeHandlers.create(viewModel = viewModel) + }, ) } } @@ -176,6 +180,7 @@ private fun VaultItemContent( viewState: VaultItemState.ViewState, vaultCommonItemTypeHandlers: VaultCommonItemTypeHandlers, vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers, + vaultCardItemTypeHandlers: VaultCardItemTypeHandlers, modifier: Modifier = Modifier, ) { when (viewState) { @@ -198,7 +203,13 @@ private fun VaultItemContent( } is VaultItemState.ViewState.Content.ItemType.Card -> { - // TODO UI for viewing Card BIT-513 + VaultItemCardContent( + commonState = viewState.common, + cardState = viewState.type, + vaultCommonItemTypeHandlers = vaultCommonItemTypeHandlers, + vaultCardItemTypeHandlers = vaultCardItemTypeHandlers, + modifier = modifier, + ) } is VaultItemState.ViewState.Content.ItemType.Identity -> { 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 d9fa5a9dcf..68630a3ce3 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 @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.vault.feature.item import android.os.Parcelable +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.CipherView @@ -17,6 +18,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.vault.feature.item.util.toViewState +import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -65,8 +67,10 @@ class VaultItemViewModel @Inject constructor( } override fun handleAction(action: VaultItemAction) { + Log.d("ramsey", "handleAction: action $action") when (action) { is VaultItemAction.ItemType.Login -> handleLoginTypeActions(action) + is VaultItemAction.ItemType.Card -> handleCardTypeActions(action) is VaultItemAction.Common -> handleCommonActions(action) is VaultItemAction.Internal -> handleInternalAction(action) } @@ -311,6 +315,43 @@ class VaultItemViewModel @Inject constructor( //endregion Login Type Handlers + //region Card Type Handlers + + private fun handleCardTypeActions(action: VaultItemAction.ItemType.Card) { + when (action) { + VaultItemAction.ItemType.Card.CopyNumberClick -> handleCopyNumberClick() + VaultItemAction.ItemType.Card.CopySecurityCodeClick -> handleCopySecurityCodeClick() + } + } + + private fun handleCopyNumberClick() { + onCardContent { content, card -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + return@onCardContent + } + val number = requireNotNull(card.number) + clipboardManager.setText(text = number) + } + } + + private fun handleCopySecurityCodeClick() { + onCardContent { content, card -> + if (content.common.requiresReprompt) { + mutableStateFlow.update { + it.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog) + } + return@onCardContent + } + val securityCode = requireNotNull(card.securityCode) + clipboardManager.setText(text = securityCode) + } + } + + //endregion Card Type Handlers + //region Internal Type Handlers private fun handleInternalAction(action: VaultItemAction.Internal) { @@ -452,6 +493,21 @@ class VaultItemViewModel @Inject constructor( } } } + + private inline fun onCardContent( + crossinline block: ( + VaultItemState.ViewState.Content, + VaultItemState.ViewState.Content.ItemType.Card, + ) -> Unit, + ) { + (state.viewState as? VaultItemState.ViewState.Content) + ?.let { content -> + (content.type as? VaultItemState.ViewState.Content.ItemType.Card) + ?.let { loginContent -> + block(content, loginContent) + } + } + } } /** @@ -646,8 +702,20 @@ data class VaultItemState( /** * Represents the `Card` item type. + * + * @property cardholderName The cardholder name for the card. + * @property number The number for the card. + * @property brand The brand for the card. + * @property expiration The expiration for the card. + * @property securityCode The securityCode for the card. */ - data object Card : ItemType() + data class Card( + val cardholderName: String?, + val number: String?, + val brand: VaultCardBrand?, + val expiration: String?, + val securityCode: String?, + ) : ItemType() } } } @@ -828,6 +896,22 @@ sealed class VaultItemAction { val isVisible: Boolean, ) : Login() } + + /** + * Represents actions specific to the Card type. + */ + sealed class Card : ItemType() { + + /** + * The user has clicked the copy button for the number. + */ + data object CopyNumberClick : Card() + + /** + * The user has clicked the copy button for the security code. + */ + data object CopySecurityCodeClick : Card() + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCardItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCardItemTypeHandlers.kt new file mode 100644 index 0000000000..cb84fb3883 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCardItemTypeHandlers.kt @@ -0,0 +1,29 @@ +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 card + * items in a vault. + */ +data class VaultCardItemTypeHandlers( + val onCopyNumberClick: () -> Unit, + val onCopySecurityCodeClick: () -> Unit, +) { + companion object { + + /** + * Creates the [VaultCardItemTypeHandlers] using the [viewModel] to send desired actions. + */ + fun create(viewModel: VaultItemViewModel): VaultCardItemTypeHandlers = + VaultCardItemTypeHandlers( + onCopyNumberClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) + }, + onCopySecurityCodeClick = { + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt index 0f6127c4ad..51142cef74 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultLoginItemTypeHandlers.kt @@ -4,7 +4,7 @@ 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 identity + * A collection of handler functions for managing actions within the context of viewing login * items in a vault. */ @Suppress("LongParameterList") 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 096ce2b295..8925462d77 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.item.util +import com.bitwarden.core.CardView import com.bitwarden.core.CipherRepromptType import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView @@ -14,7 +15,9 @@ 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 +import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType +import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull import java.time.format.DateTimeFormatter import java.util.TimeZone @@ -64,7 +67,13 @@ fun CipherView.toViewState( } CipherType.CARD -> { - VaultItemState.ViewState.Content.ItemType.Card + VaultItemState.ViewState.Content.ItemType.Card( + cardholderName = card?.cardholderName, + number = card?.number, + brand = card?.cardBrand, + expiration = card?.expiration, + securityCode = card?.code, + ) } CipherType.IDENTITY -> { @@ -140,3 +149,16 @@ private val IdentityView.identityName: String? ) .joinToString(" ") .orNullIfBlank() + +private val CardView.cardBrand: VaultCardBrand? + get() = brand + ?.findVaultCardBrandWithNameOrNull() + .takeUnless { it == VaultCardBrand.SELECT } + +private val CardView.expiration: String? + get() = listOfNotNull( + expMonth?.padStart(length = 2, padChar = '0'), + expYear, + ) + .joinToString("/") + .orNullIfBlank() 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 2014b41de8..7ad7718cdc 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 @@ -47,7 +47,7 @@ fun VaultAddEditState.ViewState.Content.toCipherView(): CipherView = // Fields we always grab from the UI name = common.name, - notes = common.notes, + notes = common.notes.orNullIfBlank(), favorite = common.favorite, // TODO Use real folder ID (BIT-528) folderId = common.originalCipher?.folderId, @@ -74,7 +74,7 @@ private fun VaultAddEditState.ViewState.Content.ItemType.toCardView(): CardView? .takeUnless { month -> month == VaultCardExpirationMonth.SELECT } - ?.name, + ?.number, expYear = it.expirationYear.orNullIfBlank(), code = it.securityCode.orNullIfBlank(), brand = it diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrand.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrand.kt index 9171573693..5082808f21 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrand.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrand.kt @@ -21,3 +21,19 @@ enum class VaultCardBrand(val value: Text) { RUPAY(value = "RuPay".asText()), OTHER(value = R.string.other.asText()), } + +/** + * Returns a [VaultCardBrand] with the provided [String] or null. + */ +fun String.findVaultCardBrandWithNameOrNull(): VaultCardBrand? = + VaultCardBrand + .entries + .find { vaultCardBrand -> + vaultCardBrand.name.lowercaseWithoutSpacesOrUnderScores == + this.lowercaseWithoutSpacesOrUnderScores + } + +private val String.lowercaseWithoutSpacesOrUnderScores: String + get() = lowercase() + .replace(" ", "") + .replace("_", "") diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardExpirationMonth.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardExpirationMonth.kt index be6bf6d977..5da2a3e4fc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardExpirationMonth.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/VaultCardExpirationMonth.kt @@ -12,20 +12,60 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.util.SELECT_TEXT */ enum class VaultCardExpirationMonth( val value: Text, + val number: String, ) { - SELECT(value = SELECT_TEXT), - JANUARY(value = R.string.january.dateText("01 - ")), - FEBRUARY(value = R.string.february.dateText("02 - ")), - MARCH(value = R.string.march.dateText("03 - ")), - APRIL(value = R.string.april.dateText("04 - ")), - MAY(value = R.string.may.dateText("05 - ")), - JUNE(value = R.string.june.dateText("06 - ")), - JULY(value = R.string.july.dateText("07 - ")), - AUGUST(value = R.string.august.dateText("08 - ")), - SEPTEMBER(value = R.string.september.dateText("09 - ")), - OCTOBER(value = R.string.october.dateText("10 - ")), - NOVEMBER(value = R.string.november.dateText("11 - ")), - DECEMBER(value = R.string.december.dateText("12 - ")), + SELECT( + value = SELECT_TEXT, + number = "0", + ), + JANUARY( + value = R.string.january.dateText("01 - "), + number = "1", + ), + FEBRUARY( + value = R.string.february.dateText("02 - "), + number = "2", + ), + MARCH( + value = R.string.march.dateText("03 - "), + number = "3", + ), + APRIL( + value = R.string.april.dateText("04 - "), + number = "4", + ), + MAY( + value = R.string.may.dateText("05 - "), + number = "5", + ), + JUNE( + value = R.string.june.dateText("06 - "), + number = "6", + ), + JULY( + value = R.string.july.dateText("07 - "), + number = "7", + ), + AUGUST( + value = R.string.august.dateText("08 - "), + number = "8", + ), + SEPTEMBER( + value = R.string.september.dateText("09 - "), + number = "9", + ), + OCTOBER( + value = R.string.october.dateText("10 - "), + number = "10", + ), + NOVEMBER( + value = R.string.november.dateText("11 - "), + number = "11", + ), + DECEMBER( + value = R.string.december.dateText("12 - "), + number = "12", + ), } private fun @receiver:StringRes Int.dateText(prefix: String): Text = 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 b3b3af1ff9..c8cd12b590 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 @@ -15,6 +15,7 @@ import com.bitwarden.core.SecureNoteView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState +import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import io.mockk.every import io.mockk.mockkStatic @@ -71,6 +72,8 @@ class CipherViewExtensionsTest { type = VaultAddEditState.ViewState.Content.ItemType.Card( cardHolderName = "Bit Warden", number = "4012888888881881", + brand = VaultCardBrand.VISA, + expirationYear = "2030", securityCode = "123", ), ), 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 fc686d4f63..3c86388ce1 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 @@ -10,8 +10,10 @@ import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onSiblings @@ -25,7 +27,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.util.assertScrollableNodeDoesNotExist import com.x8bit.bitwarden.ui.util.isProgressBar import com.x8bit.bitwarden.ui.util.onFirstNodeWithTextAfterScroll +import com.x8bit.bitwarden.ui.util.onNodeWithContentDescriptionAfterScroll import com.x8bit.bitwarden.ui.util.onNodeWithTextAfterScroll +import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import io.mockk.every import io.mockk.just @@ -649,7 +653,7 @@ class VaultItemScreenTest : BaseComposeTest() { } @Test - fun `in login state, on show password click should send CopyPasswordClick`() { + fun `in login state, on show password click should send PasswordVisibilityClicked`() { mutableStateFlow.update { it.copy(viewState = DEFAULT_LOGIN_VIEW_STATE) } composeTestRule @@ -1060,6 +1064,170 @@ class VaultItemScreenTest : BaseComposeTest() { composeTestRule.assertScrollableNodeDoesNotExist(identityName) } + + @Test + fun `in card state, cardholderName should be displayed according to state`() { + val cardholderName = "the cardholder name" + mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(cardholderName).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateCardType(currentState) { copy(cardholderName = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(cardholderName) + } + + @Test + fun `in card state the number should be displayed according to state`() { + composeTestRule.assertScrollableNodeDoesNotExist("Number") + + mutableStateFlow.update { + it.copy( + viewState = EMPTY_CARD_VIEW_STATE.copy( + type = EMPTY_CARD_TYPE.copy( + number = "number", + ), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll("Number") + .assertTextEquals("Number", "••••••") + .assertIsEnabled() + composeTestRule + .onNodeWithContentDescription("Copy number") + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription("Show") + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithText("Number") + .assertTextEquals("Number", "number") + .assertIsEnabled() + composeTestRule + .onNodeWithContentDescription("Copy number") + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription("Hide") + .assertIsDisplayed() + } + + @Test + fun `in card state, on copy number click should send CopyNumberClick`() { + val number = "123456789" + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_CARD_VIEW_STATE.copy( + type = EMPTY_CARD_TYPE.copy( + number = number, + expiration = "test", + ), + ), + ) + } + + // Scroll so we can see the Copy number button but not have it covered by the FAB + composeTestRule + .onNodeWithTextAfterScroll("Expiration") + composeTestRule + .onNodeWithContentDescription("Copy number") + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) + } + } + + @Test + fun `in card state, brand should be displayed according to state`() { + val visa = "Visa" + mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(visa).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateCardType(currentState) { copy(brand = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(visa) + } + + @Test + fun `in card state, expiration should be displayed according to state`() { + val expiration = "the expiration" + mutableStateFlow.update { it.copy(viewState = DEFAULT_CARD_VIEW_STATE) } + composeTestRule.onNodeWithTextAfterScroll(expiration).assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateCardType(currentState) { copy(expiration = null) } + } + + composeTestRule.assertScrollableNodeDoesNotExist(expiration) + } + + @Test + fun `in card state the security code should be displayed according to state`() { + composeTestRule.assertScrollableNodeDoesNotExist("Security code") + + mutableStateFlow.update { + it.copy( + viewState = EMPTY_CARD_VIEW_STATE.copy( + type = EMPTY_CARD_TYPE.copy( + securityCode = "123", + ), + ), + ) + } + + composeTestRule + .onNodeWithTextAfterScroll("Security code") + .assertTextEquals("Security code", "•••") + .assertIsEnabled() + composeTestRule + .onNodeWithContentDescription("Copy security code") + .assertIsDisplayed() + composeTestRule + .onAllNodesWithContentDescription("Show") + .onLast() + .assertIsDisplayed() + .performClick() + + composeTestRule + .onNodeWithText("Security code") + .assertTextEquals("Security code", "123") + .assertIsEnabled() + composeTestRule + .onNodeWithContentDescription("Copy security code") + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription("Hide") + .assertIsDisplayed() + } + + @Test + fun `in card state, on copy security code click should send CopySecurityCodeClick`() { + val number = "1234" + mutableStateFlow.update { currentState -> + currentState.copy( + viewState = EMPTY_CARD_VIEW_STATE.copy( + type = EMPTY_CARD_TYPE.copy( + securityCode = number, + ), + ), + ) + } + + composeTestRule + .onNodeWithContentDescriptionAfterScroll("Copy security code") + .performClick() + + verify { + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) + } + } } //region Helper functions @@ -1112,6 +1280,30 @@ private fun updateIdentityType( return currentState.copy(viewState = updatedType) } +@Suppress("MaxLineLength") +private fun updateCardType( + currentState: VaultItemState, + transform: VaultItemState.ViewState.Content.ItemType.Card.() -> + VaultItemState.ViewState.Content.ItemType.Card, +): VaultItemState { + val updatedType = when (val viewState = currentState.viewState) { + is VaultItemState.ViewState.Content -> { + when (val type = viewState.type) { + is VaultItemState.ViewState.Content.ItemType.Card -> { + viewState.copy( + type = type.transform(), + ) + } + + else -> viewState + } + } + + else -> viewState + } + return currentState.copy(viewState = updatedType) +} + @Suppress("MaxLineLength") private fun updateCommonContent( currentState: VaultItemState, @@ -1196,6 +1388,15 @@ private val DEFAULT_IDENTITY: VaultItemState.ViewState.Content.ItemType.Identity address = "the address", ) +private val DEFAULT_CARD: VaultItemState.ViewState.Content.ItemType.Card = + VaultItemState.ViewState.Content.ItemType.Card( + cardholderName = "the cardholder name", + number = "the number", + brand = VaultCardBrand.VISA, + expiration = "the expiration", + securityCode = "the security code", + ) + private val EMPTY_COMMON: VaultItemState.ViewState.Content.Common = VaultItemState.ViewState.Content.Common( name = "cipher", @@ -1229,6 +1430,15 @@ private val EMPTY_IDENTITY_TYPE: VaultItemState.ViewState.Content.ItemType.Ident address = "", ) +private val EMPTY_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card = + VaultItemState.ViewState.Content.ItemType.Card( + cardholderName = "", + number = "", + brand = VaultCardBrand.SELECT, + expiration = "", + securityCode = "", + ) + private val EMPTY_LOGIN_VIEW_STATE: VaultItemState.ViewState.Content = VaultItemState.ViewState.Content( common = EMPTY_COMMON, @@ -1241,6 +1451,12 @@ private val EMPTY_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content = type = EMPTY_IDENTITY_TYPE, ) +private val EMPTY_CARD_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = EMPTY_COMMON, + type = EMPTY_CARD_TYPE, + ) + private val EMPTY_SECURE_NOTE_VIEW_STATE = VaultItemState.ViewState.Content( common = EMPTY_COMMON, @@ -1259,6 +1475,12 @@ private val DEFAULT_IDENTITY_VIEW_STATE: VaultItemState.ViewState.Content = common = DEFAULT_COMMON, ) +private val DEFAULT_CARD_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + type = DEFAULT_CARD, + common = DEFAULT_COMMON, + ) + private val DEFAULT_SECURE_NOTE_VIEW_STATE: VaultItemState.ViewState.Content = VaultItemState.ViewState.Content( 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 02b696cbdd..ace9717bef 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 @@ -16,6 +16,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText 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.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import io.mockk.coEvery import io.mockk.coVerify @@ -588,6 +589,106 @@ class VaultItemViewModelTest : BaseViewModelTest() { } } + @Nested + inner class CardActions { + private lateinit var viewModel: VaultItemViewModel + + @BeforeEach + fun setup() { + viewModel = createViewModel( + state = DEFAULT_STATE.copy( + viewState = CARD_VIEW_STATE, + ), + ) + } + + @Test + fun `on CopyNumberClick should show password dialog when re-prompt is required`() = + runTest { + val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(cardState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) + assertEquals( + cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on CopyNumberClick should call setText on the ClipboardManager when re-prompt is not required`() { + val mockCipherView = mockk { + every { + toViewState(isPremiumUser = true) + } returns createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + type = DEFAULT_CARD_TYPE, + ) + } + every { clipboardManager.setText(text = "12345436") } just runs + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopyNumberClick) + + verify(exactly = 1) { + clipboardManager.setText(text = "12345436") + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Test + fun `on CopySecurityCodeClick should show password dialog when re-prompt is required`() = + runTest { + val cardState = DEFAULT_STATE.copy(viewState = CARD_VIEW_STATE) + val mockCipherView = mockk { + every { toViewState(isPremiumUser = true) } returns CARD_VIEW_STATE + } + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + assertEquals(cardState, viewModel.stateFlow.value) + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) + assertEquals( + cardState.copy(dialog = VaultItemState.DialogState.MasterPasswordDialog), + viewModel.stateFlow.value, + ) + + verify(exactly = 1) { + mockCipherView.toViewState(isPremiumUser = true) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on CopySecurityCodeClick should call setText on the ClipboardManager when re-prompt is not required`() { + val mockCipherView = mockk { + every { + toViewState(isPremiumUser = true) + } returns createViewState( + common = DEFAULT_COMMON.copy(requiresReprompt = false), + type = DEFAULT_CARD_TYPE, + ) + } + every { clipboardManager.setText(text = "987") } just runs + mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) + + viewModel.trySendAction(VaultItemAction.ItemType.Card.CopySecurityCodeClick) + + verify(exactly = 1) { + clipboardManager.setText(text = "987") + mockCipherView.toViewState(isPremiumUser = true) + } + } + } + private fun createViewModel( state: VaultItemState?, vaultItemId: String = VAULT_ITEM_ID, @@ -665,6 +766,15 @@ class VaultItemViewModelTest : BaseViewModelTest() { isPremiumUser = true, ) + private val DEFAULT_CARD_TYPE: VaultItemState.ViewState.Content.ItemType.Card = + VaultItemState.ViewState.Content.ItemType.Card( + cardholderName = "mockName", + number = "12345436", + brand = VaultCardBrand.VISA, + expiration = "03/2027", + securityCode = "987", + ) + private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = VaultItemState.ViewState.Content.Common( name = "login cipher", @@ -703,5 +813,11 @@ class VaultItemViewModelTest : BaseViewModelTest() { common = DEFAULT_COMMON, type = DEFAULT_LOGIN_TYPE, ) + + private val CARD_VIEW_STATE: VaultItemState.ViewState.Content = + VaultItemState.ViewState.Content( + common = DEFAULT_COMMON, + type = DEFAULT_CARD_TYPE, + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrandTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrandTest.kt new file mode 100644 index 0000000000..d2f0cd01c9 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/model/VaultCardBrandTest.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.ui.vault.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class VaultCardBrandTest { + + @Test + fun `findVaultCardBrandWithNameOrNull should return matching brand, regardless of format`() { + val names = listOf( + "UNIONpay", + "AMERICAN_EXPRESS", + "diNERs cLub", + "rupay", + "nothing card", + ) + + val result = names.map { it.findVaultCardBrandWithNameOrNull() } + + assertEquals( + listOf( + VaultCardBrand.UNIONPAY, + VaultCardBrand.AMERICAN_EXPRESS, + VaultCardBrand.DINERS_CLUB, + VaultCardBrand.RUPAY, + null, + ), + result, + ) + } +}