mirror of
https://github.com/bitwarden/android.git
synced 2025-12-05 18:46:49 -06:00
PM-28408: Update CameraPreview composable to address flakey test (#6178)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -100,6 +100,7 @@ fun QrCodeScanScreen(
|
||||
{ viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) }
|
||||
},
|
||||
qrCodeAnalyzer = qrCodeAnalyzer,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
when (rememberWindowSize()) {
|
||||
WindowSize.Compact -> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -114,6 +114,7 @@ fun QrCodeScanScreen(
|
||||
{ viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) }
|
||||
},
|
||||
qrCodeAnalyzer = qrCodeAnalyzer,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
|
||||
if (LocalConfiguration.current.isPortrait) {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user