Fix Android QR scanner lifecycle

This commit is contained in:
liuyunming521520-cell 2026-09-09 21:08:47 +08:00
parent 945c9e025b
commit d47742f5e7

View File

@ -87,6 +87,8 @@ import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -177,6 +179,7 @@ fun VerificationSheet(
)
1 -> ScanTabContent(
accent = accent,
isCameraActive = selectedTab == 1,
onScan = { code ->
val qr = VerificationService.verifyScannedQR(code)
if (qr != null && viewModel.beginQRVerification(qr)) {
@ -318,9 +321,11 @@ private fun MyQrTabContent(
@Composable
private fun ScanTabContent(
accent: Color,
isCameraActive: Boolean,
onScan: (String) -> Unit
) {
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
var cameraUnavailable by remember { mutableStateOf(false) }
Column(
modifier = Modifier
@ -338,31 +343,54 @@ private fun ScanTabContent(
.background(Color.Black),
contentAlignment = Alignment.Center
) {
ScannerView(onScan = onScan)
// Overlay border
Box(
modifier = Modifier
.size(280.dp)
.border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp))
)
// Corner accents for the overlay
Box(modifier = Modifier.size(260.dp)) {
// This could be drawn with Canvas for cooler effect, but simple border is cleaner for now
if (cameraUnavailable) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.verify_camera_unavailable),
color = Color.White,
fontFamily = BitchatFontFamily,
textAlign = TextAlign.Center
)
Button(
onClick = { cameraUnavailable = false },
colors = ButtonDefaults.buttonColors(containerColor = accent)
) {
Text(
text = stringResource(R.string.verify_retry_camera),
fontFamily = BitchatFontFamily
)
}
}
} else {
if (isCameraActive) {
ScannerView(
onScan = onScan,
onCameraUnavailable = { cameraUnavailable = true }
)
}
Box(
modifier = Modifier
.size(280.dp)
.border(2.dp, accent.copy(alpha = 0.8f), RoundedCornerShape(16.dp))
)
Text(
text = stringResource(R.string.verify_scan_prompt_friend),
color = Color.White,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
Text(
text = stringResource(R.string.verify_scan_prompt_friend),
color = Color.White,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp)
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
} else {
Column(
@ -407,63 +435,112 @@ private fun ScanTabContent(
@Composable
private fun ScannerView(
onScan: (String) -> Unit
onScan: (String) -> Unit,
onCameraUnavailable: () -> Unit
) {
val context = LocalContext.current
val context = LocalContext.current.applicationContext
val lifecycleOwner = LocalLifecycleOwner.current
var lastValid by remember { mutableStateOf<String?>(null) }
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
val cameraProviderFuture = remember(context) {
runCatching { ProcessCameraProvider.getInstance(context) }
.onFailure { error -> Log.w("VerificationSheet", "Unable to initialize QR camera", error) }
.getOrNull()
}
val cameraExecutor: ExecutorService = remember(lifecycleOwner) { Executors.newSingleThreadExecutor() }
val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
val surfaceRequest by surfaceRequests.collectAsState(initial = null)
val mainHandler = remember { Handler(Looper.getMainLooper()) }
val activeSession = remember { AtomicReference<Any?>(null) }
val onCodeState = rememberUpdatedState(onScan)
val analyzer = remember {
QRCodeAnalyzer { text ->
val onCameraUnavailableState = rememberUpdatedState(onCameraUnavailable)
val analyzer = remember(lifecycleOwner) {
QRCodeAnalyzer(
sessionForFrame = { activeSession.get() }
) { session, text ->
mainHandler.post {
if (text == lastValid) return@post
if (activeSession.get() !== session || text == lastValid) return@post
lastValid = text
onCodeState.value(text)
}
}
}
DisposableEffect(Unit) {
DisposableEffect(cameraProviderFuture, lifecycleOwner, cameraExecutor, analyzer) {
val future = cameraProviderFuture
if (future == null) {
onCameraUnavailableState.value()
return@DisposableEffect onDispose {
cameraExecutor.shutdown()
analyzer.close()
}
}
val executor = ContextCompat.getMainExecutor(context)
var cameraProvider: ProcessCameraProvider? = null
var preview: Preview? = null
var analysis: ImageAnalysis? = null
val session = Any()
activeSession.set(session)
cameraProviderFuture.addListener(
fun isCurrentSession() = activeSession.get() === session
future.addListener(
{
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also {
it.setSurfaceProvider { request -> surfaceRequests.value = request }
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, analyzer) }
if (!isCurrentSession()) return@addListener
runCatching {
provider.unbindAll()
try {
val provider = future.get()
if (!isCurrentSession() || !provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) {
if (isCurrentSession()) onCameraUnavailableState.value()
return@addListener
}
val scannerPreview = Preview.Builder().build().also {
it.setSurfaceProvider { request ->
if (isCurrentSession()) {
surfaceRequests.value = request
} else {
request.willNotProvideSurface()
}
}
}
val scannerAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, analyzer) }
cameraProvider = provider
preview = scannerPreview
analysis = scannerAnalysis
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis
scannerPreview,
scannerAnalysis
)
}.onFailure {
Log.w("VerificationSheet", "Failed to bind camera: ${it.message}")
} catch (exception: Exception) {
if (isCurrentSession()) {
Log.e("VerificationSheet", "Unable to start QR camera", exception)
onCameraUnavailableState.value()
}
}
},
executor
)
onDispose {
activeSession.compareAndSet(session, null)
surfaceRequests.value = null
runCatching { cameraProvider?.unbindAll() }
analysis?.clearAnalyzer()
val provider = cameraProvider
val scannerPreview = preview
val scannerAnalysis = analysis
if (provider != null && scannerPreview != null && scannerAnalysis != null) {
runCatching { provider.unbind(scannerPreview, scannerAnalysis) }
}
cameraExecutor.shutdown()
analyzer.close()
}
}
@ -513,26 +590,47 @@ private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
}
private class QRCodeAnalyzer(
private val onCode: (String) -> Unit
private val sessionForFrame: () -> Any?,
private val onCode: (Any, String) -> Unit
) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
)
private val closed = AtomicBoolean(false)
fun close() {
if (closed.compareAndSet(false, true)) {
runCatching { scanner.close() }
}
}
@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
if (closed.get()) {
imageProxy.close()
return
}
val session = sessionForFrame() ?: run {
imageProxy.close()
return
}
val mediaImage = imageProxy.image ?: run {
imageProxy.close()
return
}
val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull()?.rawValue
if (!text.isNullOrBlank()) onCode(text)
}
.addOnCompleteListener { imageProxy.close() }
runCatching {
scanner.process(input)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull()?.rawValue
if (!text.isNullOrBlank()) onCode(session, text)
}
.addOnCompleteListener { imageProxy.close() }
}.onFailure {
imageProxy.close()
}
}
}