-
Notifications
You must be signed in to change notification settings - Fork 950
[PM-34125] feat: Add card text analysis pipeline #6720
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ef3f7d2
0ee86f3
8419f36
989474c
65e6bd2
8299421
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| 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() | ||
|
|
||
| return when { | ||
| digits.isEmpty() -> VaultCardBrand.OTHER | ||
|
|
||
| // 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,107 @@ | ||
| 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`() { | ||
| assertEquals(VaultCardBrand.VISA, "4111111111111111".detectCardBrand()) | ||
| assertEquals(VaultCardBrand.VISA, "4012888888881881".detectCardBrand()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect Mastercard`() { | ||
| assertEquals( | ||
| VaultCardBrand.MASTERCARD, | ||
| "5500000000000004".detectCardBrand(), | ||
| ) | ||
| assertEquals( | ||
| VaultCardBrand.MASTERCARD, | ||
| "5100000000000008".detectCardBrand(), | ||
| ) | ||
| assertEquals( | ||
| VaultCardBrand.MASTERCARD, | ||
| "2221000000000009".detectCardBrand(), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect Amex`() { | ||
| assertEquals(VaultCardBrand.AMEX, "378282246310005".detectCardBrand()) | ||
| assertEquals(VaultCardBrand.AMEX, "341111111111111".detectCardBrand()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect Discover`() { | ||
| assertEquals( | ||
| VaultCardBrand.DISCOVER, | ||
| "6011111111111117".detectCardBrand(), | ||
| ) | ||
| assertEquals( | ||
| VaultCardBrand.DISCOVER, | ||
| "6500000000000002".detectCardBrand(), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect Diners Club`() { | ||
| assertEquals( | ||
| VaultCardBrand.DINERS_CLUB, | ||
| "30569309025904".detectCardBrand(), | ||
| ) | ||
| assertEquals( | ||
| VaultCardBrand.DINERS_CLUB, | ||
| "36000000000008".detectCardBrand(), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect JCB`() { | ||
| assertEquals(VaultCardBrand.JCB, "3528000000000007".detectCardBrand()) | ||
| assertEquals(VaultCardBrand.JCB, "3589000000000003".detectCardBrand()) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect Maestro`() { | ||
| assertEquals( | ||
| VaultCardBrand.MAESTRO, | ||
| "5018000000000009".detectCardBrand(), | ||
| ) | ||
| assertEquals( | ||
| VaultCardBrand.MAESTRO, | ||
| "6304000000000000".detectCardBrand(), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect UnionPay`() { | ||
| assertEquals( | ||
| VaultCardBrand.UNIONPAY, | ||
| "6200000000000005".detectCardBrand(), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should detect RuPay`() { | ||
| assertEquals( | ||
| VaultCardBrand.RUPAY, | ||
| "8100000000000005".detectCardBrand(), | ||
| ) | ||
| assertEquals( | ||
| VaultCardBrand.RUPAY, | ||
| "8200000000000004".detectCardBrand(), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `detectCardBrand should return OTHER for unknown prefixes`() { | ||
| assertEquals( | ||
| VaultCardBrand.OTHER, | ||
| "9999999999999995".detectCardBrand(), | ||
| ) | ||
| assertEquals(VaultCardBrand.OTHER, "".detectCardBrand()) | ||
| } | ||
| } | ||
| 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") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this suppression needed? |
||
| @Composable | ||
| private fun CardScanOverlayCanvas( | ||
| color: Color, | ||
| strokeWidth: Dp, | ||
| modifier: Modifier = Modifier, | ||
| ) { | ||
| Canvas(modifier = modifier) { | ||
| val strokeWidthPx = strokeWidth.toPx() | ||
| val cornerRadiusPx = 12.dp.toPx() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be passed in as a param too?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think so. The corner radius is a fixed visual property of the card shape, not something the callers should modify.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair enough |
||
| 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 |
|---|---|---|
| @@ -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 { | ||
|
|
||
| /** | ||
| * Parses the given [text] and returns a [CardScanData] containing | ||
| * any detected card details, or `null` if no card data is found. | ||
| */ | ||
| fun parseCardData(text: String): CardScanData? | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.