Skip to content
Merged
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,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())
}
}
Comment thread
SaintPatrck marked this conversation as resolved.
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.11.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")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be passed in as a param too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
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 {

/**
* 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?
}
Loading
Loading