Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
)
},
Expand Down
1 change: 1 addition & 0 deletions ui/resources/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="subtitle_usingFrontCamera">No back camera found. Using your front camera</string>
<string name="error_title_paymentFailed">Payment Failed</string>
<string name="error_description_paymentFailed">This payment request could not be paid at this time. Please try again later.</string>

Expand Down
3 changes: 3 additions & 0 deletions ui/scanner/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@ dependencies {
implementation(libs.compose.animation)
implementation(libs.compose.material)

testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.coroutines.test)

}
39 changes: 27 additions & 12 deletions ui/scanner/src/main/kotlin/com/getcode/ui/scanner/CodeScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = { },
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) }
}
}
Expand Down Expand Up @@ -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<Camera> {
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(
Expand All @@ -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 <T> pickScannerCamera(cameras: List<T>, lensFacing: (T) -> Int): T? {
return cameras.firstOrNull { lensFacing(it) == CameraSelector.LENS_FACING_BACK }
?: cameras.firstOrNull()
}

class NoCamerasAvailableException : Throwable()
Original file line number Diff line number Diff line change
@@ -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<FakeCamera>()) { it.lensFacing })
}

@Test
fun bindWithRetryDoesNotRetryAnUnmatchedSelector() = runTest {
var attempts = 0
assertFailsWith<IllegalArgumentException> {
bindWithRetry(retries = 3) {
attempts++
throw IllegalArgumentException("No available camera can be found")
}
}
assertEquals(1, attempts)
}

@Test
fun bindWithRetryRetriesOtherFailures() = runTest {
var attempts = 0
assertFailsWith<IllegalStateException> {
bindWithRetry(retries = 3) {
attempts++
throw IllegalStateException("camera in use")
}
}
assertEquals(3, attempts)
}
}
Loading