BIT-513: View Card Item (#573)

This commit is contained in:
Ramsey Smith 2024-01-11 16:16:55 -07:00 committed by Álison Fernandes
parent d16e0c6573
commit 7a6088a23d
14 changed files with 819 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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