mirror of
https://github.com/bitwarden/android.git
synced 2026-08-26 22:04:07 -05:00
[PM-36867] fix: Disable card scanner on F-Droid builds (#6888)
This commit is contained in:
@@ -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
|
||||
|
||||
+27
@@ -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()
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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(
|
||||
|
||||
+7
-17
@@ -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()
|
||||
|
||||
+24
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+15
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+5
-5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user