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.browser)
implementation(libs.androidx.biometrics) implementation(libs.androidx.biometrics)
implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)

View File

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

View File

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

View File

@@ -114,6 +114,7 @@ fun QrCodeScanScreen(
{ viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) } { viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) }
}, },
qrCodeAnalyzer = qrCodeAnalyzer, qrCodeAnalyzer = qrCodeAnalyzer,
modifier = Modifier.fillMaxSize(),
) )
if (LocalConfiguration.current.isPortrait) { 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-biometrics = { group = "androidx.biometric", name = "biometric", version.ref = "androidxBiometrics" }
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" } 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-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidxCamera" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" } androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-compose-animation = { module = "androidx.compose.animation:animation" } androidx-compose-animation = { module = "androidx.compose.animation:animation" }

View File

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

View File

@@ -1,30 +1,25 @@
package com.bitwarden.ui.platform.components.camera package com.bitwarden.ui.platform.components.camera
import android.content.Context import android.content.Context
import android.view.ViewGroup import androidx.camera.compose.CameraXViewfinder
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.lifecycle.awaitInstance
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer import com.bitwarden.ui.platform.feature.qrcodescan.util.QrCodeAnalyzer
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/** /**
* A composable for displaying the camera preview. * A composable for displaying the camera preview.
@@ -43,66 +38,50 @@ fun CameraPreview(
context: Context = LocalContext.current, context: Context = LocalContext.current,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) { ) {
var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(value = null) } val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val previewView = remember { val preview = rememberPreview { surfaceRequests.value = it }
PreviewView(context).apply { val imageAnalyzer = rememberImageAnalyzer(qrCodeAnalyzer = qrCodeAnalyzer)
scaleType = PreviewView.ScaleType.FILL_CENTER LaunchedEffect(Unit) {
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) {
try { try {
cameraProvider = suspendCoroutine { continuation -> val provider = ProcessCameraProvider.awaitInstance(context = context)
ProcessCameraProvider.getInstance(context).also { future -> provider.unbindAll()
future.addListener( if (provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) {
{ continuation.resume(value = future.get()) }, provider.bindToLifecycle(
ContextCompat.getMainExecutor(context), 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) { } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
cameraErrorReceive(e) cameraErrorReceive(e)
} }
} }
val surfaceRequest by surfaceRequests.collectAsState()
AndroidView( surfaceRequest?.let { request ->
factory = { previewView }, CameraXViewfinder(
modifier = modifier, 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 { class QrCodeAnalyzerImpl : QrCodeAnalyzer {
/** /**
* This will ensure the result is only sent once as multiple images with a valid * This will ensure that only 1 QR code is analyzed at a time.
* QR code can be sent for analysis.
*/ */
private var qrCodeRead = false private var isQrCodeInAnalysis: Boolean = false
override lateinit var onQrCodeScanned: (String) -> Unit override lateinit var onQrCodeScanned: (String) -> Unit
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
if (qrCodeRead) { if (isQrCodeInAnalysis) return
return isQrCodeInAnalysis = true
}
val source = PlanarYUVLuminanceSource( val source = PlanarYUVLuminanceSource(
image.planes[0].buffer.toByteArray(), image.planes[0].buffer.toByteArray(),
@@ -51,12 +49,12 @@ class QrCodeAnalyzerImpl : QrCodeAnalyzer {
), ),
) )
qrCodeRead = true
onQrCodeScanned(result.text) onQrCodeScanned(result.text)
} catch (_: NotFoundException) { } catch (_: NotFoundException) {
return return
} finally { } finally {
image.close() image.close()
isQrCodeInAnalysis = false
} }
} }
} }