mirror of
https://github.com/bitwarden/android.git
synced 2026-10-22 02:53:19 -05:00
[PM-34038] fix: Address card scanner QA findings (#6867)
This commit is contained in:
1 parent
a9cc18e4fd
commit
fd9618bddb
22 files changed
+1996
-146
No files matched your search
+7
-11
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+93
-83
@@ -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),
|
||||
)
|
||||
}
|
||||
+54
-2
@@ -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
|
||||
+168
-6
@@ -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(),
|
||||
|
||||
+48
-1
@@ -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,
|
||||
)
|
||||
+92
@@ -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(
|
||||
|
||||
+5
-3
@@ -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?
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+11
-5
@@ -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?,
|
||||
) {
|
||||
|
||||
+242
@@ -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
|
||||
+105
-19
@@ -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?,
|
||||
)
|
||||
}
|
||||
+33
@@ -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 }
|
||||
}
|
||||
}
|
||||
+24
@@ -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=****)"
|
||||
}
|
||||
+90
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+14
-14
@@ -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",
|
||||
|
||||
+503
@@ -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()
|
||||
}
|
||||
}
|
||||
+255
@@ -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()
|
||||
}
|
||||
}
|
||||
+65
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -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)))
|
||||
}
|
||||
}
|
||||
+75
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user