Julian Steenbakker
Committed by GitHub

Merge branch 'master' into overlay

... ... @@ -11,7 +11,7 @@ jobs:
analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.5.2
- uses: actions/checkout@v3.5.3
- uses: actions/setup-java@v3.11.0
with:
java-version: 11
... ... @@ -28,7 +28,7 @@ jobs:
formatting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.5.2
- uses: actions/checkout@v3.5.3
- uses: actions/setup-java@v3.11.0
with:
java-version: 11
... ...
... ... @@ -7,7 +7,7 @@
release-please:
runs-on: ubuntu-latest
steps:
- uses: GoogleCloudPlatform/release-please-action@v3.7.9
- uses: GoogleCloudPlatform/release-please-action@v3.7.10
with:
token: ${{ secrets.GITHUB_TOKEN }}
release-type: simple
... ...
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
# This file should be version controlled.
version:
revision: 5f105a6ca7a5ac7b8bc9b241f4c2d86f4188cf5c
revision: 796c8ef79279f9c774545b3771238c3098dbefab
channel: stable
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: android
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: ios
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: macos
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: web
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
... ...
## 3.2.1
## 3.3.0
Bugs fixed:
* Fixed bug where onDetect method was being called multiple times
* [Android] Fix Gradle 8 compatibility by adding the `namespace` attribute to the build.gradle.
Improvements:
* [Android] Upgraded camera2 dependency
* Added zoomScale value notifier in MobileScannerController for the application to know the zoom scale value set actually.
The value is notified from the native SDK(CameraX/AVFoundation).
* Added resetZoomScale() in MobileScannerController to reset zoom ratio with 1x.
Both Android and iOS, if the device have ultra-wide camera, calling setZoomScale with small value causes to use ultra-wide camera and may be diffcult to detect barcodes.
resetZoomScale() is useful to use standard camera with zoom 1x.
setZoomScale() with the specific value can realize same effect, but added resetZoomScale for avoiding floating point errors.
The application can know what zoom scale value is selected actually by subscribing zoomScale above after calling resetZoomScale.
* [iOS] Call resetZoomScale while starting scan.
Android camera is initialized with a zoom of 1x, whereas iOS is initialized with the minimum zoom value, which causes to select the ultra-wide camera unintentionally ([iOS] Impossible to focus and scan the QR code due to picking the wide back camera #554).
Fixed this issue by calling resetZoomScale
* [iOS] Remove zoom animation with ramp function to match Android behavior.
## 3.2.0
Improvements:
* [iOS] Updated GoogleMLKit/BarcodeScanning to 4.0.0
... ...
... ... @@ -53,6 +53,12 @@ Ensure that you granted camera permission in XCode -> Signing & Capabilities:
<img width="696" alt="Screenshot of XCode where Camera is checked" src="https://user-images.githubusercontent.com/24459435/193464115-d76f81d0-6355-4cb2-8bee-538e413a3ad0.png">
## Web
This package uses ZXing on web to read barcodes so it needs to be included in `index.html` as script.
```html
<script src="https://unpkg.com/@zxing/library@0.19.1" type="application/javascript"></script>
```
## Usage
Import `package:mobile_scanner/mobile_scanner.dart`, and use the widget with or without the controller.
... ...
... ... @@ -2,14 +2,14 @@ group 'dev.steenbakker.mobile_scanner'
version '1.0-SNAPSHOT'
buildscript {
ext.kotlin_version = '1.7.22'
ext.kotlin_version = '1.8.22'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.0.0'
classpath 'com.android.tools.build:gradle:8.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
... ... @@ -56,5 +56,6 @@ dependencies {
// implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
implementation 'androidx.camera:camera-camera2:1.2.2'
implementation 'androidx.camera:camera-lifecycle:1.2.2'
implementation 'androidx.camera:camera-lifecycle:1.2.3'
implementation 'androidx.camera:camera-camera2:1.2.3'
}
... ...
package dev.steenbakker.mobile_scanner
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.Rect
import android.net.Uri
import android.os.Handler
... ... @@ -16,9 +18,12 @@ import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import dev.steenbakker.mobile_scanner.objects.DetectionSpeed
import dev.steenbakker.mobile_scanner.objects.MobileScannerStartParameters
import dev.steenbakker.mobile_scanner.utils.YuvToRgbConverter
import io.flutter.view.TextureRegistry
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
class MobileScanner(
private val activity: Activity,
private val textureRegistry: TextureRegistry,
... ... @@ -82,13 +87,40 @@ class MobileScanner(
}
}
if (barcodeMap.isNotEmpty()) {
mobileScannerCallback(
barcodeMap,
if (returnImage) mediaImage.toByteArray() else null,
if (returnImage) mediaImage.width else null,
if (returnImage) mediaImage.height else null
)
if (returnImage) {
val bitmap = Bitmap.createBitmap(mediaImage.width, mediaImage.height, Bitmap.Config.ARGB_8888)
val imageFormat = YuvToRgbConverter(activity.applicationContext)
imageFormat.yuvToRgb(mediaImage, bitmap)
val bmResult = rotateBitmap(bitmap, camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 90f)
val stream = ByteArrayOutputStream()
bmResult.compress(Bitmap.CompressFormat.PNG, 100, stream)
val byteArray = stream.toByteArray()
bmResult.recycle()
mobileScannerCallback(
barcodeMap,
byteArray,
bmResult.width,
bmResult.height
)
} else {
mobileScannerCallback(
barcodeMap,
null,
null,
null
)
}
}
}
.addOnFailureListener { e ->
... ... @@ -106,6 +138,13 @@ class MobileScanner(
}
}
fun rotateBitmap(bitmap: Bitmap, degrees: Float): Bitmap {
val matrix = Matrix()
matrix.postRotate(degrees)
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(
... ... @@ -227,7 +266,6 @@ class MobileScanner(
}, executor)
}
/**
* Stop barcode scanning.
*/
... ...
package dev.steenbakker.mobile_scanner.utils
import android.graphics.ImageFormat
import android.media.Image
import androidx.annotation.IntDef
import java.nio.ByteBuffer
/*
This file is converted from part of https://github.com/gordinmitya/yuv2buf.
Follow the link to find demo app, performance benchmarks and unit tests.
Intro to YUV image formats:
YUV_420_888 - is a generic format that can be represented as I420, YV12, NV21, and NV12.
420 means that for each 4 luminosity pixels we have 2 chroma pixels: U and V.
* I420 format represents an image as Y plane followed by U then followed by V plane
without chroma channels interleaving.
For example:
Y Y Y Y
Y Y Y Y
U U V V
* NV21 format represents an image as Y plane followed by V and U interleaved. First V then U.
For example:
Y Y Y Y
Y Y Y Y
V U V U
* YV12 and NV12 are the same as previous formats but with swapped order of V and U. (U then V)
Visualization of these 4 formats:
https://user-images.githubusercontent.com/9286092/89119601-4f6f8100-d4b8-11ea-9a51-2765f7e513c2.jpg
It's guaranteed that image.getPlanes() always returns planes in order Y U V for YUV_420_888.
https://developer.android.com/reference/android/graphics/ImageFormat#YUV_420_888
Because I420 and NV21 are more widely supported (RenderScript, OpenCV, MNN)
the conversion is done into these formats.
More about each format: https://www.fourcc.org/yuv.php
*/
@kotlin.annotation.Retention(AnnotationRetention.SOURCE)
@IntDef(ImageFormat.NV21, ImageFormat.YUV_420_888)
annotation class YuvType
class YuvByteBuffer(image: Image, dstBuffer: ByteBuffer? = null) {
@YuvType
val type: Int
val buffer: ByteBuffer
init {
val wrappedImage = ImageWrapper(image)
type = if (wrappedImage.u.pixelStride == 1) {
ImageFormat.YUV_420_888
} else {
ImageFormat.NV21
}
val size = image.width * image.height * 3 / 2
buffer = if (
dstBuffer == null || dstBuffer.capacity() < size ||
dstBuffer.isReadOnly || !dstBuffer.isDirect
) {
ByteBuffer.allocateDirect(size) }
else {
dstBuffer
}
buffer.rewind()
removePadding(wrappedImage)
}
// Input buffers are always direct as described in
// https://developer.android.com/reference/android/media/Image.Plane#getBuffer()
private fun removePadding(image: ImageWrapper) {
val sizeLuma = image.y.width * image.y.height
val sizeChroma = image.u.width * image.u.height
if (image.y.rowStride > image.y.width) {
removePaddingCompact(image.y, buffer, 0)
} else {
buffer.position(0)
buffer.put(image.y.buffer)
}
if (type == ImageFormat.YUV_420_888) {
if (image.u.rowStride > image.u.width) {
removePaddingCompact(image.u, buffer, sizeLuma)
removePaddingCompact(image.v, buffer, sizeLuma + sizeChroma)
} else {
buffer.position(sizeLuma)
buffer.put(image.u.buffer)
buffer.position(sizeLuma + sizeChroma)
buffer.put(image.v.buffer)
}
} else {
if (image.u.rowStride > image.u.width * 2) {
removePaddingNotCompact(image, buffer, sizeLuma)
} else {
buffer.position(sizeLuma)
var uv = image.v.buffer
val properUVSize = image.v.height * image.v.rowStride - 1
if (uv.capacity() > properUVSize) {
uv = clipBuffer(image.v.buffer, 0, properUVSize)
}
buffer.put(uv)
val lastOne = image.u.buffer[image.u.buffer.capacity() - 1]
buffer.put(buffer.capacity() - 1, lastOne)
}
}
buffer.rewind()
}
private fun removePaddingCompact(
plane: PlaneWrapper,
dst: ByteBuffer,
offset: Int
) {
require(plane.pixelStride == 1) {
"use removePaddingCompact with pixelStride == 1"
}
val src = plane.buffer
val rowStride = plane.rowStride
var row: ByteBuffer
dst.position(offset)
for (i in 0 until plane.height) {
row = clipBuffer(src, i * rowStride, plane.width)
dst.put(row)
}
}
private fun removePaddingNotCompact(
image: ImageWrapper,
dst: ByteBuffer,
offset: Int
) {
require(image.u.pixelStride == 2) {
"use removePaddingNotCompact pixelStride == 2"
}
val width = image.u.width
val height = image.u.height
val rowStride = image.u.rowStride
var row: ByteBuffer
dst.position(offset)
for (i in 0 until height - 1) {
row = clipBuffer(image.v.buffer, i * rowStride, width * 2)
dst.put(row)
}
row = clipBuffer(image.u.buffer, (height - 1) * rowStride - 1, width * 2)
dst.put(row)
}
private fun clipBuffer(buffer: ByteBuffer, start: Int, size: Int): ByteBuffer {
val duplicate = buffer.duplicate()
duplicate.position(start)
duplicate.limit(start + size)
return duplicate.slice()
}
private class ImageWrapper(image:Image) {
val width= image.width
val height = image.height
val y = PlaneWrapper(width, height, image.planes[0])
val u = PlaneWrapper(width / 2, height / 2, image.planes[1])
val v = PlaneWrapper(width / 2, height / 2, image.planes[2])
// Check this is a supported image format
// https://developer.android.com/reference/android/graphics/ImageFormat#YUV_420_888
init {
require(y.pixelStride == 1) {
"Pixel stride for Y plane must be 1 but got ${y.pixelStride} instead."
}
require(u.pixelStride == v.pixelStride && u.rowStride == v.rowStride) {
"U and V planes must have the same pixel and row strides " +
"but got pixel=${u.pixelStride} row=${u.rowStride} for U " +
"and pixel=${v.pixelStride} and row=${v.rowStride} for V"
}
require(u.pixelStride == 1 || u.pixelStride == 2) {
"Supported" + " pixel strides for U and V planes are 1 and 2"
}
}
}
private class PlaneWrapper(width: Int, height: Int, plane: Image.Plane) {
val width = width
val height = height
val buffer: ByteBuffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
}
}
\ No newline at end of file
... ...
package dev.steenbakker.mobile_scanner.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageFormat
import android.media.Image
import android.os.Build
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicYuvToRGB
import android.renderscript.Type
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer
/**
* Helper class used to efficiently convert a [Media.Image] object from
* YUV_420_888 format to an RGB [Bitmap] object.
*
* Copied from https://github.com/owahltinez/camerax-tflite/blob/master/app/src/main/java/com/android/example/camerax/tflite/YuvToRgbConverter.kt
*
* The [yuvToRgb] method is able to achieve the same FPS as the CameraX image
* analysis use case at the default analyzer resolution, which is 30 FPS with
* 640x480 on a Pixel 3 XL device.
*/class YuvToRgbConverter(context: Context) {
private val rs = RenderScript.create(context)
private val scriptYuvToRgb =
ScriptIntrinsicYuvToRGB.create(rs, Element.U8_4(rs))
// Do not add getters/setters functions to these private variables
// because yuvToRgb() assume they won't be modified elsewhere
private var yuvBits: ByteBuffer? = null
private var bytes: ByteArray = ByteArray(0)
private var inputAllocation: Allocation? = null
private var outputAllocation: Allocation? = null
@Synchronized
fun yuvToRgb(image: Image, output: Bitmap) {
val yuvBuffer = YuvByteBuffer(image, yuvBits)
yuvBits = yuvBuffer.buffer
if (needCreateAllocations(image, yuvBuffer)) {
val yuvType = Type.Builder(rs, Element.U8(rs))
.setX(image.width)
.setY(image.height)
.setYuvFormat(yuvBuffer.type)
inputAllocation = Allocation.createTyped(
rs,
yuvType.create(),
Allocation.USAGE_SCRIPT
)
bytes = ByteArray(yuvBuffer.buffer.capacity())
val rgbaType = Type.Builder(rs, Element.RGBA_8888(rs))
.setX(image.width)
.setY(image.height)
outputAllocation = Allocation.createTyped(
rs,
rgbaType.create(),
Allocation.USAGE_SCRIPT
)
}
yuvBuffer.buffer.get(bytes)
inputAllocation!!.copyFrom(bytes)
// Convert NV21 or YUV_420_888 format to RGB
inputAllocation!!.copyFrom(bytes)
scriptYuvToRgb.setInput(inputAllocation)
scriptYuvToRgb.forEach(outputAllocation)
outputAllocation!!.copyTo(output)
}
private fun needCreateAllocations(image: Image, yuvBuffer: YuvByteBuffer): Boolean {
return (inputAllocation == null || // the very 1st call
inputAllocation!!.type.x != image.width || // image size changed
inputAllocation!!.type.y != image.height ||
inputAllocation!!.type.yuv != yuvBuffer.type || // image format changed
bytes.size == yuvBuffer.buffer.capacity())
}
}
\ No newline at end of file
... ...
buildscript {
ext.kotlin_version = '1.7.22'
ext.kotlin_version = '1.8.22'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.0.0'
classpath 'com.android.tools.build:gradle:8.0.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
... ... @@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
... ...
#Tue Aug 23 15:51:00 CEST 2022
#Tue Jun 27 18:47:05 CEST 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
... ...
... ... @@ -204,6 +204,7 @@
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
... ...
... ... @@ -50,6 +50,7 @@ class _BarcodeListScannerWithControllerState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('With ValueListenableBuilder')),
backgroundColor: Colors.black,
body: Builder(
builder: (context) {
... ...
... ... @@ -50,6 +50,7 @@ class _BarcodeScannerWithControllerState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('With controller')),
backgroundColor: Colors.black,
body: Builder(
builder: (context) {
... ...
... ... @@ -70,6 +70,7 @@ class _BarcodeScannerPageViewState extends State<BarcodeScannerPageView>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('With PageView')),
backgroundColor: Colors.black,
body: PageView(
children: [
... ...
... ... @@ -52,6 +52,7 @@ class _BarcodeScannerReturningImageState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Returning image')),
body: SafeArea(
child: Column(
children: [
... ...
... ... @@ -3,6 +3,8 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:mobile_scanner_example/scanner_error_widget.dart';
class BarcodeScannerWithScanWindow extends StatefulWidget {
const BarcodeScannerWithScanWindow({Key? key}) : super(key: key);
... ... @@ -32,6 +34,7 @@ class _BarcodeScannerWithScanWindowState
height: 200,
);
return Scaffold(
appBar: AppBar(title: const Text('With Scan window')),
backgroundColor: Colors.black,
body: Builder(
builder: (context) {
... ... @@ -47,6 +50,9 @@ class _BarcodeScannerWithScanWindowState
this.arguments = arguments;
});
},
errorBuilder: (context, error, child) {
return ScannerErrorWidget(error: error);
},
onDetect: onDetect,
),
if (barcode != null &&
... ...
... ... @@ -18,6 +18,7 @@ class _BarcodeScannerWithoutControllerState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Without controller')),
backgroundColor: Colors.black,
body: Builder(
builder: (context) {
... ...
... ... @@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:mobile_scanner_example/scanner_error_widget.dart';
class BarcodeScannerWithZoom extends StatefulWidget {
const BarcodeScannerWithZoom({Key? key}) : super(key: key);
... ... @@ -23,6 +25,7 @@ class _BarcodeScannerWithZoomState extends State<BarcodeScannerWithZoom>
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('With zoom slider')),
backgroundColor: Colors.black,
body: Builder(
builder: (context) {
... ... @@ -31,6 +34,9 @@ class _BarcodeScannerWithZoomState extends State<BarcodeScannerWithZoom>
MobileScanner(
controller: controller,
fit: BoxFit.contain,
errorBuilder: (context, error, child) {
return ScannerErrorWidget(error: error);
},
onDetect: (barcode) {
setState(() {
this.barcode = barcode;
... ...
... ... @@ -17,6 +17,9 @@ class ScannerErrorWidget extends StatelessWidget {
case MobileScannerErrorCode.permissionDenied:
errorMessage = 'Permission denied';
break;
case MobileScannerErrorCode.unsupported:
errorMessage = 'Scanning is unsupported on this device';
break;
default:
errorMessage = 'Generic Error';
break;
... ...
... ... @@ -3,12 +3,12 @@ description: Demonstrates how to use the mobile_scanner plugin.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
environment:
sdk: ">=2.12.0 <3.0.0"
sdk: ">=2.12.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
image_picker: ^0.8.7
image_picker: ^1.0.0
mobile_scanner:
path: ../
... ...
... ... @@ -8,38 +8,53 @@
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
Fore more details:
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="/">
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<meta name="description" content="Demonstrates how to use the mobile_scanner plugin.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="example">
<meta name="apple-mobile-web-app-title" content="mobile_scanner_example">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>example</title>
<title>mobile_scanner_example</title>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
var serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head>
<body>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script src="https://unpkg.com/@zxing/library@0.19.1" type="application/javascript"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('flutter-first-frame', function () {
navigator.serviceWorker.register('flutter_service_worker.js');
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="main.dart.js" type="application/javascript"></script>
</body>
</html>
... ...
{
"name": "Mobile Scanner Example",
"name": "mobile_scanner_example",
"short_name": "mobile_scanner_example",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A barcode and qr code scanner example.",
"description": "Demonstrates how to use the mobile_scanner plugin.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
... ... @@ -18,6 +18,18 @@
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
... ...
... ... @@ -51,6 +51,8 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
var detectionSpeed: DetectionSpeed = DetectionSpeed.noDuplicates
private let backgroundQueue = DispatchQueue(label: "camera-handling")
var standardZoomFactor: CGFloat = 1
init(registry: FlutterTextureRegistry?, mobileScannerCallback: @escaping MobileScannerCallback, torchModeChangeCallback: @escaping TorchModeChangeCallback, zoomScaleChangeCallback: @escaping ZoomScaleChangeCallback) {
... ... @@ -118,7 +120,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
}
/// Start scanning for barcodes
func start(barcodeScannerOptions: BarcodeScannerOptions?, returnImage: Bool, cameraPosition: AVCaptureDevice.Position, torch: AVCaptureDevice.TorchMode, detectionSpeed: DetectionSpeed) throws -> MobileScannerStartParameters {
func start(barcodeScannerOptions: BarcodeScannerOptions?, returnImage: Bool, cameraPosition: AVCaptureDevice.Position, torch: AVCaptureDevice.TorchMode, detectionSpeed: DetectionSpeed, completion: @escaping (MobileScannerStartParameters) -> ()) throws {
self.detectionSpeed = detectionSpeed
if (device != nil) {
throw MobileScannerError.alreadyStarted
... ... @@ -195,24 +197,36 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
}
}
captureSession.commitConfiguration()
captureSession.startRunning()
// Enable the torch if parameter is set and torch is available
// torch should be set after 'startRunning' is called
do {
try toggleTorch(torch)
} catch {
print("Failed to set initial torch state.")
}
do {
try resetScale()
} catch {
print("Failed to reset zoom scale")
}
backgroundQueue.async {
self.captureSession.startRunning()
// Enable the torch if parameter is set and torch is available
// torch should be set after 'startRunning' is called
do {
try self.toggleTorch(torch)
} catch {
print("Failed to set initial torch state.")
}
let dimensions = CMVideoFormatDescriptionGetDimensions(device.activeFormat.formatDescription)
do {
try self.resetScale()
} catch {
print("Failed to reset zoom scale")
}
return MobileScannerStartParameters(width: Double(dimensions.height), height: Double(dimensions.width), hasTorch: device.hasTorch, textureId: textureId)
let dimensions = CMVideoFormatDescriptionGetDimensions(self.device.activeFormat.formatDescription)
DispatchQueue.main.async {
completion(
MobileScannerStartParameters(
width: Double(dimensions.height),
height: Double(dimensions.width),
hasTorch: self.device.hasTorch,
textureId: self.textureId
)
)
}
}
}
/// Stop scanning for barcodes
... ... @@ -227,6 +241,8 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
for output in captureSession.outputs {
captureSession.removeOutput(output)
}
latestBuffer = nil
device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode))
device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor))
registry?.unregisterTexture(textureId)
... ...
... ... @@ -120,8 +120,9 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin {
let detectionSpeed: DetectionSpeed = DetectionSpeed(rawValue: speed)!
do {
let parameters = try mobileScanner.start(barcodeScannerOptions: barcodeOptions, returnImage: returnImage, cameraPosition: position, torch: torch ? .on : .off, detectionSpeed: detectionSpeed)
result(["textureId": parameters.textureId, "size": ["width": parameters.width, "height": parameters.height], "torchable": parameters.hasTorch])
try mobileScanner.start(barcodeScannerOptions: barcodeOptions, returnImage: returnImage, cameraPosition: position, torch: torch ? .on : .off, detectionSpeed: detectionSpeed) { parameters in
result(["textureId": parameters.textureId, "size": ["width": parameters.width, "height": parameters.height], "torchable": parameters.hasTorch])
}
} catch MobileScannerError.alreadyStarted {
result(FlutterError(code: "MobileScanner",
message: "Called start() while already started!",
... ...
... ... @@ -8,7 +8,6 @@ import 'package:mobile_scanner/mobile_scanner_web.dart';
import 'package:mobile_scanner/src/barcode_utility.dart';
import 'package:mobile_scanner/src/enums/camera_facing.dart';
import 'package:mobile_scanner/src/objects/barcode.dart';
import 'package:mobile_scanner/src/web/utils.dart';
/// This plugin is the web implementation of mobile_scanner.
/// It only supports QR codes.
... ... @@ -26,8 +25,6 @@ class MobileScannerWebPlugin {
);
final MobileScannerWebPlugin instance = MobileScannerWebPlugin();
_jsLibrariesLoadingFuture = injectJSLibraries(barCodeReader.jsLibraries);
channel.setMethodCallHandler(instance.handleMethodCall);
event.setController(instance.controller);
}
... ... @@ -55,11 +52,8 @@ class MobileScannerWebPlugin {
ZXingBarcodeReader(videoContainer: vidDiv);
StreamSubscription? _barCodeStreamSubscription;
static late Future _jsLibrariesLoadingFuture;
/// Handle incomming messages
Future<dynamic> handleMethodCall(MethodCall call) async {
await _jsLibrariesLoadingFuture;
switch (call.method) {
case 'start':
return _start(call.arguments as Map);
... ... @@ -67,6 +61,8 @@ class MobileScannerWebPlugin {
return _torch(call.arguments);
case 'stop':
return cancel();
case 'updateScanWindow':
return Future<void>.value();
default:
throw PlatformException(
code: 'Unimplemented',
... ... @@ -117,12 +113,14 @@ class MobileScannerWebPlugin {
.map((e) => toFormat(e))
.toList();
}
final Duration? detectionTimeout;
if (arguments.containsKey('timeout')) {
detectionTimeout = Duration(milliseconds: arguments['timeout'] as int);
} else {
detectionTimeout = null;
}
await barCodeReader.start(
cameraFacing: cameraFacing,
formats: formats,
... ... @@ -132,20 +130,31 @@ class MobileScannerWebPlugin {
_barCodeStreamSubscription =
barCodeReader.detectBarcodeContinuously().listen((code) {
if (code != null) {
final List<Offset>? corners = code.corners;
controller.add({
'name': 'barcodeWeb',
'data': {
'rawValue': code.rawValue,
'rawBytes': code.rawBytes,
'format': code.format.rawValue,
'displayValue': code.displayValue,
'type': code.type.index,
if (corners != null && corners.isNotEmpty)
'corners': corners
.map(
(Offset c) => <Object?, Object?>{'x': c.dx, 'y': c.dy},
)
.toList(),
},
});
}
});
final hasTorch = await barCodeReader.hasTorch();
if (hasTorch && arguments.containsKey('torch')) {
barCodeReader.toggleTorch(enabled: arguments['torch'] as bool);
await barCodeReader.toggleTorch(enabled: arguments['torch'] as bool);
}
return {
... ... @@ -154,8 +163,12 @@ class MobileScannerWebPlugin {
'videoHeight': barCodeReader.videoHeight,
'torchable': hasTorch,
};
} catch (e) {
throw PlatformException(code: 'MobileScannerWeb', message: '$e');
} catch (e, stackTrace) {
throw PlatformException(
code: 'MobileScannerWeb',
message: '$e',
details: stackTrace.toString(),
);
}
}
... ...
... ... @@ -9,14 +9,16 @@ Size toSize(Map data) {
return Size(width, height);
}
List<Offset>? toCorners(List? data) {
if (data != null) {
return List.unmodifiable(
data.map((e) => Offset((e as Map)['x'] as double, e['y'] as double)),
);
} else {
List<Offset>? toCorners(List<Map<Object?, Object?>>? data) {
if (data == null) {
return null;
}
return List.unmodifiable(
data.map((Map<Object?, Object?> e) {
return Offset(e['x']! as double, e['y']! as double);
}),
);
}
BarcodeFormat toFormat(int value) {
... ...
... ... @@ -11,4 +11,7 @@ enum MobileScannerErrorCode {
/// The permission to use the camera was denied.
permissionDenied,
/// Scanning is unsupported on the current device.
unsupported,
}
... ...
... ... @@ -137,7 +137,6 @@ class _MobileScannerState extends State<MobileScanner>
widget.onStart?.call(arguments);
widget.onScannerStarted?.call(arguments);
}).catchError((error) {
debugPrint('mobile_scanner: $error');
if (mounted) {
setState(() {
_startException = error as MobileScannerException;
... ... @@ -237,25 +236,25 @@ class _MobileScannerState extends State<MobileScanner>
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return ValueListenableBuilder<MobileScannerArguments?>(
valueListenable: _controller.startArguments,
builder: (context, value, child) {
if (value == null) {
return _buildPlaceholderOrError(context, child);
}
if (widget.scanWindow != null && scanWindow == null) {
scanWindow = calculateScanWindowRelativeToTextureInPercentage(
widget.fit,
widget.scanWindow!,
value.size,
Size(constraints.maxWidth, constraints.maxHeight),
);
_controller.updateScanWindow(scanWindow);
}
final Size size = MediaQuery.sizeOf(context);
return ValueListenableBuilder<MobileScannerArguments?>(
valueListenable: _controller.startArguments,
builder: (context, value, child) {
if (value == null) {
return _buildPlaceholderOrError(context, child);
}
if (widget.scanWindow != null && scanWindow == null) {
scanWindow = calculateScanWindowRelativeToTextureInPercentage(
widget.fit,
widget.scanWindow!,
value.size,
size,
);
_controller.updateScanWindow(scanWindow);
}
return Stack(
alignment: Alignment.center,
... ... @@ -283,8 +282,6 @@ class _MobileScannerState extends State<MobileScanner>
widget.overlay!
],
);
},
);
},
);
}
... ...
... ... @@ -207,9 +207,21 @@ class MobileScannerController {
} on PlatformException catch (error) {
MobileScannerErrorCode errorCode = MobileScannerErrorCode.genericError;
if (error.code == "MobileScannerWeb") {
errorCode = MobileScannerErrorCode.permissionDenied;
final String? errorMessage = error.message;
if (kIsWeb) {
if (errorMessage == null) {
errorCode = MobileScannerErrorCode.genericError;
} else if (errorMessage.contains('NotFoundError') ||
errorMessage.contains('NotSupportedError')) {
errorCode = MobileScannerErrorCode.unsupported;
} else if (errorMessage.contains('NotAllowedError')) {
errorCode = MobileScannerErrorCode.permissionDenied;
} else {
errorCode = MobileScannerErrorCode.genericError;
}
}
isStarting = false;
throw MobileScannerException(
... ... @@ -388,6 +400,10 @@ class MobileScannerController {
rawValue: barcode['rawValue'] as String?,
rawBytes: barcode['rawBytes'] as Uint8List?,
format: toFormat(barcode['format'] as int),
corners: toCorners(
(barcode['corners'] as List<Object?>? ?? [])
.cast<Map<Object?, Object?>>(),
),
),
],
),
... ...
... ... @@ -92,7 +92,9 @@ class Barcode {
/// Create a [Barcode] from native data.
Barcode.fromNative(Map data)
: corners = toCorners(data['corners'] as List?),
: corners = toCorners(
(data['corners'] as List?)?.cast<Map<Object?, Object?>>(),
),
format = toFormat(data['format'] as int),
rawBytes = data['rawBytes'] as Uint8List?,
rawValue = data['rawValue'] as String?,
... ... @@ -201,18 +203,20 @@ class ContactInfo {
/// Create a [ContactInfo] from native data.
ContactInfo.fromNative(Map data)
: addresses = List.unmodifiable(
(data['addresses'] as List).map((e) => Address.fromNative(e as Map)),
(data['addresses'] as List? ?? [])
.cast<Map>()
.map(Address.fromNative),
),
emails = List.unmodifiable(
(data['emails'] as List).map((e) => Email.fromNative(e as Map)),
(data['emails'] as List? ?? []).cast<Map>().map(Email.fromNative),
),
name = toName(data['name'] as Map?),
organization = data['organization'] as String?,
phones = List.unmodifiable(
(data['phones'] as List).map((e) => Phone.fromNative(e as Map)),
(data['phones'] as List? ?? []).cast<Map>().map(Phone.fromNative),
),
title = data['title'] as String?,
urls = List.unmodifiable(data['urls'] as List);
urls = List.unmodifiable((data['urls'] as List? ?? []).cast<String>());
}
/// An address.
... ... @@ -227,7 +231,9 @@ class Address {
/// Create a [Address] from native data.
Address.fromNative(Map data)
: addressLines = List.unmodifiable(data['addressLines'] as List),
: addressLines = List.unmodifiable(
(data['addressLines'] as List? ?? []).cast<String>(),
),
type = AddressType.values[data['type'] as int];
}
... ...
... ... @@ -39,9 +39,6 @@ abstract class WebBarcodeReaderBase {
int get videoWidth;
int get videoHeight;
/// JS libraries to be injected into html page.
List<JsLibrary> get jsLibraries;
/// Starts streaming video
Future<void> start({
required CameraFacing cameraFacing,
... ... @@ -128,10 +125,8 @@ mixin InternalTorchDetection on InternalStreamCreation {
final photoCapabilities = await promiseToFuture<PhotoCapabilities>(
imageCapture.getPhotoCapabilities(),
);
final fillLightMode = photoCapabilities.fillLightMode;
if (fillLightMode != null) {
return fillLightMode;
}
return photoCapabilities.fillLightMode;
}
} catch (e) {
// ImageCapture is not supported by some browsers:
... ... @@ -165,9 +160,16 @@ class Promise<T> {}
@JS()
@anonymous
class PhotoCapabilities {
@staticInterop
class PhotoCapabilities {}
extension PhotoCapabilitiesExtension on PhotoCapabilities {
@JS('fillLightMode')
external List<dynamic>? get _fillLightMode;
/// Returns an array of available fill light options. Options include auto, off, or flash.
external List<String>? get fillLightMode;
List<String> get fillLightMode =>
_fillLightMode?.cast<String>() ?? <String>[];
}
@JS('ImageCapture')
... ...
... ... @@ -20,12 +20,6 @@ class Code {
external Uint8ClampedList get binaryData;
}
const jsqrLibrary = JsLibrary(
contextName: 'jsQR',
url: 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js',
usesRequireJs: true,
);
/// Barcode reader that uses jsQR library.
/// jsQR supports only QR codes format.
class JsQrCodeReader extends WebBarcodeReaderBase
... ... @@ -36,9 +30,6 @@ class JsQrCodeReader extends WebBarcodeReaderBase
bool get isStarted => localMediaStream != null;
@override
List<JsLibrary> get jsLibraries => [jsqrLibrary];
@override
Future<void> start({
required CameraFacing cameraFacing,
List<BarcodeFormat>? formats,
... ...
import 'dart:async';
import 'dart:html' as html;
import 'dart:js' show JsObject, context;
import 'package:mobile_scanner/src/web/base.dart';
Future<void> loadScript(JsLibrary library) async {
dynamic amd;
dynamic define;
// ignore: avoid_dynamic_calls
if (library.usesRequireJs && context['define']?['amd'] != null) {
// In dev, requireJs is loaded in. Disable it here.
// see https://github.com/dart-lang/sdk/issues/33979
define = JsObject.fromBrowserObject(context['define'] as Object);
// ignore: avoid_dynamic_calls
amd = define['amd'];
// ignore: avoid_dynamic_calls
define['amd'] = false;
}
try {
await loadScriptUsingScriptTag(library.url);
} finally {
if (amd != null) {
// Re-enable requireJs
// ignore: avoid_dynamic_calls
define['amd'] = amd;
}
}
}
Future<void> loadScriptUsingScriptTag(String url) {
final script = html.ScriptElement()
..async = true
..defer = false
..crossOrigin = 'anonymous'
..type = 'text/javascript'
// ignore: unsafe_html
..src = url;
html.document.head!.append(script);
return script.onLoad.first;
}
/// Injects JS [libraries]
///
/// Returns a [Future] that resolves when all of the `script` tags `onLoad` events trigger.
Future<void> injectJSLibraries(List<JsLibrary> libraries) {
final List<Future<void>> loading = [];
for (final library in libraries) {
final future = loadScript(library);
loading.add(future);
}
return Future.wait(loading);
}
import 'dart:async';
import 'dart:html';
import 'dart:typed_data';
import 'dart:ui';
import 'package:js/js.dart';
import 'package:mobile_scanner/src/enums/camera_facing.dart';
... ... @@ -19,6 +20,16 @@ class JsZXingBrowserMultiFormatReader {
@JS()
@anonymous
abstract class ResultPoint {
/// The x coordinate of the point.
external double get x;
/// The y coordinate of the point.
external double get y;
}
@JS()
@anonymous
abstract class Result {
/// raw text encoded by the barcode
external String get text;
... ... @@ -28,15 +39,24 @@ abstract class Result {
/// Representing the format of the barcode that was decoded
external int? format;
/// Returns the result points of the barcode. These points represent the corners of the barcode.
external List<Object?> get resultPoints;
}
extension ResultExt on Result {
Barcode toBarcode() {
final corners = resultPoints
.cast<ResultPoint>()
.map((ResultPoint rp) => Offset(rp.x, rp.y))
.toList();
final rawBytes = this.rawBytes;
return Barcode(
rawValue: text,
rawBytes: rawBytes != null ? Uint8List.fromList(rawBytes) : null,
format: barcodeFormat,
corners: corners,
);
}
... ... @@ -168,12 +188,6 @@ extension JsZXingBrowserMultiFormatReaderExt
external MediaStream? stream;
}
const zxingJsLibrary = JsLibrary(
contextName: 'ZXing',
url: 'https://unpkg.com/@zxing/library@0.19.1',
usesRequireJs: true,
);
/// Barcode reader that uses zxing-js library.
class ZXingBarcodeReader extends WebBarcodeReaderBase
with InternalStreamCreation, InternalTorchDetection {
... ... @@ -185,9 +199,6 @@ class ZXingBarcodeReader extends WebBarcodeReaderBase
bool get isStarted => localMediaStream != null;
@override
List<JsLibrary> get jsLibraries => [zxingJsLibrary];
@override
Future<void> start({
required CameraFacing cameraFacing,
List<BarcodeFormat>? formats,
... ...
name: mobile_scanner
description: A universal barcode and QR code scanner for Flutter based on MLKit. Uses CameraX on Android, AVFoundation on iOS and Apple Vision & AVFoundation on macOS.
version: 3.2.1
version: 3.3.0
repository: https://github.com/juliansteenbakker/mobile_scanner
environment:
sdk: ">=2.17.0 <3.0.0"
sdk: ">=2.17.0 <4.0.0"
flutter: ">=3.0.0"
dependencies:
... ... @@ -14,7 +14,6 @@ dependencies:
sdk: flutter
js: ^0.6.3
dev_dependencies:
flutter_test:
sdk: flutter
... ...