Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.x8bit.bitwarden.ui.vault.util

import com.bitwarden.ui.platform.feature.cardscanner.util.sanitizeCardNumber
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand

/**
* Detects the card brand based on the card number prefix.
*
* @return The detected [VaultCardBrand], or [VaultCardBrand.OTHER] if no match is found.
*/
@Suppress("CyclomaticComplexMethod", "MagicNumber")
fun String.detectCardBrand(): VaultCardBrand {
val digits = sanitizeCardNumber()
if (digits.isEmpty()) return VaultCardBrand.OTHER

return when {
// Amex: starts with 34 or 37
digits.startsWith("34") || digits.startsWith("37") -> VaultCardBrand.AMEX

// Visa: starts with 4
digits.startsWith("4") -> VaultCardBrand.VISA

// Mastercard: 51-55 or 2221-2720
digits.isMastercardPrefix() -> VaultCardBrand.MASTERCARD

// Discover: 6011, 65, 644-649
digits.isDiscoverPrefix() -> VaultCardBrand.DISCOVER

// Diners Club: 300-305, 36, 38
digits.isDinersClubPrefix() -> VaultCardBrand.DINERS_CLUB

// JCB: 3528-3589
digits.isJcbPrefix() -> VaultCardBrand.JCB

// Maestro: 5018, 5020, 5038, 6304
digits.isMaestroPrefix() -> VaultCardBrand.MAESTRO

// UnionPay: starts with 62
digits.startsWith("62") -> VaultCardBrand.UNIONPAY

// RuPay: 60, 65, 81, 82
digits.isRuPayPrefix() -> VaultCardBrand.RUPAY

else -> VaultCardBrand.OTHER
}
}

@Suppress("MagicNumber")
private fun String.isMastercardPrefix(): Boolean {
if (length < 2) return false
val twoDigit = substring(0, 2).toIntOrNull() ?: return false
if (twoDigit in 51..55) return true
if (length < 4) return false
val fourDigit = substring(0, 4).toIntOrNull() ?: return false
return fourDigit in 2221..2720
}

@Suppress("MagicNumber")
private fun String.isDiscoverPrefix(): Boolean {
if (startsWith("6011") || startsWith("65")) return true
if (length < 3) return false
val threeDigit = substring(0, 3).toIntOrNull() ?: return false
return threeDigit in 644..649
}

@Suppress("MagicNumber")
private fun String.isDinersClubPrefix(): Boolean {
if (startsWith("36") || startsWith("38")) return true
if (length < 3) return false
val threeDigit = substring(0, 3).toIntOrNull() ?: return false
return threeDigit in 300..305
}

@Suppress("MagicNumber")
private fun String.isJcbPrefix(): Boolean {
if (length < 4) return false
val fourDigit = substring(0, 4).toIntOrNull() ?: return false
return fourDigit in 3528..3589
}

private fun String.isMaestroPrefix(): Boolean =
startsWith("5018") ||
startsWith("5020") ||
startsWith("5038") ||
startsWith("6304")

// Note: "60" and "65" overlap with Discover prefixes ("6011", "65") but are
// unreachable here because Discover is checked first in detectCardBrand().
// They are kept for documentation of the full RuPay prefix specification.
private fun String.isRuPayPrefix(): Boolean =
startsWith("60") ||
startsWith("65") ||
startsWith("81") ||
startsWith("82")
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.x8bit.bitwarden.ui.vault.util

import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class CardNumberUtilsTest {

@Test
fun `detectCardBrand should detect Visa`() {

Check warning on line 10 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect Visa" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2E&open=AZ0pt-hPJp73I62k1f2E&pullRequest=6720
assertEquals(VaultCardBrand.VISA, "4111111111111111".detectCardBrand())
assertEquals(VaultCardBrand.VISA, "4012888888881881".detectCardBrand())
}

@Test
fun `detectCardBrand should detect Mastercard`() {

Check warning on line 16 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect Mastercard" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2F&open=AZ0pt-hPJp73I62k1f2F&pullRequest=6720
assertEquals(
VaultCardBrand.MASTERCARD,
"5500000000000004".detectCardBrand(),
)
assertEquals(
VaultCardBrand.MASTERCARD,
"5100000000000008".detectCardBrand(),
)
assertEquals(
VaultCardBrand.MASTERCARD,
"2221000000000009".detectCardBrand(),
)
}

@Test
fun `detectCardBrand should detect Amex`() {

Check warning on line 32 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect Amex" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2G&open=AZ0pt-hPJp73I62k1f2G&pullRequest=6720
assertEquals(VaultCardBrand.AMEX, "378282246310005".detectCardBrand())
assertEquals(VaultCardBrand.AMEX, "341111111111111".detectCardBrand())
}

@Test
fun `detectCardBrand should detect Discover`() {

Check warning on line 38 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect Discover" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2H&open=AZ0pt-hPJp73I62k1f2H&pullRequest=6720
assertEquals(
VaultCardBrand.DISCOVER,
"6011111111111117".detectCardBrand(),
)
assertEquals(
VaultCardBrand.DISCOVER,
"6500000000000002".detectCardBrand(),
)
}

@Test
fun `detectCardBrand should detect Diners Club`() {

Check warning on line 50 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect Diners Club" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2I&open=AZ0pt-hPJp73I62k1f2I&pullRequest=6720
assertEquals(
VaultCardBrand.DINERS_CLUB,
"30569309025904".detectCardBrand(),
)
assertEquals(
VaultCardBrand.DINERS_CLUB,
"36000000000008".detectCardBrand(),
)
}

@Test
fun `detectCardBrand should detect JCB`() {

Check warning on line 62 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect JCB" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2J&open=AZ0pt-hPJp73I62k1f2J&pullRequest=6720
assertEquals(VaultCardBrand.JCB, "3528000000000007".detectCardBrand())
assertEquals(VaultCardBrand.JCB, "3589000000000003".detectCardBrand())
}

@Test
fun `detectCardBrand should detect Maestro`() {

Check warning on line 68 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect Maestro" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2K&open=AZ0pt-hPJp73I62k1f2K&pullRequest=6720
assertEquals(
VaultCardBrand.MAESTRO,
"5018000000000009".detectCardBrand(),
)
assertEquals(
VaultCardBrand.MAESTRO,
"6304000000000000".detectCardBrand(),
)
}

@Test
fun `detectCardBrand should detect UnionPay`() {

Check warning on line 80 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should detect UnionPay" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2L&open=AZ0pt-hPJp73I62k1f2L&pullRequest=6720
assertEquals(
VaultCardBrand.UNIONPAY,
"6200000000000005".detectCardBrand(),
)
}

@Test
fun `detectCardBrand should return OTHER for unknown prefixes`() {

Check warning on line 88 in app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "detectCardBrand should return OTHER for unknown prefixes" to match the regular expression ^[a-zA-Z][a-zA-Z0-9]*$

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-hPJp73I62k1f2M&open=AZ0pt-hPJp73I62k1f2M&pullRequest=6720
assertEquals(
VaultCardBrand.OTHER,
"9999999999999995".detectCardBrand(),
)
assertEquals(VaultCardBrand.OTHER, "".detectCardBrand())
}
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ firebaseBom = "34.10.0"
glide = "5.0.5"
glideCompose = "1.0.0-beta01"
googleBilling = "8.3.0"
googleMlkitTextRecognition = "16.0.1"
googleGuava = "33.5.0-jre"
googleProtoBufJava = "4.34.0"
googleProtoBufPlugin = "0.9.6"
Expand Down Expand Up @@ -110,6 +111,7 @@ google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref
google-firebase-cloud-messaging = { module = "com.google.firebase:firebase-messaging" }
google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
google-guava = { module = "com.google.guava:guava", version.ref = "googleGuava" }
google-mlkit-text-recognition = { module = "com.google.mlkit:text-recognition", version.ref = "googleMlkitTextRecognition" }
google-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
google-hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
google-hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
Expand Down
1 change: 1 addition & 0 deletions ui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ dependencies {
implementation(libs.androidx.credentials)
implementation(libs.androidx.navigation.compose)
implementation(libs.bumptech.glide)
implementation(libs.google.mlkit.text.recognition)
implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.bitwarden.ui.platform.components.camera

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.theme.BitwardenTheme

private const val CARD_ASPECT_RATIO = 1.586f

/**
* A rectangular overlay sized to a credit card aspect ratio (~1.586:1).
*
* @param overlayWidth The width of the card overlay.
* @param modifier The [Modifier] for this composable.
* @param color The color of the overlay border.
* @param strokeWidth The stroke width of the overlay border.
*/
@Composable
fun CardScanOverlay(
overlayWidth: Dp,
modifier: Modifier = Modifier,
color: Color = BitwardenTheme.colorScheme.text.primary,
strokeWidth: Dp = 3.dp,
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
CardScanOverlayCanvas(
color = color,
strokeWidth = strokeWidth,
modifier = Modifier
.padding(all = 8.dp)
.width(overlayWidth)
.aspectRatio(CARD_ASPECT_RATIO),
)
}
}

@Suppress("MagicNumber")
@Composable
private fun CardScanOverlayCanvas(
color: Color,
strokeWidth: Dp,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier) {
val strokeWidthPx = strokeWidth.toPx()
val cornerRadiusPx = 12.dp.toPx()
drawRoundRect(
color = color,
topLeft = Offset(strokeWidthPx / 2, strokeWidthPx / 2),
size = Size(
width = size.width - strokeWidthPx,
height = size.height - strokeWidthPx,
),
cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx),
style = Stroke(width = strokeWidthPx),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.bitwarden.ui.platform.composition

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

/**
* Provides access to the Card Text Analyzer throughout the app.
*/
val LocalCardTextAnalyzer: ProvidableCompositionLocal<CardTextAnalyzer> =
compositionLocalOf {
error("CompositionLocal LocalCardTextAnalyzer not present")
}

/**
* Provides access to the QR Code Analyzer throughout the app.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.bitwarden.ui.platform.feature.cardscanner.util

/**
* Parses raw OCR text from a credit card scan and extracts structured
* card data fields.
*/
interface CardDataParser {

Check warning on line 7 in ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParser.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this interface functional or replace it with a function type.

See more on https://sonarcloud.io/project/issues?id=bitwarden_android&issues=AZ0pt-vRJp73I62k1f2P&open=AZ0pt-vRJp73I62k1f2P&pullRequest=6720

/**
* Parses the given [text] and returns a [CardScanData] containing
* any detected card details.
*/
fun parseCardData(text: String): CardScanData
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.bitwarden.ui.platform.feature.cardscanner.util

private val PAN_REGEX = Regex("""\b(?:\d[ -]*?){13,19}\b""")

private val EXPIRY_REGEX = Regex("""\b(0[1-9]|1[0-2])\s?[/\-]\s?(\d{2}|\d{4})\b""")

private val CVV3_REGEX = Regex("""\b\d{3}\b""")
private val CVV4_REGEX = Regex("""\b\d{4}\b""")

private val NAME_REGEX = Regex("""^[A-Z][A-Z .'-]+$""")

/**
* Default [CardDataParser] implementation that uses regex patterns
* and Luhn validation to extract card details from OCR text.
*/
class CardDataParserImpl : CardDataParser {

@Suppress("MagicNumber")
override fun parseCardData(text: String): CardScanData {
val panMatch = PAN_REGEX.find(text)
val number = panMatch
?.value
?.filter { it.isDigit() }
?.takeIf { it.isValidLuhn() }

val expiryMatch = EXPIRY_REGEX.find(text)
val expirationMonth = expiryMatch
?.groupValues
?.getOrNull(1)
val expirationYear = expiryMatch
?.groupValues
?.getOrNull(2)
?.let { if (it.length == 2) "20$it" else it }

// Use brand-aware CVV length: Amex uses 4 digits, all others use 3.
val isAmex = number?.let { it.startsWith("34") || it.startsWith("37") } == true
val cvvRegex = if (isAmex) CVV4_REGEX else CVV3_REGEX

// Filter out digits adjacent to other digits (likely phone numbers)
// or that overlap with already-matched PAN/expiry ranges.
val panRange = panMatch?.range
val expiryRange = expiryMatch?.range
val securityCode = cvvRegex
.findAll(text)
.lastOrNull { match ->
panRange?.contains(match.range.first) != true &&
expiryRange?.contains(match.range.first) != true &&
text.getOrNull(match.range.first - 1)?.isDigit() != true &&
text.getOrNull(match.range.last + 1)?.isDigit() != true
}
?.value

return CardScanData(
number = number,
expirationMonth = expirationMonth,
expirationYear = expirationYear,
cardholderName = extractCardholderName(text),
securityCode = securityCode,
)
}
}

@Suppress("MagicNumber")
private fun extractCardholderName(text: String): String? =
text.lines()
.map { it.trim() }
.filter { it.length > 3 }
.firstOrNull { NAME_REGEX.matches(it) }
Loading
Loading