Julian Steenbakker
Committed by GitHub

Merge branch 'master' into pause_function

... ... @@ -36,7 +36,7 @@ jobs:
- uses: subosito/flutter-action@v2.12.0
with:
cache: true
flutter-version: '3.19'
flutter-version: '3.22'
channel: 'stable'
- name: Version
run: flutter doctor -v
... ...
## NEXT
* This release requires Flutter 3.22.0 and Dart 3.4.
* [Android] Fixed a leak of the barcode scanner.
* [Android] Fixed a crash when encountering invalid numbers for the scan window.
* [Web] Migrates `package:web` to 1.0.0.
## 5.1.1
* This release fixes an issue with automatic starts in the examples.
... ...
... ... @@ -13,6 +13,7 @@ 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
... ... @@ -20,12 +21,12 @@ 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
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
... ... @@ -37,12 +38,12 @@ 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,
) {
/// Internal variables
... ... @@ -50,7 +51,7 @@ class MobileScanner(
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
private var lastScanned: List<String?>? = null
private var scannerTimeout = false
private var displayListener: DisplayManager.DisplayListener? = null
... ... @@ -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 {
return if (options == null) BarcodeScanning.getClient() else BarcodeScanning.getClient(options)
}
}
/**
* callback for the camera. Every frame is passed through this function.
*/
... ... @@ -76,76 +86,75 @@ class MobileScanner(
scannerTimeout = true
}
scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
scanner?.let {
it.process(inputImage).addOnSuccessListener { barcodes ->
if (detectionSpeed == DetectionSpeed.NO_DUPLICATES) {
val newScannedBarcodes = barcodes.mapNotNull { barcode -> barcode.rawValue }.sorted()
val newScannedBarcodes = barcodes.mapNotNull {
barcode -> barcode.rawValue
}.sorted()
if (newScannedBarcodes == lastScanned) {
// New scanned is duplicate, returning
return@addOnSuccessListener
}
if (newScannedBarcodes.isNotEmpty()) lastScanned = newScannedBarcodes
if (newScannedBarcodes.isNotEmpty()) {
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) {
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)) {
barcodeMap.add(barcode.data)
}
}
imageFormat.yuvToRgb(mediaImage, bitmap)
if (barcodeMap.isEmpty()) {
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
... ... @@ -161,26 +170,35 @@ 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(
// Scales the scanWindow to the provided inputImage and checks if that scaled
// scanWindow contains the barcode.
@VisibleForTesting
fun isBarcodeInScanWindow(
scanWindow: List<Float>,
barcode: Barcode,
inputImage: ImageProxy
): Boolean {
// TODO: use `cornerPoints` instead, since the bounding box is not bound to the coordinate system of the input image
// On iOS we do this correctly, so the calculation should match that.
val barcodeBoundingBox = barcode.boundingBox ?: return false
val imageWidth = inputImage.height
val imageHeight = inputImage.width
try {
val imageWidth = inputImage.height
val imageHeight = inputImage.width
val left = (scanWindow[0] * imageWidth).roundToInt()
val top = (scanWindow[1] * imageHeight).roundToInt()
val right = (scanWindow[2] * imageWidth).roundToInt()
val bottom = (scanWindow[3] * imageHeight).roundToInt()
val left = (scanWindow[0] * imageWidth).roundToInt()
val top = (scanWindow[1] * imageHeight).roundToInt()
val right = (scanWindow[2] * imageWidth).roundToInt()
val bottom = (scanWindow[3] * imageHeight).roundToInt()
val scaledScanWindow = Rect(left, top, right, bottom)
return scaledScanWindow.contains(barcodeBoundingBox)
val scaledScanWindow = Rect(left, top, right, bottom)
return scaledScanWindow.contains(barcodeBoundingBox)
} catch (exception: IllegalArgumentException) {
// Rounding of the scan window dimensions can fail, due to encountering NaN.
// If we get NaN, rather than give a false positive, just return false.
return false
}
}
// Return the best resolution for the actual device orientation.
... ... @@ -240,11 +258,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)
... ... @@ -427,12 +441,24 @@ class MobileScanner(
}
val owner = activity as LifecycleOwner
camera?.cameraInfo?.torchState?.removeObservers(owner)
// Release the camera observers first.
camera?.cameraInfo?.let {
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()
camera = null
preview = null
cameraProvider = null
// Release the texture for the preview.
textureEntry?.release()
textureEntry = null
// Release the scanner.
scanner?.close()
scanner = null
lastScanned = null
}
private fun releaseTexture() {
... ... @@ -462,22 +488,29 @@ class MobileScanner(
/**
* Analyze a single image.
*/
fun analyzeImage(image: Uri, onSuccess: AnalyzerSuccessCallback, onError: AnalyzerErrorCallback) {
fun analyzeImage(
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()
}
}
/**
... ... @@ -497,4 +530,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.
}
}
... ...
... ... @@ -92,6 +92,7 @@ class MobileScannerHandler(
fun dispose(activityPluginBinding: ActivityPluginBinding) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
mobileScanner?.dispose()
mobileScanner = null
val listener: RequestPermissionsResultListener? = permissions.getPermissionListener()
... ... @@ -255,7 +256,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) {
... ...
package dev.steenbakker.mobile_scanner
import android.app.Activity
import android.graphics.Rect
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.common.Barcode
import kotlin.test.Test
import org.mockito.Mockito
import io.flutter.view.TextureRegistry
import kotlin.test.expect
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class MobileScannerTest {
@Test
fun isBarcodeInScanWindow_canHandleNaNValues() {
val barcodeScannerMock = Mockito.mock(BarcodeScanner::class.java)
val mobileScanner = MobileScanner(
Mockito.mock(Activity::class.java),
Mockito.mock(TextureRegistry::class.java),
{ _: List<Map<String, Any?>>, _: ByteArray?, _: Int?, _: Int? -> },
{ _: String -> },
{ _: BarcodeScannerOptions? -> barcodeScannerMock }
)
// Intentional suppression for the mock value in the test,
// since there is no NaN constant.
@Suppress("DIVISION_BY_ZERO")
val notANumber = 0.0f / 0.0f
val barcodeMock: Barcode = Mockito.mock(Barcode::class.java)
val imageMock: ImageProxy = Mockito.mock(ImageProxy::class.java)
// TODO: use corner points instead of bounding box
// Bounding box that is 100 pixels offset from the left and top,
// and is 100 pixels in width and height.
Mockito.`when`(barcodeMock.boundingBox).thenReturn(
Rect(100, 100, 200, 300))
Mockito.`when`(imageMock.height).thenReturn(400)
Mockito.`when`(imageMock.width).thenReturn(400)
// Use a scan window that has an invalid value, but otherwise uses the entire image.
val scanWindow: List<Float> = listOf(0f, notANumber, 100f, 100f)
expect(false) {
mobileScanner.isBarcodeInScanWindow(scanWindow, barcodeMock, imageMock)
}
}
}
\ No newline at end of file
... ...
... ... @@ -5,7 +5,7 @@ Demonstrates how to use the mobile_scanner plugin.
## Run Examples
1. `git clone https://github.com/juliansteenbakker/mobile_scanner.git`
2. `cd mobile_scanner/examples/lib`
2. `cd mobile_scanner/example/lib`
3. `flutter pub get`
4. `flutter run`
... ...
... ... @@ -31,29 +31,8 @@
<title>mobile_scanner_example</title>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head>
<body>
<script>
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine().then(function(appRunner) {
appRunner.runApp();
});
}
});
});
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
... ...
... ... @@ -292,6 +292,7 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> {
formats: formats,
returnImage: returnImage,
torchEnabled: torchEnabled,
useNewCameraSelector: useNewCameraSelector,
);
try {
... ...
... ... @@ -14,6 +14,7 @@ class StartOptions {
required this.formats,
required this.returnImage,
required this.torchEnabled,
required this.useNewCameraSelector,
});
/// The direction for the camera.
... ... @@ -37,6 +38,11 @@ class StartOptions {
/// Whether the torch should be turned on when the scanner starts.
final bool torchEnabled;
/// Whether the new resolution selector should be used.
///
/// This option is only supported on Android. Other platforms will ignore this option.
final bool useNewCameraSelector;
Map<String, Object?> toMap() {
return <String, Object?>{
if (cameraResolution != null)
... ... @@ -51,6 +57,7 @@ class StartOptions {
'speed': detectionSpeed.rawValue,
'timeout': detectionTimeoutMs,
'torch': torchEnabled,
'useNewCameraSelector': useNewCameraSelector,
};
}
}
... ...
... ... @@ -16,8 +16,8 @@ screenshots:
path: example/screenshots/overlay.png
environment:
sdk: ">=3.3.0 <4.0.0"
flutter: ">=3.19.0"
sdk: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"
dependencies:
flutter:
... ... @@ -25,7 +25,7 @@ dependencies:
flutter_web_plugins:
sdk: flutter
plugin_platform_interface: ^2.0.2
web: ^0.5.1
web: ^1.0.0
dev_dependencies:
flutter_test:
... ...