Skip to content

Commit d776756

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 c04ac3b commit d776756

16 files changed

Lines changed: 730 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.ui.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.ui.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+
}

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ firebaseBom = "34.10.0"
3737
glide = "5.0.5"
3838
glideCompose = "1.0.0-beta01"
3939
googleBilling = "8.3.0"
40+
googleMlkitTextRecognition = "16.0.1"
4041
googleGuava = "33.5.0-jre"
4142
googleProtoBufJava = "4.34.0"
4243
googleProtoBufPlugin = "0.9.6"
@@ -110,6 +111,7 @@ google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref
110111
google-firebase-cloud-messaging = { module = "com.google.firebase:firebase-messaging" }
111112
google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
112113
google-guava = { module = "com.google.guava:guava", version.ref = "googleGuava" }
114+
google-mlkit-text-recognition = { module = "com.google.mlkit:text-recognition", version.ref = "googleMlkitTextRecognition" }
113115
google-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
114116
google-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
115117
google-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }

ui/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ dependencies {
7676
implementation(libs.androidx.credentials)
7777
implementation(libs.androidx.navigation.compose)
7878
implementation(libs.bumptech.glide)
79+
implementation(libs.google.mlkit.text.recognition)
7980
implementation(libs.kotlinx.serialization)
8081
implementation(libs.kotlinx.coroutines.core)
8182
implementation(libs.kotlinx.collections.immutable)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.bitwarden.ui.platform.components.camera
2+
3+
import androidx.compose.foundation.Canvas
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.aspectRatio
6+
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.foundation.layout.width
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.ui.Alignment
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.geometry.CornerRadius
12+
import androidx.compose.ui.geometry.Offset
13+
import androidx.compose.ui.geometry.Size
14+
import androidx.compose.ui.graphics.Color
15+
import androidx.compose.ui.graphics.drawscope.Stroke
16+
import androidx.compose.ui.unit.Dp
17+
import androidx.compose.ui.unit.dp
18+
import com.bitwarden.ui.platform.theme.BitwardenTheme
19+
20+
private const val CARD_ASPECT_RATIO = 1.586f
21+
22+
/**
23+
* A rectangular overlay sized to a credit card aspect ratio (~1.586:1).
24+
*
25+
* @param overlayWidth The width of the card overlay.
26+
* @param modifier The [Modifier] for this composable.
27+
* @param color The color of the overlay border.
28+
* @param strokeWidth The stroke width of the overlay border.
29+
*/
30+
@Composable
31+
fun CardScanOverlay(
32+
overlayWidth: Dp,
33+
modifier: Modifier = Modifier,
34+
color: Color = BitwardenTheme.colorScheme.text.primary,
35+
strokeWidth: Dp = 3.dp,
36+
) {
37+
Box(
38+
contentAlignment = Alignment.Center,
39+
modifier = modifier,
40+
) {
41+
CardScanOverlayCanvas(
42+
color = color,
43+
strokeWidth = strokeWidth,
44+
modifier = Modifier
45+
.padding(all = 8.dp)
46+
.width(overlayWidth)
47+
.aspectRatio(CARD_ASPECT_RATIO),
48+
)
49+
}
50+
}
51+
52+
@Suppress("MagicNumber")
53+
@Composable
54+
private fun CardScanOverlayCanvas(
55+
color: Color,
56+
strokeWidth: Dp,
57+
modifier: Modifier = Modifier,
58+
) {
59+
Canvas(modifier = modifier) {
60+
val strokeWidthPx = strokeWidth.toPx()
61+
val cornerRadiusPx = 12.dp.toPx()
62+
drawRoundRect(
63+
color = color,
64+
topLeft = Offset(strokeWidthPx / 2, strokeWidthPx / 2),
65+
size = Size(
66+
width = size.width - strokeWidthPx,
67+
height = size.height - strokeWidthPx,
68+
),
69+
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
70+
style = Stroke(width = strokeWidthPx),
71+
)
72+
}
73+
}

ui/src/main/kotlin/com/bitwarden/ui/platform/composition/LocalProviders.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.bitwarden.ui.platform.composition
22

33
import androidx.compose.runtime.ProvidableCompositionLocal
44
import androidx.compose.runtime.compositionLocalOf
5+
import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer
56
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer
67
import com.bitwarden.ui.platform.manager.IntentManager
78
import com.bitwarden.ui.platform.manager.exit.ExitManager
@@ -20,6 +21,14 @@ val LocalIntentManager: ProvidableCompositionLocal<IntentManager> = compositionL
2021
error("CompositionLocal LocalIntentManager not present")
2122
}
2223

24+
/**
25+
* Provides access to the Card Text Analyzer throughout the app.
26+
*/
27+
val LocalCardTextAnalyzer: ProvidableCompositionLocal<CardTextAnalyzer> =
28+
compositionLocalOf {
29+
error("CompositionLocal LocalCardTextAnalyzer not present")
30+
}
31+
2332
/**
2433
* Provides access to the QR Code Analyzer throughout the app.
2534
*/
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.bitwarden.ui.platform.feature.cardscanner.util
2+
3+
/**
4+
* Parses raw OCR text from a credit card scan and extracts structured
5+
* card data fields.
6+
*/
7+
interface CardDataParser {
8+
9+
/**
10+
* Parses the given [text] and returns a [CardScanData] containing
11+
* any detected card details.
12+
*/
13+
fun parseCardData(text: String): CardScanData
14+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.bitwarden.ui.platform.feature.cardscanner.util
2+
3+
private val PAN_REGEX = Regex("""\b(?:\d[ -]*?){13,19}\b""")
4+
5+
private val EXPIRY_REGEX = Regex("""\b(0[1-9]|1[0-2])\s?[/\-]\s?(\d{2}|\d{4})\b""")
6+
7+
private val CVV3_REGEX = Regex("""\b\d{3}\b""")
8+
private val CVV4_REGEX = Regex("""\b\d{4}\b""")
9+
10+
private val NAME_REGEX = Regex("""^[A-Z][A-Z .'-]+$""")
11+
12+
/**
13+
* Default [CardDataParser] implementation that uses regex patterns
14+
* and Luhn validation to extract card details from OCR text.
15+
*/
16+
class CardDataParserImpl : CardDataParser {
17+
18+
@Suppress("MagicNumber")
19+
override fun parseCardData(text: String): CardScanData {
20+
val panMatch = PAN_REGEX.find(text)
21+
val number = panMatch
22+
?.value
23+
?.filter { it.isDigit() }
24+
?.takeIf { it.isValidLuhn() }
25+
26+
val expiryMatch = EXPIRY_REGEX.find(text)
27+
val expirationMonth = expiryMatch
28+
?.groupValues
29+
?.getOrNull(1)
30+
val expirationYear = expiryMatch
31+
?.groupValues
32+
?.getOrNull(2)
33+
?.let { if (it.length == 2) "20$it" else it }
34+
35+
// Use brand-aware CVV length: Amex uses 4 digits, all others use 3.
36+
val isAmex = number?.let { it.startsWith("34") || it.startsWith("37") } == true
37+
val cvvRegex = if (isAmex) CVV4_REGEX else CVV3_REGEX
38+
39+
// Filter out digits adjacent to other digits (likely phone numbers)
40+
// or that overlap with already-matched PAN/expiry ranges.
41+
val panRange = panMatch?.range
42+
val expiryRange = expiryMatch?.range
43+
val securityCode = cvvRegex
44+
.findAll(text)
45+
.lastOrNull { match ->
46+
panRange?.contains(match.range.first) != true &&
47+
expiryRange?.contains(match.range.first) != true &&
48+
text.getOrNull(match.range.first - 1)?.isDigit() != true &&
49+
text.getOrNull(match.range.last + 1)?.isDigit() != true
50+
}
51+
?.value
52+
53+
return CardScanData(
54+
number = number,
55+
expirationMonth = expirationMonth,
56+
expirationYear = expirationYear,
57+
cardholderName = extractCardholderName(text),
58+
securityCode = securityCode,
59+
)
60+
}
61+
}
62+
63+
@Suppress("MagicNumber")
64+
private fun extractCardholderName(text: String): String? =
65+
text.lines()
66+
.map { it.trim() }
67+
.filter { it.length > 3 }
68+
.firstOrNull { NAME_REGEX.matches(it) }

0 commit comments

Comments
 (0)