diff --git a/android/src/toga_android/hardware/camera.py b/android/src/toga_android/hardware/camera.py index c3ae2750f1..17b61702c3 100644 --- a/android/src/toga_android/hardware/camera.py +++ b/android/src/toga_android/hardware/camera.py @@ -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") diff --git a/changes/4458.feature.md b/changes/4458.feature.md new file mode 100644 index 0000000000..15337f22ae --- /dev/null +++ b/changes/4458.feature.md @@ -0,0 +1 @@ +Barcodes (in QR, Code 128, EAN-13, EAN-8, PDF417, Aztec, and Data Matrix formats) can now be scanned with the Camera API, with an `on_detection()` callback being invoked when the camera is in scanning mode and a barcode of the requested type is seen. diff --git a/cocoa/src/toga_cocoa/hardware/camera.py b/cocoa/src/toga_cocoa/hardware/camera.py index 6bab5b3c57..67b157ec60 100644 --- a/cocoa/src/toga_cocoa/hardware/camera.py +++ b/cocoa/src/toga_cocoa/hardware/camera.py @@ -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 @@ -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 { @@ -249,6 +268,145 @@ def photo_taken(self, photo): self.camera.preview_windows.remove(self) +class TogaCameraScannerDelegate(NSObject): # pragma: no cover + @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 + 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 @@ -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: @@ -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() diff --git a/cocoa/src/toga_cocoa/libs/av_foundation.py b/cocoa/src/toga_cocoa/libs/av_foundation.py index 114a7ecf4a..9b2ab9463b 100644 --- a/cocoa/src/toga_cocoa/libs/av_foundation.py +++ b/cocoa/src/toga_cocoa/libs/av_foundation.py @@ -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 diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 5ad70d1e2e..d61f45c0ea 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -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.""" diff --git a/core/src/toga/hardware/camera.py b/core/src/toga/hardware/camera.py index dc2d0962dd..43f0040723 100644 --- a/core/src/toga/hardware/camera.py +++ b/core/src/toga/hardware/camera.py @@ -1,9 +1,10 @@ from __future__ import annotations +from collections.abc import Callable from typing import TYPE_CHECKING, Any -from toga.constants import FlashMode -from toga.handlers import AsyncResult, PermissionResult +from toga.constants import BarcodeFormat, FlashMode +from toga.handlers import AsyncResult, PermissionResult, wrapped_handler from toga.platform import get_factory if TYPE_CHECKING: @@ -15,6 +16,10 @@ class PhotoResult(AsyncResult): RESULT_TYPE = "photo" +class ScanResult(AsyncResult): + RESULT_TYPE = "scan" + + class CameraDevice: def __init__(self, impl: Any): self._impl = impl @@ -49,6 +54,7 @@ def __init__(self, app: App): self.factory = get_factory() self._app = app self._impl = self.factory.Camera(self) + self._on_detection = wrapped_handler(self, None) @property def app(self) -> App: @@ -119,3 +125,87 @@ def take_photo( photo = PhotoResult(None) self._impl.take_photo(photo, device=device, flash=flash) return photo + + @property + def on_detection(self) -> Callable: + """A handler to invoke when a barcode is detected during scanning. + + The callback receives the camera as the first argument, and the detected content + as a keyword argument: ``on_detection(camera, content=content)``. + + If scanning was started with ``continuous=True``, the callback will be invoked + each time a barcode is detected. If ``continuous=False`` (the default), the + callback is invoked once before scanning stops automatically. + """ + return self._on_detection + + @on_detection.setter + def on_detection(self, handler: Callable | None) -> None: + self._on_detection = wrapped_handler(self, handler) + + @property + def is_scanning(self) -> bool: + """Is the camera currently scanning for barcodes?""" + return self._impl.is_scanning() + + def start_scanning( + self, + device: CameraDevice | None = None, + code_types: BarcodeFormat | list[BarcodeFormat] | None = None, + on_detection: Callable | None = None, + continuous: bool = False, + ) -> ScanResult: + """Start scanning for barcodes (including QR codes) in real-time. + + Displays a live camera preview that scans for supported barcode types. When a + barcode is detected, the ``on_detection`` callback is invoked. + + If ``continuous`` is ``False`` (the default), scanning stops automatically after + the first detection, and the returned ``ScanResult`` resolves with the detected + content string. If ``continuous`` is ``True``, scanning continues until + :meth:`stop_scanning` is called, and the ``ScanResult`` resolves with ``None``. + + If the platform requires permission to access the camera, and the user hasn't + previously provided that permission, this will cause permission to be requested. + + **This is an asynchronous method**. If you invoke this method in synchronous + context, it will start the scanning process, but will return *immediately*. + The return value can be awaited in an asynchronous context, but cannot be used + directly. + + :param device: The camera device to use for scanning. If ``None``, the default + camera will be used. + :param code_types: The types of barcodes to scan for. A single + :class:`~toga.constants.BarcodeFormat` value, or a ``list`` of values. + If ``None``, all supported types will be detected. + :param on_detection: A handler to invoke when a barcode is detected. This can + also be set via the :attr:`on_detection` property. + :param continuous: If ``False`` (default), scanning stops after the first + detection. If ``True``, scanning continues until :meth:`stop_scanning` is + called. + :returns: An asynchronous result; when awaited, returns the detected content + string if a barcode was found, or ``None`` if scanning was cancelled. + :raises PermissionError: if the app does not have permission to use the camera. + """ + if on_detection is not None: + self.on_detection = on_detection + + if code_types is None: + code_types = list(BarcodeFormat) + elif isinstance(code_types, BarcodeFormat): + code_types = [code_types] + + result = ScanResult(None) + self._impl.start_scanning( + result, device=device, code_types=code_types, continuous=continuous + ) + return result + + def stop_scanning(self) -> None: + """Stop scanning for barcodes. + + If the camera is currently scanning, the scan preview will be dismissed and the + pending :class:`ScanResult` from :meth:`start_scanning` will resolve with + ``None``. + """ + self._impl.stop_scanning() diff --git a/core/tests/hardware/test_camera.py b/core/tests/hardware/test_camera.py index 6a0c9d0afc..a2116d1533 100644 --- a/core/tests/hardware/test_camera.py +++ b/core/tests/hardware/test_camera.py @@ -1,7 +1,7 @@ import pytest import toga -from toga.constants import FlashMode +from toga.constants import BarcodeFormat, FlashMode from toga.hardware.camera import CameraDevice from toga.platform import get_factory from toga_dummy.hardware.camera import ( @@ -35,6 +35,18 @@ def test_no_camera(monkeypatch, app): _ = app.camera +def test_barcode_format_all_values(): + """All expected BarcodeFormat members can be enumerated and cross-referenced. + + A common real-world use case is building a selection list for users to choose + which barcode types to scan for. + """ + all_types = list(BarcodeFormat) + assert len(all_types) == 7 + names = {str(t) for t in all_types} + assert names == {"Qr", "Code128", "Ean13", "Ean8", "Pdf417", "Aztec", "Data_Matrix"} + + @pytest.mark.parametrize( "initial, should_request, has_permission", [ @@ -191,3 +203,233 @@ def test_take_photo_no_permission(app, photo): assert_action_performed(app.camera, "has permission") assert_action_not_performed(app.camera, "take photo") + + +########################################################################## +# Scanning API +########################################################################## + + +def test_is_scanning_initial(app): + """is_scanning is False before any scan starts.""" + assert app.camera.is_scanning is False + assert_action_performed(app.camera, "is scanning") + + +def test_on_detection_default_none(app): + """on_detection is a no-op by default.""" + assert app.camera.on_detection._raw is None + + +def test_on_detection_set_and_get(app): + """on_detection can be set and retrieved.""" + + def handler(camera, **kwargs): + pass + + app.camera.on_detection = handler + assert app.camera.on_detection._raw is handler + + +def test_start_scanning_with_permission(app): + """Start scanning with default mode (auto-stop on first detection).""" + app.camera._impl._has_permission = -1 + app.camera._impl.simulate_scan("QR_CODE_CONTENT") + + result = app.loop.run_until_complete(app.camera.start_scanning()) + + assert result == "QR_CODE_CONTENT" + assert_action_performed(app.camera, "has permission") + assert_action_performed_with( + app.camera, + "start scanning", + permission_requested=True, + device=None, + code_types=list(BarcodeFormat), + continuous=False, + ) + + +def test_start_scanning_with_device(app): + """Start scanning with a specific device.""" + app.camera._impl._has_permission = 1 + app.camera._impl.simulate_scan("content") + + device = CameraDevice(DummyCamera.CAMERA_2) + result = app.loop.run_until_complete(app.camera.start_scanning(device=device)) + + assert result == "content" + assert_action_performed_with( + app.camera, + "start scanning", + device=device, + ) + + +@pytest.mark.parametrize("code_type", list(BarcodeFormat)) +def test_start_scanning_with_code_type(app, code_type): + """Each BarcodeFormat value can be used as a scan code type.""" + app.camera._impl._has_permission = 1 + app.camera._impl.simulate_scan(f"found_{code_type.name}") + + result = app.loop.run_until_complete( + app.camera.start_scanning(code_types=[code_type]) + ) + + assert result == f"found_{code_type.name}" + assert_action_performed_with( + app.camera, + "start scanning", + code_types=[code_type], + ) + + +def test_start_scanning_with_single_code_type(app): + """A single BarcodeFormat value (not wrapped in a list) is accepted.""" + app.camera._impl._has_permission = 1 + app.camera._impl.simulate_scan("single_qr") + + result = app.loop.run_until_complete( + app.camera.start_scanning(code_types=BarcodeFormat.QR) + ) + + assert result == "single_qr" + assert_action_performed_with( + app.camera, + "start scanning", + code_types=[BarcodeFormat.QR], + ) + + +def test_start_scanning_with_all_code_types(app): + """All BarcodeFormat values combined work for scanning.""" + all_types = list(BarcodeFormat) + assert len(all_types) == 7 + + app.camera._impl._has_permission = 1 + app.camera._impl.simulate_scan("multi_type_scan") + + result = app.loop.run_until_complete( + app.camera.start_scanning(code_types=all_types) + ) + + assert result == "multi_type_scan" + assert_action_performed_with( + app.camera, + "start scanning", + code_types=all_types, + ) + + +def test_start_scanning_prior_permission(app): + """If permission was already granted, scan starts without requesting.""" + app.camera._impl._has_permission = 1 + app.camera._impl.simulate_scan("scanned data") + + result = app.loop.run_until_complete(app.camera.start_scanning()) + + assert result == "scanned data" + assert_action_performed_with( + app.camera, + "start scanning", + permission_requested=False, + ) + + +def test_start_scanning_no_permission(app): + """If permission has been denied, start_scanning raises PermissionError.""" + app.camera._impl._has_permission = 0 + + with pytest.raises( + PermissionError, + match=r"App does not have permission to take photos", + ): + app.loop.run_until_complete(app.camera.start_scanning()) + + assert_action_performed(app.camera, "has permission") + assert_action_not_performed(app.camera, "start scanning") + + +def test_start_scanning_continuous(app): + """In continuous mode, scanning continues until stop_scanning is called.""" + app.camera._impl._has_permission = 1 + + detected = [] + + def on_detected(camera, content, **kwargs): + detected.append(content) + + app.camera._impl.simulate_scan("first") + result = app.camera.start_scanning(continuous=True, on_detection=on_detected) + assert_action_performed_with( + app.camera, + "start scanning", + continuous=True, + ) + + assert detected == ["first"] + + app.camera._impl.simulate_scan("second") + assert detected == ["first", "second"] + + app.camera.stop_scanning() + assert_action_performed(app.camera, "stop scanning") + + assert app.loop.run_until_complete(result) is None + + +def test_stop_scanning(app): + """stop_scanning ends scanning and resolves the scan result with None.""" + app.camera._impl._has_permission = 1 + + result = app.camera.start_scanning() + + app.camera.stop_scanning() + assert_action_performed(app.camera, "stop scanning") + + assert app.loop.run_until_complete(result) is None + + +def test_is_scanning_during_scan(app): + """is_scanning reflects the active scanning state.""" + app.camera._impl._has_permission = 1 + + _ = app.camera.start_scanning() + + assert app.camera.is_scanning is True + assert_action_performed(app.camera, "is scanning") + + app.camera.stop_scanning() + + assert app.camera.is_scanning is False + assert_action_performed(app.camera, "is scanning") + + +def test_on_detection_callback_invoked(app): + """The on_detection callback is invoked when a barcode is detected.""" + app.camera._impl._has_permission = 1 + + detected = [] + + def handler(camera, content, **kwargs): + detected.append((camera, content)) + + app.camera._impl.simulate_scan("callback_content") + app.loop.run_until_complete(app.camera.start_scanning(on_detection=handler)) + + assert len(detected) == 1 + assert detected[0][0] is app.camera + assert detected[0][1] == "callback_content" + + +def test_scan_result_direct_comparison_error(app): + """ScanResult raises RuntimeError if compared directly.""" + result = app.camera.start_scanning() + with pytest.raises(RuntimeError): + _ = result == "anything" + + +def test_scan_result_repr(app): + """ScanResult repr is meaningful.""" + result = app.camera.start_scanning() + assert "scan" in repr(result).lower() diff --git a/docs/en/reference/api/hardware/camera.md b/docs/en/reference/api/hardware/camera.md index 65c98ba379..06e2e8c6ee 100644 --- a/docs/en/reference/api/hardware/camera.md +++ b/docs/en/reference/api/hardware/camera.md @@ -2,9 +2,9 @@ ## Usage -Cameras attached to a device running an app can be accessed using the [`camera`][toga.App.camera] attribute. This attribute exposes an API that allows you to check if you have have permission to access the camera device; and if permission exists, capture photographs. +Cameras attached to a device running an app can be accessed using the [`camera`][toga.App.camera] attribute. This attribute exposes an API that allows you to check if you have have permission to access the camera device; and if permission exists, capture photographs or scan barcodes. -The Camera API is *asynchronous*. This means the methods that have long-running behavior (such as requesting permissions and taking photographs) must be `await`-ed, rather than being invoked directly. This means they must be invoked from inside an asynchronous handler: +The Camera API is *asynchronous*. This means the methods that have long-running behavior (such as requesting permissions, taking photographs, and scanning) must be `await`-ed, rather than being invoked directly. This means they must be invoked from inside an asynchronous handler: ```python import toga @@ -13,22 +13,69 @@ class MyApp(toga.App): ... async def time_for_a_selfie(self, widget, **kwargs): photo = await self.camera.take_photo() + + async def scan_qr_code(self, widget, **kwargs): + content = await self.camera.start_scanning() + self.label.text = f"Scanned: {content}" ``` -Most platforms will require some form of device permission to access the camera. The permission APIs are paired with the specific actions performed on those APIs - that is, to take a photo, you require [`Camera.has_permission`][toga.hardware.camera.Camera.has_permission], which you can request using [`Camera.request_permission()`][toga.hardware.camera.Camera.request_permission]. +Most platforms will require some form of device permission to access the camera. The permission APIs are paired with the specific actions performed on those APIs - that is, to take a photo or scan a barcode, you require [`Camera.has_permission`][toga.hardware.camera.Camera.has_permission], which you can request using [`Camera.request_permission()`][toga.hardware.camera.Camera.request_permission]. Toga will confirm whether the app has been granted permission to use the camera before invoking any camera API. If permission has not yet been granted, the platform *may* request access at the time of first camera access; however, this is not guaranteed to be the behavior on all platforms. +## Scanning for Barcodes + +The camera can be used to scan QR codes and other barcode types in real-time. Scanning is supported on iOS, macOS, and in the Dummy (test) backend. + +To scan a barcode, call [`Camera.start_scanning()`][toga.hardware.camera.Camera.start_scanning]. By default, scanning stops automatically when the first barcode is detected, and the result resolves to the content string: + +```python +async def scan_once(self, widget, **kwargs): + content = await self.camera.start_scanning() + self.label.text = f"Found: {content}" +``` + +For continuous scanning (e.g., scanning multiple codes), pass `continuous=True` and provide an `on_detection` callback: + +```python +async def start_continuous_scan(self, widget, **kwargs): + self.camera.on_detection = self.on_barcode + await self.camera.start_scanning(continuous=True) + +def on_barcode(self, camera, content, **kwargs): + self.log(f"Detected: {content}") + +def stop_scan(self, widget, **kwargs): + self.camera.stop_scanning() +``` + +You can specify which barcode formats to scan for using the `code_types` parameter. It accepts a single [`BarcodeFormat`][toga.constants.BarcodeFormat] value or a list: + +```python +# Scan for QR codes only +content = await self.camera.start_scanning(code_types=BarcodeFormat.QR) + +# Scan for multiple formats +content = await self.camera.start_scanning( + code_types=[BarcodeFormat.QR, BarcodeFormat.CODE128], +) +``` + ## Notes - Apps that use a camera must be configured to provide permission to the camera device. The permissions required are platform specific: - iOS: `NSCameraUsageDescription` must be defined in the app's `Info.plist` file. - macOS: The `com.apple.security.device.camera` entitlement must be enabled, and `NSCameraUsageDescription` must be defined in the app's `Info.plist` file. - Android: The `android.permission.CAMERA` permission must be declared. -- The iOS simulator implements the iOS Camera APIs, but is not able to take photographs. To test your app's Camera usage, you must use a physical iOS device. +- The iOS simulator implements the iOS Camera APIs, but is not able to take photographs or scan barcodes. To test your app's Camera usage, you must use a physical iOS device. +- Barcode scanning is currently available on iOS, macOS, and in the Dummy (test) backend. Other backends will raise `NotImplementedError`. ## Reference ::: toga.hardware.camera.Camera ::: toga.hardware.camera.CameraDevice + +::: toga.hardware.camera.ScanResult + +::: toga.constants.BarcodeFormat diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index f07131d3fd..0805282108 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -16,6 +16,10 @@ asyncio awaitable backend backends +Barcode +Barcodes +barcode +barcodes Beancount Beanquick beeware diff --git a/dummy/src/toga_dummy/hardware/camera.py b/dummy/src/toga_dummy/hardware/camera.py index 00bcb24795..3ebe533b71 100644 --- a/dummy/src/toga_dummy/hardware/camera.py +++ b/dummy/src/toga_dummy/hardware/camera.py @@ -28,6 +28,10 @@ def __init__(self, interface): # 1: permission has been granted # 0: permission has been denied, or can't be granted self._has_permission = -1 + self._is_scanning = False + self._scan_future = None + self._scan_continuous = False + self._pending_scan_content = None def has_permission(self, allow_unknown=False): self._action("has permission") @@ -54,8 +58,6 @@ def take_photo(self, future, device, flash): flash=flash, ) - # Requires that the user has first called `simulate_photo()` with the - # photo to be captured. future.set_result(self._photo) del self._photo else: @@ -63,3 +65,51 @@ def take_photo(self, future, device, flash): def simulate_photo(self, image): self._photo = image + + def is_scanning(self): + self._action("is scanning") + return self._is_scanning + + def start_scanning(self, future, device, code_types, continuous): + if self.has_permission(allow_unknown=True): + self._action( + "start scanning", + permission_requested=self._has_permission < 0, + device=device, + code_types=code_types, + continuous=continuous, + ) + self._scan_future = future + self._scan_continuous = continuous + + if self._pending_scan_content is not None: + content = self._pending_scan_content + self._pending_scan_content = None + self._resolve_scan(content) + else: + self._is_scanning = True + else: + raise PermissionError("App does not have permission to take photos") + + def stop_scanning(self): + self._action("stop scanning") + self._is_scanning = False + if self._scan_future is not None: + self._scan_future.set_result(None) + self._scan_future = None + + def simulate_scan(self, content): + if self._is_scanning: + self._resolve_scan(content) + else: + self._pending_scan_content = content + + def _resolve_scan(self, content): + self.interface.on_detection(content=content) + if self._scan_continuous: + self._is_scanning = True + else: + self._is_scanning = False + if self._scan_future is not None: + self._scan_future.set_result(content) + self._scan_future = None diff --git a/iOS/src/toga_iOS/hardware/camera.py b/iOS/src/toga_iOS/hardware/camera.py index 7a7b1d593f..67a31e4992 100644 --- a/iOS/src/toga_iOS/hardware/camera.py +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -1,22 +1,54 @@ import warnings -from rubicon.objc import Block, NSObject, objc_method +from rubicon.objc import SEL, Block, NSObject, objc_method import toga -from toga.constants import FlashMode +from toga.constants import BarcodeFormat, FlashMode # for classes that need to be monkeypatched for testing from toga_iOS import libs as iOS from toga_iOS.libs import ( AVAuthorizationStatus, + AVCaptureDevice, + AVCaptureDeviceInput, + AVCaptureMetadataOutput, + AVCaptureSession, + AVCaptureVideoPreviewLayer, + AVLayerVideoGravityResizeAspectFill, AVMediaTypeVideo, + AVMetadataMachineReadableCodeObject, + AVMetadataObjectTypeAztecCode, + AVMetadataObjectTypeCode128Code, + AVMetadataObjectTypeDataMatrixCode, + AVMetadataObjectTypeEAN8Code, + AVMetadataObjectTypeEAN13Code, + AVMetadataObjectTypePDF417Code, + AVMetadataObjectTypeQRCode, NSBundle, + UIButton, + UIColor, + UIControlEventTouchUpInside, + UIControlStateNormal, UIImagePickerControllerCameraCaptureMode, UIImagePickerControllerCameraDevice, UIImagePickerControllerCameraFlashMode, UIImagePickerControllerSourceTypeCamera, + UIViewController, ) +AVCaptureDevicePositionBack = 1 +AVCaptureDevicePositionFront = 2 + +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, +} + class CameraDevice: def __init__(self, id, name, native): @@ -64,11 +96,35 @@ def imagePickerControllerDidCancel_(self, picker) -> None: self.result.set_result(None) +class TogaCameraScannerDelegate(NSObject): + @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_detection(content) + + @objc_method + def cancelScanning_(self, sender) -> None: + self.camera.stop_scanning() + + class Camera: def __init__(self, interface): self.interface = interface if NSBundle.mainBundle.objectForInfoDictionaryKey("NSCameraUsageDescription"): + self._scan_session = None + self._scan_preview_controller = None + self._scan_delegate = None + self._scan_future = None + self._scan_continuous = False + if iOS.UIImagePickerController.isSourceTypeAvailable( UIImagePickerControllerSourceTypeCamera ): @@ -105,7 +161,7 @@ def request_permission(self, future): # This block is invoked when the permission is granted; however, permission is # granted from a different (inaccessible) thread, so it isn't picked up by # coverage. - def permission_complete(result) -> None: + def permission_complete(result) -> None: # pragma: no cover future.set_result(result) iOS.AVCaptureDevice.requestAccessForMediaType( @@ -166,3 +222,124 @@ def take_photo(self, result, device, flash): ).presentViewController(self.native, animated=True, completion=None) else: raise PermissionError("App does not have permission to take photos") + + def is_scanning(self): + return self._scan_session is not None + + def start_scanning(self, future, device, code_types, continuous): + if not self.has_permission(allow_unknown=True): + raise PermissionError("App does not have permission to take photos") + + self._scan_future = future + self._scan_continuous = continuous + + session = self._build_scan_session(device, code_types) + if session is None: + future.set_result(None) + return + + self._scan_delegate = TogaCameraScannerDelegate.alloc().init() + self._scan_delegate.camera = self + + for output in session.outputs(): + if output.isKindOfClass_(AVCaptureMetadataOutput): + output.setMetadataObjectsDelegate_queue_(self._scan_delegate, None) + break + + self._scan_preview_controller = self._build_scan_ui(session) + self._scan_session = session + + session.startRunning() + self._present_scan_ui(self._scan_preview_controller) + + def _build_scan_session(self, device, code_types): + session = AVCaptureSession.alloc().init() + + capture_device = self._resolve_capture_device(device) + if capture_device is None: + warnings.warn("No camera is available for scanning", stacklevel=2) + return None + + device_input = AVCaptureDeviceInput.deviceInputWithDevice_error_( + capture_device, None + ) + if not session.canAddInput(device_input): + warnings.warn("Cannot add camera input", stacklevel=2) + return None + session.addInput(device_input) + + metadata_output = AVCaptureMetadataOutput.alloc().init() + if not session.canAddOutput(metadata_output): + warnings.warn("Cannot add metadata output", stacklevel=2) + return None + session.addOutput(metadata_output) + + objc_types = [ + BARCODE_FORMAT_MAP[ct] for ct in code_types if ct in BARCODE_FORMAT_MAP + ] + if objc_types: + metadata_output.setMetadataObjectTypes_(objc_types) + + return session + + def _resolve_capture_device(self, device): + position = ( + AVCaptureDevicePositionFront + if device is not None + and device._impl.native == UIImagePickerControllerCameraDevice.Front + else AVCaptureDevicePositionBack + ) + for dev in AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo): + if dev.position() == position: + return dev + return None + + def _build_scan_ui(self, session): + preview_layer = AVCaptureVideoPreviewLayer.layerWithSession(session) + preview_layer.setVideoGravity(AVLayerVideoGravityResizeAspectFill) + + controller = UIViewController.alloc().init() + controller.view.layer().insertSublayer_atIndex_(preview_layer, 0) + + cancel_button = UIButton.buttonWithType_(0) + cancel_button.setTitle_forState_("Cancel", UIControlStateNormal) + cancel_button.setTitleColor_forState_( + UIColor.whiteColor(), UIControlStateNormal + ) + cancel_button.sizeToFit() + cancel_button.addTarget_action_forControlEvents_( + self._scan_delegate, SEL("cancelScanning:"), UIControlEventTouchUpInside + ) + cancel_button.setTranslatesAutoresizingMaskIntoConstraints(True) + controller.view.addSubview(cancel_button) + + preview_layer.frame = controller.view.bounds + return controller + + def _present_scan_ui(self, controller): + ( + toga.App.app.current_window._impl.native.rootViewController + ).presentViewController(controller, animated=True, completion=None) + + def stop_scanning(self): + if self._scan_session is not None: + self._scan_session.stopRunning() + self._scan_session = None + + if self._scan_preview_controller is not None: + self._scan_preview_controller.dismissViewControllerAnimated( + True, completion=None + ) + self._scan_preview_controller = None + + if self._scan_future is not None: + self._scan_future.set_result(None) + self._scan_future = None + + self._scan_delegate = None + self._scan_continuous = False + + def _handle_detection(self, content): + self.interface.on_detection(content=content) + if not self._scan_continuous: + self.stop_scanning() diff --git a/iOS/src/toga_iOS/libs/av_foundation.py b/iOS/src/toga_iOS/libs/av_foundation.py index 25c2be2d50..c571dea339 100644 --- a/iOS/src/toga_iOS/libs/av_foundation.py +++ b/iOS/src/toga_iOS/libs/av_foundation.py @@ -15,6 +15,13 @@ av_foundation.AudioServicesPlayAlertSound.restype = None av_foundation.AudioServicesPlayAlertSound.argtypes = [SystemSoundID] +###################################################################### +# AVAnimation.h + +AVLayerVideoGravityResizeAspectFill = objc_const( + av_foundation, "AVLayerVideoGravityResizeAspectFill" +) + ###################################################################### # AVCaptureDevice.h AVCaptureDevice = ObjCClass("AVCaptureDevice") @@ -27,7 +34,47 @@ class AVAuthorizationStatus(Enum): Authorized = 3 +###################################################################### +# AVCaptureDeviceInput.h +AVCaptureDeviceInput = ObjCClass("AVCaptureDeviceInput") + +###################################################################### +# 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" +) + ###################################################################### # AVMediaFormat.h AVMediaTypeAudio = objc_const(av_foundation, "AVMediaTypeAudio") AVMediaTypeVideo = objc_const(av_foundation, "AVMediaTypeVideo") + +###################################################################### +# AVCaptureSession.h +AVCaptureSession = ObjCClass("AVCaptureSession") + +###################################################################### +# AVCaptureVideoPreviewLayer.h +AVCaptureVideoPreviewLayer = ObjCClass("AVCaptureVideoPreviewLayer") diff --git a/iOS/tests_backend/hardware/camera.py b/iOS/tests_backend/hardware/camera.py index 8b1ad40b6c..2b75e853ed 100644 --- a/iOS/tests_backend/hardware/camera.py +++ b/iOS/tests_backend/hardware/camera.py @@ -103,6 +103,12 @@ def cleanup(self): picker.delegate.imagePickerControllerDidCancel(picker) except AttributeError: pass + # Clean up any active scanner + try: + if self.app.camera._impl.is_scanning(): + self.app.camera._impl.stop_scanning() + except (NotImplementedError, AttributeError): + pass def known_cameras(self): return { @@ -190,3 +196,19 @@ def same_flash_mode(self, expected, actual): UIImagePickerControllerCameraFlashMode.Off: FlashMode.OFF, }[actual] ) + + async def simulate_scan_detection(self, content="scanned_content"): + """Simulate a barcode being detected during scanning.""" + impl = self.app.camera._impl + impl._handle_detection(content) + await self.redraw("Scan detected", delay=0.1) + + async def cancel_scan(self): + """Simulate the user cancelling scanning.""" + impl = self.app.camera._impl + impl.stop_scanning() + await self.redraw("Scan cancelled", delay=0.1) + + async def wait_for_scan_start(self): + """Wait for the scanner to be initialized.""" + await self.redraw("Scanner started", delay=0.3)