diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf9e104b13..5602524ec0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -294,10 +294,11 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) // Standard-specific flavor dependencies - standardImplementation(libs.google.firebase.cloud.messaging) - standardImplementation(platform(libs.google.firebase.bom)) - standardImplementation(libs.google.firebase.crashlytics) standardImplementation(libs.google.billing) + standardImplementation(platform(libs.google.firebase.bom)) + standardImplementation(libs.google.firebase.cloud.messaging) + standardImplementation(libs.google.firebase.crashlytics) + standardImplementation(libs.google.mlkit.text.recognition) standardImplementation(libs.google.play.review) // Pull in test fixtures from other modules diff --git a/app/src/fdroid/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt b/app/src/fdroid/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt new file mode 100644 index 0000000000..4c726c662c --- /dev/null +++ b/app/src/fdroid/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt @@ -0,0 +1,27 @@ +package com.bitwarden.ui.platform.feature.cardscanner.util + +import androidx.camera.core.ImageProxy +import com.bitwarden.annotation.OmitFromCoverage + +/** + * No-op [CardTextAnalyzer] for the F-Droid build flavor. + * + * Google ML Kit is not permitted in F-Droid releases, so this stub replaces the + * standard analyzer at build time. The Scan Card UI is hidden via + * `BuildInfoManager.isFdroid`; this implementation exists solely to satisfy the + * flavor-uniform construction path used by `LocalManagerProvider`. The + * `cardDataParser` argument is unused, retained so the constructor signature + * matches the standard flavor and call sites remain identical. + */ +@OmitFromCoverage +@Suppress("UnusedParameter") +class CardTextAnalyzerImpl( + cardDataParser: CardDataParser, +) : CardTextAnalyzer { + + override lateinit var onCardScanned: (CardScanData) -> Unit + + override fun analyze(image: ImageProxy) { + image.close() + } +} 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 81c836b543..85129d255e 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 @@ -5,6 +5,7 @@ import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.provider.CallingAppInfo import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.data.manager.BuildInfoManager import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.model.DataState @@ -128,6 +129,7 @@ class VaultAddEditViewModel @Inject constructor( featureFlagManager: FeatureFlagManager, generatorRepository: GeneratorRepository, cardScanManager: CardScanManager, + private val buildInfoManager: BuildInfoManager, private val snackbarRelayManager: SnackbarRelayManager, private val toastManager: ToastManager, private val authRepository: AuthRepository, @@ -185,7 +187,8 @@ class VaultAddEditViewModel @Inject constructor( } VaultAddEditState( - isCardScannerEnabled = featureFlagManager.getFeatureFlag(FlagKey.CardScanner), + isCardScannerEnabled = featureFlagManager + .getFeatureFlag(FlagKey.CardScanner) && !buildInfoManager.isFdroid, vaultAddEditType = vaultAddEditType, cipherType = vaultCipherType, viewState = when (vaultAddEditType) { @@ -1930,7 +1933,9 @@ class VaultAddEditViewModel @Inject constructor( private fun handleCardScannerFlagUpdateReceive( action: VaultAddEditAction.Internal.CardScannerFlagUpdateReceive, ) { - mutableStateFlow.update { it.copy(isCardScannerEnabled = action.isEnabled) } + mutableStateFlow.update { + it.copy(isCardScannerEnabled = action.isEnabled && !buildInfoManager.isFdroid) + } } private fun handleCardScanResultReceive( diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt b/app/src/standard/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt similarity index 89% rename from ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt rename to app/src/standard/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt index c1489dfe87..e4551f9529 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt +++ b/app/src/standard/kotlin/com/bitwarden/ui/platform/feature/cardscanner/util/CardTextAnalyzerImpl.kt @@ -13,24 +13,13 @@ import com.google.mlkit.vision.text.TextRecognition import com.google.mlkit.vision.text.latin.TextRecognizerOptions import java.util.concurrent.atomic.AtomicBoolean -/** - * 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. * + * Only used in the standard build flavor. The F-Droid flavor provides a no-op stub because + * Google ML Kit is not permitted in F-Droid builds. + * * 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. @@ -49,9 +38,10 @@ class CardTextAnalyzerImpl( private val isInAnalysis = AtomicBoolean(false) - private val recognizer = TextRecognition.getClient( - TextRecognizerOptions.DEFAULT_OPTIONS, - ) + // Lazy so ML Kit is only touched once a scan begins, never during construction. + private val recognizer by lazy { + TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + } private val voteBuffer = PanVoteBuffer() 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 59e63efe20..53121185bb 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 @@ -7,6 +7,7 @@ import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.collections.CollectionView +import com.bitwarden.core.data.manager.BuildInfoManager import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.manager.toast.ToastManager @@ -239,6 +240,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { every { getFeatureFlag(FlagKey.CardScanner) } answers { mutableCardScannerFlow.value } every { getFeatureFlagFlow(FlagKey.CardScanner) } returns mutableCardScannerFlow } + private val buildInfoManager: BuildInfoManager = mockk { + every { isFdroid } returns false + } @BeforeEach fun setup() { @@ -5350,6 +5354,25 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) } + @Test + fun `isCardScannerEnabled should remain false on F-Droid even when flag is on`() = + runTest { + every { buildInfoManager.isFdroid } returns true + mutableCardScannerFlow.value = true + val initState = createVaultAddItemState() + val viewModel = createAddVaultItemViewModel() + assertEquals( + initState.copy(isCardScannerEnabled = false), + viewModel.stateFlow.value, + ) + mutableCardScannerFlow.value = false + mutableCardScannerFlow.value = true + assertEquals( + initState.copy(isCardScannerEnabled = false), + viewModel.stateFlow.value, + ) + } + @Test fun `CardScanResultReceive with Success should update card fields and focus name`() = runTest { @@ -5892,6 +5915,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { savedStateHandle = savedStateHandle, featureFlagManager = featureFlagManager, authRepository = authRepository, + buildInfoManager = buildInfoManager, clipboardManager = bitwardenClipboardManager, cardScanManager = cardScanManager, policyManager = policyManager, diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 8b0616089b..312268ae33 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -76,7 +76,6 @@ dependencies { implementation(libs.androidx.credentials) implementation(libs.androidx.navigation.compose) implementation(libs.bumptech.glide) - implementation(libs.google.mlkit.text.recognition) implementation(libs.kotlinx.serialization) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.collections.immutable) 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 index 793843de8f..6bb33b7e2a 100644 --- 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 @@ -162,7 +162,7 @@ internal fun RecognizedTextLine.isApproximatelyHorizontal( * @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( +fun filterScannedText( recognized: RecognizedText, rawImageWidth: Int, rawImageHeight: Int, 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 index 411567aeac..950f799a94 100644 --- 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 @@ -12,7 +12,7 @@ package com.bitwarden.ui.platform.feature.cardscanner.util * * @property windowSize The maximum number of recent frames retained. */ -internal class ExpiryBuffer( +class ExpiryBuffer( private val windowSize: Int = TEMPORAL_VOTE_WINDOW_SIZE, ) { private val recent = ArrayDeque(windowSize) 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 index 1a22eaee1d..67f9c38766 100644 --- 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 @@ -1,5 +1,19 @@ package com.bitwarden.ui.platform.feature.cardscanner.util +/** + * 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 + /** * 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. @@ -12,7 +26,7 @@ package com.bitwarden.ui.platform.feature.cardscanner.util * @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( +class PanVoteBuffer( private val windowSize: Int = TEMPORAL_VOTE_WINDOW_SIZE, private val voteThreshold: Int = TEMPORAL_VOTE_THRESHOLD, ) { 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 index b5e5771a5f..337a1d68d5 100644 --- 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 @@ -9,7 +9,7 @@ package com.bitwarden.ui.platform.feature.cardscanner.util * pulling in `android.graphics` (whose stub `Rect`/`Point` types would yield zeros under the * `isReturnDefaultValues = true` JVM unit test configuration). */ -internal interface RecognizedText { +interface RecognizedText { /** * The text blocks in the recognized image. */ @@ -19,7 +19,7 @@ internal interface RecognizedText { /** * A single block of recognized text. */ -internal interface RecognizedTextBlock { +interface RecognizedTextBlock { /** * The axis-aligned bounding box of the block in unrotated image coordinates, or `null` if * unavailable. @@ -35,7 +35,7 @@ internal interface RecognizedTextBlock { /** * A single line of recognized text. */ -internal interface RecognizedTextLine { +interface RecognizedTextLine { /** * The recognized text for this line. */ @@ -59,14 +59,14 @@ internal interface RecognizedTextLine { * 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) +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( +data class ImageRect( val left: Int, val top: Int, val right: Int,