Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions android/src/toga_android/hardware/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,12 @@ def photo_taken(code, data):
self.interface.app._impl.start_activity(intent, on_complete=photo_taken)
else:
raise PermissionError("App does not have permission to take photos")

def is_scanning(self):
raise NotImplementedError("Barcode scanning is not yet implemented on Android")

def start_scanning(self, future, device, code_types, continuous):
raise NotImplementedError("Barcode scanning is not yet implemented on Android")

def stop_scanning(self):
raise NotImplementedError("Barcode scanning is not yet implemented on Android")
1 change: 1 addition & 0 deletions changes/camera-scanning.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Camera API gained `start_scanning()`, `stop_scanning()`, and `is_scanning()` methods for real-time barcode and QR code scanning, along with an `on_detection` callback and a `BarcodeFormat` enum. The `code_types` parameter accepts a single `BarcodeFormat` value or a list. The iOS and macOS backends implement scanning using `AVCaptureSession` with `AVCaptureMetadataOutput`, supporting QR codes, Code 128, EAN-13, EAN-8, PDF417, Aztec, and Data Matrix formats. The Android backend raises `NotImplementedError` for scanning operations.
Comment thread
phildini marked this conversation as resolved.
Outdated
183 changes: 181 additions & 2 deletions cocoa/src/toga_cocoa/hardware/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import warnings
from threading import Thread

from rubicon.objc import Block, objc_method
from rubicon.objc import Block, NSObject, objc_method

import toga
from toga.colors import BLACK, RED
from toga.constants import FlashMode
from toga.constants import BarcodeFormat, FlashMode
from toga.style import Pack
from toga.style.pack import COLUMN

Expand All @@ -17,15 +17,34 @@
from toga_cocoa.libs import (
AVAuthorizationStatus,
AVCaptureFlashMode,
AVCaptureMetadataOutput,
AVCapturePhotoOutput,
AVCaptureSession,
AVCaptureSessionPresetPhoto,
AVCaptureVideoPreviewLayer,
AVLayerVideoGravityResizeAspectFill,
AVMediaTypeVideo,
AVMetadataMachineReadableCodeObject,
AVMetadataObjectTypeAztecCode,
AVMetadataObjectTypeCode128Code,
AVMetadataObjectTypeDataMatrixCode,
AVMetadataObjectTypeEAN8Code,
AVMetadataObjectTypeEAN13Code,
AVMetadataObjectTypePDF417Code,
AVMetadataObjectTypeQRCode,
NSBundle,
)

BARCODE_FORMAT_MAP = {
BarcodeFormat.QR: AVMetadataObjectTypeQRCode,
BarcodeFormat.CODE128: AVMetadataObjectTypeCode128Code,
BarcodeFormat.EAN13: AVMetadataObjectTypeEAN13Code,
BarcodeFormat.EAN8: AVMetadataObjectTypeEAN8Code,
BarcodeFormat.PDF417: AVMetadataObjectTypePDF417Code,
BarcodeFormat.AZTEC: AVMetadataObjectTypeAztecCode,
BarcodeFormat.DATA_MATRIX: AVMetadataObjectTypeDataMatrixCode,
}


def native_flash_mode(flash):
return {
Expand Down Expand Up @@ -249,6 +268,145 @@ def photo_taken(self, photo):
self.camera.preview_windows.remove(self)


class TogaCameraScannerDelegate(NSObject): # pragma: no cover

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be a standalone class? Could we use the same AVSession subclass as the photo object uses, set it to be it's own delegate, migrate the body of the delegate method to a utility method on the Window, and use that to provide a point were we can just mock the Session object and everything else is tested explicitly?

@objc_method
def metadataOutput_didOutputMetadataObjects_fromConnection_(
self, output, metadata_objects, connection
) -> None:
count = metadata_objects.count()
if count > 0:
metadata_object = metadata_objects.objectAtIndex(0)
if metadata_object.isKindOfClass_(AVMetadataMachineReadableCodeObject):
content = str(metadata_object.stringValue())
if content:
self.camera._handle_scan(content)


class TogaCameraScannerWindow(toga.Window): # pragma: no cover

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a big block to have marked no cover. If TogaCameraWindow is an indicator, only creating the session should be marked no-cover; everything else should be reachable in tests.

To that end ... it seems like there's a lot of overlapping content in the TogaCamera window. Is there any way to merge those two classes so there's a single UI for camera activity?

def __init__(self, camera, device, code_types, future, continuous):
super().__init__(
title="Scan Barcode",
on_close=self.close_window,
resizable=False,
size=(640, 360),
)
self.camera = camera
self.future = future
self.continuous = continuous
self.code_types = code_types

self.create_preview_window()
self.create_scan_session(device)

def create_preview_window(self):
self.preview = toga.Box(style=Pack(width=640, height=360))

self.device_select = toga.Selection(
items=[],
on_change=self.change_camera,
style=Pack(width=200),
)

self.close_button = toga.Button(
text="Cancel",
on_press=self.close_window,
style=Pack(width=100),
)

self.content = toga.Box(
children=[
toga.Box(
children=[self.preview],
style=Pack(background_color=BLACK),
),
toga.Box(
children=[
toga.Box(children=[self.device_select], style=Pack(flex=1)),
self.close_button,
toga.Box(style=Pack(flex=1)),
],
style=Pack(margin=10),
),
],
style=Pack(direction=COLUMN),
)

def create_scan_session(self, device):
self.camera_session = AVCaptureSession.alloc().init()
self.camera_session.beginConfiguration()

preview_layer = AVCaptureVideoPreviewLayer.layerWithSession(self.camera_session)
preview_layer.setVideoGravity(AVLayerVideoGravityResizeAspectFill)
preview_layer.frame = self.preview._impl.native.bounds
self.preview._impl.native.setLayer(preview_layer)

metadata_output = AVCaptureMetadataOutput.alloc().init()
self.camera_session.addOutput(metadata_output)

objc_types = [
BARCODE_FORMAT_MAP[ct] for ct in self.code_types if ct in BARCODE_FORMAT_MAP
]
if objc_types:
metadata_output.setMetadataObjectTypes_(objc_types)

delegate = TogaCameraScannerDelegate.alloc().init()
delegate.camera = self
metadata_output.setMetadataObjectsDelegate_queue_(delegate, None)

self.camera_session.commitConfiguration()

self.camera_input = None
self.scan_delegate = delegate

Thread(
target=self._enable_camera,
kwargs={"device": device},
).start()

def _enable_camera(self, device):
self.camera_session.startRunning()
self.camera.interface.app.loop.create_task(
self._update_camera_list(toga.App.app.camera.devices, device)
)

async def _update_camera_list(self, devices, device):
self.device_select.items = devices
if device:
self.device_select.value = device

def change_camera(self, widget=None, **kwargs):
for input in self.camera_session.inputs:
self.camera_session.removeInput(input)

if device := self.device_select.value:
input = cocoa.AVCaptureDeviceInput.deviceInputWithDevice(
device._impl.native, error=None
)
self.camera_session.addInput(input)

def close_window(self, widget, **kwargs):
self.camera_session.stopRunning()
if self.future is not None:
self.future.set_result(None)
self.future = None
self._cleanup()
return True

def _handle_scan(self, content):
self.camera.interface.on_detection(content=content)
if not self.continuous:
future = self.future
self.future = None
self.camera_session.stopRunning()
self._cleanup()
future.set_result(content)
self.close()

def _cleanup(self):
self.camera.preview_windows.remove(self)
self.future = None


class Camera:
def __init__(self, interface):
self.interface = interface
Expand All @@ -269,6 +427,7 @@ def __init__(self, interface):
else:
warnings.warn(msg, stacklevel=2)
self.preview_windows = []
self._scan_future = None

def has_permission(self, allow_unknown=False):
# To reset permissions to "factory" status, run:
Expand Down Expand Up @@ -322,3 +481,23 @@ def take_photo(self, result, device, flash):
window.show()
else:
raise PermissionError("App does not have permission to take photos")

def is_scanning(self):
return self._scan_future is not None

def start_scanning(self, future, device, code_types, continuous):
if self.has_permission(allow_unknown=True):
self._scan_future = future
window = TogaCameraScannerWindow(
self, device, code_types, future, continuous
)
self.preview_windows.append(window)
window.show()
else:
raise PermissionError("App does not have permission to scan barcodes")

def stop_scanning(self):
self._scan_future = None
for window in list(self.preview_windows):
if isinstance(window, TogaCameraScannerWindow):
window.close()
28 changes: 28 additions & 0 deletions cocoa/src/toga_cocoa/libs/av_foundation.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ class AVCaptureFlashMode(Enum):

AVCaptureSession = ObjCClass("AVCaptureSession")

######################################################################
# AVCaptureMetadataOutput.h
AVCaptureMetadataOutput = ObjCClass("AVCaptureMetadataOutput")

######################################################################
# AVMetadataObject.h
AVMetadataMachineReadableCodeObject = ObjCClass("AVMetadataMachineReadableCodeObject")

######################################################################
# AVMetadataObjectType constants
AVMetadataObjectTypeQRCode = objc_const(av_foundation, "AVMetadataObjectTypeQRCode")
AVMetadataObjectTypeCode128Code = objc_const(
av_foundation, "AVMetadataObjectTypeCode128Code"
)
AVMetadataObjectTypeEAN13Code = objc_const(
av_foundation, "AVMetadataObjectTypeEAN13Code"
)
AVMetadataObjectTypeEAN8Code = objc_const(av_foundation, "AVMetadataObjectTypeEAN8Code")
AVMetadataObjectTypePDF417Code = objc_const(
av_foundation, "AVMetadataObjectTypePDF417Code"
)
AVMetadataObjectTypeAztecCode = objc_const(
av_foundation, "AVMetadataObjectTypeAztecCode"
)
AVMetadataObjectTypeDataMatrixCode = objc_const(
av_foundation, "AVMetadataObjectTypeDataMatrixCode"
)

######################################################################
# AVCaptureVideoPreviewLayer.h

Expand Down
15 changes: 15 additions & 0 deletions core/src/toga/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ class FillRule(Enum):
##########################################################################


class BarcodeFormat(Enum):
"""The types of barcodes that can be detected during scanning."""

QR = auto()
CODE128 = auto()
EAN13 = auto()
EAN8 = auto()
PDF417 = auto()
AZTEC = auto()
DATA_MATRIX = auto()

def __str__(self) -> str:
return self.name.title()


class FlashMode(Enum):
"""The flash mode to use when capturing photos or videos."""

Expand Down
Loading