Skip to content

Commit 0a86880

Browse files
committed
fix(scanner): fall back to any camera when there is no back camera
The scanner required LENS_FACING_BACK. An Infinix X688B exposes one camera to CameraX and reports it as front facing, so every bind threw IllegalArgumentException("No available camera can be found"), was retried three times a second apart, and left a blank preview (Bugsnag 6aca4823396a2e952015e21d, 9 events on 2026.10.2). Prefer the back camera and otherwise bind the first camera the device exposes. Stop retrying IllegalArgumentException, which doesn't change between attempts. A device with no camera now fails with NoCamerasAvailableException, which shows the unavailable state and is no longer reported as an error.
1 parent 141e59c commit 0a86880

4 files changed

Lines changed: 90 additions & 13 deletions

File tree

‎apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,13 @@ internal fun Scanner() {
287287
}
288288
},
289289
onError = {
290+
// A device without a usable camera is an expected state, not an error
291+
// to report.
290292
if (it is NoCamerasAvailableException) {
291293
cameraAvailable = false
294+
} else {
295+
ErrorUtils.handleError(it)
292296
}
293-
ErrorUtils.handleError(it)
294297
}
295298
)
296299
},

‎ui/scanner/build.gradle.kts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@ dependencies {
3030
implementation(libs.compose.animation)
3131
implementation(libs.compose.material)
3232

33+
testImplementation(kotlin("test"))
34+
testImplementation(libs.kotlinx.coroutines.test)
35+
3336
}

‎ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,6 @@ fun CodeScanner(
8989
Preview.Builder().build().apply { surfaceProvider = previewView.surfaceProvider }
9090
}
9191

92-
val cameraSelector = remember {
93-
val lensFacing = CameraSelector.LENS_FACING_BACK
94-
CameraSelector.Builder()
95-
.requireLensFacing(lensFacing)
96-
.build()
97-
}
98-
9992
// Ask for the analysis stream through ResolutionSelector rather than the deprecated
10093
// setTargetResolution. The old API is only a hint, and on a 4:3 sensor it resolves a 1920x1080
10194
// request down to a 720x720 square -- which costs range twice over. A square crop keeps the
@@ -150,7 +143,6 @@ fun CodeScanner(
150143
cameraProvider.bindToLifecycleSafely(
151144
context,
152145
lifecycleOwner,
153-
cameraSelector,
154146
preview,
155147
imageAnalysis
156148
).onSuccess {
@@ -187,7 +179,6 @@ fun CodeScanner(
187179
cameraProvider.bindToLifecycleSafely(
188180
context,
189181
lifecycleOwner,
190-
cameraSelector,
191182
preview,
192183
imageAnalysis
193184
).onSuccess {
@@ -289,16 +280,22 @@ private suspend fun Context.getCameraProvider(): ProcessCameraProvider {
289280
private suspend fun ProcessCameraProvider.bindToLifecycleSafely(
290281
context: Context,
291282
lifecycleOwner: LifecycleOwner,
292-
cameraSelector: CameraSelector,
293283
preview: Preview,
294284
imageAnalysis: ImageAnalysis,
295285
retries: Int = 3,
296286
): Result<Camera> {
297287
val hasCamera = context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
298288
if (!hasCamera) {
299-
return Result.failure(IllegalStateException("No camera available on this device"))
289+
return Result.failure(NoCamerasAvailableException())
300290
}
301291

292+
// Some devices expose no back camera to CameraX -- an Infinix X688B lists a single camera and
293+
// reports it as front facing -- so requiring LENS_FACING_BACK fails every bind there. Prefer
294+
// the back camera and fall back to whatever the device has; a code held up to a front camera
295+
// still scans.
296+
val cameraSelector = pickScannerCamera(availableCameraInfos) { it.lensFacing }?.cameraSelector
297+
?: return Result.failure(NoCamerasAvailableException())
298+
302299
return runCatching {
303300
bindWithRetry(retries) {
304301
bindToLifecycle(
@@ -319,11 +316,23 @@ suspend fun bindWithRetry(
319316
try {
320317
return bind()
321318
} catch (e: Exception) {
322-
if (attempt == retries - 1) throw e
319+
// CameraX throws IllegalArgumentException when no camera matches the selector or the
320+
// use cases can't be combined. Neither changes between attempts, so retrying only
321+
// delays the failure.
322+
if (e is IllegalArgumentException || attempt == retries - 1) throw e
323323
delay(1000)
324324
}
325325
}
326326
throw NoCamerasAvailableException()
327327
}
328328

329+
/**
330+
* The back camera when there is one, otherwise the first camera the device exposes, or null when
331+
* it exposes none.
332+
*/
333+
internal fun <T> pickScannerCamera(cameras: List<T>, lensFacing: (T) -> Int): T? {
334+
return cameras.firstOrNull { lensFacing(it) == CameraSelector.LENS_FACING_BACK }
335+
?: cameras.firstOrNull()
336+
}
337+
329338
class NoCamerasAvailableException : Throwable()
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.getcode.ui.scanner
2+
3+
import androidx.camera.core.CameraSelector
4+
import kotlinx.coroutines.test.runTest
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertFailsWith
8+
import kotlin.test.assertNull
9+
10+
class ScannerCameraSelectionTest {
11+
12+
private data class FakeCamera(val id: String, val lensFacing: Int)
13+
14+
private val front = FakeCamera("front", CameraSelector.LENS_FACING_FRONT)
15+
private val back = FakeCamera("back", CameraSelector.LENS_FACING_BACK)
16+
private val external = FakeCamera("external", CameraSelector.LENS_FACING_EXTERNAL)
17+
18+
@Test
19+
fun picksTheBackCameraWhenThereIsOne() {
20+
assertEquals(back, pickScannerCamera(listOf(front, back)) { it.lensFacing })
21+
}
22+
23+
@Test
24+
fun fallsBackToTheOnlyFrontCamera() {
25+
// Infinix X688B: CameraX lists one camera, id 0, reporting LENS_FACING_FRONT.
26+
assertEquals(front, pickScannerCamera(listOf(front)) { it.lensFacing })
27+
}
28+
29+
@Test
30+
fun fallsBackToTheFirstCameraWithoutABackOne() {
31+
assertEquals(external, pickScannerCamera(listOf(external, front)) { it.lensFacing })
32+
}
33+
34+
@Test
35+
fun picksNothingWithoutCameras() {
36+
assertNull(pickScannerCamera(emptyList<FakeCamera>()) { it.lensFacing })
37+
}
38+
39+
@Test
40+
fun bindWithRetryDoesNotRetryAnUnmatchedSelector() = runTest {
41+
var attempts = 0
42+
assertFailsWith<IllegalArgumentException> {
43+
bindWithRetry(retries = 3) {
44+
attempts++
45+
throw IllegalArgumentException("No available camera can be found")
46+
}
47+
}
48+
assertEquals(1, attempts)
49+
}
50+
51+
@Test
52+
fun bindWithRetryRetriesOtherFailures() = runTest {
53+
var attempts = 0
54+
assertFailsWith<IllegalStateException> {
55+
bindWithRetry(retries = 3) {
56+
attempts++
57+
throw IllegalStateException("camera in use")
58+
}
59+
}
60+
assertEquals(3, attempts)
61+
}
62+
}

0 commit comments

Comments
 (0)