[PM-34038] fix: Address card scanner QA findings (#6867)

This commit is contained in:
Patrick Honkonen authored and GitHub committed 2026-05-07 19:29:00 +00:00
1 parent a9cc18e4fd
commit fd9618bddb
22 files changed
+1996 -146

No files matched your search

@@ -1941,19 +1941,15 @@ class VaultAddEditViewModel @Inject constructor(
val data = result.cardScanData
updateCardContent { cardType ->
cardType.copy(
number = data.number ?: cardType.number,
expirationYear = data.expirationYear
?: cardType.expirationYear,
expirationMonth = data.expirationMonth
?.toExpirationMonth()
?: cardType.expirationMonth,
securityCode = data.securityCode
?: cardType.securityCode,
brand = data.number
?.detectCardBrand()
?: cardType.brand,
number = data.number,
brand = data.number.detectCardBrand(),
expirationMonth = data.expirationMonth.toExpirationMonth(),
expirationYear = data.expirationYear ?: cardType.expirationYear,
)
}
sendEvent(
VaultAddEditEvent.ShowSnackbar(BitwardenString.card_scanned.asText()),
)
sendEvent(VaultAddEditEvent.FocusCardHolderName)
}
@@ -1,27 +1,34 @@
package com.x8bit.bitwarden.ui.vault.feature.cardscanner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.StatusBarsAppearanceAffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
@@ -50,6 +57,8 @@ fun CardScanScreen(
viewModel: CardScanViewModel = hiltViewModel(),
cardTextAnalyzer: CardTextAnalyzer = LocalCardTextAnalyzer.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
cardTextAnalyzer.onCardScanned = { cardScanData ->
viewModel.trySendAction(
CardScanAction.CardScanReceive(cardScanData = cardScanData),
@@ -87,97 +96,98 @@ fun CardScanScreen(
)
},
) {
CameraPreview(
cameraErrorReceive = {
viewModel.trySendAction(
CardScanAction.CameraSetupErrorReceive,
)
},
analyzer = cardTextAnalyzer,
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
)
when (rememberWindowSize()) {
WindowSize.Compact -> {
CardScanContentCompact()
}
WindowSize.Medium -> {
CardScanContentMedium()
) {
Text(
text = stringResource(id = BitwardenString.scan_card_instruction),
textAlign = TextAlign.Center,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier
.testTag("CardScanInstruction")
.fillMaxWidth()
.padding(all = 12.dp),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.testTag("CardScanFrame")
.fillMaxSize(),
) {
CameraPreview(
cameraErrorReceive = {
viewModel.trySendAction(
CardScanAction.CameraSetupErrorReceive,
)
},
analyzer = cardTextAnalyzer,
modifier = Modifier.fillMaxSize(),
)
CardScanOverlay(
overlayWidth = when (rememberWindowSize()) {
WindowSize.Compact -> 300.dp
WindowSize.Medium -> 250.dp
},
modifier = Modifier.fillMaxSize(),
)
AnimatedScanHintBanner(
visible = state.showHint,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
}
}
/**
* Wraps [ScanHintBanner] in a fade-in/fade-out so the hint doesn't snap into place when the
* timeout elapses or vanish abruptly once a scan succeeds.
*/
@Composable
private fun CardScanContentCompact(
private fun AnimatedScanHintBanner(
visible: Boolean,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier,
) {
CardScanOverlay(
overlayWidth = 300.dp,
modifier = Modifier.weight(2f),
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround,
ScanHintBanner(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.background(color = BitwardenTheme.colorScheme.background.scrim)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(
id = BitwardenString.scan_card_instruction,
),
textAlign = TextAlign.Center,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun CardScanContentMedium(
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
CardScanOverlay(
overlayWidth = 250.dp,
modifier = Modifier.weight(2f),
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier
.weight(1f)
.fillMaxSize()
.background(color = BitwardenTheme.colorScheme.background.scrim)
.padding(horizontal = 16.dp)
.fillMaxWidth()
.navigationBarsPadding()
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(
id = BitwardenString.scan_card_instruction,
),
textAlign = TextAlign.Center,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodySmall,
)
}
.padding(horizontal = 16.dp, vertical = 16.dp),
)
}
}
/**
* A timeout-driven hint shown over the camera preview when no successful card scan has been
* received within the expected window. Wrapped in a polite live region so TalkBack announces it
* to users when it appears.
*/
@Composable
private fun ScanHintBanner(
modifier: Modifier = Modifier,
) {
Text(
text = stringResource(
id = BitwardenString.hold_steady_and_ensure_all_card_details_are_visible,
),
textAlign = TextAlign.Center,
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
modifier = modifier
.semantics { liveRegion = LiveRegionMode.Polite }
.background(
color = BitwardenTheme.colorScheme.background.scrim,
shape = RoundedCornerShape(size = 8.dp),
)
.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
@@ -2,18 +2,28 @@ package com.x8bit.bitwarden.ui.vault.feature.cardscanner
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager
import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData
import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* The duration the scanner waits for a successful scan before surfacing a hint to the user
* suggesting they hold the device steady so all card details are visible.
*/
private const val SCAN_HINT_TIMEOUT_MS = 5_000L
/**
* Handles [CardScanAction] and launches [CardScanEvent] for the [CardScanScreen].
*/
@@ -23,34 +33,64 @@ class CardScanViewModel @Inject constructor(
private val cardScanManager: CardScanManager,
) : BaseViewModel<CardScanState, CardScanEvent, CardScanAction>(
initialState = savedStateHandle[KEY_STATE]
?: CardScanState(hasHandledScan = false),
?: CardScanState(hasHandledScan = false, showHint = false),
) {
private var hintTimeoutJob: Job? = null
init {
startHintTimeout()
}
override fun onCleared() {
hintTimeoutJob?.cancel()
super.onCleared()
}
override fun handleAction(action: CardScanAction) {
when (action) {
is CardScanAction.CloseClick -> handleCloseClick()
is CardScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive()
is CardScanAction.CardScanReceive -> handleCardScanReceive(action)
is CardScanAction.Internal.HintTimeoutElapsed -> handleHintTimeoutElapsed()
}
}
private fun handleCloseClick() {
hintTimeoutJob?.cancel()
sendEvent(CardScanEvent.NavigateBack)
}
private fun handleCameraErrorReceive() {
hintTimeoutJob?.cancel()
cardScanManager.emitCardScanResult(CardScanResult.ScanError())
sendEvent(CardScanEvent.NavigateBack)
}
private fun handleCardScanReceive(action: CardScanAction.CardScanReceive) {
if (state.hasHandledScan) return
mutableStateFlow.update { it.copy(hasHandledScan = true) }
hintTimeoutJob?.cancel()
mutableStateFlow.update {
it.copy(hasHandledScan = true, showHint = false)
}
cardScanManager.emitCardScanResult(
CardScanResult.Success(cardScanData = action.cardScanData),
)
sendEvent(CardScanEvent.NavigateBack)
}
private fun handleHintTimeoutElapsed() {
if (state.hasHandledScan) return
mutableStateFlow.update { it.copy(showHint = true) }
}
private fun startHintTimeout() {
hintTimeoutJob?.cancel()
hintTimeoutJob = viewModelScope.launch {
delay(SCAN_HINT_TIMEOUT_MS)
sendAction(CardScanAction.Internal.HintTimeoutElapsed)
}
}
}
/**
@@ -86,6 +126,17 @@ sealed class CardScanAction {
* The camera is unable to be set up.
*/
data object CameraSetupErrorReceive : CardScanAction()
/**
* Models actions that the [CardScanViewModel] itself might send.
*/
sealed class Internal : CardScanAction() {
/**
* The hint timeout has elapsed without a successful scan.
*/
data object HintTimeoutElapsed : Internal()
}
}
/**
@@ -94,4 +145,5 @@ sealed class CardScanAction {
@Parcelize
data class CardScanState(
val hasHandledScan: Boolean,
val showHint: Boolean,
) : Parcelable
@@ -5378,6 +5378,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
),
),
)
// CVV is intentionally dropped from the apply path to match iOS.
val expectedCard = VaultAddEditState
.ViewState
.Content
@@ -5386,12 +5387,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
number = "4111111111111111",
expirationYear = "2025",
expirationMonth = VaultCardExpirationMonth.DECEMBER,
securityCode = "123",
brand = VaultCardBrand.VISA,
)
val content = viewModel.stateFlow.value.viewState
as VaultAddEditState.ViewState.Content
assertEquals(expectedCard, content.type)
assertEquals(
VaultAddEditEvent.ShowSnackbar(BitwardenString.card_scanned.asText()),
awaitItem(),
)
assertEquals(
VaultAddEditEvent.FocusCardHolderName,
awaitItem(),
@@ -5561,17 +5565,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `CardScanResultReceive with partial scan should preserve existing fields`() =
fun `CardScanResultReceive with number and month present should not modify CVV when scan carries securityCode`() =
runTest {
val initialCard = VaultAddEditState
.ViewState
.Content
.ItemType
.Card(
cardHolderName = "EXISTING NAME",
expirationMonth = VaultCardExpirationMonth.JUNE,
expirationYear = "2030",
securityCode = "999",
)
val viewModel = createAddVaultItemViewModel(
@@ -5589,7 +5591,161 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
CardScanResult.Success(
cardScanData = CardScanData(
number = "4111111111111111",
expirationMonth = null,
expirationMonth = "12",
expirationYear = "2025",
securityCode = "123",
),
),
)
val content = viewModel.stateFlow.value.viewState
as VaultAddEditState.ViewState.Content
val card = content.type as VaultAddEditState.ViewState.Content.ItemType.Card
assertEquals("999", card.securityCode)
assertEquals(
VaultAddEditEvent.ShowSnackbar(BitwardenString.card_scanned.asText()),
awaitItem(),
)
assertEquals(
VaultAddEditEvent.FocusCardHolderName,
awaitItem(),
)
}
}
@Test
fun `CardScanResultReceive should overwrite manually-typed values`() =
runTest {
val typedCard = VaultAddEditState
.ViewState
.Content
.ItemType
.Card(
number = "5555555555554444",
brand = VaultCardBrand.MASTERCARD,
expirationMonth = VaultCardExpirationMonth.JUNE,
expirationYear = "2030",
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultAddItemState(
vaultItemCipherType = VaultItemCipherType.CARD,
typeContentViewState = typedCard,
),
vaultAddEditType = VaultAddEditType.AddItem,
vaultItemCipherType = VaultItemCipherType.CARD,
),
)
viewModel.eventFlow.test {
mutableCardScanResultFlow.tryEmit(
CardScanResult.Success(
cardScanData = CardScanData(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
securityCode = null,
),
),
)
val expectedCard = typedCard.copy(
number = "4111111111111111",
brand = VaultCardBrand.VISA,
expirationMonth = VaultCardExpirationMonth.DECEMBER,
expirationYear = "2025",
)
val content = viewModel.stateFlow.value.viewState
as VaultAddEditState.ViewState.Content
assertEquals(expectedCard, content.type)
assertEquals(
VaultAddEditEvent.ShowSnackbar(BitwardenString.card_scanned.asText()),
awaitItem(),
)
assertEquals(
VaultAddEditEvent.FocusCardHolderName,
awaitItem(),
)
}
}
@Test
fun `CardScanResultReceive should refresh all four fields when re-scanned`() =
runTest {
val firstScanCard = VaultAddEditState
.ViewState
.Content
.ItemType
.Card(
number = "5555555555554444",
brand = VaultCardBrand.MASTERCARD,
expirationMonth = VaultCardExpirationMonth.JUNE,
expirationYear = "2030",
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultAddItemState(
vaultItemCipherType = VaultItemCipherType.CARD,
typeContentViewState = firstScanCard,
),
vaultAddEditType = VaultAddEditType.AddItem,
vaultItemCipherType = VaultItemCipherType.CARD,
),
)
viewModel.eventFlow.test {
mutableCardScanResultFlow.tryEmit(
CardScanResult.Success(
cardScanData = CardScanData(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
securityCode = null,
),
),
)
val expectedCard = firstScanCard.copy(
number = "4111111111111111",
brand = VaultCardBrand.VISA,
expirationMonth = VaultCardExpirationMonth.DECEMBER,
expirationYear = "2025",
)
val content = viewModel.stateFlow.value.viewState
as VaultAddEditState.ViewState.Content
assertEquals(expectedCard, content.type)
assertEquals(
VaultAddEditEvent.ShowSnackbar(BitwardenString.card_scanned.asText()),
awaitItem(),
)
assertEquals(
VaultAddEditEvent.FocusCardHolderName,
awaitItem(),
)
}
}
@Test
fun `CardScanResultReceive with null year should preserve existing year`() =
runTest {
val initialCard = VaultAddEditState
.ViewState
.Content
.ItemType
.Card(
expirationYear = "2030",
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultAddItemState(
vaultItemCipherType = VaultItemCipherType.CARD,
typeContentViewState = initialCard,
),
vaultAddEditType = VaultAddEditType.AddItem,
vaultItemCipherType = VaultItemCipherType.CARD,
),
)
viewModel.eventFlow.test {
mutableCardScanResultFlow.tryEmit(
CardScanResult.Success(
cardScanData = CardScanData(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = null,
securityCode = null,
),
@@ -5598,10 +5754,16 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
val expectedCard = initialCard.copy(
number = "4111111111111111",
brand = VaultCardBrand.VISA,
expirationMonth = VaultCardExpirationMonth.DECEMBER,
// expirationYear unchanged at "2030".
)
val content = viewModel.stateFlow.value.viewState
as VaultAddEditState.ViewState.Content
assertEquals(expectedCard, content.type)
assertEquals(
VaultAddEditEvent.ShowSnackbar(BitwardenString.card_scanned.asText()),
awaitItem(),
)
assertEquals(
VaultAddEditEvent.FocusCardHolderName,
awaitItem(),
@@ -1,16 +1,21 @@
package com.x8bit.bitwarden.ui.vault.feature.cardscanner
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.feature.cardscanner.util.FakeCardTextAnalyzer
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
import org.robolectric.annotation.Config
@@ -22,9 +27,12 @@ class CardScanScreenTest : BitwardenComposeTest() {
private val cardTextAnalyzer = FakeCardTextAnalyzer()
private val mutableEventFlow = bufferedMutableSharedFlow<CardScanEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<CardScanViewModel>(relaxed = true) {
private val viewModel = mockk<CardScanViewModel> {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
every { trySendAction(any()) } just runs
}
@Before
@@ -77,4 +85,43 @@ class CardScanScreenTest : BitwardenComposeTest() {
.onNodeWithText("Position your card within the frame to scan it.")
.assertIsDisplayed()
}
@Test
fun `instruction text should render above the camera scan frame`() {
val instructionBottom = composeTestRule
.onNodeWithTag("CardScanInstruction")
.assertIsDisplayed()
.getUnclippedBoundsInRoot()
.bottom
val scanFrameTop = composeTestRule
.onNodeWithTag("CardScanFrame")
.getUnclippedBoundsInRoot()
.top
// The instruction's bottom edge must sit at or above the scan-frame's top edge so
// the instruction never visually overlaps or sits below the scan-frame region.
assertTrue(instructionBottom <= scanFrameTop)
}
@Test
fun `hint text should not be displayed when showHint is false`() {
mutableStateFlow.value = DEFAULT_STATE.copy(showHint = false)
composeTestRule
.onNodeWithText("Hold steady and ensure all card details are visible")
.assertDoesNotExist()
}
@Test
fun `hint text should be displayed when showHint is true`() {
mutableStateFlow.value = DEFAULT_STATE.copy(showHint = true)
composeTestRule
.onNodeWithText("Hold steady and ensure all card details are visible")
.assertIsDisplayed()
}
}
private val DEFAULT_STATE = CardScanState(
hasHandledScan = false,
showHint = false,
)
@@ -11,10 +11,13 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CardScanViewModelTest : BaseViewModelTest() {
private val cardScanManager: CardScanManager = mockk {
@@ -94,6 +97,92 @@ class CardScanViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `showHint should be true after the timeout elapses with no scan`() = runTest {
val viewModel = createViewModel()
assertEquals(false, viewModel.stateFlow.value.showHint)
advanceTimeBy(SCAN_HINT_TIMEOUT_MS + 100)
assertEquals(
DEFAULT_STATE.copy(showHint = true),
viewModel.stateFlow.value,
)
}
@Test
fun `showHint should remain false when scan succeeds before the timeout`() = runTest {
val viewModel = createViewModel()
// Advance well below the timeout, then emit a successful scan.
advanceTimeBy(SCAN_HINT_TIMEOUT_MS - 1_000)
viewModel.trySendAction(
CardScanAction.CardScanReceive(cardScanData = CARD_SCAN_DATA),
)
// Advance past where the timeout would have fired.
advanceTimeBy(2_000)
assertEquals(
DEFAULT_STATE.copy(hasHandledScan = true, showHint = false),
viewModel.stateFlow.value,
)
}
@Test
fun `showHint should be reset to false when a scan succeeds after the hint shows`() = runTest {
val viewModel = createViewModel()
// Let the timeout fire so the hint is showing.
advanceTimeBy(SCAN_HINT_TIMEOUT_MS + 100)
assertEquals(true, viewModel.stateFlow.value.showHint)
// Receive a successful scan.
viewModel.trySendAction(
CardScanAction.CardScanReceive(cardScanData = CARD_SCAN_DATA),
)
assertEquals(
DEFAULT_STATE.copy(hasHandledScan = true, showHint = false),
viewModel.stateFlow.value,
)
}
@Test
fun `hint timeout should be cancelled when the screen exits via CloseClick`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CardScanAction.CloseClick)
// Advance well past the timeout window. Without cancellation, showHint would flip true.
advanceTimeBy(SCAN_HINT_TIMEOUT_MS + 1_000)
assertEquals(false, viewModel.stateFlow.value.showHint)
}
@Test
fun `hint timeout should be cancelled when the camera fails to set up`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(CardScanAction.CameraSetupErrorReceive)
advanceTimeBy(SCAN_HINT_TIMEOUT_MS + 1_000)
assertEquals(false, viewModel.stateFlow.value.showHint)
}
@Test
fun `HintTimeoutElapsed is a no-op when state already shows hasHandledScan`() = runTest {
val viewModel = createViewModel(
initialState = DEFAULT_STATE.copy(hasHandledScan = true),
)
viewModel.trySendAction(CardScanAction.Internal.HintTimeoutElapsed)
assertEquals(false, viewModel.stateFlow.value.showHint)
}
private fun createViewModel(
initialState: CardScanState? = null,
): CardScanViewModel =
@@ -105,8 +194,11 @@ class CardScanViewModelTest : BaseViewModelTest() {
)
}
private const val SCAN_HINT_TIMEOUT_MS = 5_000L
private val DEFAULT_STATE = CardScanState(
hasHandledScan = false,
showHint = false,
)
private val CARD_SCAN_DATA = CardScanData(
@@ -7,8 +7,10 @@ package com.bitwarden.ui.platform.feature.cardscanner.util
interface CardDataParser {
/**
* Parses the given [text] and returns a [CardScanData] containing
* any detected card details, or `null` if no card data is found.
* Parses the given [text] and returns a [ParsedCardFields] containing any detected card
* fields, or `null` if no card data is found. Individual fields on the returned value may be
* `null` because OCR may surface only a subset of fields per frame; the downstream pipeline is
* responsible for assembling a confirmed [CardScanData] from accumulated observations.
*/
fun parseCardData(text: String): CardScanData?
fun parseCardData(text: String): ParsedCardFields?
}
@@ -13,7 +13,7 @@ private val CVV4_REGEX = Regex("""\b\d{4}\b""")
*/
class CardDataParserImpl : CardDataParser {
override fun parseCardData(text: String): CardScanData? {
override fun parseCardData(text: String): ParsedCardFields? {
val panMatch = PAN_REGEX.find(text)
val number = panMatch
?.value
@@ -55,7 +55,7 @@ class CardDataParserImpl : CardDataParser {
)
.takeIf { it.isNotEmpty() }
?.let {
CardScanData(
ParsedCardFields(
number = number,
expirationMonth = expirationMonth,
expirationYear = expirationYear,
@@ -1,16 +1,22 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
/**
* Data class representing the results of a credit card scan.
* A confirmed credit card scan result delivered to consumers of [CardScanResult.Success].
*
* The card scanner pipeline emits this only after [number] has been confirmed by temporal voting
* and an [expirationMonth] has been observed in the same window, so both are guaranteed non-null
* at this boundary. [expirationYear] remains optional because expiry text occasionally contains
* only a month, and [securityCode] remains optional because the CVV may not be visible in the
* captured frame.
*
* @property number The detected card number.
* @property expirationMonth The detected expiration month (01-12).
* @property expirationYear The detected expiration year.
* @property securityCode The detected security code.
* @property expirationYear The detected expiration year, or `null` if not observed.
* @property securityCode The detected security code, or `null` if not observed.
*/
data class CardScanData(
val number: String?,
val expirationMonth: String?,
val number: String,
val expirationMonth: String,
val expirationYear: String?,
val securityCode: String?,
) {
@@ -0,0 +1,242 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* The fraction of the rotated image's longer edge that the scan rectangle's long edge occupies.
*
* The scan UI on `CardScanScreen` renders a rounded card-shaped overlay roughly centered in the
* camera preview. Rather than plumb the exact composable bounds through the camera pipeline, we
* approximate the on-screen scan rectangle as a centered region whose long edge is this fraction
* of the rotated frame's longer edge. The fraction is intentionally generous so that a card held
* within the visible overlay reliably falls inside the gate even when small misalignments occur,
* while still excluding text rendered far away from the overlay (e.g. another card on the
* counter, or app chrome above and below the preview).
*/
private const val SCAN_RECT_LONG_EDGE_FRACTION_OF_LONG_EDGE = 0.9f
/**
* Standard credit card aspect ratio (long edge / short edge), per ISO/IEC 7810 ID-1.
*/
private const val CARD_ASPECT_RATIO = 1.586f
/**
* The maximum baseline deviation, in degrees, that a recognized line may have from horizontal in
* display space before being rejected. Cards must be approximately upright; sideways or tilted
* cards are rejected before parsing.
*/
internal const val MAX_BASELINE_ANGLE_DEGREES: Double = 10.0
/**
* Number of corner points reported by ML Kit for each recognized line.
*/
private const val LINE_CORNER_POINT_COUNT: Int = 4
/**
* Full rotation in degrees, used to normalize incoming rotation values into the [0, 360) range.
*/
private const val FULL_ROTATION_DEGREES: Int = 360
/**
* Half rotation in degrees. A 90 or 270 degree rotation swaps raw image dimensions; rotations
* that are even multiples of [HALF_ROTATION_DEGREES] do not.
*/
private const val HALF_ROTATION_DEGREES: Int = 180
/**
* Quarter rotation in degrees (clockwise).
*/
private const val QUARTER_ROTATION_DEGREES: Int = 90
/**
* Three-quarter rotation in degrees (clockwise).
*/
private const val THREE_QUARTER_ROTATION_DEGREES: Int = 270
/**
* Computes the approximated scan rectangle in display-space coordinates (i.e. coordinates after
* the supplied rotation has been applied to the raw image).
*
* The rectangle is centered, has its long edge oriented along the image's longer axis, and is
* sized to a credit card aspect ratio. If the card-aspect rectangle would be taller than the
* image's short edge, the rectangle is clamped to the short edge to avoid producing a region
* that extends past the visible frame.
*/
internal fun computeScanRect(
imageWidth: Int,
imageHeight: Int,
): ImageRect {
val shortEdge = min(imageWidth, imageHeight).toFloat()
val longEdge = max(imageWidth, imageHeight).toFloat()
val isLandscape = imageWidth >= imageHeight
val rectLong = (longEdge * SCAN_RECT_LONG_EDGE_FRACTION_OF_LONG_EDGE)
.coerceAtMost(shortEdge * CARD_ASPECT_RATIO)
val rectShort = rectLong / CARD_ASPECT_RATIO
val rectWidth = if (isLandscape) rectLong else rectShort
val rectHeight = if (isLandscape) rectShort else rectLong
val left = ((imageWidth - rectWidth) / 2f).roundToInt()
val top = ((imageHeight - rectHeight) / 2f).roundToInt()
return ImageRect(
left = left,
top = top,
right = left + rectWidth.roundToInt(),
bottom = top + rectHeight.roundToInt(),
)
}
/**
* Rotates an [ImagePoint] from raw image space into display space given the supplied
* [rotationDegrees] (one of 0, 90, 180, 270) and the raw image dimensions.
*/
internal fun rotatePoint(
point: ImagePoint,
rotationDegrees: Int,
rawImageWidth: Int,
rawImageHeight: Int,
): ImagePoint = when (rotationDegrees.normalizeRotation()) {
QUARTER_ROTATION_DEGREES -> ImagePoint(x = rawImageHeight - point.y, y = point.x)
HALF_ROTATION_DEGREES -> ImagePoint(x = rawImageWidth - point.x, y = rawImageHeight - point.y)
THREE_QUARTER_ROTATION_DEGREES -> ImagePoint(x = point.y, y = rawImageWidth - point.x)
else -> point
}
/**
* Rotates an [ImageRect] from raw image space into display space.
*/
internal fun rotateRect(
rect: ImageRect,
rotationDegrees: Int,
rawImageWidth: Int,
rawImageHeight: Int,
): ImageRect {
val corners = listOf(
ImagePoint(rect.left, rect.top),
ImagePoint(rect.right, rect.top),
ImagePoint(rect.right, rect.bottom),
ImagePoint(rect.left, rect.bottom),
).map { rotatePoint(it, rotationDegrees, rawImageWidth, rawImageHeight) }
val xs = corners.map { it.x }
val ys = corners.map { it.y }
return ImageRect(left = xs.min(), top = ys.min(), right = xs.max(), bottom = ys.max())
}
/**
* Returns `true` if the line's baseline (from top-left to top-right corner, in display space)
* deviates by no more than [MAX_BASELINE_ANGLE_DEGREES] from horizontal.
*
* Lines without exactly four corner points are conservatively rejected — we cannot verify the
* orientation of an unbounded line and would rather drop it than risk parsing a sideways card.
*/
internal fun RecognizedTextLine.isApproximatelyHorizontal(
rotationDegrees: Int,
rawImageWidth: Int,
rawImageHeight: Int,
): Boolean {
val corners = cornerPoints?.takeIf { it.size == LINE_CORNER_POINT_COUNT } ?: return false
val topLeft = rotatePoint(corners[0], rotationDegrees, rawImageWidth, rawImageHeight)
val topRight = rotatePoint(corners[1], rotationDegrees, rawImageWidth, rawImageHeight)
val dx = (topRight.x - topLeft.x).toDouble()
val dy = (topRight.y - topLeft.y).toDouble()
if (dx == 0.0 && dy == 0.0) return false
val angleDegrees = Math.toDegrees(atan2(dy, dx))
return abs(angleDegrees) <= MAX_BASELINE_ANGLE_DEGREES
}
/**
* Filters [recognized] text down to lines whose bounding box lies inside the on-screen scan
* rectangle and whose baseline is approximately horizontal in display space, then reassembles
* the surviving lines into a single newline-separated string.
*
* Returns an empty string if no lines survive filtering — callers should treat this as "no card
* text found in this frame" rather than passing it to the parser.
*
* @param recognized The text recognized in the raw camera image.
* @param rawImageWidth The width of the raw camera image (before rotation is applied).
* @param rawImageHeight The height of the raw camera image (before rotation is applied).
* @param rotationDegrees The rotation that should be applied to display the image upright.
*/
internal fun filterScannedText(
recognized: RecognizedText,
rawImageWidth: Int,
rawImageHeight: Int,
rotationDegrees: Int,
): String {
if (rawImageWidth <= 0 || rawImageHeight <= 0) return ""
val normalizedRotation = rotationDegrees.normalizeRotation()
val swapDimensions = normalizedRotation % HALF_ROTATION_DEGREES != 0
val displayWidth = if (swapDimensions) rawImageHeight else rawImageWidth
val displayHeight = if (swapDimensions) rawImageWidth else rawImageHeight
val scanRect = computeScanRect(imageWidth = displayWidth, imageHeight = displayHeight)
return recognized
.textBlocks
.filter { block ->
block.intersectsScanRect(
scanRect = scanRect,
rotationDegrees = normalizedRotation,
rawImageWidth = rawImageWidth,
rawImageHeight = rawImageHeight,
)
}
.flatMap { block -> block.lines }
.filter { line ->
line.isInsideScanRect(
scanRect = scanRect,
rotationDegrees = normalizedRotation,
rawImageWidth = rawImageWidth,
rawImageHeight = rawImageHeight,
)
}
.filter { line ->
line.isApproximatelyHorizontal(
rotationDegrees = normalizedRotation,
rawImageWidth = rawImageWidth,
rawImageHeight = rawImageHeight,
)
}
.joinToString(separator = "\n") { it.text }
}
/**
* Returns `true` when the rotated bounding box of this block overlaps [scanRect]. Blocks with
* no bounding box are conservatively excluded.
*/
private fun RecognizedTextBlock.intersectsScanRect(
scanRect: ImageRect,
rotationDegrees: Int,
rawImageWidth: Int,
rawImageHeight: Int,
): Boolean {
val box = boundingBox ?: return false
val rotated = rotateRect(box, rotationDegrees, rawImageWidth, rawImageHeight)
return scanRect.intersects(rotated)
}
/**
* Returns `true` when the rotated bounding box of this line lies entirely within [scanRect].
* Lines with no bounding box are conservatively excluded.
*/
private fun RecognizedTextLine.isInsideScanRect(
scanRect: ImageRect,
rotationDegrees: Int,
rawImageWidth: Int,
rawImageHeight: Int,
): Boolean {
val box = boundingBox ?: return false
val rotated = rotateRect(box, rotationDegrees, rawImageWidth, rawImageHeight)
return scanRect.contains(rotated)
}
/**
* Normalizes a rotation in degrees to the range `[0, 360)`.
*/
private fun Int.normalizeRotation(): Int =
((this % FULL_ROTATION_DEGREES) + FULL_ROTATION_DEGREES) % FULL_ROTATION_DEGREES
@@ -1,20 +1,46 @@
@file:OmitFromCoverage
package com.bitwarden.ui.platform.feature.cardscanner.util
import android.graphics.Rect
import androidx.annotation.OptIn
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageProxy
import com.bitwarden.annotation.OmitFromCoverage
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.Text
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import java.util.concurrent.atomic.AtomicBoolean
/**
* [CardTextAnalyzer] implementation that uses ML Kit Text Recognition
* to detect credit card details from camera frames.
* The maximum number of recent frames whose Luhn-valid PAN candidates are tracked for temporal
* voting.
*/
internal const val TEMPORAL_VOTE_WINDOW_SIZE: Int = 3
/**
* The minimum number of times the same PAN must appear in the temporal window before it is
* emitted to the caller. Two-of-three voting eliminates one-frame OCR flukes (a Luhn-valid PAN
* that briefly appears in the corner of the viewport, for example) without unduly delaying
* legitimate scans.
*/
internal const val TEMPORAL_VOTE_THRESHOLD: Int = 2
/**
* [CardTextAnalyzer] implementation that uses ML Kit Text Recognition to detect credit card
* details from camera frames.
*
* @property cardDataParser The parser used to extract card data from
* recognized text.
* The analyzer applies three layered defenses to prevent committing the wrong card data when
* multiple cards are visible or a card is held off-axis:
* 1. **Frame gating** — text outside the on-screen scan rectangle is discarded.
* 2. **Orientation gating** — text whose baseline is more than ±10° from horizontal in display
* space is discarded (rejecting sideways and upside-down cards).
* 3. **Temporal voting** — a PAN is only emitted once it has been observed in at least
* [TEMPORAL_VOTE_THRESHOLD] of the last [TEMPORAL_VOTE_WINDOW_SIZE] frames, and the emission
* is held until an expiration month has also been observed somewhere in that window.
*
* @property cardDataParser The parser used to extract card data from recognized text.
*/
@OmitFromCoverage
class CardTextAnalyzerImpl(
@@ -27,6 +53,10 @@ class CardTextAnalyzerImpl(
TextRecognizerOptions.DEFAULT_OPTIONS,
)
private val voteBuffer = PanVoteBuffer()
private val expiryBuffer = ExpiryBuffer()
override lateinit var onCardScanned: (CardScanData) -> Unit
@OptIn(ExperimentalGetImage::class)
@@ -36,28 +66,84 @@ class CardTextAnalyzerImpl(
return
}
val inputImage = image.image
?.let {
InputImage.fromMediaImage(
it,
image.imageInfo.rotationDegrees,
)
}
?: run {
image.close()
isInAnalysis.set(false)
return
}
val mediaImage = image.image
if (mediaImage == null) {
image.close()
isInAnalysis.set(false)
return
}
val rotationDegrees = image.imageInfo.rotationDegrees
val rawImageWidth = mediaImage.width
val rawImageHeight = mediaImage.height
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
recognizer.process(inputImage)
.addOnSuccessListener { result ->
cardDataParser.parseCardData(result.text)
?.takeIf { it.number != null }
?.let(onCardScanned)
val filteredText = filterScannedText(
recognized = result.toRecognizedText(),
rawImageWidth = rawImageWidth,
rawImageHeight = rawImageHeight,
rotationDegrees = rotationDegrees,
)
val parsed = filteredText
.takeIf { it.isNotEmpty() }
?.let { cardDataParser.parseCardData(it) }
voteAndMaybeEmit(parsed)
}
.addOnCompleteListener {
image.close()
isInAnalysis.set(false)
}
}
/**
* Records the latest frame's parse result in the temporal window and emits the parsed data
* only when its PAN has been confirmed by [PanVoteBuffer] and an expiration month has been
* observed somewhere in the same window via [ExpiryBuffer]. The PAN-confirming frame and the
* expiry-bearing frame may differ, so the emission composes the confirmed PAN with the most
* recent buffered expiry.
*/
private fun voteAndMaybeEmit(parsed: ParsedCardFields?) {
val confirmedPan = voteBuffer.record(parsed?.number)
val latestExpiry = expiryBuffer.record(
month = parsed?.expirationMonth,
year = parsed?.expirationYear,
)
if (confirmedPan != null && latestExpiry != null) {
onCardScanned(
CardScanData(
number = confirmedPan,
expirationMonth = latestExpiry.month,
expirationYear = latestExpiry.year,
securityCode = parsed?.securityCode,
),
)
}
}
}
/**
* Adapts ML Kit's [Text] result into the analyzer's internal [RecognizedText] abstraction so the
* geometric filtering logic can be unit-tested without mocking final ML Kit classes.
*/
private fun Text.toRecognizedText(): RecognizedText = object : RecognizedText {
override val textBlocks: List<RecognizedTextBlock> =
this@toRecognizedText.textBlocks.map { block ->
object : RecognizedTextBlock {
override val boundingBox: ImageRect? = block.boundingBox?.toImageRect()
override val lines: List<RecognizedTextLine> = block.lines.map { line ->
object : RecognizedTextLine {
override val text: String = line.text
override val boundingBox: ImageRect? = line.boundingBox?.toImageRect()
override val cornerPoints: List<ImagePoint>? =
line.cornerPoints?.map { ImagePoint(x = it.x, y = it.y) }
}
}
}
}
}
private fun Rect.toImageRect(): ImageRect =
ImageRect(left = left, top = top, right = right, bottom = bottom)
@@ -0,0 +1,38 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
/**
* A small rolling buffer that accumulates the latest non-null expiration month and year parsed
* from any frame in the recent window.
*
* The PAN-confirming frame and the expiry-bearing frame are not always the same frame: ML Kit may
* resolve the card number on one pass and the embossed expiration date on another. This buffer
* holds onto the most recent non-null expiry so the analyzer can compose a complete scan once the
* PAN has been confirmed via [PanVoteBuffer]. Frames where no expiry was parsed do not erase
* earlier observations within the window — they only displace the oldest entry.
*
* @property windowSize The maximum number of recent frames retained.
*/
internal class ExpiryBuffer(
private val windowSize: Int = TEMPORAL_VOTE_WINDOW_SIZE,
) {
private val recent = ArrayDeque<Expiry?>(windowSize)
/**
* Records the [month] and [year] from this frame and returns the most recent non-null
* expiry observed within the window, or `null` if no frame in the window has produced an
* expiration month yet.
*/
fun record(month: String?, year: String?): Expiry? {
if (recent.size >= windowSize) recent.removeFirst()
recent.addLast(month?.let { Expiry(month = it, year = year) })
return recent.filterNotNull().lastOrNull()
}
/**
* A scanned expiration date. [year] may be `null` when the expiry text only included a month.
*/
data class Expiry(
val month: String,
val year: String?,
)
}
@@ -0,0 +1,33 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
/**
* A small rolling buffer that records the Luhn-valid PAN parsed from each recent frame and
* answers "should we emit this PAN now?" using a temporal voting threshold.
*
* A single-frame Luhn-valid PAN is treated as a fluke and not emitted; a PAN must appear in at
* least [voteThreshold] of the last [windowSize] frames before the buffer reports it as
* confirmed. Frames where no PAN was parsed are recorded as `null` so that brief disappearances
* naturally age out prior observations.
*
* @property windowSize The maximum number of recent frames retained.
* @property voteThreshold The minimum number of matching observations required to emit a PAN.
*/
internal class PanVoteBuffer(
private val windowSize: Int = TEMPORAL_VOTE_WINDOW_SIZE,
private val voteThreshold: Int = TEMPORAL_VOTE_THRESHOLD,
) {
private val recent = ArrayDeque<String?>(windowSize)
/**
* Records [pan] (or `null` for a frame with no Luhn-valid PAN) as the latest observation and
* returns the PAN if it has now been observed in at least [voteThreshold] of the last
* [windowSize] frames; otherwise returns `null`.
*/
fun record(pan: String?): String? {
if (recent.size >= windowSize) recent.removeFirst()
recent.addLast(pan)
if (pan == null) return null
val occurrences = recent.count { it == pan }
return pan.takeIf { occurrences >= voteThreshold }
}
}
@@ -0,0 +1,24 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
/**
* Loose tuple of card fields extracted from a single OCR pass. Any individual field may be `null`
* because the parser surfaces whatever it can find in the frame; downstream gating in
* [CardTextAnalyzer] is responsible for assembling a confirmed [CardScanData] only once the
* required fields have been observed.
*
* @property number The detected card number, or `null` if absent or fails Luhn validation.
* @property expirationMonth The detected expiration month (01-12), or `null` if absent.
* @property expirationYear The detected expiration year, or `null` if absent.
* @property securityCode The detected security code, or `null` if absent.
*/
data class ParsedCardFields(
val number: String?,
val expirationMonth: String?,
val expirationYear: String?,
val securityCode: String?,
) {
override fun toString(): String = "ParsedCardFields(number=****," +
" expirationMonth=$expirationMonth," +
" expirationYear=$expirationYear," +
" securityCode=****)"
}
@@ -0,0 +1,90 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
/**
* A minimal abstraction over ML Kit's text recognition output that exposes only the geometry and
* text needed for credit card scan filtering.
*
* Wrapping ML Kit's `Text` / `TextBlock` / `Line` types behind this interface keeps geometric
* filtering logic pure JVM code that can be unit-tested without mocking final ML Kit classes or
* pulling in `android.graphics` (whose stub `Rect`/`Point` types would yield zeros under the
* `isReturnDefaultValues = true` JVM unit test configuration).
*/
internal interface RecognizedText {
/**
* The text blocks in the recognized image.
*/
val textBlocks: List<RecognizedTextBlock>
}
/**
* A single block of recognized text.
*/
internal interface RecognizedTextBlock {
/**
* The axis-aligned bounding box of the block in unrotated image coordinates, or `null` if
* unavailable.
*/
val boundingBox: ImageRect?
/**
* The lines that make up this block.
*/
val lines: List<RecognizedTextLine>
}
/**
* A single line of recognized text.
*/
internal interface RecognizedTextLine {
/**
* The recognized text for this line.
*/
val text: String
/**
* The axis-aligned bounding box of the line in unrotated image coordinates, or `null` if
* unavailable.
*/
val boundingBox: ImageRect?
/**
* The four corner points of this line in unrotated image coordinates, ordered clockwise
* starting from the top-left as the text reads, or `null` if unavailable.
*/
val cornerPoints: List<ImagePoint>?
}
/**
* An immutable, framework-free 2D point in image coordinates. Mirrors `android.graphics.Point`
* but does not depend on the Android stub `android.jar` so the geometry filter can be exercised
* in pure JVM unit tests.
*/
internal data class ImagePoint(val x: Int, val y: Int)
/**
* An immutable, framework-free axis-aligned rectangle in image coordinates. Mirrors
* `android.graphics.Rect`'s `(left, top, right, bottom)` semantics — `right` and `bottom` are
* exclusive.
*/
internal data class ImageRect(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) {
val width: Int get() = right - left
val height: Int get() = bottom - top
/**
* Returns `true` when [other] lies entirely within this rectangle (inclusive on left/top,
* exclusive on right/bottom).
*/
fun contains(other: ImageRect): Boolean =
other.left >= left && other.top >= top && other.right <= right && other.bottom <= bottom
/**
* Returns `true` when this rectangle and [other] overlap.
*/
fun intersects(other: ImageRect): Boolean =
left < other.right && other.left < right && top < other.bottom && other.top < bottom
}
+2
View File
@@ -230,6 +230,7 @@ Scanning will happen automatically.</string>
<string name="security_code">Security code</string>
<string name="scan_card">Scan card</string>
<string name="scan_card_instruction">Position your card within the frame to scan it.</string>
<string name="hold_steady_and_ensure_all_card_details_are_visible">Hold steady and ensure all card details are visible</string>
<string name="type_card">Card</string>
<string name="type_identity">Identity</string>
<string name="type_login">Login</string>
@@ -241,6 +242,7 @@ Scanning will happen automatically.</string>
<string name="august">August</string>
<string name="brand">Brand</string>
<string name="card_details">Card Details</string>
<string name="card_scanned">Card scanned</string>
<string name="cardholder_name">Cardholder name</string>
<string name="city_town">City / Town</string>
<string name="company">Company</string>
@@ -12,7 +12,7 @@ class CardDataParserImplTest {
fun `parseCardData extracts valid card number with Luhn check`() {
val text = "4111 1111 1111 1111 12/25"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -26,7 +26,7 @@ class CardDataParserImplTest {
fun `parseCardData rejects invalid card number failing Luhn`() {
val text = "4111 1111 1111 1112 12/25"
assertEquals(
CardScanData(
ParsedCardFields(
number = null,
expirationMonth = "12",
expirationYear = "2025",
@@ -40,7 +40,7 @@ class CardDataParserImplTest {
fun `parseCardData extracts four digit expiration year`() {
val text = "4111 1111 1111 1111 06/2028"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "06",
expirationYear = "2028",
@@ -54,7 +54,7 @@ class CardDataParserImplTest {
fun `parseCardData extracts standalone CVV not adjacent to other digits`() {
val text = "4111 1111 1111 1111 12/25 CVV 789"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -68,7 +68,7 @@ class CardDataParserImplTest {
fun `parseCardData filters out CVV candidates adjacent to other digits`() {
val text = "4111 1111 1111 1111 12/25\n5551234567"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -86,7 +86,7 @@ class CardDataParserImplTest {
Call 8005551234
""".trimIndent()
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -100,7 +100,7 @@ class CardDataParserImplTest {
fun `parseCardData does not pick up expiry digits as CVV`() {
val text = "4111 1111 1111 1111 12/25"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -119,7 +119,7 @@ class CardDataParserImplTest {
456
""".trimIndent()
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -133,7 +133,7 @@ class CardDataParserImplTest {
fun `parseCardData extracts four digit CVV for Amex`() {
val text = "3782 822463 10005 09/26 1234"
assertEquals(
CardScanData(
ParsedCardFields(
number = "378282246310005",
expirationMonth = "09",
expirationYear = "2026",
@@ -152,7 +152,7 @@ class CardDataParserImplTest {
fun `parseCardData handles card number with dashes`() {
val text = "4111-1111-1111-1111 03/27"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "03",
expirationYear = "2027",
@@ -166,7 +166,7 @@ class CardDataParserImplTest {
fun `parseCardData rejects four digit CVV for non-Amex card`() {
val text = "4111 1111 1111 1111 12/25 1234"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -181,7 +181,7 @@ class CardDataParserImplTest {
fun `parseCardData rejects three digit CVV for Amex card`() {
val text = "3782 822463 10005 09/26 789"
assertEquals(
CardScanData(
ParsedCardFields(
number = "378282246310005",
expirationMonth = "09",
expirationYear = "2026",
@@ -196,7 +196,7 @@ class CardDataParserImplTest {
fun `parseCardData extracts three digit CVV for Visa`() {
val text = "4111 1111 1111 1111 12/25 CVV 321"
assertEquals(
CardScanData(
ParsedCardFields(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2025",
@@ -212,7 +212,7 @@ class CardDataParserImplTest {
// so CVV detection falls back to 3-digit (non-Amex default)
val text = "1234 5678 9012 3456 12/25 789"
assertEquals(
CardScanData(
ParsedCardFields(
// Card number fails Luhn, so no brand detection possible
number = null,
expirationMonth = "12",
@@ -0,0 +1,503 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class CardScanFrameFilterTest {
/**
* Square test image. With this size and zero rotation, the computed scan rectangle is roughly
* (50, 216, 950, 783) in display coordinates.
*/
private val testImageSize = 1000
@Test
fun `computeScanRect produces a centered card-shaped region for a square image`() {
val rect = computeScanRect(imageWidth = 1000, imageHeight = 1000)
// Centered horizontally and vertically (allow 1px slack for rounding).
assertTrue(kotlin.math.abs((1000 - rect.right) - rect.left) <= 1)
assertTrue(kotlin.math.abs((1000 - rect.bottom) - rect.top) <= 1)
// Card aspect ratio is approximately 1.586.
val ratio = rect.width.toFloat() / rect.height.toFloat()
assertTrue(
ratio in 1.55f..1.62f,
"Expected card aspect ratio, got $ratio",
)
}
@Test
fun `filterScannedText returns the line text when the line is centered and horizontal`() {
val recognized = recognizedText(
block(
bounds = ImageRect(400, 450, 700, 550),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(400, 450, 700, 550),
corners = horizontalCorners(left = 400, top = 450, right = 700, bottom = 550),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("4111 1111 1111 1111", result)
}
@Test
fun `filterScannedText drops a line that lies entirely outside the scan rectangle`() {
// A Luhn-valid PAN positioned in the top-left corner, well outside the centered scan
// rectangle. The filter must drop it so the parser never sees it.
val recognized = recognizedText(
block(
bounds = ImageRect(0, 0, 200, 100),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(0, 0, 200, 100),
corners = horizontalCorners(left = 0, top = 0, right = 200, bottom = 100),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `filterScannedText drops a line that only partially overlaps the scan rectangle`() {
// The line straddles the left edge of the scan rect; we require full containment.
val recognized = recognizedText(
block(
bounds = ImageRect(20, 450, 300, 550),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(20, 450, 300, 550),
corners = horizontalCorners(left = 20, top = 450, right = 300, bottom = 550),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `filterScannedText drops a line whose baseline is rotated more than ten degrees`() {
// Line is centered in the scan rect but rotated ~30 degrees from horizontal.
val tiltedCorners = listOf(
ImagePoint(400, 500),
ImagePoint(659, 650), // ~30 degree slope (atan2(150, 259) ~= 30)
ImagePoint(640, 700),
ImagePoint(380, 550),
)
val recognized = recognizedText(
block(
bounds = ImageRect(380, 500, 660, 700),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(380, 500, 660, 700),
corners = tiltedCorners,
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `filterScannedText drops a line rotated 90 degrees in image space`() {
// The four corner points describe a vertical baseline (sideways card text).
val sidewaysCorners = listOf(
ImagePoint(500, 400),
ImagePoint(500, 700), // top-left to top-right is straight down -> 90 degrees
ImagePoint(550, 700),
ImagePoint(550, 400),
)
val recognized = recognizedText(
block(
bounds = ImageRect(500, 400, 550, 700),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(500, 400, 550, 700),
corners = sidewaysCorners,
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `filterScannedText accepts a line tilted within the ten degree tolerance`() {
// ~5 degree tilt: dy/dx = tan(5 deg) ~= 0.0875. dx=200 -> dy~=17.
val slightlyTiltedCorners = listOf(
ImagePoint(400, 500),
ImagePoint(600, 517),
ImagePoint(600, 547),
ImagePoint(400, 530),
)
val recognized = recognizedText(
block(
bounds = ImageRect(400, 500, 600, 547),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(400, 500, 600, 547),
corners = slightlyTiltedCorners,
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("4111 1111 1111 1111", result)
}
@Test
fun `filterScannedText drops a line missing corner points`() {
val recognized = recognizedText(
block(
bounds = ImageRect(400, 450, 700, 550),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(400, 450, 700, 550),
corners = null,
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `filterScannedText reassembles multiple surviving lines separated by newlines`() {
val recognized = recognizedText(
block(
bounds = ImageRect(400, 400, 700, 600),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(400, 450, 700, 480),
corners = horizontalCorners(left = 400, top = 450, right = 700, bottom = 480),
),
line(
text = "12/25",
bounds = ImageRect(400, 500, 500, 530),
corners = horizontalCorners(left = 400, top = 500, right = 500, bottom = 530),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("4111 1111 1111 1111\n12/25", result)
}
@Test
fun `filterScannedText keeps inside lines and drops outside lines from the same block`() {
val recognized = recognizedText(
block(
bounds = ImageRect(0, 0, 1000, 600),
line(
text = "outside",
bounds = ImageRect(0, 0, 200, 100),
corners = horizontalCorners(left = 0, top = 0, right = 200, bottom = 100),
),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(400, 450, 700, 480),
corners = horizontalCorners(left = 400, top = 450, right = 700, bottom = 480),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("4111 1111 1111 1111", result)
}
@Test
fun `filterScannedText respects rotation when judging line orientation`() {
// Raw image is 1000 x 1000 and rotationDegrees=90 means the displayed image is rotated
// 90 degrees clockwise from raw. A line that reads horizontally in display space, with
// corner points reported in display reading order but expressed in raw image
// coordinates, will have its raw corner points oriented vertically. After rotation, both
// the bounding box and the baseline land in display space as horizontal — the line must
// be accepted.
val rawTopLeft = ImagePoint(490, 700)
val rawTopRight = ImagePoint(490, 500)
val rawBottomRight = ImagePoint(510, 500)
val rawBottomLeft = ImagePoint(510, 700)
val recognized = recognizedText(
block(
bounds = ImageRect(490, 500, 510, 700),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(490, 500, 510, 700),
corners = listOf(rawTopLeft, rawTopRight, rawBottomRight, rawBottomLeft),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 90,
)
assertEquals("4111 1111 1111 1111", result)
}
@Test
fun `filterScannedText returns empty string when there are no text blocks`() {
val recognized = recognizedText()
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `filterScannedText returns empty string for zero-sized images`() {
val recognized = recognizedText(
block(
bounds = ImageRect(0, 0, 0, 0),
line(
text = "anything",
bounds = ImageRect(0, 0, 0, 0),
corners = horizontalCorners(left = 0, top = 0, right = 0, bottom = 0),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = 0,
rawImageHeight = 0,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `isApproximatelyHorizontal accepts a perfectly horizontal line`() {
val line = line(
text = "x",
bounds = ImageRect(0, 0, 100, 10),
corners = horizontalCorners(left = 0, top = 0, right = 100, bottom = 10),
)
assertTrue(
line.isApproximatelyHorizontal(
rotationDegrees = 0,
rawImageWidth = 1000,
rawImageHeight = 1000,
),
)
}
@Test
fun `isApproximatelyHorizontal rejects a line missing corner points`() {
val line = line(text = "x", bounds = ImageRect(0, 0, 100, 10), corners = null)
assertFalse(
line.isApproximatelyHorizontal(
rotationDegrees = 0,
rawImageWidth = 1000,
rawImageHeight = 1000,
),
)
}
@Test
fun `isApproximatelyHorizontal rejects a line whose top corners coincide`() {
// A degenerate line with a zero-length baseline cannot be classified — reject it.
val degenerate = listOf(
ImagePoint(500, 500),
ImagePoint(500, 500),
ImagePoint(500, 500),
ImagePoint(500, 500),
)
val line = line(text = "x", bounds = ImageRect(500, 500, 500, 500), corners = degenerate)
assertFalse(
line.isApproximatelyHorizontal(
rotationDegrees = 0,
rawImageWidth = 1000,
rawImageHeight = 1000,
),
)
}
@Test
fun `filterScannedText normalizes rotation values outside the canonical range`() {
// 360 normalizes to 0 and should produce the same result as the centered-line zero-
// rotation test, exercising the negative- and overflow-aware modulo arithmetic.
val recognized = recognizedText(
block(
bounds = ImageRect(400, 450, 700, 550),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(400, 450, 700, 550),
corners = horizontalCorners(left = 400, top = 450, right = 700, bottom = 550),
),
),
)
val resultPositive = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 360,
)
val resultNegative = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = -360,
)
assertEquals("4111 1111 1111 1111", resultPositive)
assertEquals("4111 1111 1111 1111", resultNegative)
}
@Test
fun `filterScannedText drops a block whose bounding box is null`() {
val recognized = recognizedText(
block(
bounds = null,
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(400, 450, 700, 550),
corners = horizontalCorners(left = 400, top = 450, right = 700, bottom = 550),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
@Test
fun `filterScannedText drops a line whose bounding box is null`() {
val recognized = recognizedText(
block(
bounds = ImageRect(400, 450, 700, 550),
line(
text = "4111 1111 1111 1111",
bounds = null,
corners = horizontalCorners(left = 400, top = 450, right = 700, bottom = 550),
),
),
)
val result = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
assertEquals("", result)
}
private fun horizontalCorners(
left: Int,
top: Int,
right: Int,
bottom: Int,
): List<ImagePoint> = listOf(
ImagePoint(left, top),
ImagePoint(right, top),
ImagePoint(right, bottom),
ImagePoint(left, bottom),
)
private fun line(
text: String,
bounds: ImageRect?,
corners: List<ImagePoint>?,
): RecognizedTextLine = object : RecognizedTextLine {
override val text: String = text
override val boundingBox: ImageRect? = bounds
override val cornerPoints: List<ImagePoint>? = corners
}
private fun block(
bounds: ImageRect?,
vararg lines: RecognizedTextLine,
): RecognizedTextBlock = object : RecognizedTextBlock {
override val boundingBox: ImageRect? = bounds
override val lines: List<RecognizedTextLine> = lines.toList()
}
private fun recognizedText(vararg blocks: RecognizedTextBlock): RecognizedText =
object : RecognizedText {
override val textBlocks: List<RecognizedTextBlock> = blocks.toList()
}
}
@@ -0,0 +1,255 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* End-to-end tests for the geometric filter -> data parser -> vote buffer pipeline that
* `CardTextAnalyzerImpl` runs on every camera frame. These tests do not exercise ML Kit itself,
* but they validate the post-recognition logic that turns recognized text into emitted scans.
*/
class CardScanPipelineTest {
private val parser = CardDataParserImpl()
private val testImageSize = 1000
@Test
fun `pipeline ignores Luhn-valid PAN positioned outside the scan rectangle`() {
// The PAN is in the top-left corner, far outside the centered scan rectangle.
val recognized = horizontalLines(
"4111 1111 1111 1111" at ImageRect(0, 0, 200, 30),
)
val emission = runFrame(
recognized = recognized,
voteBuffer = PanVoteBuffer(),
expiryBuffer = ExpiryBuffer(),
)
assertNull(emission)
}
@Test
fun `pipeline ignores Luhn-valid PAN whose baseline is rotated more than ten degrees`() {
// Sideways line: baseline drops straight down (90 degrees from horizontal).
val sidewaysCorners = listOf(
ImagePoint(500, 400),
ImagePoint(500, 700),
ImagePoint(550, 700),
ImagePoint(550, 400),
)
val recognized = recognizedText(
block(
bounds = ImageRect(500, 400, 550, 700),
line(
text = "4111 1111 1111 1111",
bounds = ImageRect(500, 400, 550, 700),
corners = sidewaysCorners,
),
),
)
val emission = runFrame(
recognized = recognized,
voteBuffer = PanVoteBuffer(),
expiryBuffer = ExpiryBuffer(),
)
assertNull(emission)
}
@Test
fun `pipeline does not emit a Luhn-valid PAN seen for the first time`() {
val recognized = horizontalLines(
"4111 1111 1111 1111" at ImageRect(400, 480, 700, 510),
"12/28" at ImageRect(400, 540, 500, 570),
)
val emission = runFrame(
recognized = recognized,
voteBuffer = PanVoteBuffer(),
expiryBuffer = ExpiryBuffer(),
)
assertNull(emission)
}
@Test
fun `pipeline emits when both PAN and expiry are seen in two frames`() {
val recognized = horizontalLines(
"4111 1111 1111 1111" at ImageRect(400, 480, 700, 510),
"12/28" at ImageRect(400, 540, 500, 570),
)
val voteBuffer = PanVoteBuffer()
val expiryBuffer = ExpiryBuffer()
val firstEmission = runFrame(
recognized = recognized,
voteBuffer = voteBuffer,
expiryBuffer = expiryBuffer,
)
val secondEmission = runFrame(
recognized = recognized,
voteBuffer = voteBuffer,
expiryBuffer = expiryBuffer,
)
assertNull(firstEmission)
assertEquals(
CardScanData(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2028",
securityCode = null,
),
secondEmission,
)
}
@Test
fun `pipeline does not emit when PAN is confirmed but expiry has never been seen`() {
val voteBuffer = PanVoteBuffer()
val expiryBuffer = ExpiryBuffer()
val panOnly = horizontalLines(
"4111 1111 1111 1111" at ImageRect(400, 480, 700, 510),
)
assertNull(
runFrame(recognized = panOnly, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
assertNull(
runFrame(recognized = panOnly, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
assertNull(
runFrame(recognized = panOnly, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
}
@Test
fun `pipeline emits using an expiry observed in an earlier frame within the window`() {
val voteBuffer = PanVoteBuffer()
val expiryBuffer = ExpiryBuffer()
val expiryOnly = horizontalLines(
"12/28" at ImageRect(400, 540, 500, 570),
)
val panOnly = horizontalLines(
"4111 1111 1111 1111" at ImageRect(400, 480, 700, 510),
)
// Frame 1: expiry observed, no PAN.
assertNull(
runFrame(recognized = expiryOnly, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
// Frame 2: PAN seen for the first time, expiry not in this frame's parse.
assertNull(
runFrame(recognized = panOnly, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
// Frame 3: PAN seen again — confirmed. Buffered expiry from frame 1 composes the emission.
assertEquals(
CardScanData(
number = "4111111111111111",
expirationMonth = "12",
expirationYear = "2028",
securityCode = null,
),
runFrame(recognized = panOnly, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
}
@Test
fun `pipeline does not emit a Luhn-valid PAN that briefly appears in a single frame`() {
val voteBuffer = PanVoteBuffer()
val expiryBuffer = ExpiryBuffer()
val withPan = horizontalLines(
"4111 1111 1111 1111" at ImageRect(400, 480, 700, 510),
"12/28" at ImageRect(400, 540, 500, 570),
)
val withoutPan = recognizedText()
assertNull(
runFrame(recognized = withPan, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
assertNull(
runFrame(recognized = withoutPan, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
assertNull(
runFrame(recognized = withoutPan, voteBuffer = voteBuffer, expiryBuffer = expiryBuffer),
)
}
/**
* Runs a single simulated frame through the same logical pipeline that `CardTextAnalyzerImpl`
* applies and returns the emitted [CardScanData], or `null` if nothing should be emitted.
*/
private fun runFrame(
recognized: RecognizedText,
voteBuffer: PanVoteBuffer,
expiryBuffer: ExpiryBuffer,
): CardScanData? {
val filteredText = filterScannedText(
recognized = recognized,
rawImageWidth = testImageSize,
rawImageHeight = testImageSize,
rotationDegrees = 0,
)
val parsed = filteredText
.takeIf { it.isNotEmpty() }
?.let { parser.parseCardData(it) }
val confirmedPan = voteBuffer.record(pan = parsed?.number)
val latestExpiry = expiryBuffer.record(
month = parsed?.expirationMonth,
year = parsed?.expirationYear,
)
if (confirmedPan == null || latestExpiry == null) return null
return CardScanData(
number = confirmedPan,
expirationMonth = latestExpiry.month,
expirationYear = latestExpiry.year,
securityCode = parsed?.securityCode,
)
}
private infix fun String.at(bounds: ImageRect): Pair<String, ImageRect> = this to bounds
private fun horizontalLines(vararg lines: Pair<String, ImageRect>): RecognizedText =
recognizedText(
*lines
.map { (text, bounds) ->
block(
bounds = bounds,
line(
text = text,
bounds = bounds,
corners = listOf(
ImagePoint(bounds.left, bounds.top),
ImagePoint(bounds.right, bounds.top),
ImagePoint(bounds.right, bounds.bottom),
ImagePoint(bounds.left, bounds.bottom),
),
),
)
}
.toTypedArray(),
)
private fun line(
text: String,
bounds: ImageRect?,
corners: List<ImagePoint>?,
): RecognizedTextLine = object : RecognizedTextLine {
override val text: String = text
override val boundingBox: ImageRect? = bounds
override val cornerPoints: List<ImagePoint>? = corners
}
private fun block(
bounds: ImageRect?,
vararg lines: RecognizedTextLine,
): RecognizedTextBlock = object : RecognizedTextBlock {
override val boundingBox: ImageRect? = bounds
override val lines: List<RecognizedTextLine> = lines.toList()
}
private fun recognizedText(vararg blocks: RecognizedTextBlock): RecognizedText =
object : RecognizedText {
override val textBlocks: List<RecognizedTextBlock> = blocks.toList()
}
}
@@ -0,0 +1,65 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class ExpiryBufferTest {
@Test
fun `record returns null when no expiry has ever been seen`() {
val buffer = ExpiryBuffer()
assertNull(buffer.record(month = null, year = null))
}
@Test
fun `record returns the recorded expiry on the same frame it is observed`() {
val buffer = ExpiryBuffer()
assertEquals(
ExpiryBuffer.Expiry(month = "12", year = "2028"),
buffer.record(month = "12", year = "2028"),
)
}
@Test
fun `record returns the latest expiry seen even when later frames produce no expiry`() {
val buffer = ExpiryBuffer()
buffer.record(month = "12", year = "2028")
assertEquals(
ExpiryBuffer.Expiry(month = "12", year = "2028"),
buffer.record(month = null, year = null),
)
}
@Test
fun `record returns null once the only observed expiry has aged out of the window`() {
val buffer = ExpiryBuffer()
buffer.record(month = "12", year = "2028")
buffer.record(month = null, year = null)
buffer.record(month = null, year = null)
// Window size is 3; the original observation is now displaced by this fourth frame.
assertNull(buffer.record(month = null, year = null))
}
@Test
fun `record overwrites an older expiry with the most recent observation`() {
val buffer = ExpiryBuffer()
buffer.record(month = "01", year = "2027")
assertEquals(
ExpiryBuffer.Expiry(month = "12", year = "2028"),
buffer.record(month = "12", year = "2028"),
)
}
@Test
fun `record preserves a month-only expiry when no year was parsed`() {
val buffer = ExpiryBuffer()
assertEquals(
ExpiryBuffer.Expiry(month = "12", year = null),
buffer.record(month = "12", year = null),
)
}
}
@@ -0,0 +1,70 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ImageRectTest {
private val outer = ImageRect(left = 0, top = 0, right = 100, bottom = 100)
@Test
fun `contains returns true when other is fully inside`() {
assertTrue(outer.contains(ImageRect(10, 10, 90, 90)))
}
@Test
fun `contains returns true when edges align with this rectangle's edges`() {
assertTrue(outer.contains(ImageRect(0, 0, 100, 100)))
}
@Test
fun `contains returns false when other extends past the left edge`() {
assertFalse(outer.contains(ImageRect(-1, 10, 50, 50)))
}
@Test
fun `contains returns false when other extends past the top edge`() {
assertFalse(outer.contains(ImageRect(10, -1, 50, 50)))
}
@Test
fun `contains returns false when other extends past the right edge`() {
assertFalse(outer.contains(ImageRect(10, 10, 101, 50)))
}
@Test
fun `contains returns false when other extends past the bottom edge`() {
assertFalse(outer.contains(ImageRect(10, 10, 50, 101)))
}
@Test
fun `intersects returns true when rectangles overlap on a corner`() {
assertTrue(outer.intersects(ImageRect(50, 50, 150, 150)))
}
@Test
fun `intersects returns true when one rectangle is fully inside the other`() {
assertTrue(outer.intersects(ImageRect(10, 10, 90, 90)))
}
@Test
fun `intersects returns false when other lies entirely to the left`() {
assertFalse(outer.intersects(ImageRect(-50, 10, 0, 50)))
}
@Test
fun `intersects returns false when other lies entirely to the right`() {
assertFalse(outer.intersects(ImageRect(100, 10, 150, 50)))
}
@Test
fun `intersects returns false when other lies entirely above`() {
assertFalse(outer.intersects(ImageRect(10, -50, 50, 0)))
}
@Test
fun `intersects returns false when other lies entirely below`() {
assertFalse(outer.intersects(ImageRect(10, 100, 50, 150)))
}
}
@@ -0,0 +1,75 @@
package com.bitwarden.ui.platform.feature.cardscanner.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class PanVoteBufferTest {
@Test
fun `record returns null for the first occurrence of a PAN`() {
val buffer = PanVoteBuffer()
assertNull(buffer.record(pan = "4111111111111111"))
}
@Test
fun `record returns the PAN once it has been seen in two of three frames`() {
val buffer = PanVoteBuffer()
val pan = "4111111111111111"
assertNull(buffer.record(pan = pan))
assertEquals(pan, buffer.record(pan = pan))
}
@Test
fun `record returns null when only a single frame contains a PAN`() {
val buffer = PanVoteBuffer()
val pan = "4111111111111111"
assertNull(buffer.record(pan = pan))
assertNull(buffer.record(pan = null))
assertNull(buffer.record(pan = null))
// The original PAN has now been pushed out of the window.
assertNull(buffer.record(pan = null))
}
@Test
fun `record returns null when a PAN appears once and then disappears for two frames`() {
val buffer = PanVoteBuffer()
val pan = "4111111111111111"
assertNull(buffer.record(pan = pan))
assertNull(buffer.record(pan = null))
assertNull(buffer.record(pan = null))
}
@Test
fun `record does not confirm two different PANs that each appeared only once`() {
val buffer = PanVoteBuffer()
assertNull(buffer.record(pan = "4111111111111111"))
assertNull(buffer.record(pan = "5500000000000004"))
assertNull(buffer.record(pan = "378282246310005"))
}
@Test
fun `record confirms a PAN seen in two non-consecutive frames within the window`() {
val buffer = PanVoteBuffer()
val pan = "4111111111111111"
assertNull(buffer.record(pan = pan))
assertNull(buffer.record(pan = null))
assertEquals(pan, buffer.record(pan = pan))
}
@Test
fun `record continues to emit the same PAN on every subsequent frame it is seen`() {
val buffer = PanVoteBuffer()
val pan = "4111111111111111"
assertNull(buffer.record(pan = pan))
assertEquals(pan, buffer.record(pan = pan))
assertEquals(pan, buffer.record(pan = pan))
assertEquals(pan, buffer.record(pan = pan))
}
}