diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index c51a9fa632..81c836b543 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -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) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt index d72a15cc10..4cc4583cb7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt @@ -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), + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt index 1bbcd7a02c..4f7a6b8468 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt @@ -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( 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 diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 814bfde76b..59e63efe20 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -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(), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt index df9a95d940..f0af3a95de 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) - private val viewModel = mockk(relaxed = true) { + private val viewModel = mockk { 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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt index c1cac8c78a..db6f1660c4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt @@ -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( diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParser.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParser.kt index e51b775a6a..530d3242f3 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParser.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParser.kt @@ -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? } diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImpl.kt index b4ce583de7..95ed7bfdb2 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImpl.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImpl.kt @@ -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, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanData.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanData.kt index a5a481effa..aecb549866 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanData.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanData.kt @@ -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?, ) { diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanFrameFilter.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanFrameFilter.kt new file mode 100644 index 0000000000..793843de8f --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanFrameFilter.kt @@ -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 diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt index 784adba7a6..c1489dfe87 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt @@ -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 = + this@toRecognizedText.textBlocks.map { block -> + object : RecognizedTextBlock { + override val boundingBox: ImageRect? = block.boundingBox?.toImageRect() + override val lines: List = block.lines.map { line -> + object : RecognizedTextLine { + override val text: String = line.text + override val boundingBox: ImageRect? = line.boundingBox?.toImageRect() + override val cornerPoints: List? = + line.cornerPoints?.map { ImagePoint(x = it.x, y = it.y) } + } + } + } + } +} + +private fun Rect.toImageRect(): ImageRect = + ImageRect(left = left, top = top, right = right, bottom = bottom) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ExpiryBuffer.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ExpiryBuffer.kt new file mode 100644 index 0000000000..411567aeac --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ExpiryBuffer.kt @@ -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(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?, + ) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/PanVoteBuffer.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/PanVoteBuffer.kt new file mode 100644 index 0000000000..1a22eaee1d --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/PanVoteBuffer.kt @@ -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(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 } + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ParsedCardFields.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ParsedCardFields.kt new file mode 100644 index 0000000000..369fd6ab8a --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ParsedCardFields.kt @@ -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=****)" +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/RecognizedText.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/RecognizedText.kt new file mode 100644 index 0000000000..b5e5771a5f --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/RecognizedText.kt @@ -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 +} + +/** + * 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 +} + +/** + * 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? +} + +/** + * 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 +} diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 66ec9d4ffd..fce69d4ce9 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -230,6 +230,7 @@ Scanning will happen automatically. Security code Scan card Position your card within the frame to scan it. + Hold steady and ensure all card details are visible Card Identity Login @@ -241,6 +242,7 @@ Scanning will happen automatically. August Brand Card Details + Card scanned Cardholder name City / Town Company diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImplTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImplTest.kt index 9f6e7edd54..cf709841b7 100644 --- a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImplTest.kt +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardDataParserImplTest.kt @@ -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", diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanFrameFilterTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanFrameFilterTest.kt new file mode 100644 index 0000000000..dd884cb4ba --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanFrameFilterTest.kt @@ -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 = listOf( + ImagePoint(left, top), + ImagePoint(right, top), + ImagePoint(right, bottom), + ImagePoint(left, bottom), + ) + + private fun line( + text: String, + bounds: ImageRect?, + corners: List?, + ): RecognizedTextLine = object : RecognizedTextLine { + override val text: String = text + override val boundingBox: ImageRect? = bounds + override val cornerPoints: List? = corners + } + + private fun block( + bounds: ImageRect?, + vararg lines: RecognizedTextLine, + ): RecognizedTextBlock = object : RecognizedTextBlock { + override val boundingBox: ImageRect? = bounds + override val lines: List = lines.toList() + } + + private fun recognizedText(vararg blocks: RecognizedTextBlock): RecognizedText = + object : RecognizedText { + override val textBlocks: List = blocks.toList() + } +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanPipelineTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanPipelineTest.kt new file mode 100644 index 0000000000..367a164f77 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanPipelineTest.kt @@ -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 = this to bounds + + private fun horizontalLines(vararg lines: Pair): 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?, + ): RecognizedTextLine = object : RecognizedTextLine { + override val text: String = text + override val boundingBox: ImageRect? = bounds + override val cornerPoints: List? = corners + } + + private fun block( + bounds: ImageRect?, + vararg lines: RecognizedTextLine, + ): RecognizedTextBlock = object : RecognizedTextBlock { + override val boundingBox: ImageRect? = bounds + override val lines: List = lines.toList() + } + + private fun recognizedText(vararg blocks: RecognizedTextBlock): RecognizedText = + object : RecognizedText { + override val textBlocks: List = blocks.toList() + } +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ExpiryBufferTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ExpiryBufferTest.kt new file mode 100644 index 0000000000..4acada8eed --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ExpiryBufferTest.kt @@ -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), + ) + } +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ImageRectTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ImageRectTest.kt new file mode 100644 index 0000000000..c01c4b5920 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/ImageRectTest.kt @@ -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))) + } +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/PanVoteBufferTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/PanVoteBufferTest.kt new file mode 100644 index 0000000000..3fdb34b508 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/PanVoteBufferTest.kt @@ -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)) + } +}