[PM-35352] [PM-21264] Group card numbers in vault item display (#6810)

This commit is contained in:
Colin Rinke
2026-04-28 20:55:13 +02:00
committed by GitHub
parent 0586edb592
commit 41142a3d4d
6 changed files with 187 additions and 3 deletions

View File

@@ -828,8 +828,9 @@ class VaultItemViewModel @Inject constructor(
private fun handleCopyNumberClick() {
onCardContent { _, card ->
val cardNumber = requireNotNull(card.number).number
clipboardManager.setText(
text = requireNotNull(card.number).number,
text = cardNumber.filter { it.isDigit() },
toastDescriptorOverride = BitwardenString.number.asText(),
)
}

View File

@@ -27,6 +27,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
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 com.x8bit.bitwarden.ui.vault.util.formatCardNumber
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.time.Clock
@@ -162,7 +163,7 @@ fun CipherView.toViewState(
cardholderName = card?.cardholderName,
number = card?.number?.let {
VaultItemState.ViewState.Content.ItemType.Card.NumberData(
number = it,
number = it.formatCardNumber(),
isVisible = (previousState?.type
as? VaultItemState.ViewState.Content.ItemType.Card)
?.number

View File

@@ -1,8 +1,69 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.ui.vault.util
import com.bitwarden.ui.platform.feature.cardscanner.util.sanitizeCardNumber
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
/**
* Formats a card number using brand-specific spacing rules.
*
* The input string is first sanitized to remove non-digit characters, then the card brand is
* detected based on the digit patterns. Finally, the digits are grouped into blocks according to
* the brand's formatting rules, and spaces are inserted between the blocks for improved
* readability.
*
* @return The formatted card number.
*/
fun String.formatCardNumber(): String {
val digits = sanitizeCardNumber()
if (digits.isEmpty()) return this
val blocks = digits.detectCardBrand().formattingBlocks(digitCount = digits.length)
return digits.chunkByBlocks(blocks).joinToString(separator = " ")
}
/**
* Returns the digit group sizes used to format a card number for a specific brand.
*
* @param digitCount The total number of sanitized digits available for formatting.
* @return A list of block sizes that defines how the card number should be grouped.
*/
@Suppress("MagicNumber")
private fun VaultCardBrand.formattingBlocks(digitCount: Int): List<Int> {
val default = listOf(4, 4, 4, 4)
return when (this) {
VaultCardBrand.AMEX -> listOf(4, 6, 5)
VaultCardBrand.DINERS_CLUB -> if (digitCount == 14) listOf(4, 6, 4) else default
VaultCardBrand.MAESTRO -> when (digitCount) {
13 -> listOf(4, 4, 5)
15 -> listOf(4, 6, 5)
19 -> listOf(4, 4, 4, 4, 3)
else -> default
}
VaultCardBrand.UNIONPAY -> if (digitCount == 19) listOf(6, 13) else default
else -> default
}
}
/**
* Splits the string into blocks of specified sizes.
*
* If the total of the block sizes is less than the string length, the remaining characters are
* included as an additional block at the end of the list.
*
* @param blocks A list of integers specifying the size of each block.
* @return A list of string blocks based on the specified sizes.
*/
private fun String.chunkByBlocks(blocks: List<Int>): List<String> = buildList {
var remaining = this@chunkByBlocks
for (size in blocks) {
if (remaining.isEmpty()) return@buildList
add(remaining.take(size))
remaining = remaining.drop(size)
}
if (remaining.isNotEmpty()) add(remaining)
}
/**
* Detects the card brand based on the card number prefix.
*

View File

@@ -2102,6 +2102,17 @@ class VaultItemViewModelTest : BaseViewModelTest() {
@Test
fun `on CopyNumberClick should call setText on the ClipboardManager`() = runTest {
val cardTypeWithFormattedNumber = DEFAULT_CARD_TYPE.copy(
number = VaultItemState.ViewState.Content.ItemType.Card.NumberData(
number = "1234 5436",
isVisible = false,
),
)
viewModel = createViewModel(
state = DEFAULT_STATE.copy(
viewState = createViewState(type = cardTypeWithFormattedNumber),
),
)
every {
mockCipherView.toViewState(
previousState = null,
@@ -2116,7 +2127,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns createViewState(type = DEFAULT_CARD_TYPE)
} returns createViewState(type = cardTypeWithFormattedNumber)
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
@@ -2129,6 +2140,8 @@ class VaultItemViewModelTest : BaseViewModelTest() {
text = "12345436",
toastDescriptorOverride = BitwardenString.number.asText(),
)
}
verify(atLeast = 1) {
mockCipherView.toViewState(
previousState = null,
isPremiumUser = true,

View File

@@ -513,6 +513,39 @@ class CipherViewExtensionsTest {
}
}
@Test
fun `toViewState should format card number when transforming card content`() {
val cipherView = createCipherView(type = CipherType.CARD, isEmpty = false)
.copy(
card = createMockCardView(
number = 1,
cardNumber = "4111111111111111",
brand = VaultCardBrand.VISA.name,
),
)
val viewState = cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
clock = fixedClock,
canDelete = true,
canRestore = true,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
assertEquals(
"4111 1111 1111 1111",
(viewState.asContentOrNull()?.type as? VaultItemState.ViewState.Content.ItemType.Card)
?.number
?.number,
)
}
private fun setupMockUri() {
mockkStatic(Uri::class)
val uriMock = mockk<Uri>()

View File

@@ -104,4 +104,79 @@ class CardNumberUtilsTest {
)
assertEquals(VaultCardBrand.OTHER, "".detectCardBrand())
}
@Test
fun `formatCardNumber should format Amex correctly`() {
assertEquals("3782 822463 10005", "378282246310005".formatCardNumber())
assertEquals("3411 111111 11111", "341111111111111".formatCardNumber())
}
@Test
fun `formatCardNumber should format Diners Club 14 digits correctly`() {
assertEquals("3056 930902 5904", "30569309025904".formatCardNumber())
}
@Test
fun `formatCardNumber should format Diners Club with non 14 digits as default`() {
assertEquals("3600 0000 0000 0084", "3600000000000084".formatCardNumber())
}
@Test
fun `formatCardNumber should format Maestro 13 digits correctly`() {
assertEquals("5018 5753 94858", "5018575394858".formatCardNumber())
}
@Test
fun `formatCardNumber should format Maestro 15 digits correctly`() {
assertEquals("5018 575394 85843", "501857539485843".formatCardNumber())
}
@Test
fun `formatCardNumber should format Maestro 19 digits correctly`() {
assertEquals("5018 5753 9485 8437 306", "5018575394858437306".formatCardNumber())
}
@Test
fun `formatCardNumber should format Maestro other digits as default`() {
assertEquals("5018 5753 9485 8437", "5018575394858437".formatCardNumber())
}
@Test
fun `formatCardNumber should format UnionPay 19 digits correctly`() {
assertEquals("622795 5237950556428", "6227955237950556428".formatCardNumber())
}
@Test
fun `formatCardNumber should format UnionPay with non 19 digits as default`() {
assertEquals("6227 9552 3795 0556", "6227955237950556".formatCardNumber())
}
@Test
fun `formatCardNumber should format standard card numbers in groups of 4`() {
assertEquals("4111 1111 1111 1111", "4111111111111111".formatCardNumber())
assertEquals("5500 0000 0000 0004", "5500000000000004".formatCardNumber())
assertEquals("6011 1111 1111 1117", "6011111111111117".formatCardNumber())
}
@Test
fun `formatCardNumber should format card numbers in 4 groups of 4 and append remainder`() {
assertEquals("4111 1111 1111 1111 1", "41111111111111111".formatCardNumber())
assertEquals("5500 0000 0000 0004 0000", "55000000000000040000".formatCardNumber())
assertEquals("6011 1111 1111 1117 11111", "601111111111111711111".formatCardNumber())
}
@Test
fun `formatCardNumber should keep short standard card numbers unchanged`() {
assertEquals("123", "123".formatCardNumber())
}
@Test
fun `formatCardNumber should return empty string for empty input`() {
assertEquals("", "".formatCardNumber())
}
@Test
fun `formatCardNumber should sanitize and return empty when input has no digits`() {
assertEquals("----", "----".formatCardNumber())
}
}