Julian Steenbakker

Merge remote-tracking branch 'origin/master'

## 0.2.0
You can provide a path to controller.analyzeImage(path) in order to scan a local photo from the gallery!
Check out the example app to see how you can use the image_picker plugin to retrieve a photo from
the gallery. Please keep in mind that this feature is only supported on Android and iOS.
Another feature that has been added is a format selector!
Just keep in mind that iOS for now only supports 1 selected barcode.
## 0.1.3
* Fixed crash after asking permission. [#29](https://github.com/juliansteenbakker/mobile_scanner/issues/29)
* Upgraded cameraX from 1.1.0-beta01 to 1.1.0-beta02
## 0.1.2
* MobileScannerArguments is now exported. [#7](https://github.com/juliansteenbakker/mobile_scanner/issues/7)
... ...
... ... @@ -11,11 +11,30 @@ A universal barcode and QR code scanner for Flutter based on MLKit. Uses CameraX
| :-----: | :-: | :---: | :-: | :---: | :-----: |
| ✔️ | ✔️ | ✔️ | | | |
CameraX for Android requires at least SDK 21.
### Android
SDK 21 and newer. Reason: CameraX requires at least SDK 21.
MLKit for iOS requires at least iOS 11 and a [64bit device](https://developers.google.com/ml-kit/migration/ios).
### iOS
iOS 11 and newer. Reason: MLKit for iOS requires at least iOS 11 and a [64bit device](https://developers.google.com/ml-kit/migration/ios).
# Usage
**Add the following keys to your Info.plist file, located in <project root>/ios/Runner/Info.plist:**
NSCameraUsageDescription - describe why your app needs access to the camera. This is called Privacy - Camera Usage Description in the visual editor.
**If you want to use the local gallery feature from [image_picker](https://pub.dev/packages/image_picker)**
NSPhotoLibraryUsageDescription - describe why your app needs permission for the photo library. This is called Privacy - Photo Library Usage Description in the visual editor.
### macOS
macOS 10.13 or newer. Reason: Apple Vision library.
## Features Supported
| Features | Android | iOS | macOS | Web |
|------------------------|--------------------|--------------------|-------|-----|
| analyzeImage (Gallery) | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: |
## Usage
Import `package:mobile_scanner/mobile_scanner.dart`, and use the widget with or without the controller.
... ...
... ... @@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.1'
classpath 'com.android.tools.build:gradle:7.1.2'
}
}
... ... @@ -47,8 +47,8 @@ android {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.google.mlkit:barcode-scanning:17.0.2'
implementation "androidx.camera:camera-camera2:1.1.0-beta01"
implementation 'androidx.camera:camera-lifecycle:1.1.0-beta01'
implementation "androidx.camera:camera-camera2:1.1.0-beta02"
implementation 'androidx.camera:camera-lifecycle:1.1.0-beta02'
// // The following line is optional, as the core library is included indirectly by camera-camera2
// implementation "androidx.camera:camera-core:1.1.0-alpha11"
... ...
package dev.steenbakker.mobile_scanner
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import java.util.ArrayList
enum class BarcodeFormats(val intValue: Int) {
UNKNOWN(com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UNKNOWN),
ALL_FORMATS(com.google.mlkit.vision.barcode.common.Barcode.FORMAT_ALL_FORMATS), CODE_128(com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_128), CODE_39(
com.google.mlkit.vision.barcode.common.Barcode.FORMAT_CODE_39
),
... ... @@ -17,76 +17,4 @@ enum class BarcodeFormats(val intValue: Int) {
com.google.mlkit.vision.barcode.common.Barcode.FORMAT_UPC_E
),
PDF417(com.google.mlkit.vision.barcode.common.Barcode.FORMAT_PDF417), AZTEC(com.google.mlkit.vision.barcode.common.Barcode.FORMAT_AZTEC);
companion object {
private var formatsMap: MutableMap<String, Int>? = null
/**
* Return the integer value resuling from OR-ing all of the values
* of the supplied strings.
*
*
* Note that if ALL_FORMATS is defined as well as other values, ALL_FORMATS
* will be ignored (following how it would work with just OR-ing the ints).
*
* @param strings - list of strings representing the various formats
* @return integer value corresponding to OR of all the values.
*/
fun intFromStringList(strings: List<String>?): Int {
if (strings == null) return ALL_FORMATS.intValue
var `val` = 0
for (string in strings) {
val asInt = formatsMap!![string]
if (asInt != null) {
`val` = `val` or asInt
}
}
return `val`
}
fun optionsFromStringList(strings: List<String>?): BarcodeScannerOptions {
if (strings == null) {
return BarcodeScannerOptions.Builder().setBarcodeFormats(ALL_FORMATS.intValue)
.build()
}
val ints: MutableList<Int> = ArrayList(strings.size)
run {
var i = 0
val l = strings.size
while (i < l) {
val integer =
formatsMap!![strings[i]]
if (integer != null) {
ints.add(integer)
}
++i
}
}
if (ints.size == 0) {
return BarcodeScannerOptions.Builder().setBarcodeFormats(ALL_FORMATS.intValue)
.build()
}
if (ints.size == 1) {
return BarcodeScannerOptions.Builder().setBarcodeFormats(ints[0]).build()
}
val first = ints[0]
val rest = IntArray(ints.size - 1)
var i = 0
for (e in ints.subList(1, ints.size)) {
rest[i++] = e
}
return BarcodeScannerOptions.Builder()
.setBarcodeFormats(first, *rest).build()
}
init {
val values = values()
formatsMap =
HashMap<String, Int>(values.size * 4 / 3)
for (value in values) {
formatsMap!![value.name] =
value.intValue
}
}
}
}
\ No newline at end of file
... ...
... ... @@ -4,6 +4,7 @@ import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Point
import android.net.Uri
import android.util.Log
import android.util.Size
import android.view.Surface
... ... @@ -22,6 +23,8 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import io.flutter.view.TextureRegistry
import java.io.File
import java.net.URI
class MobileScanner(private val activity: Activity, private val textureRegistry: TextureRegistry)
... ... @@ -50,6 +53,7 @@ class MobileScanner(private val activity: Activity, private val textureRegistry:
"torch" -> toggleTorch(call, result)
// "analyze" -> switchAnalyzeMode(call, result)
"stop" -> stop(result)
"analyzeImage" -> analyzeImage(call, result)
else -> result.notImplemented()
}
}
... ... @@ -124,11 +128,18 @@ class MobileScanner(private val activity: Activity, private val textureRegistry:
val facing: Int = call.argument<Int>("facing") ?: 0
val ratio: Int? = call.argument<Int>("ratio")
val torch: Boolean = call.argument<Boolean>("torch") ?: false
val formatStrings: List<String>? = call.argument<List<String>>("formats")
val formats: List<Int>? = call.argument<List<Int>>("formats")
if (formatStrings != null) {
val options: BarcodeScannerOptions = BarcodeFormats.optionsFromStringList(formatStrings)
scanner = BarcodeScanning.getClient(options)
if (formats != null) {
val formatsList: MutableList<Int> = mutableListOf()
for (index in formats) {
formatsList.add(BarcodeFormats.values()[index].intValue)
}
scanner = if (formatsList.size == 1) {
BarcodeScanning.getClient( BarcodeScannerOptions.Builder().setBarcodeFormats(formatsList.first()).build());
} else {
BarcodeScanning.getClient( BarcodeScannerOptions.Builder().setBarcodeFormats(formatsList.first(), *formatsList.subList(1, formatsList.size).toIntArray() ).build());
}
}
val future = ProcessCameraProvider.getInstance(activity)
... ... @@ -136,6 +147,7 @@ class MobileScanner(private val activity: Activity, private val textureRegistry:
future.addListener({
cameraProvider = future.get()
cameraProvider!!.unbindAll()
textureEntry = textureRegistry.createSurfaceTexture()
// Preview
... ... @@ -204,6 +216,24 @@ class MobileScanner(private val activity: Activity, private val textureRegistry:
// result.success(null)
// }
private fun analyzeImage(call: MethodCall, result: MethodChannel.Result) {
val uri = Uri.fromFile( File(call.arguments.toString()))
val inputImage = InputImage.fromFilePath(activity, uri)
var barcodeFound = false
scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
for (barcode in barcodes) {
barcodeFound = true
sink?.success(mapOf("name" to "barcode", "data" to barcode.data))
}
}
.addOnFailureListener { e -> Log.e(TAG, e.message, e)
result.error(TAG, e.message, e)}
.addOnCompleteListener { result.success(barcodeFound) }
}
private fun stop(result: MethodChannel.Result) {
if (camera == null) {
result.error(TAG,"Called stop() while already stopped!", null)
... ...
... ... @@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.1'
classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
... ...
... ... @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access in order to open photos of barcodes</string>
<key>NSCameraUsageDescription</key>
<string>We use the camera to scan barcodes</string>
<key>CFBundleDevelopmentRegion</key>
... ...
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class BarcodeScannerWithController extends StatefulWidget {
... ... @@ -16,9 +17,12 @@ class _BarcodeScannerWithControllerState
MobileScannerController controller = MobileScannerController(
torchEnabled: true,
// formats: [BarcodeFormat.qrCode]
// facing: CameraFacing.front,
);
bool isStarted = true;
@override
Widget build(BuildContext context) {
return MaterialApp(
... ... @@ -69,9 +73,21 @@ class _BarcodeScannerWithControllerState
iconSize: 32.0,
onPressed: () => controller.toggleTorch(),
),
IconButton(
color: Colors.white,
icon: isStarted
? const Icon(Icons.stop)
: const Icon(Icons.play_arrow),
iconSize: 32.0,
onPressed: () => setState(() {
isStarted
? controller.stop()
: controller.start();
isStarted = !isStarted;
})),
Center(
child: SizedBox(
width: MediaQuery.of(context).size.width - 120,
width: MediaQuery.of(context).size.width - 200,
height: 50,
child: FittedBox(
child: Text(
... ... @@ -101,6 +117,32 @@ class _BarcodeScannerWithControllerState
iconSize: 32.0,
onPressed: () => controller.switchCamera(),
),
IconButton(
color: Colors.white,
icon: const Icon(Icons.image),
iconSize: 32.0,
onPressed: () async {
final ImagePicker _picker = ImagePicker();
// Pick an image
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery);
if (image != null) {
if (await controller.analyzeImage(image.path)) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text('Barcode found!'),
backgroundColor: Colors.green,
));
} else {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text('No barcode found!'),
backgroundColor: Colors.red,
));
}
}
},
),
],
),
),
... ...
... ... @@ -6,6 +6,7 @@ environment:
sdk: ">=2.12.0 <3.0.0"
dependencies:
image_picker: ^0.8.4+9
flutter:
sdk: flutter
... ...
... ... @@ -22,11 +22,12 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHan
// Image to be sent to the texture
var latestBuffer: CVImageBuffer!
// var analyzeMode: Int = 0
var analyzing: Bool = false
var position = AVCaptureDevice.Position.back
var scanner = BarcodeScanner.barcodeScanner()
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = SwiftMobileScannerPlugin(registrar.textures())
... ... @@ -58,6 +59,8 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHan
// switchAnalyzeMode(call, result)
case "stop":
stop(result)
case "analyzeImage":
analyzeImage(call, result)
default:
result(FlutterMethodNotImplemented)
}
... ... @@ -102,7 +105,6 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHan
defaultOrientation: .portrait
)
let scanner = BarcodeScanner.barcodeScanner()
scanner.process(image) { [self] barcodes, error in
if error == nil && barcodes != nil {
for barcode in barcodes! {
... ... @@ -169,7 +171,16 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHan
// let ratio: Int = argReader.int(key: "ratio")
let torch: Bool = argReader.bool(key: "torch") ?? false
let facing: Int = argReader.int(key: "facing") ?? 1
let formats: Array = argReader.intArray(key: "formats") ?? []
let formatList: NSMutableArray = []
for index in formats {
formatList.add(BarcodeFormat(rawValue: index))
}
let barcodeOptions = BarcodeScannerOptions(formats: formatList.firstObject as! BarcodeFormat)
scanner = BarcodeScanner.barcodeScanner(options: barcodeOptions)
// Set the camera to use
position = facing == 0 ? AVCaptureDevice.Position.front : .back
... ... @@ -255,6 +266,42 @@ public class SwiftMobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHan
// result(nil)
// }
func analyzeImage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
let uiImage = UIImage(contentsOfFile: call.arguments as! String)
if (uiImage == nil) {
result(FlutterError(code: "MobileScanner",
message: "No image found in analyzeImage!",
details: nil))
return
}
let image = VisionImage(image: uiImage!)
image.orientation = imageOrientation(
deviceOrientation: UIDevice.current.orientation,
defaultOrientation: .portrait
)
var barcodeFound = false
scanner.process(image) { [self] barcodes, error in
if error == nil && barcodes != nil {
for barcode in barcodes! {
barcodeFound = true
let event: [String: Any?] = ["name": "barcode", "data": barcode.data]
sink?(event)
}
} else if error != nil {
result(FlutterError(code: "MobileScanner",
message: error?.localizedDescription,
details: "analyzeImage()"))
}
analyzing = false
result(barcodeFound)
}
}
func stop(_ result: FlutterResult) {
if (device == nil) {
result(FlutterError(code: "MobileScanner",
... ... @@ -318,5 +365,9 @@ class MapArgumentReader {
func stringArray(key: String) -> [String]? {
return args?[key] as? [String]
}
func intArray(key: String) -> [Int]? {
return args?[key] as? [Int]
}
}
... ...
... ... @@ -24,12 +24,9 @@ class MobileScanner extends StatefulWidget {
final BoxFit fit;
/// Create a [MobileScanner] with a [controller], the [controller] must has been initialized.
const MobileScanner({
Key? key,
this.onDetect,
this.controller,
this.fit = BoxFit.cover,
}) : super(key: key);
const MobileScanner(
{Key? key, this.onDetect, this.controller, this.fit = BoxFit.cover})
: super(key: key);
@override
State<MobileScanner> createState() => _MobileScannerState();
... ... @@ -50,7 +47,7 @@ class _MobileScannerState extends State<MobileScanner>
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
controller.start();
if (!controller.isStarting) controller.start();
break;
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
... ...
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
... ... @@ -43,6 +44,11 @@ class MobileScannerController {
final Ratio? ratio;
final bool? torchEnabled;
/// If provided, the scanner will only detect those specific formats.
///
/// WARNING: On iOS, only 1 format is supported.
final List<BarcodeFormat>? formats;
CameraFacing facing;
bool hasTorch = false;
late StreamController<Barcode> barcodesController;
... ... @@ -50,7 +56,10 @@ class MobileScannerController {
Stream<Barcode> get barcodes => barcodesController.stream;
MobileScannerController(
{this.facing = CameraFacing.back, this.ratio, this.torchEnabled}) {
{this.facing = CameraFacing.back,
this.ratio,
this.torchEnabled,
this.formats}) {
// In case a new instance is created before calling dispose()
if (_controllerHashcode != null) {
stop();
... ... @@ -102,12 +111,18 @@ class MobileScannerController {
// }
// List<BarcodeFormats>? formats = _defaultBarcodeFormats,
bool isStarting = false;
/// Start barcode scanning. This will first check if the required permissions
/// are set.
Future<void> start() async {
ensure('startAsync');
if (isStarting) {
throw Exception('mobile_scanner: Called start() while already starting.');
}
isStarting = true;
// setAnalyzeMode(AnalyzeMode.barcode.index);
// Check authorization status
MobileScannerState state =
MobileScannerState.values[await methodChannel.invokeMethod('state')];
... ... @@ -118,6 +133,7 @@ class MobileScannerController {
result ? MobileScannerState.authorized : MobileScannerState.denied;
break;
case MobileScannerState.denied:
isStarting = false;
throw PlatformException(code: 'NO ACCESS');
case MobileScannerState.authorized:
break;
... ... @@ -131,6 +147,14 @@ class MobileScannerController {
if (ratio != null) arguments['ratio'] = ratio;
if (torchEnabled != null) arguments['torch'] = torchEnabled;
if (formats != null) {
if (Platform.isAndroid) {
arguments['formats'] = formats!.map((e) => e.index).toList();
} else if (Platform.isIOS || Platform.isMacOS) {
arguments['formats'] = formats!.map((e) => e.rawValue).toList();
}
}
// Start the camera with arguments
Map<String, dynamic>? startResult = {};
try {
... ... @@ -138,11 +162,13 @@ class MobileScannerController {
'start', arguments);
} on PlatformException catch (error) {
debugPrint('${error.code}: ${error.message}');
isStarting = false;
// setAnalyzeMode(AnalyzeMode.none.index);
return;
}
if (startResult == null) {
isStarting = false;
throw PlatformException(code: 'INITIALIZATION ERROR');
}
... ... @@ -151,6 +177,7 @@ class MobileScannerController {
textureId: startResult['textureId'],
size: toSize(startResult['size']),
hasTorch: hasTorch);
isStarting = false;
}
Future<void> stop() async {
... ... @@ -198,7 +225,16 @@ class MobileScannerController {
await start();
}
/// Disposes the controller and closes all listeners.
/// Handles a local image file.
/// Returns true if a barcode or QR code is found.
/// Returns false if nothing is found.
///
/// [path] The path of the image on the devices
Future<bool> analyzeImage(String path) async {
return await methodChannel.invokeMethod('analyzeImage', path);
}
/// Disposes the MobileScannerController and closes all listeners.
void dispose() {
if (hashCode == _controllerHashcode) {
stop();
... ... @@ -209,11 +245,11 @@ class MobileScannerController {
barcodesController.close();
}
/// Checks if the controller is bound to the correct MobileScanner object.
/// Checks if the MobileScannerController is bound to the correct MobileScanner object.
void ensure(String name) {
final message =
'CameraController.$name called after CameraController.dispose\n'
'CameraController methods should not be used after calling dispose.';
'MobileScannerController.$name called after MobileScannerController.dispose\n'
'MobileScannerController methods should not be used after calling dispose.';
assert(hashCode == _controllerHashcode, message);
}
}
... ...
... ... @@ -553,6 +553,43 @@ enum BarcodeFormat {
aztec,
}
extension BarcodeValue on BarcodeFormat {
int get rawValue {
switch (this) {
case BarcodeFormat.unknown:
return -1;
case BarcodeFormat.all:
return 0;
case BarcodeFormat.code128:
return 1;
case BarcodeFormat.code39:
return 2;
case BarcodeFormat.code93:
return 4;
case BarcodeFormat.codebar:
return 8;
case BarcodeFormat.dataMatrix:
return 16;
case BarcodeFormat.ean13:
return 32;
case BarcodeFormat.ean8:
return 64;
case BarcodeFormat.itf:
return 128;
case BarcodeFormat.qrCode:
return 256;
case BarcodeFormat.upcA:
return 512;
case BarcodeFormat.upcE:
return 1024;
case BarcodeFormat.pdf417:
return 2048;
case BarcodeFormat.aztec:
return 4096;
}
}
}
/// Address type constants.
enum AddressType {
/// Unknown address type.
... ...
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: 0.1.2
version: 0.2.0
repository: https://github.com/juliansteenbakker/mobile_scanner
environment:
sdk: ">=2.12.0 <3.0.0"
flutter: ">=2.2.0"
dependencies:
flutter:
... ...