Avoid crash when camera is unavailable (#92)

This commit is contained in:
Pierre-Yves Nicolas
2026-01-15 21:31:05 +01:00
parent 5500fc5175
commit 855dbe75ed
3 changed files with 89 additions and 27 deletions

View File

@@ -16,6 +16,7 @@ package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap import android.graphics.Bitmap
import android.util.Log import android.util.Log
import android.util.Size
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.camera.core.CameraControl import androidx.camera.core.CameraControl
@@ -31,17 +32,24 @@ import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import android.util.Size
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
@@ -49,15 +57,14 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.graphics.scale import androidx.core.graphics.scale
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.common.util.concurrent.ListenableFuture import org.fairscan.app.ui.components.CameraPermissionState
import org.fairscan.imageprocessing.Point import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.scaledTo import org.fairscan.imageprocessing.scaledTo
import org.fairscan.app.ui.components.CameraPermissionState
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -68,6 +75,7 @@ fun CameraPreview(
captureController: CameraCaptureController, captureController: CameraCaptureController,
onPreviewViewReady: (PreviewView) -> Unit, onPreviewViewReady: (PreviewView) -> Unit,
cameraPermission: CameraPermissionState, cameraPermission: CameraPermissionState,
onError: (String, Throwable) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -79,44 +87,87 @@ fun CameraPreview(
val cameraProviderFuture by remember { val cameraProviderFuture by remember {
mutableStateOf(ProcessCameraProvider.getInstance(context)) mutableStateOf(ProcessCameraProvider.getInstance(context))
} }
val analysisExecutor = remember { Executors.newSingleThreadExecutor() }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
onDispose { onDispose {
cameraProviderFuture.get().unbindAll() cameraProviderFuture.get().unbindAll()
analysisExecutor.shutdown()
} }
} }
AndroidView(modifier = modifier, factory = { var bindState by remember { mutableStateOf<CameraBindState>(CameraBindState.Idle) }
val previewView = PreviewView(it).apply { var retryKey by remember { mutableStateOf(0) }
var previewView: PreviewView? by remember { mutableStateOf(null) }
when (bindState) {
is CameraBindState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Camera unavailable")
Spacer(Modifier.height(8.dp))
Button(onClick = { retryKey++ }) {
Text("Retry")
}
}
}
else -> {
AndroidView(
modifier = modifier,
factory = {
PreviewView(it).apply {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
scaleType = PreviewView.ScaleType.FIT_CENTER scaleType = PreviewView.ScaleType.FIT_CENTER
onPreviewViewReady(this) onPreviewViewReady(this)
previewView = this
}
} }
val executor = Executors.newSingleThreadExecutor()
cameraProviderFuture.addListener({
bindCameraUseCases(
lifecycleOwner = lifecycleOwner,
cameraProviderFuture = cameraProviderFuture,
executor = executor,
previewView = previewView,
onImageAnalyzed = onImageAnalyzed,
captureController = captureController
) )
}, ContextCompat.getMainExecutor(context))
previewView LaunchedEffect(previewView, retryKey) {
}) val view = previewView ?: return@LaunchedEffect
val provider = cameraProviderFuture.get()
val result = runCatching {
bindCameraUseCases(
lifecycleOwner,
provider,
analysisExecutor,
view,
onImageAnalyzed,
captureController
)
}
bindState = result.fold(
onSuccess = { CameraBindState.Bound },
onFailure = {
onError("Camera unavailable", it)
CameraBindState.Error(it)
}
)
}
}
}
} }
fun bindCameraUseCases( fun bindCameraUseCases(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
cameraProviderFuture: ListenableFuture<ProcessCameraProvider>, cameraProvider: ProcessCameraProvider,
executor: ExecutorService, executor: ExecutorService,
previewView: PreviewView, previewView: PreviewView,
onImageAnalyzed: (ImageProxy) -> Unit, onImageAnalyzed: (ImageProxy) -> Unit,
captureController: CameraCaptureController, captureController: CameraCaptureController,
) { ) {
cameraProvider.unbindAll()
val ratio_4_3 = ResolutionSelector.Builder() val ratio_4_3 = ResolutionSelector.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
.build() .build()
@@ -149,7 +200,6 @@ fun bindCameraUseCases(
.build() .build()
captureController.imageCapture = imageCapture captureController.imageCapture = imageCapture
val cameraProvider = cameraProviderFuture.get()
val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector,
imageAnalysis, preview, imageCapture) imageAnalysis, preview, imageCapture)
captureController.cameraControl = camera.cameraControl captureController.cameraControl = camera.cameraControl
@@ -236,3 +286,9 @@ class CameraCaptureController {
) )
} }
} }
sealed interface CameraBindState {
object Idle : CameraBindState
object Bound : CameraBindState
data class Error(val throwable: Throwable) : CameraBindState
}

View File

@@ -158,6 +158,7 @@ fun CameraScreen(
captureController = captureController, captureController = captureController,
onPreviewViewReady = { view -> previewView = view }, onPreviewViewReady = { view -> previewView = view },
cameraPermission = cameraPermission, cameraPermission = cameraPermission,
onError = { message, throwable -> cameraViewModel.logError(message, throwable) }
) )
}, },
pageListState = pageListState =

View File

@@ -52,6 +52,7 @@ sealed interface CameraEvent {
class CameraViewModel(appContainer: AppContainer): ViewModel() { class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val imageSegmentationService = appContainer.imageSegmentationService private val imageSegmentationService = appContainer.imageSegmentationService
private val logger = appContainer.logger
private val _events = MutableSharedFlow<CameraEvent>() private val _events = MutableSharedFlow<CameraEvent>()
val events = _events.asSharedFlow() val events = _events.asSharedFlow()
@@ -156,6 +157,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
_captureState.value = CaptureState.Idle _captureState.value = CaptureState.Idle
} }
fun logError(message:String, throwable: Throwable) {
viewModelScope.launch {
logger.e("Camera", message, throwable)
}
}
} }
sealed class CaptureState { sealed class CaptureState {
@@ -203,4 +209,3 @@ fun toBitmap(bgr: Mat): Bitmap {
rgba.release() rgba.release()
return bmp return bmp
} }