[PM-36867] fix: Disable card scanner on F-Droid builds (#6888)

This commit is contained in:
Patrick Honkonen
2026-05-08 15:25:39 +00:00
committed by GitHub
parent d15744b5e8
commit 710ee64d11
10 changed files with 91 additions and 31 deletions
+4 -3
View File
@@ -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
@@ -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()
}
}
@@ -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<SnackbarRelay>,
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(
@@ -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()
@@ -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,
-1
View File
@@ -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)
@@ -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,
@@ -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<Expiry?>(windowSize)
@@ -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,
) {
@@ -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,