Toggle navigation
Toggle navigation
This project
Loading...
Sign in
flutter_package
/
mobile_scanner
Go to a project
Toggle navigation
Projects
Groups
Snippets
Help
Toggle navigation pinning
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
Julian Steenbakker
2024-08-13 14:33:51 +0200
Browse Files
Options
Browse Files
Download
Plain Diff
Committed by
GitHub
2024-08-13 14:33:51 +0200
Commit
95dd7bdb358dd9f369a5797d46c61f089c15a2b4
95dd7bdb
2 parents
6bf2f2c8
7f4c6b03
Merge branch 'master' into pause_function
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
215 additions
and
112 deletions
.github/workflows/flutter.yml
CHANGELOG.md
android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt
android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt
android/src/test/kotlin/dev/steenbakker/mobile_scanner/MobileScannerTest.kt
example/README.md
example/web/index.html
lib/src/mobile_scanner_controller.dart
lib/src/objects/start_options.dart
pubspec.yaml
.github/workflows/flutter.yml
View file @
95dd7bd
...
...
@@ -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
...
...
CHANGELOG.md
View file @
95dd7bd
## 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.
...
...
android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScanner.kt
View file @
95dd7bd
...
...
@@ -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.
}
}
...
...
android/src/main/kotlin/dev/steenbakker/mobile_scanner/MobileScannerHandler.kt
View file @
95dd7bd
...
...
@@ -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) {
...
...
android/src/test/kotlin/dev/steenbakker/mobile_scanner/MobileScannerTest.kt
0 → 100644
View file @
95dd7bd
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
...
...
example/README.md
View file @
95dd7bd
...
...
@@ -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/example
s
/lib`
2.
`cd mobile_scanner/example/lib`
3.
`flutter pub get`
4.
`flutter run`
...
...
example/web/index.html
View file @
95dd7bd
...
...
@@ -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>
...
...
lib/src/mobile_scanner_controller.dart
View file @
95dd7bd
...
...
@@ -292,6 +292,7 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> {
formats:
formats
,
returnImage:
returnImage
,
torchEnabled:
torchEnabled
,
useNewCameraSelector:
useNewCameraSelector
,
);
try
{
...
...
lib/src/objects/start_options.dart
View file @
95dd7bd
...
...
@@ -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
,
};
}
}
...
...
pubspec.yaml
View file @
95dd7bd
...
...
@@ -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
:
...
...
Please
register
or
login
to post a comment