Julian Steenbakker
Committed by GitHub

Merge pull request #994 from fumin65/pause_function

feat: add pause feature
... ... @@ -273,7 +273,7 @@ class MobileScanner(
}
cameraProvider?.unbindAll()
textureEntry = textureRegistry.createSurfaceTexture()
textureEntry = textureEntry ?: textureRegistry.createSurfaceTexture()
// Preview
val surfaceProvider = Preview.SurfaceProvider { request ->
... ... @@ -405,14 +405,33 @@ class MobileScanner(
}, executor)
}
/**
* Pause barcode scanning.
*/
fun pause() {
if (isPaused()) {
throw AlreadyPaused()
} else if (isStopped()) {
throw AlreadyStopped()
}
releaseCamera()
}
/**
* Stop barcode scanning.
*/
fun stop() {
if (isStopped()) {
if (!isPaused() && isStopped()) {
throw AlreadyStopped()
}
releaseCamera()
releaseTexture()
}
private fun releaseCamera() {
if (displayListener != null) {
val displayManager = activity.applicationContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
... ... @@ -430,9 +449,6 @@ class MobileScanner(
// 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()
cameraProvider = null
camera = null
preview = null
// Release the texture for the preview.
textureEntry?.release()
... ... @@ -444,7 +460,13 @@ class MobileScanner(
lastScanned = null
}
private fun releaseTexture() {
textureEntry?.release()
textureEntry = null
}
private fun isStopped() = camera == null && preview == null
private fun isPaused() = isStopped() && textureEntry != null
/**
* Toggles the flash light on or off.
... ...
... ... @@ -3,6 +3,7 @@ package dev.steenbakker.mobile_scanner
class NoCamera : Exception()
class AlreadyStarted : Exception()
class AlreadyStopped : Exception()
class AlreadyPaused : Exception()
class CameraError : Exception()
class ZoomWhenStopped : Exception()
class ZoomNotInRange : Exception()
\ No newline at end of file
... ...
... ... @@ -118,6 +118,7 @@ class MobileScannerHandler(
}
})
"start" -> start(call, result)
"pause" -> pause(result)
"stop" -> stop(result)
"toggleTorch" -> toggleTorch(result)
"analyzeImage" -> analyzeImage(call, result)
... ... @@ -213,6 +214,18 @@ class MobileScannerHandler(
)
}
private fun pause(result: MethodChannel.Result) {
try {
mobileScanner!!.pause()
result.success(null)
} catch (e: Exception) {
when (e) {
is AlreadyPaused, is AlreadyStopped -> result.success(null)
else -> throw e
}
}
}
private fun stop(result: MethodChannel.Result) {
try {
mobileScanner!!.stop()
... ...
... ... @@ -105,6 +105,7 @@ class _BarcodeScannerWithControllerState
children: [
ToggleFlashlightButton(controller: controller),
StartStopMobileScannerButton(controller: controller),
PauseMobileScannerButton(controller: controller),
Expanded(child: Center(child: _buildBarcode(_barcode))),
SwitchCameraButton(controller: controller),
AnalyzeImageFromGalleryButton(controller: controller),
... ...
... ... @@ -180,3 +180,30 @@ class ToggleFlashlightButton extends StatelessWidget {
);
}
}
class PauseMobileScannerButton extends StatelessWidget {
const PauseMobileScannerButton({required this.controller, super.key});
final MobileScannerController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller,
builder: (context, state, child) {
if (!state.isInitialized || !state.isRunning) {
return const SizedBox.shrink();
}
return IconButton(
color: Colors.white,
iconSize: 32.0,
icon: const Icon(Icons.pause),
onPressed: () async {
await controller.pause();
},
);
},
);
}
}
... ...
... ... @@ -58,6 +58,14 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
public var timeoutSeconds: Double = 0
private var stopped: Bool {
return device == nil || captureSession == nil
}
private var paused: Bool {
return stopped && textureId != nil
}
init(registry: FlutterTextureRegistry?, mobileScannerCallback: @escaping MobileScannerCallback, torchModeChangeCallback: @escaping TorchModeChangeCallback, zoomScaleChangeCallback: @escaping ZoomScaleChangeCallback) {
self.registry = registry
self.mobileScannerCallback = mobileScannerCallback
... ... @@ -123,6 +131,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
/// Gets called when a new image is added to the buffer
public func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
... ... @@ -157,7 +166,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
if (error == nil && barcodesString != nil && newScannedBarcodes != nil && barcodesString!.elementsEqual(newScannedBarcodes!)) {
return
}
if (newScannedBarcodes?.isEmpty == false) {
barcodesString = newScannedBarcodes
}
... ... @@ -178,7 +187,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
barcodesString = nil
scanner = barcodeScannerOptions != nil ? BarcodeScanner.barcodeScanner(options: barcodeScannerOptions!) : BarcodeScanner.barcodeScanner()
captureSession = AVCaptureSession()
textureId = registry?.register(self)
textureId = textureId ?? registry?.register(self)
// Open the camera device
device = getDefaultCameraDevice(position: cameraPosition)
... ... @@ -293,27 +302,49 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
}
}
/// Pause scanning for barcodes
func pause() throws {
if (paused) {
throw MobileScannerError.alreadyPaused
} else if (stopped) {
throw MobileScannerError.alreadyStopped
}
releaseCamera()
}
/// Stop scanning for barcodes
func stop() throws {
if (device == nil || captureSession == nil) {
if (!paused && stopped) {
throw MobileScannerError.alreadyStopped
}
captureSession!.stopRunning()
for input in captureSession!.inputs {
captureSession!.removeInput(input)
releaseCamera()
releaseTexture()
}
private func releaseCamera() {
guard let captureSession = captureSession else {
return
}
captureSession.stopRunning()
for input in captureSession.inputs {
captureSession.removeInput(input)
}
for output in captureSession!.outputs {
captureSession!.removeOutput(output)
for output in captureSession.outputs {
captureSession.removeOutput(output)
}
latestBuffer = nil
device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode))
device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.videoZoomFactor))
self.captureSession = nil
device = nil
}
private func releaseTexture() {
registry?.unregisterTexture(textureId)
textureId = nil
captureSession = nil
device = nil
scanner = nil
}
... ... @@ -440,7 +471,7 @@ public class MobileScanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelega
defaultOrientation: .portrait,
position: position
)
let scanner: BarcodeScanner = barcodeScannerOptions != nil ? BarcodeScanner.barcodeScanner(options: barcodeScannerOptions!) : BarcodeScanner.barcodeScanner()
scanner.process(image, completion: callback)
... ...
... ... @@ -16,6 +16,7 @@ enum MobileScannerError: Error {
case noCamera
case alreadyStarted
case alreadyStopped
case alreadyPaused
case cameraError(_ error: Error)
case zoomWhenStopped
case zoomError(_ error: Error)
... ...
... ... @@ -105,6 +105,8 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin {
AVCaptureDevice.requestAccess(for: .video, completionHandler: { result($0) })
case "start":
start(call, result)
case "pause":
pause(result)
case "stop":
stop(result)
case "toggleTorch":
... ... @@ -166,6 +168,14 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin {
details: nil))
}
}
/// Stops the mobileScanner without closing the texture.
private func pause(_ result: @escaping FlutterResult) {
do {
try mobileScanner.pause()
} catch {}
result(nil)
}
/// Stops the mobileScanner and closes the texture.
private func stop(_ result: @escaping FlutterResult) {
... ...
... ... @@ -46,6 +46,7 @@ class MethodChannelMobileScanner extends MobileScannerPlatform {
}
int? _textureId;
bool _pausing = false;
/// Parse a [BarcodeCapture] from the given [event].
BarcodeCapture? _parseBarcode(Map<Object?, Object?>? event) {
... ... @@ -216,7 +217,7 @@ class MethodChannelMobileScanner extends MobileScannerPlatform {
@override
Future<MobileScannerViewAttributes> start(StartOptions startOptions) async {
if (_textureId != null) {
if (!_pausing && _textureId != null) {
throw const MobileScannerException(
errorCode: MobileScannerErrorCode.controllerAlreadyInitialized,
errorDetails: MobileScannerErrorDetails(
... ... @@ -281,6 +282,8 @@ class MethodChannelMobileScanner extends MobileScannerPlatform {
size = Size.zero;
}
_pausing = false;
return MobileScannerViewAttributes(
currentTorchMode: currentTorchState,
numberOfCameras: numberOfCameras,
... ... @@ -295,11 +298,23 @@ class MethodChannelMobileScanner extends MobileScannerPlatform {
}
_textureId = null;
_pausing = false;
await methodChannel.invokeMethod<void>('stop');
}
@override
Future<void> pause() async {
if (_pausing) {
return;
}
_pausing = true;
await methodChannel.invokeMethod<void>('pause');
}
@override
Future<void> toggleTorch() async {
await methodChannel.invokeMethod<void>('toggleTorch');
}
... ...
... ... @@ -183,6 +183,30 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> {
}
}
void _stop() {
// Do nothing if not initialized or already stopped.
// On the web, the permission popup triggers a lifecycle change from resumed to inactive,
// due to the permission popup gaining focus.
// This would 'stop' the camera while it is not ready yet.
if (!value.isInitialized || !value.isRunning || _isDisposed) {
return;
}
_disposeListeners();
final TorchState oldTorchState = value.torchState;
// After the camera stopped, set the torch state to off,
// as the torch state callback is never called when the camera is stopped.
// If the device does not have a torch, do not report "off".
value = value.copyWith(
isRunning: false,
torchState: oldTorchState == TorchState.unavailable
? TorchState.unavailable
: TorchState.off,
);
}
/// Analyze an image file.
///
/// The [path] points to a file on the device.
... ... @@ -336,31 +360,21 @@ class MobileScannerController extends ValueNotifier<MobileScannerState> {
///
/// Does nothing if the camera is already stopped.
Future<void> stop() async {
// Do nothing if not initialized or already stopped.
// On the web, the permission popup triggers a lifecycle change from resumed to inactive,
// due to the permission popup gaining focus.
// This would 'stop' the camera while it is not ready yet.
if (!value.isInitialized || !value.isRunning || _isDisposed) {
return;
}
_disposeListeners();
final TorchState oldTorchState = value.torchState;
// After the camera stopped, set the torch state to off,
// as the torch state callback is never called when the camera is stopped.
// If the device does not have a torch, do not report "off".
value = value.copyWith(
isRunning: false,
torchState: oldTorchState == TorchState.unavailable
? TorchState.unavailable
: TorchState.off,
);
_stop();
await MobileScannerPlatform.instance.stop();
}
/// Pause the camera.
///
/// This method stops to update camera frame and scan barcodes.
/// After calling this method, the camera can be restarted using [start].
///
/// Does nothing if the camera is already paused or stopped.
Future<void> pause() async {
_stop();
await MobileScannerPlatform.instance.pause();
}
/// Switch between the front and back camera.
///
/// Does nothing if the device has less than 2 cameras.
... ...
... ... @@ -97,6 +97,11 @@ abstract class MobileScannerPlatform extends PlatformInterface {
throw UnimplementedError('stop() has not been implemented.');
}
/// Pause the camera.
Future<void> pause() {
throw UnimplementedError('pause() has not been implemented.');
}
/// Toggle the torch on the active camera on or off.
Future<void> toggleTorch() {
throw UnimplementedError('toggleTorch() has not been implemented.');
... ...
... ... @@ -128,6 +128,11 @@ abstract class BarcodeReader {
throw UnimplementedError('start() has not been implemented.');
}
/// Pause the barcode reader.
Future<void> pause() {
throw UnimplementedError('pause() has not been implemented.');
}
/// Stop the barcode reader and dispose of the video stream.
Future<void> stop() {
throw UnimplementedError('stop() has not been implemented.');
... ...
... ... @@ -271,6 +271,11 @@ class MobileScannerWeb extends MobileScannerPlatform {
);
}
// If the previous state is a pause, reset scanner.
if (_barcodesSubscription != null && _barcodesSubscription!.isPaused) {
await stop();
}
_barcodeReader = ZXingBarcodeReader();
await _barcodeReader?.maybeLoadLibrary(
... ... @@ -358,6 +363,12 @@ class MobileScannerWeb extends MobileScannerPlatform {
}
@override
Future<void> pause() async {
_barcodesSubscription?.pause();
await _barcodeReader?.pause();
}
@override
Future<void> stop() async {
// Ensure the barcode scanner is stopped, by cancelling the subscription.
await _barcodesSubscription?.cancel();
... ...
... ... @@ -169,6 +169,11 @@ final class ZXingBarcodeReader extends BarcodeReader {
}
@override
Future<void> pause() async {
_reader?.videoElement?.pause();
}
@override
Future<void> stop() async {
_onMediaTrackSettingsChanged = null;
_reader?.stopContinuousDecode.callAsFunction(_reader);
... ...
... ... @@ -25,7 +25,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
// optional window to limit scan search
var scanWindow: CGRect?
/// Whether to return the input image with the barcode event.
/// This is static to avoid accessing `self` in the `VNDetectBarcodesRequest` callback.
private static var returnImage: Bool = false
... ... @@ -35,9 +35,17 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
var timeoutSeconds: Double = 0
var symbologies:[VNBarcodeSymbology] = []
var position = AVCaptureDevice.Position.back
private var stopped: Bool {
return device == nil || captureSession == nil
}
private var paused: Bool {
return stopped && textureId != nil
}
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = MobileScannerPlugin(registrar.textures)
let method = FlutterMethodChannel(name:
... ... @@ -67,6 +75,8 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
setScale(call, result)
case "resetScale":
resetScale(call, result)
case "pause":
pause(result)
case "stop":
stop(result)
case "updateScanWindow":
... ... @@ -128,7 +138,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
do {
let barcodeRequest: VNDetectBarcodesRequest = VNDetectBarcodesRequest(completionHandler: { [weak self] (request, error) in
self?.imagesCurrentlyBeingProcessed = false
if error != nil {
DispatchQueue.main.async {
self?.sink?(FlutterError(
... ... @@ -137,24 +147,24 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
}
return
}
guard let results: [VNBarcodeObservation] = request.results as? [VNBarcodeObservation] else {
return
}
if results.isEmpty {
return
}
let barcodes: [VNBarcodeObservation] = results.compactMap({ barcode in
// If there is a scan window, check if the barcode is within said scan window.
if self?.scanWindow != nil && cgImage != nil && !(self?.isBarCodeInScanWindow(self!.scanWindow!, barcode, cgImage!) ?? false) {
return nil
}
return barcode
})
DispatchQueue.main.async {
guard let image = cgImage else {
self?.sink?([
... ... @@ -163,7 +173,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
])
return
}
// The image dimensions are always provided.
// The image bytes are only non-null when `returnImage` is true.
let imageData: [String: Any?] = [
... ... @@ -171,7 +181,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
"width": Double(image.width),
"height": Double(image.height),
]
self?.sink?([
"name": "barcode",
"data": barcodes.map({ $0.toMap() }),
... ... @@ -179,7 +189,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
])
}
})
if self?.symbologies.isEmpty == false {
// Add the symbologies the user wishes to support.
barcodeRequest.symbologies = self!.symbologies
... ... @@ -276,7 +286,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
return
}
textureId = registry.register(self)
textureId = textureId ?? registry.register(self)
captureSession = AVCaptureSession()
let argReader = MapArgumentReader(call.arguments as? [String: Any])
... ... @@ -438,52 +448,73 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
result(nil)
}
func pause(_ result: FlutterResult) {
if (paused || stopped) {
result(nil)
return
}
releaseCamera()
}
func stop(_ result: FlutterResult) {
if (device == nil || captureSession == nil) {
if (!paused && stopped) {
result(nil)
return
}
captureSession!.stopRunning()
for input in captureSession!.inputs {
captureSession!.removeInput(input)
releaseCamera()
releaseTexture()
result(nil)
}
private func releaseCamera() {
guard let captureSession = captureSession else {
return
}
captureSession.stopRunning()
for input in captureSession.inputs {
captureSession.removeInput(input)
}
for output in captureSession!.outputs {
captureSession!.removeOutput(output)
for output in captureSession.outputs {
captureSession.removeOutput(output)
}
device.removeObserver(self, forKeyPath: #keyPath(AVCaptureDevice.torchMode))
registry.unregisterTexture(textureId)
latestBuffer = nil
captureSession = nil
self.captureSession = nil
device = nil
}
private func releaseTexture() {
registry.unregisterTexture(textureId)
textureId = nil
result(nil)
}
func analyzeImage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
let argReader = MapArgumentReader(call.arguments as? [String: Any])
let symbologies:[VNBarcodeSymbology] = argReader.toSymbology()
guard let filePath: String = argReader.string(key: "filePath") else {
result(nil)
return
}
let fileUrl = URL(fileURLWithPath: filePath)
guard let ciImage = CIImage(contentsOf: fileUrl) else {
result(nil)
return
}
let imageRequestHandler = VNImageRequestHandler(ciImage: ciImage, orientation: CGImagePropertyOrientation.up, options: [:])
do {
let barcodeRequest: VNDetectBarcodesRequest = VNDetectBarcodesRequest(
completionHandler: { [] (request, error) in
if error != nil {
DispatchQueue.main.async {
result(FlutterError(
... ... @@ -492,26 +523,26 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
}
return
}
guard let barcodes: [VNBarcodeObservation] = request.results as? [VNBarcodeObservation] else {
return
}
if barcodes.isEmpty {
return
}
result([
"name": "barcode",
"data": barcodes.map({ $0.toMap() }),
])
})
if !symbologies.isEmpty {
// Add the symbologies the user wishes to support.
barcodeRequest.symbologies = symbologies
}
try imageRequestHandler.perform([barcodeRequest])
} catch let error {
DispatchQueue.main.async {
... ... @@ -521,7 +552,7 @@ public class MobileScannerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler,
}
}
}
// Observer for torch state
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
switch keyPath {
... ... @@ -584,29 +615,29 @@ class MapArgumentReader {
extension CGImage {
public func jpegData(compressionQuality: CGFloat) -> Data? {
let mutableData = CFDataCreateMutable(nil, 0)
let formatHint: CFString
if #available(macOS 11.0, *) {
formatHint = UTType.jpeg.identifier as CFString
} else {
formatHint = kUTTypeJPEG
}
guard let destination = CGImageDestinationCreateWithData(mutableData!, formatHint, 1, nil) else {
return nil
}
let options: NSDictionary = [
kCGImageDestinationLossyCompressionQuality: compressionQuality,
]
CGImageDestinationAddImage(destination, self, options)
if !CGImageDestinationFinalize(destination) {
return nil
}
return mutableData as Data?
}
}
... ... @@ -615,7 +646,7 @@ extension VNBarcodeObservation {
private func distanceBetween(_ p1: CGPoint, _ p2: CGPoint) -> CGFloat {
return sqrt(pow(p1.x - p2.x, 2) + pow(p1.y - p2.y, 2))
}
public func toMap() -> [String: Any?] {
return [
"corners": [
... ...