Skip to content

Commit b9bece6

Browse files
committed
[PM-34125] feat: Add card text analysis pipeline
Add the complete text analysis pipeline for credit card scanning: - CardNumberUtils: sanitize, Luhn validation, brand detection - CardDataParser: interface and implementation for OCR text parsing - CardTextAnalyzer: ML Kit-based camera frame analysis - CardScanOverlay: camera overlay composable - CardScanData: data class for parsed card fields - FakeCardTextAnalyzer: test fixture - LocalProviders: composition local for CardTextAnalyzer
1 parent a58b58c commit b9bece6

2 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.x8bit.bitwarden.data.vault.util
2+
3+
import com.bitwarden.ui.platform.feature.cardscanner.util.sanitizeCardNumber
4+
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
5+
6+
/**
7+
* Detects the card brand based on the card number prefix.
8+
*
9+
* @return The detected [VaultCardBrand], or [VaultCardBrand.OTHER] if no match is found.
10+
*/
11+
@Suppress("CyclomaticComplexMethod", "MagicNumber")
12+
fun String.detectCardBrand(): VaultCardBrand {
13+
val digits = sanitizeCardNumber()
14+
if (digits.isEmpty()) return VaultCardBrand.OTHER
15+
16+
return when {
17+
// Amex: starts with 34 or 37
18+
digits.startsWith("34") || digits.startsWith("37") -> VaultCardBrand.AMEX
19+
20+
// Visa: starts with 4
21+
digits.startsWith("4") -> VaultCardBrand.VISA
22+
23+
// Mastercard: 51-55 or 2221-2720
24+
digits.isMastercardPrefix() -> VaultCardBrand.MASTERCARD
25+
26+
// Discover: 6011, 65, 644-649
27+
digits.isDiscoverPrefix() -> VaultCardBrand.DISCOVER
28+
29+
// Diners Club: 300-305, 36, 38
30+
digits.isDinersClubPrefix() -> VaultCardBrand.DINERS_CLUB
31+
32+
// JCB: 3528-3589
33+
digits.isJcbPrefix() -> VaultCardBrand.JCB
34+
35+
// Maestro: 5018, 5020, 5038, 6304
36+
digits.isMaestroPrefix() -> VaultCardBrand.MAESTRO
37+
38+
// UnionPay: starts with 62
39+
digits.startsWith("62") -> VaultCardBrand.UNIONPAY
40+
41+
// RuPay: 60, 65, 81, 82
42+
digits.isRuPayPrefix() -> VaultCardBrand.RUPAY
43+
44+
else -> VaultCardBrand.OTHER
45+
}
46+
}
47+
48+
@Suppress("MagicNumber")
49+
private fun String.isMastercardPrefix(): Boolean {
50+
if (length < 2) return false
51+
val twoDigit = substring(0, 2).toIntOrNull() ?: return false
52+
if (twoDigit in 51..55) return true
53+
if (length < 4) return false
54+
val fourDigit = substring(0, 4).toIntOrNull() ?: return false
55+
return fourDigit in 2221..2720
56+
}
57+
58+
@Suppress("MagicNumber")
59+
private fun String.isDiscoverPrefix(): Boolean {
60+
if (startsWith("6011") || startsWith("65")) return true
61+
if (length < 3) return false
62+
val threeDigit = substring(0, 3).toIntOrNull() ?: return false
63+
return threeDigit in 644..649
64+
}
65+
66+
@Suppress("MagicNumber")
67+
private fun String.isDinersClubPrefix(): Boolean {
68+
if (startsWith("36") || startsWith("38")) return true
69+
if (length < 3) return false
70+
val threeDigit = substring(0, 3).toIntOrNull() ?: return false
71+
return threeDigit in 300..305
72+
}
73+
74+
@Suppress("MagicNumber")
75+
private fun String.isJcbPrefix(): Boolean {
76+
if (length < 4) return false
77+
val fourDigit = substring(0, 4).toIntOrNull() ?: return false
78+
return fourDigit in 3528..3589
79+
}
80+
81+
private fun String.isMaestroPrefix(): Boolean =
82+
startsWith("5018") ||
83+
startsWith("5020") ||
84+
startsWith("5038") ||
85+
startsWith("6304")
86+
87+
// Note: "60" and "65" overlap with Discover prefixes ("6011", "65") but are
88+
// unreachable here because Discover is checked first in detectCardBrand().
89+
// They are kept for documentation of the full RuPay prefix specification.
90+
private fun String.isRuPayPrefix(): Boolean =
91+
startsWith("60") ||
92+
startsWith("65") ||
93+
startsWith("81") ||
94+
startsWith("82")
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.x8bit.bitwarden.data.vault.util
2+
3+
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
4+
import org.junit.jupiter.api.Assertions.assertEquals
5+
import org.junit.jupiter.api.Test
6+
7+
class CardNumberUtilsTest {
8+
9+
@Test
10+
fun `detectCardBrand should detect Visa`() {
11+
assertEquals(VaultCardBrand.VISA, "4111111111111111".detectCardBrand())
12+
assertEquals(VaultCardBrand.VISA, "4012888888881881".detectCardBrand())
13+
}
14+
15+
@Test
16+
fun `detectCardBrand should detect Mastercard`() {
17+
assertEquals(
18+
VaultCardBrand.MASTERCARD,
19+
"5500000000000004".detectCardBrand(),
20+
)
21+
assertEquals(
22+
VaultCardBrand.MASTERCARD,
23+
"5100000000000008".detectCardBrand(),
24+
)
25+
assertEquals(
26+
VaultCardBrand.MASTERCARD,
27+
"2221000000000009".detectCardBrand(),
28+
)
29+
}
30+
31+
@Test
32+
fun `detectCardBrand should detect Amex`() {
33+
assertEquals(VaultCardBrand.AMEX, "378282246310005".detectCardBrand())
34+
assertEquals(VaultCardBrand.AMEX, "341111111111111".detectCardBrand())
35+
}
36+
37+
@Test
38+
fun `detectCardBrand should detect Discover`() {
39+
assertEquals(
40+
VaultCardBrand.DISCOVER,
41+
"6011111111111117".detectCardBrand(),
42+
)
43+
assertEquals(
44+
VaultCardBrand.DISCOVER,
45+
"6500000000000002".detectCardBrand(),
46+
)
47+
}
48+
49+
@Test
50+
fun `detectCardBrand should detect Diners Club`() {
51+
assertEquals(
52+
VaultCardBrand.DINERS_CLUB,
53+
"30569309025904".detectCardBrand(),
54+
)
55+
assertEquals(
56+
VaultCardBrand.DINERS_CLUB,
57+
"36000000000008".detectCardBrand(),
58+
)
59+
}
60+
61+
@Test
62+
fun `detectCardBrand should detect JCB`() {
63+
assertEquals(VaultCardBrand.JCB, "3528000000000007".detectCardBrand())
64+
assertEquals(VaultCardBrand.JCB, "3589000000000003".detectCardBrand())
65+
}
66+
67+
@Test
68+
fun `detectCardBrand should detect Maestro`() {
69+
assertEquals(
70+
VaultCardBrand.MAESTRO,
71+
"5018000000000009".detectCardBrand(),
72+
)
73+
assertEquals(
74+
VaultCardBrand.MAESTRO,
75+
"6304000000000000".detectCardBrand(),
76+
)
77+
}
78+
79+
@Test
80+
fun `detectCardBrand should detect UnionPay`() {
81+
assertEquals(
82+
VaultCardBrand.UNIONPAY,
83+
"6200000000000005".detectCardBrand(),
84+
)
85+
}
86+
87+
@Test
88+
fun `detectCardBrand should return OTHER for unknown prefixes`() {
89+
assertEquals(
90+
VaultCardBrand.OTHER,
91+
"9999999999999995".detectCardBrand(),
92+
)
93+
assertEquals(VaultCardBrand.OTHER, "".detectCardBrand())
94+
}
95+
}

0 commit comments

Comments
 (0)