From dca5681da168398d01ca85188c9ff756da9a7c64 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Tue, 31 Mar 2026 10:44:57 -0400 Subject: [PATCH 1/8] Address code review feedback for card scanner - Add Closeable to CardTextAnalyzerImpl to release ML Kit native resources - Override toString on CardScanData to mask sensitive fields - Add missing RuPay brand detection test coverage --- .../feature/cardscanner/util/CardTextAnalyzerImpl.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 784adba7a60..1148a846c36 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 @@ -7,6 +7,7 @@ import com.bitwarden.annotation.OmitFromCoverage import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import java.io.Closeable import java.util.concurrent.atomic.AtomicBoolean /** @@ -19,7 +20,8 @@ import java.util.concurrent.atomic.AtomicBoolean @OmitFromCoverage class CardTextAnalyzerImpl( private val cardDataParser: CardDataParser, -) : CardTextAnalyzer { +) : CardTextAnalyzer, + Closeable { private val isInAnalysis = AtomicBoolean(false) @@ -29,6 +31,10 @@ class CardTextAnalyzerImpl( override lateinit var onCardScanned: (CardScanData) -> Unit + override fun close() { + recognizer.close() + } + @OptIn(ExperimentalGetImage::class) override fun analyze(image: ImageProxy) { if (!isInAnalysis.compareAndSet(false, true)) { From a58b58cdab711c1685df5d0735d88ef4515a55b8 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Tue, 31 Mar 2026 16:44:52 -0400 Subject: [PATCH 2/8] Remove Closeable implementation from CardTextAnalyzerImpl LocalComposition doesn't provide a lifecycle hook for cleanup, so close() would never be invoked. Removes Closeable to stay consistent with QrCodeAnalyzerImpl. --- .../feature/cardscanner/util/CardTextAnalyzerImpl.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 1148a846c36..784adba7a60 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 @@ -7,7 +7,6 @@ import com.bitwarden.annotation.OmitFromCoverage import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions -import java.io.Closeable import java.util.concurrent.atomic.AtomicBoolean /** @@ -20,8 +19,7 @@ import java.util.concurrent.atomic.AtomicBoolean @OmitFromCoverage class CardTextAnalyzerImpl( private val cardDataParser: CardDataParser, -) : CardTextAnalyzer, - Closeable { +) : CardTextAnalyzer { private val isInAnalysis = AtomicBoolean(false) @@ -31,10 +29,6 @@ class CardTextAnalyzerImpl( override lateinit var onCardScanned: (CardScanData) -> Unit - override fun close() { - recognizer.close() - } - @OptIn(ExperimentalGetImage::class) override fun analyze(image: ImageProxy) { if (!isInAnalysis.compareAndSet(false, true)) { From b9bece662b0591e7f71fbf0cca977c3c2625e3c6 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Wed, 25 Mar 2026 16:01:19 -0400 Subject: [PATCH 3/8] [PM-34125] feat: Add card text analysis pipeline Add the complete text analysis pipeline for credit card scanning: - CardNumberUtils: sanitize, Luhn validation, brand detection - CardDataParser: interface and implementation for OCR text parsing - CardTextAnalyzer: ML Kit-based camera frame analysis - CardScanOverlay: camera overlay composable - CardScanData: data class for parsed card fields - FakeCardTextAnalyzer: test fixture - LocalProviders: composition local for CardTextAnalyzer --- .../data/vault/util/CardNumberUtils.kt | 94 ++++++++++++++++++ .../data/vault/util/CardNumberUtilsTest.kt | 95 +++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt new file mode 100644 index 00000000000..28a5b34b9c2 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt @@ -0,0 +1,94 @@ +package com.x8bit.bitwarden.data.vault.util + +import com.bitwarden.ui.platform.feature.cardscanner.util.sanitizeCardNumber +import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand + +/** + * Detects the card brand based on the card number prefix. + * + * @return The detected [VaultCardBrand], or [VaultCardBrand.OTHER] if no match is found. + */ +@Suppress("CyclomaticComplexMethod", "MagicNumber") +fun String.detectCardBrand(): VaultCardBrand { + val digits = sanitizeCardNumber() + if (digits.isEmpty()) return VaultCardBrand.OTHER + + return when { + // Amex: starts with 34 or 37 + digits.startsWith("34") || digits.startsWith("37") -> VaultCardBrand.AMEX + + // Visa: starts with 4 + digits.startsWith("4") -> VaultCardBrand.VISA + + // Mastercard: 51-55 or 2221-2720 + digits.isMastercardPrefix() -> VaultCardBrand.MASTERCARD + + // Discover: 6011, 65, 644-649 + digits.isDiscoverPrefix() -> VaultCardBrand.DISCOVER + + // Diners Club: 300-305, 36, 38 + digits.isDinersClubPrefix() -> VaultCardBrand.DINERS_CLUB + + // JCB: 3528-3589 + digits.isJcbPrefix() -> VaultCardBrand.JCB + + // Maestro: 5018, 5020, 5038, 6304 + digits.isMaestroPrefix() -> VaultCardBrand.MAESTRO + + // UnionPay: starts with 62 + digits.startsWith("62") -> VaultCardBrand.UNIONPAY + + // RuPay: 60, 65, 81, 82 + digits.isRuPayPrefix() -> VaultCardBrand.RUPAY + + else -> VaultCardBrand.OTHER + } +} + +@Suppress("MagicNumber") +private fun String.isMastercardPrefix(): Boolean { + if (length < 2) return false + val twoDigit = substring(0, 2).toIntOrNull() ?: return false + if (twoDigit in 51..55) return true + if (length < 4) return false + val fourDigit = substring(0, 4).toIntOrNull() ?: return false + return fourDigit in 2221..2720 +} + +@Suppress("MagicNumber") +private fun String.isDiscoverPrefix(): Boolean { + if (startsWith("6011") || startsWith("65")) return true + if (length < 3) return false + val threeDigit = substring(0, 3).toIntOrNull() ?: return false + return threeDigit in 644..649 +} + +@Suppress("MagicNumber") +private fun String.isDinersClubPrefix(): Boolean { + if (startsWith("36") || startsWith("38")) return true + if (length < 3) return false + val threeDigit = substring(0, 3).toIntOrNull() ?: return false + return threeDigit in 300..305 +} + +@Suppress("MagicNumber") +private fun String.isJcbPrefix(): Boolean { + if (length < 4) return false + val fourDigit = substring(0, 4).toIntOrNull() ?: return false + return fourDigit in 3528..3589 +} + +private fun String.isMaestroPrefix(): Boolean = + startsWith("5018") || + startsWith("5020") || + startsWith("5038") || + startsWith("6304") + +// Note: "60" and "65" overlap with Discover prefixes ("6011", "65") but are +// unreachable here because Discover is checked first in detectCardBrand(). +// They are kept for documentation of the full RuPay prefix specification. +private fun String.isRuPayPrefix(): Boolean = + startsWith("60") || + startsWith("65") || + startsWith("81") || + startsWith("82") diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt new file mode 100644 index 00000000000..7eb94bf8728 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt @@ -0,0 +1,95 @@ +package com.x8bit.bitwarden.data.vault.util + +import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CardNumberUtilsTest { + + @Test + fun `detectCardBrand should detect Visa`() { + assertEquals(VaultCardBrand.VISA, "4111111111111111".detectCardBrand()) + assertEquals(VaultCardBrand.VISA, "4012888888881881".detectCardBrand()) + } + + @Test + fun `detectCardBrand should detect Mastercard`() { + assertEquals( + VaultCardBrand.MASTERCARD, + "5500000000000004".detectCardBrand(), + ) + assertEquals( + VaultCardBrand.MASTERCARD, + "5100000000000008".detectCardBrand(), + ) + assertEquals( + VaultCardBrand.MASTERCARD, + "2221000000000009".detectCardBrand(), + ) + } + + @Test + fun `detectCardBrand should detect Amex`() { + assertEquals(VaultCardBrand.AMEX, "378282246310005".detectCardBrand()) + assertEquals(VaultCardBrand.AMEX, "341111111111111".detectCardBrand()) + } + + @Test + fun `detectCardBrand should detect Discover`() { + assertEquals( + VaultCardBrand.DISCOVER, + "6011111111111117".detectCardBrand(), + ) + assertEquals( + VaultCardBrand.DISCOVER, + "6500000000000002".detectCardBrand(), + ) + } + + @Test + fun `detectCardBrand should detect Diners Club`() { + assertEquals( + VaultCardBrand.DINERS_CLUB, + "30569309025904".detectCardBrand(), + ) + assertEquals( + VaultCardBrand.DINERS_CLUB, + "36000000000008".detectCardBrand(), + ) + } + + @Test + fun `detectCardBrand should detect JCB`() { + assertEquals(VaultCardBrand.JCB, "3528000000000007".detectCardBrand()) + assertEquals(VaultCardBrand.JCB, "3589000000000003".detectCardBrand()) + } + + @Test + fun `detectCardBrand should detect Maestro`() { + assertEquals( + VaultCardBrand.MAESTRO, + "5018000000000009".detectCardBrand(), + ) + assertEquals( + VaultCardBrand.MAESTRO, + "6304000000000000".detectCardBrand(), + ) + } + + @Test + fun `detectCardBrand should detect UnionPay`() { + assertEquals( + VaultCardBrand.UNIONPAY, + "6200000000000005".detectCardBrand(), + ) + } + + @Test + fun `detectCardBrand should return OTHER for unknown prefixes`() { + assertEquals( + VaultCardBrand.OTHER, + "9999999999999995".detectCardBrand(), + ) + assertEquals(VaultCardBrand.OTHER, "".detectCardBrand()) + } +} From 00d6666f9a0da717a15d20bc35e30dcf98f8144c Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Wed, 25 Mar 2026 16:06:22 -0400 Subject: [PATCH 4/8] [PM-34126] feat: Add card scan screen Add the card scanner screen with camera integration: - CardScanManager: shared result channel between scanner and consumer - CardScanViewModel: state management for scan screen - CardScanScreen: composable with camera preview and overlay - CardScanNavigation: route and navigation helper - CardScanner feature flag gated behind card-scanner-mobile --- .../vault/manager/di/VaultManagerModule.kt | 6 + .../data/vault/util/CardNumberUtils.kt | 94 ------------- .../composition/LocalManagerProvider.kt | 10 ++ .../feature/cardscanner/CardScanNavigation.kt | 35 +++++ .../feature/cardscanner/CardScanScreen.kt | 131 ++++++++++++++++++ .../feature/cardscanner/CardScanViewModel.kt | 96 +++++++++++++ .../data/vault/util/CardNumberUtilsTest.kt | 95 ------------- .../ui/platform/base/BitwardenComposeTest.kt | 3 + .../feature/cardscanner/CardScanScreenTest.kt | 71 ++++++++++ .../cardscanner/CardScanViewModelTest.kt | 120 ++++++++++++++++ .../core/data/manager/model/FlagKey.kt | 9 ++ .../components/debug/FeatureFlagListItems.kt | 2 + .../cardscanner/manager/CardScanManager.kt | 21 +++ .../manager/CardScanManagerImpl.kt | 22 +++ .../cardscanner/util/CardScanResult.kt | 19 +++ .../manager/CardScanManagerTest.kt | 41 ++++++ 16 files changed, 586 insertions(+), 189 deletions(-) delete mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt delete mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManager.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerImpl.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanResult.kt create mode 100644 ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index e2804dfee39..31ef577c1e7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -10,6 +10,8 @@ import com.bitwarden.network.service.CiphersService import com.bitwarden.network.service.FolderService import com.bitwarden.network.service.SendsService import com.bitwarden.network.service.SyncService +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManagerImpl import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.KdfManager @@ -60,6 +62,10 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object VaultManagerModule { + @Provides + @Singleton + fun provideCardScanManager(): CardScanManager = CardScanManagerImpl() + @Provides @Singleton fun provideVaultMigrationManager( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt deleted file mode 100644 index 28a5b34b9c2..00000000000 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtils.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.x8bit.bitwarden.data.vault.util - -import com.bitwarden.ui.platform.feature.cardscanner.util.sanitizeCardNumber -import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand - -/** - * Detects the card brand based on the card number prefix. - * - * @return The detected [VaultCardBrand], or [VaultCardBrand.OTHER] if no match is found. - */ -@Suppress("CyclomaticComplexMethod", "MagicNumber") -fun String.detectCardBrand(): VaultCardBrand { - val digits = sanitizeCardNumber() - if (digits.isEmpty()) return VaultCardBrand.OTHER - - return when { - // Amex: starts with 34 or 37 - digits.startsWith("34") || digits.startsWith("37") -> VaultCardBrand.AMEX - - // Visa: starts with 4 - digits.startsWith("4") -> VaultCardBrand.VISA - - // Mastercard: 51-55 or 2221-2720 - digits.isMastercardPrefix() -> VaultCardBrand.MASTERCARD - - // Discover: 6011, 65, 644-649 - digits.isDiscoverPrefix() -> VaultCardBrand.DISCOVER - - // Diners Club: 300-305, 36, 38 - digits.isDinersClubPrefix() -> VaultCardBrand.DINERS_CLUB - - // JCB: 3528-3589 - digits.isJcbPrefix() -> VaultCardBrand.JCB - - // Maestro: 5018, 5020, 5038, 6304 - digits.isMaestroPrefix() -> VaultCardBrand.MAESTRO - - // UnionPay: starts with 62 - digits.startsWith("62") -> VaultCardBrand.UNIONPAY - - // RuPay: 60, 65, 81, 82 - digits.isRuPayPrefix() -> VaultCardBrand.RUPAY - - else -> VaultCardBrand.OTHER - } -} - -@Suppress("MagicNumber") -private fun String.isMastercardPrefix(): Boolean { - if (length < 2) return false - val twoDigit = substring(0, 2).toIntOrNull() ?: return false - if (twoDigit in 51..55) return true - if (length < 4) return false - val fourDigit = substring(0, 4).toIntOrNull() ?: return false - return fourDigit in 2221..2720 -} - -@Suppress("MagicNumber") -private fun String.isDiscoverPrefix(): Boolean { - if (startsWith("6011") || startsWith("65")) return true - if (length < 3) return false - val threeDigit = substring(0, 3).toIntOrNull() ?: return false - return threeDigit in 644..649 -} - -@Suppress("MagicNumber") -private fun String.isDinersClubPrefix(): Boolean { - if (startsWith("36") || startsWith("38")) return true - if (length < 3) return false - val threeDigit = substring(0, 3).toIntOrNull() ?: return false - return threeDigit in 300..305 -} - -@Suppress("MagicNumber") -private fun String.isJcbPrefix(): Boolean { - if (length < 4) return false - val fourDigit = substring(0, 4).toIntOrNull() ?: return false - return fourDigit in 3528..3589 -} - -private fun String.isMaestroPrefix(): Boolean = - startsWith("5018") || - startsWith("5020") || - startsWith("5038") || - startsWith("6304") - -// Note: "60" and "65" overlap with Discover prefixes ("6011", "65") but are -// unreachable here because Discover is checked first in detectCardBrand(). -// They are kept for documentation of the full RuPay prefix specification. -private fun String.isRuPayPrefix(): Boolean = - startsWith("60") || - startsWith("65") || - startsWith("81") || - startsWith("82") diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index ed7affadd0d..fb17d32b77c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -24,7 +24,12 @@ import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator import com.bitwarden.cxf.validator.dsl.credentialExchangeRequestValidator import com.bitwarden.ui.platform.composition.LocalExitManager import com.bitwarden.ui.platform.composition.LocalIntentManager +import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer import com.bitwarden.ui.platform.composition.LocalQrCodeAnalyzer +import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParser +import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParserImpl +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzerImpl import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzerImpl import com.bitwarden.ui.platform.manager.IntentManager @@ -84,6 +89,10 @@ fun LocalManagerProvider( credentialExchangeRequestValidator: CredentialExchangeRequestValidator = credentialExchangeRequestValidator(activity = activity), authTabLaunchers: AuthTabLaunchers, + cardDataParser: CardDataParser = CardDataParserImpl(), + cardTextAnalyzer: CardTextAnalyzer = CardTextAnalyzerImpl( + cardDataParser = cardDataParser, + ), qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(), content: @Composable () -> Unit, ) { @@ -103,6 +112,7 @@ fun LocalManagerProvider( LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager, LocalCredentialExchangeRequestValidator provides credentialExchangeRequestValidator, LocalAuthTabLaunchers provides authTabLaunchers, + LocalCardTextAnalyzer provides cardTextAnalyzer, LocalQrCodeAnalyzer provides qrCodeAnalyzer, content = content, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt new file mode 100644 index 00000000000..4beef3488d6 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions +import kotlinx.serialization.Serializable + +/** + * The type-safe route for the card scan screen. + */ +@Serializable +data object CardScanRoute + +/** + * Add the card scan screen to the nav graph. + */ +fun NavGraphBuilder.cardScanDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions { + CardScanScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the card scan screen. + */ +fun NavController.navigateToCardScanScreen( + navOptions: NavOptions? = null, +) { + this.navigate(route = CardScanRoute, navOptions = navOptions) +} 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 new file mode 100644 index 00000000000..9a698f91569 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreen.kt @@ -0,0 +1,131 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.StatusBarsAppearanceAffect +import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.ui.platform.components.camera.CameraPreview +import com.bitwarden.ui.platform.components.camera.CardScanOverlay +import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer +import com.bitwarden.ui.platform.resource.BitwardenDrawable +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.platform.theme.LocalBitwardenColorScheme +import com.bitwarden.ui.platform.theme.color.darkBitwardenColorScheme + +/** + * The screen to scan credit cards for the application. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CardScanScreen( + onNavigateBack: () -> Unit, + viewModel: CardScanViewModel = hiltViewModel(), + cardTextAnalyzer: CardTextAnalyzer = LocalCardTextAnalyzer.current, +) { + cardTextAnalyzer.onCardScanned = { cardScanData -> + viewModel.trySendAction( + CardScanAction.CardScanReceive(cardScanData = cardScanData), + ) + } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is CardScanEvent.NavigateBack -> onNavigateBack() + } + } + + // This screen should always look like it's in dark mode + CompositionLocalProvider( + LocalBitwardenColorScheme provides darkBitwardenColorScheme, + ) { + StatusBarsAppearanceAffect() + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = BitwardenString.scan_card), + navigationIcon = rememberVectorPainter( + id = BitwardenDrawable.ic_close, + ), + navigationIconContentDescription = stringResource( + id = BitwardenString.close, + ), + onNavigationIconClick = { + viewModel.trySendAction(CardScanAction.CloseClick) + }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + state = rememberTopAppBarState(), + ), + ) + }, + ) { + CameraPreview( + cameraErrorReceive = { + viewModel.trySendAction( + CardScanAction.CameraSetupErrorReceive, + ) + }, + analyzer = cardTextAnalyzer, + modifier = Modifier.fillMaxSize(), + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + CardScanOverlay( + overlayWidth = 300.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), + ) { + 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()) + } + } + } + } +} 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 new file mode 100644 index 00000000000..c0de0e9b0f6 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModel.kt @@ -0,0 +1,96 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.bitwarden.ui.platform.base.BaseViewModel +import com.bitwarden.ui.platform.base.DeferredBackgroundEvent +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * Handles [CardScanAction] and launches [CardScanEvent] for the [CardScanScreen]. + */ +@HiltViewModel +class CardScanViewModel @Inject constructor( + private val cardScanManager: CardScanManager, + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: CardScanState(hasHandledScan = false), +) { + override fun handleAction(action: CardScanAction) { + when (action) { + is CardScanAction.CloseClick -> handleCloseClick() + is CardScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive() + is CardScanAction.CardScanReceive -> handleCardScanReceive(action) + } + } + + private fun handleCloseClick() { + sendEvent(CardScanEvent.NavigateBack) + } + + private fun handleCameraErrorReceive() { + cardScanManager.emitCardScanResult(CardScanResult.ScanError()) + sendEvent(CardScanEvent.NavigateBack) + } + + private fun handleCardScanReceive(action: CardScanAction.CardScanReceive) { + if (state.hasHandledScan) return + mutableStateFlow.update { it.copy(hasHandledScan = true) } + cardScanManager.emitCardScanResult( + CardScanResult.Success(cardScanData = action.cardScanData), + ) + sendEvent(CardScanEvent.NavigateBack) + } +} + +/** + * Models events for the [CardScanScreen]. + */ +sealed class CardScanEvent { + + /** + * Navigate back. Added [DeferredBackgroundEvent] as scan might fire before + * events are consumed. + */ + data object NavigateBack : CardScanEvent(), DeferredBackgroundEvent +} + +/** + * Models actions for the [CardScanScreen]. + */ +sealed class CardScanAction { + + /** + * User clicked close. + */ + data object CloseClick : CardScanAction() + + /** + * A card has been scanned with the detected fields. + */ + data class CardScanReceive( + val cardScanData: CardScanData, + ) : CardScanAction() + + /** + * The camera is unable to be set up. + */ + data object CameraSetupErrorReceive : CardScanAction() +} + +/** + * Represents the state of the card scan screen. + */ +@Parcelize +data class CardScanState( + val hasHandledScan: Boolean, +) : Parcelable diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt deleted file mode 100644 index 7eb94bf8728..00000000000 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/util/CardNumberUtilsTest.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.x8bit.bitwarden.data.vault.util - -import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class CardNumberUtilsTest { - - @Test - fun `detectCardBrand should detect Visa`() { - assertEquals(VaultCardBrand.VISA, "4111111111111111".detectCardBrand()) - assertEquals(VaultCardBrand.VISA, "4012888888881881".detectCardBrand()) - } - - @Test - fun `detectCardBrand should detect Mastercard`() { - assertEquals( - VaultCardBrand.MASTERCARD, - "5500000000000004".detectCardBrand(), - ) - assertEquals( - VaultCardBrand.MASTERCARD, - "5100000000000008".detectCardBrand(), - ) - assertEquals( - VaultCardBrand.MASTERCARD, - "2221000000000009".detectCardBrand(), - ) - } - - @Test - fun `detectCardBrand should detect Amex`() { - assertEquals(VaultCardBrand.AMEX, "378282246310005".detectCardBrand()) - assertEquals(VaultCardBrand.AMEX, "341111111111111".detectCardBrand()) - } - - @Test - fun `detectCardBrand should detect Discover`() { - assertEquals( - VaultCardBrand.DISCOVER, - "6011111111111117".detectCardBrand(), - ) - assertEquals( - VaultCardBrand.DISCOVER, - "6500000000000002".detectCardBrand(), - ) - } - - @Test - fun `detectCardBrand should detect Diners Club`() { - assertEquals( - VaultCardBrand.DINERS_CLUB, - "30569309025904".detectCardBrand(), - ) - assertEquals( - VaultCardBrand.DINERS_CLUB, - "36000000000008".detectCardBrand(), - ) - } - - @Test - fun `detectCardBrand should detect JCB`() { - assertEquals(VaultCardBrand.JCB, "3528000000000007".detectCardBrand()) - assertEquals(VaultCardBrand.JCB, "3589000000000003".detectCardBrand()) - } - - @Test - fun `detectCardBrand should detect Maestro`() { - assertEquals( - VaultCardBrand.MAESTRO, - "5018000000000009".detectCardBrand(), - ) - assertEquals( - VaultCardBrand.MAESTRO, - "6304000000000000".detectCardBrand(), - ) - } - - @Test - fun `detectCardBrand should detect UnionPay`() { - assertEquals( - VaultCardBrand.UNIONPAY, - "6200000000000005".detectCardBrand(), - ) - } - - @Test - fun `detectCardBrand should return OTHER for unknown prefixes`() { - assertEquals( - VaultCardBrand.OTHER, - "9999999999999995".detectCardBrand(), - ) - assertEquals(VaultCardBrand.OTHER, "".detectCardBrand()) - } -} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt index 03eaa7824b0..81eb7c44000 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt @@ -5,6 +5,7 @@ import com.bitwarden.cxf.importer.CredentialExchangeImporter import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator import com.bitwarden.ui.platform.base.BaseComposeTest +import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.bitwarden.ui.platform.manager.IntentManager @@ -49,6 +50,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { credentialExchangeImporter: CredentialExchangeImporter = mockk(), credentialExchangeCompletionManager: CredentialExchangeCompletionManager = mockk(), credentialExchangeRequestValidator: CredentialExchangeRequestValidator = mockk(), + cardTextAnalyzer: CardTextAnalyzer = mockk(), qrCodeAnalyzer: QrCodeAnalyzer = mockk(), test: @Composable () -> Unit, ) { @@ -69,6 +71,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { credentialExchangeImporter = credentialExchangeImporter, credentialExchangeCompletionManager = credentialExchangeCompletionManager, credentialExchangeRequestValidator = credentialExchangeRequestValidator, + cardTextAnalyzer = cardTextAnalyzer, qrCodeAnalyzer = qrCodeAnalyzer, ) { BitwardenTheme( 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 new file mode 100644 index 00000000000..7f55a510f55 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanScreenTest.kt @@ -0,0 +1,71 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +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.mockk +import io.mockk.verify +import junit.framework.TestCase.assertTrue +import org.junit.Before +import org.junit.Test + +class CardScanScreenTest : BitwardenComposeTest() { + + private var onNavigateBackCalled = false + + private val cardTextAnalyzer = FakeCardTextAnalyzer() + + private val mutableEventFlow = bufferedMutableSharedFlow() + + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setup() { + setContent( + cardTextAnalyzer = cardTextAnalyzer, + ) { + CardScanScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `screen should render with close button`() { + composeTestRule + .onNodeWithContentDescription("Close") + .assertIsDisplayed() + } + + @Test + fun `close button click should send CloseClick action`() { + composeTestRule + .onNodeWithContentDescription("Close") + .performClick() + + verify { + viewModel.trySendAction(CardScanAction.CloseClick) + } + } + + @Test + fun `on NavigateBack event should invoke onNavigateBack`() { + mutableEventFlow.tryEmit(CardScanEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `title should display Scan card`() { + composeTestRule + .onNodeWithText("Scan card") + .assertExists() + } +} 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 new file mode 100644 index 00000000000..9d90542c2a9 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanViewModelTest.kt @@ -0,0 +1,120 @@ +package com.x8bit.bitwarden.ui.vault.feature.cardscanner + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.bitwarden.ui.platform.base.BaseViewModelTest +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData +import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CardScanViewModelTest : BaseViewModelTest() { + + private val cardScanManager: CardScanManager = mockk { + every { emitCardScanResult(any()) } just runs + } + + @Test + fun `CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(CardScanAction.CloseClick) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `CameraSetupErrorReceive should emit ScanError and NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(CardScanAction.CameraSetupErrorReceive) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + } + + verify(exactly = 1) { + cardScanManager.emitCardScanResult( + match { it is CardScanResult.ScanError }, + ) + } + } + + @Test + fun `CardScanReceive should emit result and NavigateBack`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction( + CardScanAction.CardScanReceive( + cardScanData = CARD_SCAN_DATA, + ), + ) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + } + + verify(exactly = 1) { + cardScanManager.emitCardScanResult( + CardScanResult.Success(cardScanData = CARD_SCAN_DATA), + ) + } + } + + @Test + fun `CardScanReceive should only handle first scan`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction( + CardScanAction.CardScanReceive( + cardScanData = CARD_SCAN_DATA, + ), + ) + assertEquals(CardScanEvent.NavigateBack, awaitItem()) + + viewModel.trySendAction( + CardScanAction.CardScanReceive( + cardScanData = CARD_SCAN_DATA.copy( + number = "5500000000000004", + ), + ), + ) + expectNoEvents() + } + + verify(exactly = 1) { cardScanManager.emitCardScanResult(any()) } + assertEquals( + DEFAULT_STATE.copy(hasHandledScan = true), + viewModel.stateFlow.value, + ) + } + + private fun createViewModel( + initialState: CardScanState? = null, + ): CardScanViewModel = + CardScanViewModel( + savedStateHandle = SavedStateHandle().apply { + set("state", initialState) + }, + cardScanManager = cardScanManager, + ) +} + +private val DEFAULT_STATE = CardScanState( + hasHandledScan = false, +) + +private val CARD_SCAN_DATA = CardScanData( + number = "4111111111111111", + expirationMonth = "12", + expirationYear = "2025", + cardholderName = "JOHN DOE", + securityCode = "123", +) diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index 0027f238df3..e4a2153c6b8 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -37,6 +37,7 @@ sealed class FlagKey { MigrateMyVaultToMyItems, ArchiveItems, SendEmailVerification, + CardScanner, MobilePremiumUpgrade, AttachmentUpdates, ) @@ -109,6 +110,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false } + /** + * Data object holding the feature flag key for the card scanner feature. + */ + data object CardScanner : FlagKey() { + override val keyName: String = "card-scanner-mobile" + override val defaultValue: Boolean = false + } + /** * Data object holding the feature flag key for the mobile Premium upgrade feature. */ diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt index 2e26e29c19d..0e892640c39 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt @@ -30,6 +30,7 @@ fun FlagKey.ListItemContent( FlagKey.NoLogoutOnKdfChange, FlagKey.MigrateMyVaultToMyItems, FlagKey.ArchiveItems, + FlagKey.CardScanner, FlagKey.SendEmailVerification, FlagKey.MobilePremiumUpgrade, FlagKey.AttachmentUpdates, @@ -84,6 +85,7 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.MigrateMyVaultToMyItems -> stringResource(BitwardenString.migrate_my_vault_to_my_items) FlagKey.ArchiveItems -> stringResource(BitwardenString.archive_items) + FlagKey.CardScanner -> stringResource(BitwardenString.scan_card) FlagKey.SendEmailVerification -> stringResource(BitwardenString.send_email_verification) FlagKey.MobilePremiumUpgrade -> stringResource(BitwardenString.mobile_premium_upgrade) FlagKey.AttachmentUpdates -> stringResource(BitwardenString.attachment_updates) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManager.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManager.kt new file mode 100644 index 00000000000..5adf2b9a136 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManager.kt @@ -0,0 +1,21 @@ +package com.bitwarden.ui.platform.feature.cardscanner.manager + +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import kotlinx.coroutines.flow.Flow + +/** + * Manages the communication of credit card scan results between the card scanner + * screen and the vault add/edit screen. + */ +interface CardScanManager { + + /** + * Flow that emits card scan results. + */ + val cardScanResultFlow: Flow + + /** + * Emits a [CardScanResult] to all active subscribers. + */ + fun emitCardScanResult(cardScanResult: CardScanResult) +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerImpl.kt new file mode 100644 index 00000000000..1ee8ab062e5 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerImpl.kt @@ -0,0 +1,22 @@ +package com.bitwarden.ui.platform.feature.cardscanner.manager + +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Primary implementation of [CardScanManager]. + */ +class CardScanManagerImpl : CardScanManager { + + private val mutableCardScanResultFlow = + bufferedMutableSharedFlow() + + override val cardScanResultFlow: Flow + get() = mutableCardScanResultFlow.asSharedFlow() + + override fun emitCardScanResult(cardScanResult: CardScanResult) { + mutableCardScanResultFlow.tryEmit(cardScanResult) + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanResult.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanResult.kt new file mode 100644 index 00000000000..a473b3563b5 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardScanResult.kt @@ -0,0 +1,19 @@ +package com.bitwarden.ui.platform.feature.cardscanner.util + +/** + * Models result of the user scanning a credit card. + */ +sealed class CardScanResult { + + /** + * Card has been successfully scanned with the detected fields. + * + * @property cardScanData The scanned card data. + */ + data class Success(val cardScanData: CardScanData) : CardScanResult() + + /** + * There was an error scanning the card. + */ + data class ScanError(val error: Throwable? = null) : CardScanResult() +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt new file mode 100644 index 00000000000..e89de2579db --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt @@ -0,0 +1,41 @@ +package com.bitwarden.ui.platform.feature.cardscanner.manager + +import app.cash.turbine.test +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData +import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CardScanManagerTest { + + private val manager = CardScanManagerImpl() + + @Test + fun `emitCardScanResult should emit Success to cardScanResultFlow`() = + runTest { + manager.cardScanResultFlow.test { + val expected = CardScanResult.Success( + cardScanData = CardScanData( + number = "4111111111111111", + expirationMonth = "12", + expirationYear = "2025", + cardholderName = "JOHN DOE", + securityCode = "123", + ), + ) + manager.emitCardScanResult(expected) + assertEquals(expected, awaitItem()) + } + } + + @Test + fun `emitCardScanResult should emit ScanError to cardScanResultFlow`() = + runTest { + manager.cardScanResultFlow.test { + val expected = CardScanResult.ScanError() + manager.emitCardScanResult(expected) + assertEquals(expected, awaitItem()) + } + } +} From 1d7dde7f0c2873c747f2fd51e4c54d3c0b981174 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Wed, 1 Apr 2026 14:10:33 -0400 Subject: [PATCH 5/8] Address code review feedback for card scan screen Remove stale cardholderName parameter from test data, simplify ScanError verify to use direct value instead of match lambda, and fix import ordering. --- .../ui/platform/composition/LocalManagerProvider.kt | 2 +- .../ui/vault/feature/cardscanner/CardScanViewModel.kt | 2 +- .../ui/vault/feature/cardscanner/CardScanViewModelTest.kt | 7 ++----- .../feature/cardscanner/manager/CardScanManagerTest.kt | 1 - 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index fb17d32b77c..8df5b79302a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -22,9 +22,9 @@ import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeRequestValidator import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator import com.bitwarden.cxf.validator.dsl.credentialExchangeRequestValidator +import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer import com.bitwarden.ui.platform.composition.LocalExitManager import com.bitwarden.ui.platform.composition.LocalIntentManager -import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer import com.bitwarden.ui.platform.composition.LocalQrCodeAnalyzer import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParser import com.bitwarden.ui.platform.feature.cardscanner.util.CardDataParserImpl 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 c0de0e9b0f6..faa98bffaca 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 @@ -4,8 +4,8 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.base.DeferredBackgroundEvent -import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData 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.flow.update 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 9d90542c2a9..c1cac8c78ab 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 @@ -3,8 +3,8 @@ package com.x8bit.bitwarden.ui.vault.feature.cardscanner import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.ui.platform.base.BaseViewModelTest -import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData 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 io.mockk.every import io.mockk.just @@ -41,9 +41,7 @@ class CardScanViewModelTest : BaseViewModelTest() { } verify(exactly = 1) { - cardScanManager.emitCardScanResult( - match { it is CardScanResult.ScanError }, - ) + cardScanManager.emitCardScanResult(CardScanResult.ScanError()) } } @@ -115,6 +113,5 @@ private val CARD_SCAN_DATA = CardScanData( number = "4111111111111111", expirationMonth = "12", expirationYear = "2025", - cardholderName = "JOHN DOE", securityCode = "123", ) diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt index e89de2579db..ddfd7dfa9e2 100644 --- a/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/feature/cardscanner/manager/CardScanManagerTest.kt @@ -20,7 +20,6 @@ class CardScanManagerTest { number = "4111111111111111", expirationMonth = "12", expirationYear = "2025", - cardholderName = "JOHN DOE", securityCode = "123", ), ) From 167de2c0aecc52f5d85c0f64597fdb3f76097ab3 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Thu, 2 Apr 2026 13:22:23 -0400 Subject: [PATCH 6/8] Simplify CardScanViewModel state and update flag key Move hasHandledScan from Parcelable state to a private var since it is internal bookkeeping, not UI state. Update flag key name to pm-34171-card-scanner. Add OmitFromCoverage to navigation, fix minor formatting. --- .../feature/cardscanner/CardScanNavigation.kt | 4 ++++ .../feature/cardscanner/CardScanScreen.kt | 7 +------ .../feature/cardscanner/CardScanViewModel.kt | 18 ++++++------------ .../cardscanner/CardScanViewModelTest.kt | 16 +--------------- .../core/data/manager/model/FlagKey.kt | 2 +- 5 files changed, 13 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt index 4beef3488d6..29b96446a9a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/cardscanner/CardScanNavigation.kt @@ -1,14 +1,18 @@ +@file:OmitFromCoverage + package com.x8bit.bitwarden.ui.vault.feature.cardscanner import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions import kotlinx.serialization.Serializable /** * The type-safe route for the card scan screen. */ +@OmitFromCoverage @Serializable data object CardScanRoute 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 9a698f91569..b527ba07725 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 @@ -106,12 +106,7 @@ fun CardScanScreen( modifier = Modifier .weight(1f) .fillMaxSize() - .background( - color = BitwardenTheme - .colorScheme - .background - .scrim, - ) + .background(color = BitwardenTheme.colorScheme.background.scrim) .padding(horizontal = 16.dp), ) { Text( 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 faa98bffaca..d51bb9afe88 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 @@ -1,30 +1,26 @@ package com.x8bit.bitwarden.ui.vault.feature.cardscanner import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle 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.flow.update import kotlinx.parcelize.Parcelize import javax.inject.Inject -private const val KEY_STATE = "state" - /** * Handles [CardScanAction] and launches [CardScanEvent] for the [CardScanScreen]. */ @HiltViewModel class CardScanViewModel @Inject constructor( private val cardScanManager: CardScanManager, - savedStateHandle: SavedStateHandle, ) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] - ?: CardScanState(hasHandledScan = false), + initialState = CardScanState, ) { + private var hasHandledScan = false + override fun handleAction(action: CardScanAction) { when (action) { is CardScanAction.CloseClick -> handleCloseClick() @@ -43,8 +39,8 @@ class CardScanViewModel @Inject constructor( } private fun handleCardScanReceive(action: CardScanAction.CardScanReceive) { - if (state.hasHandledScan) return - mutableStateFlow.update { it.copy(hasHandledScan = true) } + if (hasHandledScan) return + hasHandledScan = true cardScanManager.emitCardScanResult( CardScanResult.Success(cardScanData = action.cardScanData), ) @@ -91,6 +87,4 @@ sealed class CardScanAction { * Represents the state of the card scan screen. */ @Parcelize -data class CardScanState( - val hasHandledScan: Boolean, -) : Parcelable +data object CardScanState : Parcelable 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 c1cac8c78ab..c61e346f992 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 @@ -1,6 +1,5 @@ package com.x8bit.bitwarden.ui.vault.feature.cardscanner -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager @@ -88,27 +87,14 @@ class CardScanViewModelTest : BaseViewModelTest() { } verify(exactly = 1) { cardScanManager.emitCardScanResult(any()) } - assertEquals( - DEFAULT_STATE.copy(hasHandledScan = true), - viewModel.stateFlow.value, - ) } - private fun createViewModel( - initialState: CardScanState? = null, - ): CardScanViewModel = + private fun createViewModel(): CardScanViewModel = CardScanViewModel( - savedStateHandle = SavedStateHandle().apply { - set("state", initialState) - }, cardScanManager = cardScanManager, ) } -private val DEFAULT_STATE = CardScanState( - hasHandledScan = false, -) - private val CARD_SCAN_DATA = CardScanData( number = "4111111111111111", expirationMonth = "12", diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index e4a2153c6b8..d8e56071c9f 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -114,7 +114,7 @@ sealed class FlagKey { * Data object holding the feature flag key for the card scanner feature. */ data object CardScanner : FlagKey() { - override val keyName: String = "card-scanner-mobile" + override val keyName: String = "pm-34171-card-scanner" override val defaultValue: Boolean = false } From d076c373328fa630c5b8e276e696f35c101e7fe4 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Tue, 7 Apr 2026 07:23:13 -0400 Subject: [PATCH 7/8] Move hasHandledScan back into CardScanState --- .../feature/cardscanner/CardScanViewModel.kt | 17 ++++++++++++----- .../cardscanner/CardScanViewModelTest.kt | 2 ++ 2 files changed, 14 insertions(+), 5 deletions(-) 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 d51bb9afe88..1bbcd7a02c0 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 @@ -1,25 +1,30 @@ package com.x8bit.bitwarden.ui.vault.feature.cardscanner import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle 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.flow.update import kotlinx.parcelize.Parcelize import javax.inject.Inject +private const val KEY_STATE = "state" + /** * Handles [CardScanAction] and launches [CardScanEvent] for the [CardScanScreen]. */ @HiltViewModel class CardScanViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, private val cardScanManager: CardScanManager, ) : BaseViewModel( - initialState = CardScanState, + initialState = savedStateHandle[KEY_STATE] + ?: CardScanState(hasHandledScan = false), ) { - private var hasHandledScan = false override fun handleAction(action: CardScanAction) { when (action) { @@ -39,8 +44,8 @@ class CardScanViewModel @Inject constructor( } private fun handleCardScanReceive(action: CardScanAction.CardScanReceive) { - if (hasHandledScan) return - hasHandledScan = true + if (state.hasHandledScan) return + mutableStateFlow.update { it.copy(hasHandledScan = true) } cardScanManager.emitCardScanResult( CardScanResult.Success(cardScanData = action.cardScanData), ) @@ -87,4 +92,6 @@ sealed class CardScanAction { * Represents the state of the card scan screen. */ @Parcelize -data object CardScanState : Parcelable +data class CardScanState( + val hasHandledScan: Boolean, +) : Parcelable 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 c61e346f992..a4d6e5c6ee0 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.cardscanner +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.ui.platform.base.BaseViewModelTest import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager @@ -91,6 +92,7 @@ class CardScanViewModelTest : BaseViewModelTest() { private fun createViewModel(): CardScanViewModel = CardScanViewModel( + savedStateHandle = SavedStateHandle(), cardScanManager = cardScanManager, ) } From 82b689abb0fb444b76f5627d21c5705b0d0e0816 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen Date: Tue, 7 Apr 2026 15:17:26 -0400 Subject: [PATCH 8/8] Add window-size-based layout switching to CardScanScreen --- .../feature/cardscanner/CardScanScreen.kt | 111 +++++++++++++----- .../feature/cardscanner/CardScanScreenTest.kt | 9 ++ 2 files changed, 93 insertions(+), 27 deletions(-) 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 b527ba07725..d72a15cc108 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 @@ -3,10 +3,13 @@ package com.x8bit.bitwarden.ui.vault.feature.cardscanner import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement 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.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -28,11 +31,13 @@ import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer +import com.bitwarden.ui.platform.model.WindowSize import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.platform.theme.LocalBitwardenColorScheme import com.bitwarden.ui.platform.theme.color.darkBitwardenColorScheme +import com.bitwarden.ui.platform.util.rememberWindowSize /** * The screen to scan credit cards for the application. @@ -91,36 +96,88 @@ fun CardScanScreen( analyzer = cardTextAnalyzer, modifier = Modifier.fillMaxSize(), ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize(), - ) { - CardScanOverlay( - overlayWidth = 300.dp, - modifier = Modifier.weight(2f), - ) + when (rememberWindowSize()) { + WindowSize.Compact -> { + CardScanContentCompact() + } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceAround, - modifier = Modifier - .weight(1f) - .fillMaxSize() - .background(color = BitwardenTheme.colorScheme.background.scrim) - .padding(horizontal = 16.dp), - ) { - 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()) + WindowSize.Medium -> { + CardScanContentMedium() } } } } } + +@Composable +private fun CardScanContentCompact( + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + CardScanOverlay( + overlayWidth = 300.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) + .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) + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource( + id = BitwardenString.scan_card_instruction, + ), + textAlign = TextAlign.Center, + color = BitwardenTheme.colorScheme.text.primary, + style = BitwardenTheme.typography.bodySmall, + ) + } + } +} 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 7f55a510f55..df9a95d9409 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 @@ -13,6 +13,7 @@ import io.mockk.verify import junit.framework.TestCase.assertTrue import org.junit.Before import org.junit.Test +import org.robolectric.annotation.Config class CardScanScreenTest : BitwardenComposeTest() { @@ -68,4 +69,12 @@ class CardScanScreenTest : BitwardenComposeTest() { .onNodeWithText("Scan card") .assertExists() } + + @Config(qualifiers = "land") + @Test + fun `instruction text should display in landscape mode`() { + composeTestRule + .onNodeWithText("Position your card within the frame to scan it.") + .assertIsDisplayed() + } }