Skip to content

Commit 6bed84a

Browse files
authored
fix(scanner): fall back to any camera when there is no back camera (#1752)
1 parent 141e59c commit 6bed84a

5 files changed

Lines changed: 115 additions & 13 deletions

File tree

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
1414
import androidx.compose.runtime.mutableStateOf
1515
import androidx.compose.runtime.remember
1616
import androidx.compose.runtime.rememberCoroutineScope
17+
import androidx.compose.runtime.saveable.rememberSaveable
1718
import androidx.compose.runtime.setValue
1819
import androidx.compose.ui.Alignment
1920
import androidx.compose.ui.Modifier
@@ -50,6 +51,7 @@ import com.getcode.media.StaticImageResult
5051
import com.getcode.navigation.core.LocalCodeNavigator
5152
import com.getcode.theme.Black50
5253
import com.getcode.theme.CodeTheme
54+
import com.getcode.ui.components.toast.LocalFloatingToastHost
5355
import com.getcode.ui.biometrics.LocalBiometricsState
5456
import com.getcode.ui.core.noRippleClickable
5557
import com.getcode.ui.scanner.CodeScanner
@@ -92,6 +94,8 @@ internal fun Scanner() {
9294

9395
val vibrator = LocalVibrator.current
9496
val scope = rememberCoroutineScope()
97+
val toasts = LocalFloatingToastHost.current
98+
var frontCameraNoticeShown by rememberSaveable { mutableStateOf(false) }
9599

96100
// Built by hand for the same reason `rememberMultiCodeAnalyzer` builds the camera's: nothing
97101
// binds these types, and neither the module that declares them nor `:ui:scanner` is
@@ -286,11 +290,28 @@ internal fun Scanner() {
286290
}
287291
}
288292
},
293+
onFrontCameraFallback = {
294+
// Once per scanner, not on every rebind: the preview already shows which
295+
// camera is in use, this only says why.
296+
if (!frontCameraNoticeShown && toasts != null) {
297+
frontCameraNoticeShown = true
298+
scope.launch {
299+
toasts.show(
300+
message = resources.getString(R.string.subtitle_usingFrontCamera),
301+
hugContent = true,
302+
passThrough = true,
303+
)
304+
}
305+
}
306+
},
289307
onError = {
308+
// A device without a usable camera is an expected state, not an error
309+
// to report.
290310
if (it is NoCamerasAvailableException) {
291311
cameraAvailable = false
312+
} else {
313+
ErrorUtils.handleError(it)
292314
}
293-
ErrorUtils.handleError(it)
294315
}
295316
)
296317
},

‎ui/resources/src/main/res/values/strings.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<resources>
3+
<string name="subtitle_usingFrontCamera">No back camera found. Using your front camera</string>
34
<string name="error_title_paymentFailed">Payment Failed</string>
45
<string name="error_description_paymentFailed">This payment request could not be paid at this time. Please try again later.</string>
56

‎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: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ fun CodeScanner(
6767
onPreviewStateChanged: (Boolean) -> Unit,
6868
onCodeScanned: (CodeScanResult) -> Unit,
6969
onError: (Throwable) -> Unit = { },
70+
// Called when the device has no back camera and the scanner bound the front one instead.
71+
onFrontCameraFallback: () -> Unit = { },
7072
// Surfaces the underlying PreviewView so callers can snapshot the live feed (e.g. to render a
7173
// blurred camera backdrop behind an overlay). Non-null while mounted, null on dispose.
7274
onPreviewViewChanged: (PreviewView?) -> Unit = { },
@@ -89,13 +91,6 @@ fun CodeScanner(
8991
Preview.Builder().build().apply { surfaceProvider = previewView.surfaceProvider }
9092
}
9193

92-
val cameraSelector = remember {
93-
val lensFacing = CameraSelector.LENS_FACING_BACK
94-
CameraSelector.Builder()
95-
.requireLensFacing(lensFacing)
96-
.build()
97-
}
98-
9994
// Ask for the analysis stream through ResolutionSelector rather than the deprecated
10095
// setTargetResolution. The old API is only a hint, and on a 4:3 sensor it resolves a 1920x1080
10196
// request down to a 720x720 square -- which costs range twice over. A square crop keeps the
@@ -150,11 +145,11 @@ fun CodeScanner(
150145
cameraProvider.bindToLifecycleSafely(
151146
context,
152147
lifecycleOwner,
153-
cameraSelector,
154148
preview,
155149
imageAnalysis
156150
).onSuccess {
157151
camera = it
152+
if (it.cameraInfo.lensFacing == CameraSelector.LENS_FACING_FRONT) onFrontCameraFallback()
158153
}.onFailure { onError(it) }
159154
} else {
160155
cameraProvider.unbindAll()
@@ -187,11 +182,13 @@ fun CodeScanner(
187182
cameraProvider.bindToLifecycleSafely(
188183
context,
189184
lifecycleOwner,
190-
cameraSelector,
191185
preview,
192186
imageAnalysis
193187
).onSuccess {
194188
camera = it
189+
if (it.cameraInfo.lensFacing == CameraSelector.LENS_FACING_FRONT) {
190+
onFrontCameraFallback()
191+
}
195192
}.onFailure { onError(it) }
196193
}
197194
}
@@ -289,16 +286,22 @@ private suspend fun Context.getCameraProvider(): ProcessCameraProvider {
289286
private suspend fun ProcessCameraProvider.bindToLifecycleSafely(
290287
context: Context,
291288
lifecycleOwner: LifecycleOwner,
292-
cameraSelector: CameraSelector,
293289
preview: Preview,
294290
imageAnalysis: ImageAnalysis,
295291
retries: Int = 3,
296292
): Result<Camera> {
297293
val hasCamera = context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
298294
if (!hasCamera) {
299-
return Result.failure(IllegalStateException("No camera available on this device"))
295+
return Result.failure(NoCamerasAvailableException())
300296
}
301297

298+
// Some devices expose no back camera to CameraX -- an Infinix X688B lists a single camera and
299+
// reports it as front facing -- so requiring LENS_FACING_BACK fails every bind there. Prefer
300+
// the back camera and fall back to whatever the device has; a code held up to a front camera
301+
// still scans.
302+
val cameraSelector = pickScannerCamera(availableCameraInfos) { it.lensFacing }?.cameraSelector
303+
?: return Result.failure(NoCamerasAvailableException())
304+
302305
return runCatching {
303306
bindWithRetry(retries) {
304307
bindToLifecycle(
@@ -319,11 +322,23 @@ suspend fun bindWithRetry(
319322
try {
320323
return bind()
321324
} catch (e: Exception) {
322-
if (attempt == retries - 1) throw e
325+
// CameraX throws IllegalArgumentException when no camera matches the selector or the
326+
// use cases can't be combined. Neither changes between attempts, so retrying only
327+
// delays the failure.
328+
if (e is IllegalArgumentException || attempt == retries - 1) throw e
323329
delay(1000)
324330
}
325331
}
326332
throw NoCamerasAvailableException()
327333
}
328334

335+
/**
336+
* The back camera when there is one, otherwise the first camera the device exposes, or null when
337+
* it exposes none.
338+
*/
339+
internal fun <T> pickScannerCamera(cameras: List<T>, lensFacing: (T) -> Int): T? {
340+
return cameras.firstOrNull { lensFacing(it) == CameraSelector.LENS_FACING_BACK }
341+
?: cameras.firstOrNull()
342+
}
343+
329344
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)