Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Forms: Barcode Scanner Enhancements #603

Merged
merged 19 commits into from
Oct 13, 2024
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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ artifactoryPassword=""
# A final build before release is such a special build, in which case these SDK version numbers
# are overridden via command line, see sdkVersionNumber in settings.gradle.kts.
sdkVersionNumber=200.6.0
sdkBuildNumber=4376
sdkBuildNumber=4384
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,27 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode

/**
* Information about a barcode detected in the image.
*
* @param boundingBox The bounding box of the barcode in the view coordinate system.
* @param rawValue The raw value of the barcode.
*/
internal data class BarcodeInfo(
val boundingBox: Rect?,
val rawValue: String
)

/**
* An [ImageAnalysis.Analyzer] that processes images from the camera preview to detect barcodes.
*
* @param frame The frame in which the barcode should be detected. This should be in the view
* coordinate system.
* @param onSuccess The callback that is called when a barcode is detected. The first parameter is
* the bounding box of the barcode in the view coordinate system, and the second parameter is the
* raw value of the barcode.
* @param onSuccess The callback invoked with the list of [BarcodeInfo] detected in the frame.
*/
internal class BarcodeImageAnalyzer(
private val frame: Rect,
private val onSuccess: (Rect?, String) -> Unit
private val onSuccess: (List<BarcodeInfo>) -> Unit
) : ImageAnalysis.Analyzer {

// set by updateTransform
Expand All @@ -57,27 +66,30 @@ internal class BarcodeImageAnalyzer(

override fun analyze(image: ImageProxy) {
// using bitmap here is inefficient but ImageProxy.getImage() is experimental
barcodeScanner.process(image.toBitmap(), image.imageInfo.rotationDegrees).addOnSuccessListener { barcodes ->
processBarcodes(barcodes, image)
}.addOnFailureListener {
image.close()
}
barcodeScanner.process(image.toBitmap(), image.imageInfo.rotationDegrees)
.addOnSuccessListener { barcodes ->
processBarcodes(barcodes, image)
}.addOnFailureListener {
image.close()
}
}

override fun updateTransform(matrix: Matrix?) {
sensorToTargetMatrix = matrix
}

/**
* Processes the list of [Barcode]s and returns the first barcode that has a non-empty raw value.
* Processes the list of [Barcode]s and calls [onSuccess] with the list of [BarcodeInfo] detected
* in the frame.
*/
private fun processBarcodes(barcodes: List<Barcode>, image: ImageProxy) {
image.use { proxy ->
// Find the first barcode that has a non-empty raw value.
val barcode = barcodes.firstOrNull {
it.rawValue != null && it.rawValue!!.isNotEmpty()
}
if (barcode != null) {
val barcodesInfo = mutableListOf<BarcodeInfo>()
barcodes.forEach { barcode ->
// filter out barcodes that do not have a raw value or the raw value is empty
if (barcode.rawValue == null || barcode.rawValue!!.isEmpty()) {
return@forEach
}
// Get the bounding box of the barcode and convert it to the view coordinate system.
val rect = barcode.boundingBox?.let { box ->
getTransformationMatrix(proxy)?.let { matrix ->
Expand All @@ -102,14 +114,15 @@ internal class BarcodeImageAnalyzer(
}
if (rect != null) {
// If the barcode has a bounding box, check if it is inside the frame.
if (frame.contains(rect.center)) {
onSuccess(rect, barcode.rawValue!!)
if (rect.isRectInside(frame)) {
barcodesInfo.add(BarcodeInfo(rect, barcode.rawValue!!))
}
} else {
// If the barcode does not have a bounding box, return the raw value.
onSuccess(null, barcode.rawValue!!)
barcodesInfo.add(BarcodeInfo(null, barcode.rawValue!!))
}
}
onSuccess(barcodesInfo)
}
}

Expand Down Expand Up @@ -150,3 +163,14 @@ internal class BarcodeImageAnalyzer(
return analysisToTarget
}
}

/**
* Checks if the [Rect] is inside the [other] [Rect]. Returns true if all the corners of the [Rect]
* are inside the [other] [Rect].
*/
internal fun Rect.isRectInside(other: Rect): Boolean {
return other.contains(this.topLeft) &&
other.contains(this.topRight) &&
other.contains(this.bottomLeft) &&
other.contains(this.bottomRight)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.core.resolutionselector.ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -53,7 +57,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -65,13 +69,17 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
Expand All @@ -80,9 +88,13 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.arcgismaps.toolkit.featureforms.R
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private const val SCANNER_FRAME_WIDTH = 300
private const val SCANNER_FRAME_HEIGHT = 300
private const val AUTO_SCAN_DELAY_MS = 1000L

/**
* A composable that displays a barcode scanner. When a barcode is scanned, the [onScan] callback is
Expand All @@ -97,21 +109,17 @@ internal fun BarcodeScanner(
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val hapticFeedback = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val cameraController = remember {
LifecycleCameraController(context).apply {
this.imageAnalysisBackpressureStrategy = STRATEGY_KEEP_ONLY_LATEST
bindToLifecycle(lifecycleOwner)
}
}
val executor = remember {
ContextCompat.getMainExecutor(context)
}
val cameraController = rememberCameraController(lifecycleOwner)
val executor = remember { ContextCompat.getMainExecutor(context) }
val permissionsGranted = hasCameraPermissions(context)
// A frame that represents the area where the barcode should be detected
val scannerFrame = getFrameRect(width = 300.dp, height = 300.dp)
// A rect that represents the barcode detected
var barcodeRect by remember { mutableStateOf<Rect?>(null) }
val scannerFrame =
getFrameRect(width = SCANNER_FRAME_WIDTH.dp, height = SCANNER_FRAME_HEIGHT.dp)
// State representing the detected barcodes
var barcodeInfoList by remember { mutableStateOf<List<BarcodeInfo>>(emptyList()) }
// State to automatically scan the barcode if only one barcode is detected
var autoScanBarcode by remember { mutableStateOf<BarcodeInfo?>(null) }

if (!permissionsGranted) {
PermissionsDeniedDialog(onDismiss = onDismiss)
} else {
Expand All @@ -134,33 +142,76 @@ internal fun BarcodeScanner(
cameraController.unbind()
}
)
BarcodeFrame(scannerFrame, barcodeRect)
BarcodeFrame(scannerFrame, barcodeInfoList) { code ->
// Handle tap on barcode
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
// Unbind the camera controller to stop processing frames
cameraController.unbind()
onScan(code.rawValue)
}
BarcodeToolbar(cameraController = cameraController, onDismiss = onDismiss)
}
}
}
LaunchedEffect(Unit) {
if (!permissionsGranted) {
return@LaunchedEffect
}
if (!permissionsGranted) return@LaunchedEffect
cameraController.setImageAnalysisAnalyzer(
executor,
BarcodeImageAnalyzer(
scannerFrame,
) { box, barcode ->
barcodeRect = box
// Perform haptic feedback when a barcode is detected
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
// Unbind the camera controller to stop processing frames
cameraController.unbind()
scope.launch {
// Delay to allow the user to see the barcode detected
delay(300)
onScan(barcode)
BarcodeImageAnalyzer(scannerFrame) { barcodes ->
barcodeInfoList = barcodes
// set the auto-scan barcode if only one barcode is detected
if (barcodeInfoList.count() == 1) {
// compare the current auto-scan barcode with the detected barcode to avoid setting
// the same barcode multiple times
if (autoScanBarcode?.rawValue != barcodeInfoList.first().rawValue) {
// once the auto-scan barcode is set, the delay routine will start
autoScanBarcode = barcodeInfoList.first()
}
} else {
// disable auto-scan if multiple barcodes are detected
autoScanBarcode = null
}
}
)
}
// Automatically scan the barcode if only one barcode is detected within the given delay. If
// the auto-scan barcode changes, then this effect will be recomposed and the delay will be
// reset.
LaunchedEffect(autoScanBarcode) {
if (autoScanBarcode == null) {
return@LaunchedEffect
}
delay(AUTO_SCAN_DELAY_MS)
if (barcodeInfoList.count() == 1 &&
autoScanBarcode != null &&
autoScanBarcode!!.rawValue == barcodeInfoList.first().rawValue
) {
// Perform haptic feedback when a barcode is auto scanned
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
// Unbind the camera controller to stop processing frames
cameraController.unbind()
onScan(autoScanBarcode!!.rawValue)
}
}
}

@Composable
private fun rememberCameraController(lifecycleOwner: LifecycleOwner): LifecycleCameraController {
val context = LocalContext.current
return remember {
LifecycleCameraController(context).apply {
imageAnalysisBackpressureStrategy = STRATEGY_KEEP_ONLY_LATEST
imageAnalysisResolutionSelector = ResolutionSelector.Builder()
.setResolutionStrategy(
ResolutionStrategy(
android.util.Size(1920, 1080),
FALLBACK_RULE_CLOSEST_HIGHER
)
)
.build()
bindToLifecycle(lifecycleOwner)
}
}
}

@Composable
Expand Down Expand Up @@ -189,8 +240,34 @@ private fun PermissionsDeniedDialog(onDismiss: () -> Unit) {
}

@Composable
private fun BarcodeFrame(frame: Rect, barcode: Rect?) {
Canvas(modifier = Modifier.fillMaxSize()) {
private fun BarcodeFrame(
frame: Rect,
info: List<BarcodeInfo>,
onTap: (BarcodeInfo) -> Unit
) {
// save the latest barcodes
val barcodes by rememberUpdatedState(newValue = info)
val textMeasurer = rememberTextMeasurer()
val codeTextStyle = MaterialTheme.typography.titleMedium.copy(
color = Color.White
)
val titleTextStyle = MaterialTheme.typography.titleSmall.copy(
color = Color.White
)
Canvas(modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { offset ->
barcodes
.firstOrNull {
it.boundingBox?.contains(offset) == true
}
?.let { barcode ->
onTap(barcode)
}
}
}
) {
drawRect(
color = Color.Black.copy(alpha = 0.5f),
topLeft = Offset(0f, 0f),
Expand All @@ -210,13 +287,29 @@ private fun BarcodeFrame(frame: Rect, barcode: Rect?) {
cornerRadius = CornerRadius(50f),
style = Stroke(width = 12f)
)
barcode?.let {
drawRoundRect(
color = Color.Blue.copy(alpha = 0.3f),
topLeft = it.topLeft,
size = Size(it.width, it.height),
cornerRadius = CornerRadius(15f)
)
barcodes.forEach { barcode ->
barcode.boundingBox?.let {
drawRoundRect(
color = Color.Blue.copy(alpha = 0.3f),
topLeft = it.topLeft,
size = Size(it.width, it.height),
cornerRadius = CornerRadius(15f)
)
drawText(
textMeasurer = textMeasurer,
text = barcode.rawValue,
topLeft = it.topLeft,
style = codeTextStyle,
overflow = TextOverflow.Ellipsis,
size = Size(it.width, it.height)
)
drawText(
textMeasurer = textMeasurer,
text = "Tap to scan",
topLeft = Offset(it.left, it.bottom + 5f),
style = titleTextStyle
)
}
}
}
}
Expand Down Expand Up @@ -318,6 +411,6 @@ private fun hasCameraPermissions(context: Context): Boolean = ContextCompat.chec
@Composable
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
private fun BarcodeFramePreview() {
val frame = getFrameRect(width = 300.dp, height = 300.dp)
BarcodeFrame(frame, null)
val frame = getFrameRect(width = SCANNER_FRAME_WIDTH.dp, height = SCANNER_FRAME_HEIGHT.dp)
BarcodeFrame(frame, emptyList()) {}
}
Loading