mirror of
https://github.com/bitwarden/android.git
synced 2026-03-11 12:44:17 -05: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.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)
|
||||||
|
|||||||
@@ -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 -> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user