PM-28408: Update CameraPreview composable to address flakey test (#6178)

This commit is contained in:
David Perez
2025-11-19 09:43:02 -06:00
committed by GitHub
parent 621f97d161
commit 979237b751
8 changed files with 56 additions and 73 deletions

View File

@@ -235,6 +235,7 @@ dependencies {
implementation(libs.androidx.browser)
implementation(libs.androidx.biometrics)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material3)

View File

@@ -100,6 +100,7 @@ fun QrCodeScanScreen(
{ viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) }
},
qrCodeAnalyzer = qrCodeAnalyzer,
modifier = Modifier.fillMaxSize(),
)
when (rememberWindowSize()) {
WindowSize.Compact -> {

View File

@@ -190,6 +190,7 @@ dependencies {
implementation(libs.androidx.browser)
implementation(libs.androidx.biometrics)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material3)

View File

@@ -114,6 +114,7 @@ fun QrCodeScanScreen(
{ viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) }
},
qrCodeAnalyzer = qrCodeAnalyzer,
modifier = Modifier.fillMaxSize(),
)
if (LocalConfiguration.current.isPortrait) {

View File

@@ -67,6 +67,7 @@ androidx-autofill = { group = "androidx.autofill", name = "autofill", version.re
androidx-biometrics = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometrics" }
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" }
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "androidxCamera" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidxCamera" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-compose-animation = { module = "androidx.compose.animation:animation" }

View File

@@ -58,6 +58,7 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.compose)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.compose.animation)

View File

@@ -1,30 +1,25 @@
package com.bitwarden.ui.platform.components.camera
import android.content.Context
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.camera.lifecycle.awaitInstance
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* A composable for displaying the camera preview.
@@ -43,66 +38,50 @@ fun CameraPreview(
context: Context = LocalContext.current,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {
var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(value = null) }
val previewView = remember {
PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
}
val imageAnalyzer = remember(qrCodeAnalyzer) {
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.apply { setAnalyzer(Executors.newSingleThreadExecutor(), qrCodeAnalyzer) }
}
val preview = Preview.Builder()
.build()
.apply { surfaceProvider = previewView.surfaceProvider }
// Unbind from the camera provider when we leave the screen.
DisposableEffect(Unit) {
onDispose {
cameraProvider?.unbindAll()
}
}
// Set up the camera provider on a background thread. This is necessary because
// ProcessCameraProvider.getInstance returns a ListenableFuture. For an example see
// https://github.com/JetBrains/compose-multiplatform/blob/1c7154b975b79901f40f28278895183e476ed36d/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt#L85
LaunchedEffect(imageAnalyzer) {
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val preview = rememberPreview { surfaceRequests.value = it }
val imageAnalyzer = rememberImageAnalyzer(qrCodeAnalyzer = qrCodeAnalyzer)
LaunchedEffect(Unit) {
try {
cameraProvider = suspendCoroutine { continuation ->
ProcessCameraProvider.getInstance(context).also { future ->
future.addListener(
{ continuation.resume(value = future.get()) },
ContextCompat.getMainExecutor(context),
)
}
val provider = ProcessCameraProvider.awaitInstance(context = context)
provider.unbindAll()
if (provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) {
provider.bindToLifecycle(
lifecycleOwner = lifecycleOwner,
cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA,
useCases = arrayOf(preview, imageAnalyzer),
)
} else {
cameraErrorReceive(IllegalStateException("Missing Back Camera"))
}
cameraProvider
?.let {
it.unbindAll()
if (it.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) {
it.bindToLifecycle(
lifecycleOwner = lifecycleOwner,
cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA,
useCases = arrayOf(preview, imageAnalyzer),
)
} else {
cameraErrorReceive(IllegalStateException("Missing Back Camera"))
}
}
?: cameraErrorReceive(IllegalStateException("Missing Camera Provider"))
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
cameraErrorReceive(e)
}
}
AndroidView(
factory = { previewView },
modifier = modifier,
)
val surfaceRequest by surfaceRequests.collectAsState()
surfaceRequest?.let { request ->
CameraXViewfinder(
surfaceRequest = request,
modifier = modifier,
)
}
}
@Composable
private fun rememberImageAnalyzer(
qrCodeAnalyzer: QrCodeAnalyzer,
): ImageAnalysis = remember(qrCodeAnalyzer) {
ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.apply { setAnalyzer(Executors.newSingleThreadExecutor(), qrCodeAnalyzer) }
}
@Composable
private fun rememberPreview(
surfaceCallback: (SurfaceRequest) -> Unit,
): Preview = remember {
Preview.Builder().build().apply {
setSurfaceProvider { request -> surfaceCallback(request) }
}
}

View File

@@ -19,17 +19,15 @@ import java.nio.ByteBuffer
class QrCodeAnalyzerImpl : QrCodeAnalyzer {
/**
* This will ensure the result is only sent once as multiple images with a valid
* QR code can be sent for analysis.
* This will ensure that only 1 QR code is analyzed at a time.
*/
private var qrCodeRead = false
private var isQrCodeInAnalysis: Boolean = false
override lateinit var onQrCodeScanned: (String) -> Unit
override fun analyze(image: ImageProxy) {
if (qrCodeRead) {
return
}
if (isQrCodeInAnalysis) return
isQrCodeInAnalysis = true
val source = PlanarYUVLuminanceSource(
image.planes[0].buffer.toByteArray(),
@@ -51,12 +49,12 @@ class QrCodeAnalyzerImpl : QrCodeAnalyzer {
),
)
qrCodeRead = true
onQrCodeScanned(result.text)
} catch (_: NotFoundException) {
return
} finally {
image.close()
isQrCodeInAnalysis = false
}
}
}