diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index f03c598ba2..0e8eda5e22 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,6 +51,7 @@ import com.getcode.media.StaticImageResult import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.Black50 import com.getcode.theme.CodeTheme +import com.getcode.ui.components.toast.LocalFloatingToastHost import com.getcode.ui.biometrics.LocalBiometricsState import com.getcode.ui.core.noRippleClickable import com.getcode.ui.scanner.CodeScanner @@ -92,6 +94,8 @@ internal fun Scanner() { val vibrator = LocalVibrator.current val scope = rememberCoroutineScope() + val toasts = LocalFloatingToastHost.current + var frontCameraNoticeShown by rememberSaveable { mutableStateOf(false) } // Built by hand for the same reason `rememberMultiCodeAnalyzer` builds the camera's: nothing // binds these types, and neither the module that declares them nor `:ui:scanner` is @@ -286,11 +290,28 @@ internal fun Scanner() { } } }, + onFrontCameraFallback = { + // Once per scanner, not on every rebind: the preview already shows which + // camera is in use, this only says why. + if (!frontCameraNoticeShown && toasts != null) { + frontCameraNoticeShown = true + scope.launch { + toasts.show( + message = resources.getString(R.string.subtitle_usingFrontCamera), + hugContent = true, + passThrough = true, + ) + } + } + }, onError = { + // A device without a usable camera is an expected state, not an error + // to report. if (it is NoCamerasAvailableException) { cameraAvailable = false + } else { + ErrorUtils.handleError(it) } - ErrorUtils.handleError(it) } ) }, diff --git a/ui/resources/src/main/res/values/strings.xml b/ui/resources/src/main/res/values/strings.xml index 596d567b96..3474b53bfb 100644 --- a/ui/resources/src/main/res/values/strings.xml +++ b/ui/resources/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + No back camera found. Using your front camera Payment Failed This payment request could not be paid at this time. Please try again later. diff --git a/ui/scanner/build.gradle.kts b/ui/scanner/build.gradle.kts index c0ff47879f..fb883592f2 100644 --- a/ui/scanner/build.gradle.kts +++ b/ui/scanner/build.gradle.kts @@ -30,4 +30,7 @@ dependencies { implementation(libs.compose.animation) implementation(libs.compose.material) + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + } diff --git a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt index fdd8cb4c33..cb7876e4f8 100644 --- a/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt +++ b/ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt @@ -67,6 +67,8 @@ fun CodeScanner( onPreviewStateChanged: (Boolean) -> Unit, onCodeScanned: (CodeScanResult) -> Unit, onError: (Throwable) -> Unit = { }, + // Called when the device has no back camera and the scanner bound the front one instead. + onFrontCameraFallback: () -> Unit = { }, // Surfaces the underlying PreviewView so callers can snapshot the live feed (e.g. to render a // blurred camera backdrop behind an overlay). Non-null while mounted, null on dispose. onPreviewViewChanged: (PreviewView?) -> Unit = { }, @@ -89,13 +91,6 @@ fun CodeScanner( Preview.Builder().build().apply { surfaceProvider = previewView.surfaceProvider } } - val cameraSelector = remember { - val lensFacing = CameraSelector.LENS_FACING_BACK - CameraSelector.Builder() - .requireLensFacing(lensFacing) - .build() - } - // Ask for the analysis stream through ResolutionSelector rather than the deprecated // setTargetResolution. The old API is only a hint, and on a 4:3 sensor it resolves a 1920x1080 // request down to a 720x720 square -- which costs range twice over. A square crop keeps the @@ -150,11 +145,11 @@ fun CodeScanner( cameraProvider.bindToLifecycleSafely( context, lifecycleOwner, - cameraSelector, preview, imageAnalysis ).onSuccess { camera = it + if (it.cameraInfo.lensFacing == CameraSelector.LENS_FACING_FRONT) onFrontCameraFallback() }.onFailure { onError(it) } } else { cameraProvider.unbindAll() @@ -187,11 +182,13 @@ fun CodeScanner( cameraProvider.bindToLifecycleSafely( context, lifecycleOwner, - cameraSelector, preview, imageAnalysis ).onSuccess { camera = it + if (it.cameraInfo.lensFacing == CameraSelector.LENS_FACING_FRONT) { + onFrontCameraFallback() + } }.onFailure { onError(it) } } } @@ -289,16 +286,22 @@ private suspend fun Context.getCameraProvider(): ProcessCameraProvider { private suspend fun ProcessCameraProvider.bindToLifecycleSafely( context: Context, lifecycleOwner: LifecycleOwner, - cameraSelector: CameraSelector, preview: Preview, imageAnalysis: ImageAnalysis, retries: Int = 3, ): Result { val hasCamera = context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) if (!hasCamera) { - return Result.failure(IllegalStateException("No camera available on this device")) + return Result.failure(NoCamerasAvailableException()) } + // Some devices expose no back camera to CameraX -- an Infinix X688B lists a single camera and + // reports it as front facing -- so requiring LENS_FACING_BACK fails every bind there. Prefer + // the back camera and fall back to whatever the device has; a code held up to a front camera + // still scans. + val cameraSelector = pickScannerCamera(availableCameraInfos) { it.lensFacing }?.cameraSelector + ?: return Result.failure(NoCamerasAvailableException()) + return runCatching { bindWithRetry(retries) { bindToLifecycle( @@ -319,11 +322,23 @@ suspend fun bindWithRetry( try { return bind() } catch (e: Exception) { - if (attempt == retries - 1) throw e + // CameraX throws IllegalArgumentException when no camera matches the selector or the + // use cases can't be combined. Neither changes between attempts, so retrying only + // delays the failure. + if (e is IllegalArgumentException || attempt == retries - 1) throw e delay(1000) } } throw NoCamerasAvailableException() } +/** + * The back camera when there is one, otherwise the first camera the device exposes, or null when + * it exposes none. + */ +internal fun pickScannerCamera(cameras: List, lensFacing: (T) -> Int): T? { + return cameras.firstOrNull { lensFacing(it) == CameraSelector.LENS_FACING_BACK } + ?: cameras.firstOrNull() +} + class NoCamerasAvailableException : Throwable() \ No newline at end of file diff --git a/ui/scanner/src/test/kotlin/com/getcode/ui/scanner/ScannerCameraSelectionTest.kt b/ui/scanner/src/test/kotlin/com/getcode/ui/scanner/ScannerCameraSelectionTest.kt new file mode 100644 index 0000000000..f7b065e5f6 --- /dev/null +++ b/ui/scanner/src/test/kotlin/com/getcode/ui/scanner/ScannerCameraSelectionTest.kt @@ -0,0 +1,62 @@ +package com.getcode.ui.scanner + +import androidx.camera.core.CameraSelector +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class ScannerCameraSelectionTest { + + private data class FakeCamera(val id: String, val lensFacing: Int) + + private val front = FakeCamera("front", CameraSelector.LENS_FACING_FRONT) + private val back = FakeCamera("back", CameraSelector.LENS_FACING_BACK) + private val external = FakeCamera("external", CameraSelector.LENS_FACING_EXTERNAL) + + @Test + fun picksTheBackCameraWhenThereIsOne() { + assertEquals(back, pickScannerCamera(listOf(front, back)) { it.lensFacing }) + } + + @Test + fun fallsBackToTheOnlyFrontCamera() { + // Infinix X688B: CameraX lists one camera, id 0, reporting LENS_FACING_FRONT. + assertEquals(front, pickScannerCamera(listOf(front)) { it.lensFacing }) + } + + @Test + fun fallsBackToTheFirstCameraWithoutABackOne() { + assertEquals(external, pickScannerCamera(listOf(external, front)) { it.lensFacing }) + } + + @Test + fun picksNothingWithoutCameras() { + assertNull(pickScannerCamera(emptyList()) { it.lensFacing }) + } + + @Test + fun bindWithRetryDoesNotRetryAnUnmatchedSelector() = runTest { + var attempts = 0 + assertFailsWith { + bindWithRetry(retries = 3) { + attempts++ + throw IllegalArgumentException("No available camera can be found") + } + } + assertEquals(1, attempts) + } + + @Test + fun bindWithRetryRetriesOtherFailures() = runTest { + var attempts = 0 + assertFailsWith { + bindWithRetry(retries = 3) { + attempts++ + throw IllegalStateException("camera in use") + } + } + assertEquals(3, attempts) + } +}