Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,20 @@ import android.os.Looper
import android.util.Size
import android.view.Surface
import android.view.WindowManager
import androidx.annotation.VisibleForTesting
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.TorchState
import androidx.camera.core.resolutionselector.AspectRatioStrategy
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import

import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
Expand All @@ -37,20 +38,20 @@ import io.flutter.view.TextureRegistry
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt


class MobileScanner(
private val activity: Activity,
private val textureRegistry: TextureRegistry,
private val mobileScannerCallback: MobileScannerCallback,
private val mobileScannerErrorCallback: MobileScannerErrorCallback
private val mobileScannerErrorCallback: MobileScannerErrorCallback,
private val barcodeScannerFactory: (options: BarcodeScannerOptions?) -> BarcodeScanner = ::defaultBarcodeScannerFactory,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new callback is mainly for tests. I'm adding a new test soon that needs this, in a follow up PR for the rounding bug #1134

) {

/// Internal variables
private var cameraProvider: ProcessCameraProvider? = null
private var camera: Camera? = null
private var preview: Preview? = null
private var textureEntry: TextureRegistry.SurfaceTextureEntry? = null
private var scanner = BarcodeScanning.getClient()
private var scanner: BarcodeScanner? = null
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This client was never disposed

private var lastScanned: List<String?>? = null
private var scannerTimeout = false
private var displayListener: DisplayManager.DisplayListener? = null
Expand All @@ -61,6 +62,15 @@ class MobileScanner(
private var detectionTimeout: Long = 250
private var returnImage = false

companion object {
/**
* Create a barcode scanner from the given options.
*/
fun defaultBarcodeScannerFactory(options: BarcodeScannerOptions?) : BarcodeScanner {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default for the barcode scanner creator function

return if (options == null) BarcodeScanning.getClient() else BarcodeScanning.getClient(options)
}
}

/**
* callback for the camera. Every frame is passed through this function.
*/
Expand All @@ -76,76 +86,75 @@ class MobileScanner(
scannerTimeout = true
}

scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
scanner?.let {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scanner is nullable now, so used a let block

it.process(inputImage).addOnSuccessListener { barcodes ->
if (detectionSpeed == DetectionSpeed.NO_DUPLICATES) {
val newScannedBarcodes = barcodes.mapNotNull { barcode -> barcode.rawValue }.sorted()
val newScannedBarcodes = barcodes.mapNotNull {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting of mapNotNull to use braces

barcode -> barcode.rawValue
}.sorted()

if (newScannedBarcodes == lastScanned) {
// New scanned is duplicate, returning
return@addOnSuccessListener
}
if (newScannedBarcodes.isNotEmpty()) lastScanned = newScannedBarcodes
if (newScannedBarcodes.isNotEmpty()) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same formatting change here

lastScanned = newScannedBarcodes
}
}

val barcodeMap: MutableList<Map<String, Any?>> = mutableListOf()

for (barcode in barcodes) {
if (scanWindow != null) {
val match = isBarcodeInScanWindow(scanWindow!!, barcode, imageProxy)
if (!match) {
continue
} else {
barcodeMap.add(barcode.data)
}
} else {
if (scanWindow == null) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inverting the check here made more sense

barcodeMap.add(barcode.data)
continue
}
}


if (barcodeMap.isNotEmpty()) {
if (returnImage) {

val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888)

val imageFormat = YuvToRgbConverter(activity.applicationContext)
if (isBarcodeInScanWindow(scanWindow!!, barcode, imageProxy)) {
Copy link
Collaborator Author

@navaronbracke navaronbracke Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also invert the check here, which drops a redundant continue statement

barcodeMap.add(barcode.data)
}
}

imageFormat.yuvToRgb(mediaImage, bitmap)
if (barcodeMap.isEmpty()) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early return 2x

return@addOnSuccessListener
}

val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f)
if (!returnImage) {
mobileScannerCallback(
barcodeMap,
null,
null,
null
)
return@addOnSuccessListener
}

val stream = ByteArrayOutputStream()
bmResult.compress(Bitmap.CompressFormat.PNG, 100, stream)
val byteArray = stream.toByteArray()
val bmWidth = bmResult.width
val bmHeight = bmResult.height
bmResult.recycle()
val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888)
val imageFormat = YuvToRgbConverter(activity.applicationContext)

imageFormat.yuvToRgb(mediaImage, bitmap)

mobileScannerCallback(
barcodeMap,
byteArray,
bmWidth,
bmHeight
)
val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f)

} else {
val stream = ByteArrayOutputStream()
bmResult.compress(Bitmap.CompressFormat.PNG, 100, stream)
val byteArray = stream.toByteArray()
val bmWidth = bmResult.width
val bmHeight = bmResult.height
bmResult.recycle()

mobileScannerCallback(
barcodeMap,
null,
null,
null
)
}
}
}
.addOnFailureListener { e ->
mobileScannerCallback(
barcodeMap,
byteArray,
bmWidth,
bmHeight
)
}.addOnFailureListener { e ->
mobileScannerErrorCallback(
e.localizedMessage ?: e.toString()
)
}
.addOnCompleteListener { imageProxy.close() }
}.addOnCompleteListener { imageProxy.close() }
}

if (detectionSpeed == DetectionSpeed.NORMAL) {
// Set timer and continue
Expand All @@ -161,7 +170,6 @@ class MobileScanner(
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}


// scales the scanWindow to the provided inputImage and checks if that scaled
// scanWindow contains the barcode
private fun isBarcodeInScanWindow(
Expand Down Expand Up @@ -240,11 +248,7 @@ class MobileScanner(
}

lastScanned = null
scanner = if (barcodeScannerOptions != null) {
BarcodeScanning.getClient(barcodeScannerOptions)
} else {
BarcodeScanning.getClient()
}
scanner = barcodeScannerFactory(barcodeScannerOptions)

val cameraProviderFuture = ProcessCameraProvider.getInstance(activity)
val executor = ContextCompat.getMainExecutor(activity)
Expand Down Expand Up @@ -408,14 +412,27 @@ class MobileScanner(
}

val owner = activity as LifecycleOwner
camera?.cameraInfo?.torchState?.removeObservers(owner)
// Release the camera observers first.
camera?.cameraInfo?.let {
Copy link
Collaborator Author

@navaronbracke navaronbracke Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug, we didn't remove all observers. removeObservers does nothing if the owner is not a listener, so calling this is safe for all three LiveData instances.

But we are listening to torch & zoom

it.torchState.removeObservers(owner)
it.zoomState.removeObservers(owner)
it.cameraState.removeObservers(owner)
}
// Unbind the camera use cases, the preview is a use case.
// The camera will be closed when the last use case is unbound.
cameraProvider?.unbindAll()
textureEntry?.release()

cameraProvider = null
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following is just reflowing code so that the relevant things are together.

camera = null
preview = null

// Release the texture for the preview.
textureEntry?.release()
textureEntry = null
cameraProvider = null

// Release the scanner.
scanner?.close()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is new, we release the scanner when the users stops the camera. No need to keep it around when the preview / camera is gone.

scanner = null
lastScanned = null
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting lastScanned to null is to keep things consistent.

}

private fun isStopped() = camera == null && preview == null
Expand All @@ -439,22 +456,29 @@ class MobileScanner(
/**
* Analyze a single image.
*/
fun analyzeImage(image: Uri, onSuccess: AnalyzerSuccessCallback, onError: AnalyzerErrorCallback) {
fun analyzeImage(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Analyze image now takes configurable options and uses a short lived scanner.

Using the short lived scanner is part of the fix for the retain cycle thing and allowing options is part of the fix for analyze image allowing all formats.

image: Uri,
scannerOptions: BarcodeScannerOptions?,
onSuccess: AnalyzerSuccessCallback,
onError: AnalyzerErrorCallback) {
val inputImage = InputImage.fromFilePath(activity, image)

scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
val barcodeMap = barcodes.map { barcode -> barcode.data }
// Use a short lived scanner instance, which is closed when the analysis is done.
val barcodeScanner: BarcodeScanner = barcodeScannerFactory(scannerOptions)

if (barcodeMap.isNotEmpty()) {
onSuccess(barcodeMap)
} else {
onSuccess(null)
}
}
.addOnFailureListener { e ->
onError(e.localizedMessage ?: e.toString())
barcodeScanner.process(inputImage).addOnSuccessListener { barcodes ->
val barcodeMap = barcodes.map { barcode -> barcode.data }

if (barcodeMap.isEmpty()) {
onSuccess(null)
} else {
onSuccess(barcodeMap)
}
}.addOnFailureListener { e ->
onError(e.localizedMessage ?: e.toString())
}.addOnCompleteListener {
barcodeScanner.close()
}
}

/**
Expand All @@ -474,4 +498,14 @@ class MobileScanner(
camera?.cameraControl?.setZoomRatio(1f)
}

/**
* Dispose of this scanner instance.
*/
fun dispose() {
if (isStopped()) {
return
}

stop() // Defer to the stop method, which disposes all resources anyway.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class MobileScannerHandler(
fun dispose(activityPluginBinding: ActivityPluginBinding) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
mobileScanner?.dispose()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user calls MobileScannerController.dispose() we should also dispose the underlying camera / scanner. There is no guarantee that stop() is called before dispose().

We might get only dispose() when the user leaves a scanning page for example.

mobileScanner = null

val listener: RequestPermissionsResultListener? = permissions.getPermissionListener()
Expand Down Expand Up @@ -242,7 +243,13 @@ class MobileScannerHandler(
analyzerResult = result
val uri = Uri.fromFile(File(call.arguments.toString()))

mobileScanner!!.analyzeImage(uri, analyzeImageSuccessCallback, analyzeImageErrorCallback)
// TODO: parse options from the method call
// See https://github.com/juliansteenbakker/mobile_scanner/issues/1069
mobileScanner!!.analyzeImage(
uri,
null,
analyzeImageSuccessCallback,
analyzeImageErrorCallback)
}

private fun toggleTorch(result: MethodChannel.Result) {
Expand Down
Loading