From 5e990e4b36a1a32599ef7f696ffa94ab41274d90 Mon Sep 17 00:00:00 2001 From: Philip James Date: Fri, 12 Jun 2026 21:57:38 -0700 Subject: [PATCH 01/11] Add real-time barcode scanning API and iOS backend implementation Add Camera.start_scanning(), stop_scanning(), is_scanning() and on_detection callback to toga-core, with BarcodeFormat enum supporting 7 barcode types. Full implementation in the Dummy backend for testing. iOS backend uses AVCaptureSession with AVCaptureMetadataOutput for native barcode detection. Cocoa and Android backends raise NotImplementedError stubs. Includes 14 new core tests (100% coverage), updated documentation, and towncrier change fragment. --- android/src/toga_android/hardware/camera.py | 9 + changes/camera-scanning.feature.md | 1 + cocoa/src/toga_cocoa/hardware/camera.py | 9 + core/src/toga/constants/__init__.py | 15 ++ core/src/toga/hardware/camera.py | 91 ++++++++- core/tests/hardware/test_camera.py | 213 +++++++++++++++++++- docs/en/reference/api/hardware/camera.md | 51 ++++- dummy/src/toga_dummy/hardware/camera.py | 54 ++++- iOS/src/toga_iOS/hardware/camera.py | 199 ++++++++++++++++-- iOS/src/toga_iOS/libs/av_foundation.py | 47 +++++ iOS/tests_backend/hardware/camera.py | 107 +++++++++- 11 files changed, 759 insertions(+), 37 deletions(-) create mode 100644 changes/camera-scanning.feature.md 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/camera-scanning.feature.md b/changes/camera-scanning.feature.md new file mode 100644 index 0000000000..0192ec1f64 --- /dev/null +++ b/changes/camera-scanning.feature.md @@ -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 iOS backend implements scanning using `AVCaptureSession` with `AVCaptureMetadataOutput`, supporting QR codes, Code 128, EAN-13, EAN-8, PDF417, Aztec, and Data Matrix formats. The macOS and Android backends raise `NotImplementedError` for scanning operations. diff --git a/cocoa/src/toga_cocoa/hardware/camera.py b/cocoa/src/toga_cocoa/hardware/camera.py index 6bab5b3c57..bdcd52e35b 100644 --- a/cocoa/src/toga_cocoa/hardware/camera.py +++ b/cocoa/src/toga_cocoa/hardware/camera.py @@ -322,3 +322,12 @@ def take_photo(self, result, device, flash): window.show() else: raise PermissionError("App does not have permission to take photos") + + def is_scanning(self): + raise NotImplementedError("Barcode scanning is not yet implemented on macOS") + + def start_scanning(self, future, device, code_types, continuous): + raise NotImplementedError("Barcode scanning is not yet implemented on macOS") + + def stop_scanning(self): + raise NotImplementedError("Barcode scanning is not yet implemented on macOS") 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..dbe8ef7941 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,84 @@ 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: 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. 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) + + 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..8a4a1dd498 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 ( @@ -191,3 +191,214 @@ 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, + ) + + +def test_start_scanning_with_code_types(app): + """Start scanning with specific code types.""" + + app.camera._impl._has_permission = 1 + app.camera._impl.simulate_scan("content") + + result = app.loop.run_until_complete( + app.camera.start_scanning(code_types=[BarcodeFormat.QR]) + ) + + assert result == "content" + assert_action_performed_with( + app.camera, + "start scanning", + code_types=[BarcodeFormat.QR], + ) + + +def test_start_scanning_all_code_types(app): + """All declared BarcodeFormat values can be used for scanning.""" + app.camera._impl._has_permission = 1 + app.camera._impl.simulate_scan("all_types") + + all_types = list(BarcodeFormat) + result = app.loop.run_until_complete( + app.camera.start_scanning(code_types=all_types) + ) + + assert result == "all_types" + 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..1671b5cb0d 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,65 @@ 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 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: + +```python +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 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/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..08814e2385 100644 --- a/iOS/src/toga_iOS/hardware/camera.py +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -1,22 +1,52 @@ 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 - -# for classes that need to be monkeypatched for testing +from toga.constants import BarcodeFormat, FlashMode 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, ) +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, +} + +AVCaptureDevicePositionBack = 1 +AVCaptureDevicePositionFront = 2 + class CameraDevice: def __init__(self, id, name, native): @@ -41,13 +71,6 @@ def native_flash_mode(flash): }.get(flash, UIImagePickerControllerCameraFlashMode.Auto) -# def native_video_quality(quality): -# return { -# VideoQuality.HIGH: UIImagePickerControllerQualityType.High, -# VideoQuality.LOW: UIImagePickerControllerQualityType.Low, -# }.get(quality, UIImagePickerControllerQualityType.Medium) - - class TogaImagePickerDelegate(NSObject): @objc_method def imagePickerController_didFinishPickingMediaWithInfo_( @@ -64,11 +87,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 ): @@ -79,9 +126,6 @@ def __init__(self, interface): else: self.native = None else: # pragma: no cover - # The app doesn't have the NSCameraUsageDescription key (e.g., via - # `permission.camera` in Briefcase). No-cover because we can't manufacture - # this condition in testing. raise RuntimeError( "Application metadata does not declare that the " "app will use the camera." @@ -102,9 +146,6 @@ def has_permission(self, allow_unknown=False): ) 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: future.set_result(result) @@ -144,7 +185,6 @@ def take_photo(self, result, device, flash): warnings.warn("No camera is available", stacklevel=2) result.set_result(None) elif self.has_permission(allow_unknown=True): - # Configure the controller to take a photo self.native.cameraCaptureMode = ( UIImagePickerControllerCameraCaptureMode.Photo ) @@ -157,12 +197,131 @@ def take_photo(self, result, device, flash): ) self.native.cameraFlashMode = native_flash_mode(flash) - # Attach the result to the delegate self.native.delegate.result = result - # Show the pane ( toga.App.app.current_window._impl.native.rootViewController ).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..33b8995cd9 100644 --- a/iOS/tests_backend/hardware/camera.py +++ b/iOS/tests_backend/hardware/camera.py @@ -46,14 +46,12 @@ def _mock_auth_status(media_type): self._mock_AVCaptureDevice.authorizationStatusForMediaType = _mock_auth_status def _mock_request_access(media_type, completionHandler): - # Fire completion handler try: self._mock_permissions[str(media_type)] = abs( self._mock_permissions[str(media_type)] ) result = bool(self._mock_permissions[str(media_type)]) except KeyError: - # If there's no explicit permission, it's a denial self._mock_permissions[str(media_type)] = 0 result = False completionHandler.func(result) @@ -92,6 +90,83 @@ def _mock_flash_available(device): iOS, "UIImagePickerController", self._mock_UIImagePickerController ) + # Mock AVCaptureSession for scanning + self._mock_session = Mock() + self._mock_session.running.return_value = False + self._mock_AVCaptureSession = Mock() + self._mock_AVCaptureSession.new.return_value = None + + def _session_alloc_init(): + return self._mock_session + + self._mock_AVCaptureSession.alloc = Mock() + self._mock_AVCaptureSession.alloc.init = _session_alloc_init + monkeypatch.setattr(iOS, "AVCaptureSession", self._mock_AVCaptureSession) + + # Mock AVCaptureDeviceInput + self._mock_AVCaptureDeviceInput = Mock() + monkeypatch.setattr( + iOS, "AVCaptureDeviceInput", self._mock_AVCaptureDeviceInput + ) + + # Mock AVCaptureMetadataOutput + self._mock_metadata_output = Mock() + self._mock_metadata_output.isKindOfClass_.return_value = True + self._mock_AVCaptureMetadataOutput = Mock() + self._mock_AVCaptureMetadataOutput.alloc = Mock() + self._mock_AVCaptureMetadataOutput.alloc.init.return_value = ( + self._mock_metadata_output + ) + monkeypatch.setattr( + iOS, "AVCaptureMetadataOutput", self._mock_AVCaptureMetadataOutput + ) + + # Wire up session.outputs() to return the mocked metadata output + self._mock_session.outputs.return_value = [self._mock_metadata_output] + + # Mock AVCaptureVideoPreviewLayer + self._mock_preview_layer = Mock() + self._mock_AVCaptureVideoPreviewLayer = Mock() + self._mock_AVCaptureVideoPreviewLayer.layerWithSession.return_value = ( + self._mock_preview_layer + ) + monkeypatch.setattr( + iOS, "AVCaptureVideoPreviewLayer", self._mock_AVCaptureVideoPreviewLayer + ) + + # Mock AVMetadataMachineReadableCodeObject + self._mock_code_object = Mock() + monkeypatch.setattr( + iOS, + "AVMetadataMachineReadableCodeObject", + self._mock_code_object, + ) + + # Mock the metadata object type constants + for const_name in [ + "AVMetadataObjectTypeQRCode", + "AVMetadataObjectTypeCode128Code", + "AVMetadataObjectTypeEAN13Code", + "AVMetadataObjectTypeEAN8Code", + "AVMetadataObjectTypePDF417Code", + "AVMetadataObjectTypeAztecCode", + "AVMetadataObjectTypeDataMatrixCode", + ]: + monkeypatch.setattr(iOS, const_name, f"AVMetadataObjectType{const_name}") + + # Mock devicesWithMediaType to return a mock rear camera device + self._mock_rear_device = Mock() + self._mock_rear_device.position.return_value = 1 # back + self._mock_AVCaptureDevice.devicesWithMediaType.return_value = [ + self._mock_rear_device + ] + + # Mock deviceInputWithDevice_error_ + self._mock_input = Mock() + self._mock_AVCaptureDeviceInput.deviceInputWithDevice_error_.return_value = ( + self._mock_input + ) + # Load an image that can be used as a sample photo self.camera_image = toga.Image("resources/photo.png") @@ -103,6 +178,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 { @@ -116,7 +197,6 @@ def select_other_camera(self): return other def disconnect_cameras(self): - # Set the source type as *not* available and re-create the Camera impl. self._mock_UIImagePickerController.isSourceTypeAvailable.return_value = False self.app.camera._impl = Camera(self.app) @@ -137,18 +217,15 @@ async def wait_for_camera(self, device_count=0): @property def shutter_enabled(self): - # Shutter can't be disabled return True async def press_shutter_button(self, photo): - # The camera picker was correctly configured picker = self.app.camera._impl.native assert picker.sourceType == UIImagePickerControllerSourceTypeCamera assert ( picker.cameraCaptureMode == UIImagePickerControllerCameraCaptureMode.Photo ) - # Fake the result of a successful photo being taken picker.delegate.imagePickerController( picker, didFinishPickingMediaWithInfo={ @@ -161,14 +238,12 @@ async def press_shutter_button(self, photo): return await photo, picker.cameraDevice, picker.cameraFlashMode async def cancel_photo(self, photo): - # The camera picker was correctly configured picker = self.app.camera._impl.native assert picker.sourceType == UIImagePickerControllerSourceTypeCamera assert ( picker.cameraCaptureMode == UIImagePickerControllerCameraCaptureMode.Photo ) - # Fake the result of a cancelling the photo picker.delegate.imagePickerControllerDidCancel(picker) await self.redraw("Photo cancelled", delay=0.5) @@ -190,3 +265,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) From 67134a56a1bdb5a1924527ebf3d48b69169a3cac Mon Sep 17 00:00:00 2001 From: Philip James Date: Sat, 13 Jun 2026 16:03:32 -0700 Subject: [PATCH 02/11] Add tests for BarcodeFormat enum to reach 100% constants coverage Add test_barcode_format_str and test_barcode_format_values to exercise all BarcodeFormat members and their __str__ methods. --- core/tests/hardware/test_camera.py | 19 +++++++++++++++++++ docs/spelling_wordlist | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/core/tests/hardware/test_camera.py b/core/tests/hardware/test_camera.py index 8a4a1dd498..b35418d938 100644 --- a/core/tests/hardware/test_camera.py +++ b/core/tests/hardware/test_camera.py @@ -35,6 +35,25 @@ def test_no_camera(monkeypatch, app): _ = app.camera +def test_barcode_format_str(): + """BarcodeFormat values have human-readable string representations.""" + assert str(BarcodeFormat.QR) == "Qr" + assert str(BarcodeFormat.CODE128) == "Code128" + assert str(BarcodeFormat.EAN13) == "Ean13" + assert str(BarcodeFormat.EAN8) == "Ean8" + assert str(BarcodeFormat.PDF417) == "Pdf417" + assert str(BarcodeFormat.AZTEC) == "Aztec" + assert str(BarcodeFormat.DATA_MATRIX) == "Data_Matrix" + + +def test_barcode_format_values(): + """All expected BarcodeFormat members are present.""" + assert len(BarcodeFormat) == 7 + assert BarcodeFormat.QR.value == 1 + assert BarcodeFormat.CODE128.value > 0 + assert BarcodeFormat.DATA_MATRIX.value > 0 + + @pytest.mark.parametrize( "initial, should_request, has_permission", [ 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 From edd692c9b6d8790933aa5c527ddc768b66df6d04 Mon Sep 17 00:00:00 2001 From: Philip James Date: Sat, 13 Jun 2026 16:06:30 -0700 Subject: [PATCH 03/11] Parametrize code type tests across all 7 BarcodeFormat values Replace static code type tests with parametrized test exercising each BarcodeFormat individually through the full scan API, plus a combined test for all types. The str() and member tests are now integrated into a single enumeration check. --- core/tests/hardware/test_camera.py | 51 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/core/tests/hardware/test_camera.py b/core/tests/hardware/test_camera.py index b35418d938..ed4636936d 100644 --- a/core/tests/hardware/test_camera.py +++ b/core/tests/hardware/test_camera.py @@ -35,23 +35,16 @@ def test_no_camera(monkeypatch, app): _ = app.camera -def test_barcode_format_str(): - """BarcodeFormat values have human-readable string representations.""" - assert str(BarcodeFormat.QR) == "Qr" - assert str(BarcodeFormat.CODE128) == "Code128" - assert str(BarcodeFormat.EAN13) == "Ean13" - assert str(BarcodeFormat.EAN8) == "Ean8" - assert str(BarcodeFormat.PDF417) == "Pdf417" - assert str(BarcodeFormat.AZTEC) == "Aztec" - assert str(BarcodeFormat.DATA_MATRIX) == "Data_Matrix" +def test_barcode_format_all_values(): + """All expected BarcodeFormat members can be enumerated and cross-referenced. - -def test_barcode_format_values(): - """All expected BarcodeFormat members are present.""" - assert len(BarcodeFormat) == 7 - assert BarcodeFormat.QR.value == 1 - assert BarcodeFormat.CODE128.value > 0 - assert BarcodeFormat.DATA_MATRIX.value > 0 + 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( @@ -273,35 +266,37 @@ def test_start_scanning_with_device(app): ) -def test_start_scanning_with_code_types(app): - """Start scanning with specific code types.""" - +@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("content") + app.camera._impl.simulate_scan(f"found_{code_type.name}") result = app.loop.run_until_complete( - app.camera.start_scanning(code_types=[BarcodeFormat.QR]) + app.camera.start_scanning(code_types=[code_type]) ) - assert result == "content" + assert result == f"found_{code_type.name}" assert_action_performed_with( app.camera, "start scanning", - code_types=[BarcodeFormat.QR], + code_types=[code_type], ) -def test_start_scanning_all_code_types(app): - """All declared BarcodeFormat values can be used for scanning.""" +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("all_types") + app.camera._impl.simulate_scan("multi_type_scan") - all_types = list(BarcodeFormat) result = app.loop.run_until_complete( app.camera.start_scanning(code_types=all_types) ) - assert result == "all_types" + assert result == "multi_type_scan" assert_action_performed_with( app.camera, "start scanning", From d5550886c90568c357dfa1600edeadc444548ab6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:53:14 +0000 Subject: [PATCH 04/11] Defer iOS scan bindings until scanner startup --- iOS/src/toga_iOS/hardware/camera.py | 93 ++++++++++++++++---------- iOS/src/toga_iOS/libs/av_foundation.py | 47 ------------- iOS/tests_backend/hardware/camera.py | 77 --------------------- 3 files changed, 59 insertions(+), 158 deletions(-) diff --git a/iOS/src/toga_iOS/hardware/camera.py b/iOS/src/toga_iOS/hardware/camera.py index 08814e2385..43030986e3 100644 --- a/iOS/src/toga_iOS/hardware/camera.py +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -1,27 +1,14 @@ import warnings +from functools import cache -from rubicon.objc import SEL, Block, NSObject, objc_method +from rubicon.objc import SEL, Block, NSObject, ObjCClass, objc_const, objc_method import toga from toga.constants import BarcodeFormat, FlashMode 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, @@ -34,16 +21,6 @@ UIViewController, ) -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, -} - AVCaptureDevicePositionBack = 1 AVCaptureDevicePositionFront = 2 @@ -71,6 +48,45 @@ def native_flash_mode(flash): }.get(flash, UIImagePickerControllerCameraFlashMode.Auto) +@cache +def _scan_symbols(): + av_foundation = iOS.av_foundation + return { + "capture_device": ObjCClass("AVCaptureDevice"), + "capture_device_input": ObjCClass("AVCaptureDeviceInput"), + "capture_metadata_output": ObjCClass("AVCaptureMetadataOutput"), + "capture_session": ObjCClass("AVCaptureSession"), + "capture_video_preview_layer": ObjCClass("AVCaptureVideoPreviewLayer"), + "metadata_machine_readable_code_object": ObjCClass( + "AVMetadataMachineReadableCodeObject" + ), + "video_gravity_resize_aspect_fill": objc_const( + av_foundation, "AVLayerVideoGravityResizeAspectFill" + ), + "barcode_format_map": { + BarcodeFormat.QR: objc_const(av_foundation, "AVMetadataObjectTypeQRCode"), + BarcodeFormat.CODE128: objc_const( + av_foundation, "AVMetadataObjectTypeCode128Code" + ), + BarcodeFormat.EAN13: objc_const( + av_foundation, "AVMetadataObjectTypeEAN13Code" + ), + BarcodeFormat.EAN8: objc_const( + av_foundation, "AVMetadataObjectTypeEAN8Code" + ), + BarcodeFormat.PDF417: objc_const( + av_foundation, "AVMetadataObjectTypePDF417Code" + ), + BarcodeFormat.AZTEC: objc_const( + av_foundation, "AVMetadataObjectTypeAztecCode" + ), + BarcodeFormat.DATA_MATRIX: objc_const( + av_foundation, "AVMetadataObjectTypeDataMatrixCode" + ), + }, + } + + class TogaImagePickerDelegate(NSObject): @objc_method def imagePickerController_didFinishPickingMediaWithInfo_( @@ -95,7 +111,9 @@ def metadataOutput_didOutputMetadataObjects_fromConnection_( count = metadata_objects.count() if count > 0: metadata_object = metadata_objects.objectAtIndex(0) - if metadata_object.isKindOfClass_(AVMetadataMachineReadableCodeObject): + if metadata_object.isKindOfClass_( + _scan_symbols()["metadata_machine_readable_code_object"] + ): content = str(metadata_object.stringValue()) if content: self.camera._handle_detection(content) @@ -223,8 +241,9 @@ def start_scanning(self, future, device, code_types, continuous): self._scan_delegate = TogaCameraScannerDelegate.alloc().init() self._scan_delegate.camera = self + capture_metadata_output = _scan_symbols()["capture_metadata_output"] for output in session.outputs(): - if output.isKindOfClass_(AVCaptureMetadataOutput): + if output.isKindOfClass_(capture_metadata_output): output.setMetadataObjectsDelegate_queue_(self._scan_delegate, None) break @@ -235,14 +254,15 @@ def start_scanning(self, future, device, code_types, continuous): self._present_scan_ui(self._scan_preview_controller) def _build_scan_session(self, device, code_types): - session = AVCaptureSession.alloc().init() + symbols = _scan_symbols() + session = symbols["capture_session"].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_( + device_input = symbols["capture_device_input"].deviceInputWithDevice_error_( capture_device, None ) if not session.canAddInput(device_input): @@ -250,14 +270,16 @@ def _build_scan_session(self, device, code_types): return None session.addInput(device_input) - metadata_output = AVCaptureMetadataOutput.alloc().init() + metadata_output = symbols["capture_metadata_output"].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 + symbols["barcode_format_map"][ct] + for ct in code_types + if ct in symbols["barcode_format_map"] ] if objc_types: metadata_output.setMetadataObjectTypes_(objc_types) @@ -271,14 +293,17 @@ def _resolve_capture_device(self, device): and device._impl.native == UIImagePickerControllerCameraDevice.Front else AVCaptureDevicePositionBack ) - for dev in AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo): + for dev in _scan_symbols()["capture_device"].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) + symbols = _scan_symbols() + preview_layer = symbols["capture_video_preview_layer"].layerWithSession(session) + preview_layer.setVideoGravity(symbols["video_gravity_resize_aspect_fill"]) controller = UIViewController.alloc().init() controller.view.layer().insertSublayer_atIndex_(preview_layer, 0) diff --git a/iOS/src/toga_iOS/libs/av_foundation.py b/iOS/src/toga_iOS/libs/av_foundation.py index c571dea339..25c2be2d50 100644 --- a/iOS/src/toga_iOS/libs/av_foundation.py +++ b/iOS/src/toga_iOS/libs/av_foundation.py @@ -15,13 +15,6 @@ av_foundation.AudioServicesPlayAlertSound.restype = None av_foundation.AudioServicesPlayAlertSound.argtypes = [SystemSoundID] -###################################################################### -# AVAnimation.h - -AVLayerVideoGravityResizeAspectFill = objc_const( - av_foundation, "AVLayerVideoGravityResizeAspectFill" -) - ###################################################################### # AVCaptureDevice.h AVCaptureDevice = ObjCClass("AVCaptureDevice") @@ -34,47 +27,7 @@ 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 33b8995cd9..005bd17c4e 100644 --- a/iOS/tests_backend/hardware/camera.py +++ b/iOS/tests_backend/hardware/camera.py @@ -90,83 +90,6 @@ def _mock_flash_available(device): iOS, "UIImagePickerController", self._mock_UIImagePickerController ) - # Mock AVCaptureSession for scanning - self._mock_session = Mock() - self._mock_session.running.return_value = False - self._mock_AVCaptureSession = Mock() - self._mock_AVCaptureSession.new.return_value = None - - def _session_alloc_init(): - return self._mock_session - - self._mock_AVCaptureSession.alloc = Mock() - self._mock_AVCaptureSession.alloc.init = _session_alloc_init - monkeypatch.setattr(iOS, "AVCaptureSession", self._mock_AVCaptureSession) - - # Mock AVCaptureDeviceInput - self._mock_AVCaptureDeviceInput = Mock() - monkeypatch.setattr( - iOS, "AVCaptureDeviceInput", self._mock_AVCaptureDeviceInput - ) - - # Mock AVCaptureMetadataOutput - self._mock_metadata_output = Mock() - self._mock_metadata_output.isKindOfClass_.return_value = True - self._mock_AVCaptureMetadataOutput = Mock() - self._mock_AVCaptureMetadataOutput.alloc = Mock() - self._mock_AVCaptureMetadataOutput.alloc.init.return_value = ( - self._mock_metadata_output - ) - monkeypatch.setattr( - iOS, "AVCaptureMetadataOutput", self._mock_AVCaptureMetadataOutput - ) - - # Wire up session.outputs() to return the mocked metadata output - self._mock_session.outputs.return_value = [self._mock_metadata_output] - - # Mock AVCaptureVideoPreviewLayer - self._mock_preview_layer = Mock() - self._mock_AVCaptureVideoPreviewLayer = Mock() - self._mock_AVCaptureVideoPreviewLayer.layerWithSession.return_value = ( - self._mock_preview_layer - ) - monkeypatch.setattr( - iOS, "AVCaptureVideoPreviewLayer", self._mock_AVCaptureVideoPreviewLayer - ) - - # Mock AVMetadataMachineReadableCodeObject - self._mock_code_object = Mock() - monkeypatch.setattr( - iOS, - "AVMetadataMachineReadableCodeObject", - self._mock_code_object, - ) - - # Mock the metadata object type constants - for const_name in [ - "AVMetadataObjectTypeQRCode", - "AVMetadataObjectTypeCode128Code", - "AVMetadataObjectTypeEAN13Code", - "AVMetadataObjectTypeEAN8Code", - "AVMetadataObjectTypePDF417Code", - "AVMetadataObjectTypeAztecCode", - "AVMetadataObjectTypeDataMatrixCode", - ]: - monkeypatch.setattr(iOS, const_name, f"AVMetadataObjectType{const_name}") - - # Mock devicesWithMediaType to return a mock rear camera device - self._mock_rear_device = Mock() - self._mock_rear_device.position.return_value = 1 # back - self._mock_AVCaptureDevice.devicesWithMediaType.return_value = [ - self._mock_rear_device - ] - - # Mock deviceInputWithDevice_error_ - self._mock_input = Mock() - self._mock_AVCaptureDeviceInput.deviceInputWithDevice_error_.return_value = ( - self._mock_input - ) - # Load an image that can be used as a sample photo self.camera_image = toga.Image("resources/photo.png") From fcc83436882517559dc0f77207e321293c51ac01 Mon Sep 17 00:00:00 2001 From: Philip James Date: Mon, 15 Jun 2026 12:47:47 -0700 Subject: [PATCH 05/11] Update changes/camera-scanning.feature.md Co-authored-by: Russell Keith-Magee --- changes/camera-scanning.feature.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/camera-scanning.feature.md b/changes/camera-scanning.feature.md index 0192ec1f64..15337f22ae 100644 --- a/changes/camera-scanning.feature.md +++ b/changes/camera-scanning.feature.md @@ -1 +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 iOS backend implements scanning using `AVCaptureSession` with `AVCaptureMetadataOutput`, supporting QR codes, Code 128, EAN-13, EAN-8, PDF417, Aztec, and Data Matrix formats. The macOS and Android backends raise `NotImplementedError` for scanning operations. +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. From bb76ae06dca5c5cfb3101faffd9b5c902d424996 Mon Sep 17 00:00:00 2001 From: Philip James Date: Mon, 15 Jun 2026 20:09:38 -0700 Subject: [PATCH 06/11] Accept a single BarcodeFormat value for code_types parameter As a convenience for the common use case of scanning for a single barcode type (e.g. QR), start_scanning() now accepts a bare BarcodeFormat value in addition to a list. A single value is normalized to a one-element list before being passed to the backend. --- core/src/toga/hardware/camera.py | 9 ++++++--- core/tests/hardware/test_camera.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/core/src/toga/hardware/camera.py b/core/src/toga/hardware/camera.py index dbe8ef7941..43f0040723 100644 --- a/core/src/toga/hardware/camera.py +++ b/core/src/toga/hardware/camera.py @@ -151,7 +151,7 @@ def is_scanning(self) -> bool: def start_scanning( self, device: CameraDevice | None = None, - code_types: list[BarcodeFormat] | None = None, + code_types: BarcodeFormat | list[BarcodeFormat] | None = None, on_detection: Callable | None = None, continuous: bool = False, ) -> ScanResult: @@ -175,8 +175,9 @@ def start_scanning( :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. If ``None``, all supported - types will be detected. + :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 @@ -191,6 +192,8 @@ def start_scanning( 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( diff --git a/core/tests/hardware/test_camera.py b/core/tests/hardware/test_camera.py index ed4636936d..a2116d1533 100644 --- a/core/tests/hardware/test_camera.py +++ b/core/tests/hardware/test_camera.py @@ -284,6 +284,23 @@ def test_start_scanning_with_code_type(app, 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) From 589dfaf9fbc3b0809519c128c41f30757b96fa06 Mon Sep 17 00:00:00 2001 From: Philip James Date: Mon, 15 Jun 2026 20:19:53 -0700 Subject: [PATCH 07/11] Implement macOS barcode scanning and restore removed comments Replace NotImplementedError stubs on macOS with real AVCaptureMetadataOutput implementation via TogaCameraScannerDelegate and TogaCameraScannerWindow. Add AVCaptureMetadataOutput, AVMetadataMachineReadableCodeObject, and 7 AVMetadataObjectType* constants to cocoa AVFoundation bindings. Restore # for classes that need to be monkeypatched for testing comment and commented-out native_video_quality() function in iOS backend. The code_types parameter now accepts a single BarcodeFormat value as a convenience shorthand. Update docs and changes fragment to reflect macOS support and single-value acceptance. --- changes/camera-scanning.feature.md | 2 +- cocoa/src/toga_cocoa/hardware/camera.py | 180 ++++++++++++++++++++- cocoa/src/toga_cocoa/libs/av_foundation.py | 28 ++++ docs/en/reference/api/hardware/camera.md | 10 +- iOS/src/toga_iOS/hardware/camera.py | 7 + 5 files changed, 218 insertions(+), 9 deletions(-) diff --git a/changes/camera-scanning.feature.md b/changes/camera-scanning.feature.md index 15337f22ae..1ad9d5c96d 100644 --- a/changes/camera-scanning.feature.md +++ b/changes/camera-scanning.feature.md @@ -1 +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. +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. diff --git a/cocoa/src/toga_cocoa/hardware/camera.py b/cocoa/src/toga_cocoa/hardware/camera.py index bdcd52e35b..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: @@ -324,10 +483,21 @@ def take_photo(self, result, device, flash): raise PermissionError("App does not have permission to take photos") def is_scanning(self): - raise NotImplementedError("Barcode scanning is not yet implemented on macOS") + return self._scan_future is not None def start_scanning(self, future, device, code_types, continuous): - raise NotImplementedError("Barcode scanning is not yet implemented on macOS") + 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): - raise NotImplementedError("Barcode scanning is not yet implemented on macOS") + 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/docs/en/reference/api/hardware/camera.md b/docs/en/reference/api/hardware/camera.md index 1671b5cb0d..06e2e8c6ee 100644 --- a/docs/en/reference/api/hardware/camera.md +++ b/docs/en/reference/api/hardware/camera.md @@ -25,7 +25,7 @@ Toga will confirm whether the app has been granted permission to use the camera ## Scanning for Barcodes -The camera can be used to scan QR codes and other barcode types in real-time. Scanning is supported on iOS and in the Dummy (test) backend. +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: @@ -49,9 +49,13 @@ def stop_scan(self, widget, **kwargs): self.camera.stop_scanning() ``` -You can specify which barcode formats to scan for using the `code_types` parameter: +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], ) @@ -64,7 +68,7 @@ content = await self.camera.start_scanning( - 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 or scan barcodes. To test your app's Camera usage, you must use a physical iOS device. -- Barcode scanning is currently available on iOS and in the Dummy (test) backend. Other backends will raise `NotImplementedError`. +- Barcode scanning is currently available on iOS, macOS, and in the Dummy (test) backend. Other backends will raise `NotImplementedError`. ## Reference diff --git a/iOS/src/toga_iOS/hardware/camera.py b/iOS/src/toga_iOS/hardware/camera.py index 43030986e3..b6eeab4b74 100644 --- a/iOS/src/toga_iOS/hardware/camera.py +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -5,6 +5,8 @@ import toga 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, @@ -87,6 +89,11 @@ def _scan_symbols(): } +# def native_video_quality(quality): +# return { +# VideoQuality.HIGH: UIImagePickerControllerQualityType.High, +# VideoQuality.LOW: UIImagePickerControllerQualityType.Low, +# }.get(quality, UIImagePickerControllerQualityType.Medium) class TogaImagePickerDelegate(NSObject): @objc_method def imagePickerController_didFinishPickingMediaWithInfo_( From ea52118e41b0191b5001cc2bde8917ae41933656 Mon Sep 17 00:00:00 2001 From: Philip James Date: Mon, 15 Jun 2026 21:08:41 -0700 Subject: [PATCH 08/11] Restore explanatory comments in iOS test probe --- iOS/tests_backend/hardware/camera.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/iOS/tests_backend/hardware/camera.py b/iOS/tests_backend/hardware/camera.py index 005bd17c4e..2b75e853ed 100644 --- a/iOS/tests_backend/hardware/camera.py +++ b/iOS/tests_backend/hardware/camera.py @@ -46,12 +46,14 @@ def _mock_auth_status(media_type): self._mock_AVCaptureDevice.authorizationStatusForMediaType = _mock_auth_status def _mock_request_access(media_type, completionHandler): + # Fire completion handler try: self._mock_permissions[str(media_type)] = abs( self._mock_permissions[str(media_type)] ) result = bool(self._mock_permissions[str(media_type)]) except KeyError: + # If there's no explicit permission, it's a denial self._mock_permissions[str(media_type)] = 0 result = False completionHandler.func(result) @@ -120,6 +122,7 @@ def select_other_camera(self): return other def disconnect_cameras(self): + # Set the source type as *not* available and re-create the Camera impl. self._mock_UIImagePickerController.isSourceTypeAvailable.return_value = False self.app.camera._impl = Camera(self.app) @@ -140,15 +143,18 @@ async def wait_for_camera(self, device_count=0): @property def shutter_enabled(self): + # Shutter can't be disabled return True async def press_shutter_button(self, photo): + # The camera picker was correctly configured picker = self.app.camera._impl.native assert picker.sourceType == UIImagePickerControllerSourceTypeCamera assert ( picker.cameraCaptureMode == UIImagePickerControllerCameraCaptureMode.Photo ) + # Fake the result of a successful photo being taken picker.delegate.imagePickerController( picker, didFinishPickingMediaWithInfo={ @@ -161,12 +167,14 @@ async def press_shutter_button(self, photo): return await photo, picker.cameraDevice, picker.cameraFlashMode async def cancel_photo(self, photo): + # The camera picker was correctly configured picker = self.app.camera._impl.native assert picker.sourceType == UIImagePickerControllerSourceTypeCamera assert ( picker.cameraCaptureMode == UIImagePickerControllerCameraCaptureMode.Photo ) + # Fake the result of a cancelling the photo picker.delegate.imagePickerControllerDidCancel(picker) await self.redraw("Photo cancelled", delay=0.5) From 57c73c23bc7365060151e53c911a958f80535eac Mon Sep 17 00:00:00 2001 From: Philip James Date: Mon, 15 Jun 2026 21:16:40 -0700 Subject: [PATCH 09/11] Replace lazy _scan_symbols dict with direct AVFoundation imports Follow the standard codebase pattern by declaring ObjCClass and objc_const symbols directly in libs/av_foundation.py instead of using a lazy dict lookup via _scan_symbols(). The BARCODE_FORMAT_MAP is now a static module-level dict using the directly imported AVMetadataObjectType constants. --- iOS/src/toga_iOS/hardware/camera.py | 95 ++++++++++---------------- iOS/src/toga_iOS/libs/av_foundation.py | 47 +++++++++++++ 2 files changed, 83 insertions(+), 59 deletions(-) diff --git a/iOS/src/toga_iOS/hardware/camera.py b/iOS/src/toga_iOS/hardware/camera.py index b6eeab4b74..3a658bf68d 100644 --- a/iOS/src/toga_iOS/hardware/camera.py +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -1,7 +1,6 @@ import warnings -from functools import cache -from rubicon.objc import SEL, Block, NSObject, ObjCClass, objc_const, objc_method +from rubicon.objc import SEL, Block, NSObject, objc_method import toga from toga.constants import BarcodeFormat, FlashMode @@ -10,7 +9,21 @@ 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, @@ -26,6 +39,16 @@ 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): @@ -50,50 +73,13 @@ def native_flash_mode(flash): }.get(flash, UIImagePickerControllerCameraFlashMode.Auto) -@cache -def _scan_symbols(): - av_foundation = iOS.av_foundation - return { - "capture_device": ObjCClass("AVCaptureDevice"), - "capture_device_input": ObjCClass("AVCaptureDeviceInput"), - "capture_metadata_output": ObjCClass("AVCaptureMetadataOutput"), - "capture_session": ObjCClass("AVCaptureSession"), - "capture_video_preview_layer": ObjCClass("AVCaptureVideoPreviewLayer"), - "metadata_machine_readable_code_object": ObjCClass( - "AVMetadataMachineReadableCodeObject" - ), - "video_gravity_resize_aspect_fill": objc_const( - av_foundation, "AVLayerVideoGravityResizeAspectFill" - ), - "barcode_format_map": { - BarcodeFormat.QR: objc_const(av_foundation, "AVMetadataObjectTypeQRCode"), - BarcodeFormat.CODE128: objc_const( - av_foundation, "AVMetadataObjectTypeCode128Code" - ), - BarcodeFormat.EAN13: objc_const( - av_foundation, "AVMetadataObjectTypeEAN13Code" - ), - BarcodeFormat.EAN8: objc_const( - av_foundation, "AVMetadataObjectTypeEAN8Code" - ), - BarcodeFormat.PDF417: objc_const( - av_foundation, "AVMetadataObjectTypePDF417Code" - ), - BarcodeFormat.AZTEC: objc_const( - av_foundation, "AVMetadataObjectTypeAztecCode" - ), - BarcodeFormat.DATA_MATRIX: objc_const( - av_foundation, "AVMetadataObjectTypeDataMatrixCode" - ), - }, - } - - # def native_video_quality(quality): # return { # VideoQuality.HIGH: UIImagePickerControllerQualityType.High, # VideoQuality.LOW: UIImagePickerControllerQualityType.Low, # }.get(quality, UIImagePickerControllerQualityType.Medium) + + class TogaImagePickerDelegate(NSObject): @objc_method def imagePickerController_didFinishPickingMediaWithInfo_( @@ -118,9 +104,7 @@ def metadataOutput_didOutputMetadataObjects_fromConnection_( count = metadata_objects.count() if count > 0: metadata_object = metadata_objects.objectAtIndex(0) - if metadata_object.isKindOfClass_( - _scan_symbols()["metadata_machine_readable_code_object"] - ): + if metadata_object.isKindOfClass_(AVMetadataMachineReadableCodeObject): content = str(metadata_object.stringValue()) if content: self.camera._handle_detection(content) @@ -248,9 +232,8 @@ def start_scanning(self, future, device, code_types, continuous): self._scan_delegate = TogaCameraScannerDelegate.alloc().init() self._scan_delegate.camera = self - capture_metadata_output = _scan_symbols()["capture_metadata_output"] for output in session.outputs(): - if output.isKindOfClass_(capture_metadata_output): + if output.isKindOfClass_(AVCaptureMetadataOutput): output.setMetadataObjectsDelegate_queue_(self._scan_delegate, None) break @@ -261,15 +244,14 @@ def start_scanning(self, future, device, code_types, continuous): self._present_scan_ui(self._scan_preview_controller) def _build_scan_session(self, device, code_types): - symbols = _scan_symbols() - session = symbols["capture_session"].alloc().init() + 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 = symbols["capture_device_input"].deviceInputWithDevice_error_( + device_input = AVCaptureDeviceInput.deviceInputWithDevice_error_( capture_device, None ) if not session.canAddInput(device_input): @@ -277,16 +259,14 @@ def _build_scan_session(self, device, code_types): return None session.addInput(device_input) - metadata_output = symbols["capture_metadata_output"].alloc().init() + 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 = [ - symbols["barcode_format_map"][ct] - for ct in code_types - if ct in symbols["barcode_format_map"] + BARCODE_FORMAT_MAP[ct] for ct in code_types if ct in BARCODE_FORMAT_MAP ] if objc_types: metadata_output.setMetadataObjectTypes_(objc_types) @@ -300,17 +280,14 @@ def _resolve_capture_device(self, device): and device._impl.native == UIImagePickerControllerCameraDevice.Front else AVCaptureDevicePositionBack ) - for dev in _scan_symbols()["capture_device"].devicesWithMediaType( - AVMediaTypeVideo - ): + for dev in AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo): if dev.position() == position: return dev return None def _build_scan_ui(self, session): - symbols = _scan_symbols() - preview_layer = symbols["capture_video_preview_layer"].layerWithSession(session) - preview_layer.setVideoGravity(symbols["video_gravity_resize_aspect_fill"]) + preview_layer = AVCaptureVideoPreviewLayer.layerWithSession(session) + preview_layer.setVideoGravity(AVLayerVideoGravityResizeAspectFill) controller = UIViewController.alloc().init() controller.view.layer().insertSublayer_atIndex_(preview_layer, 0) 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") From 8ff7b03238a89656d7dfb7c1baa79eaffc1d84ca Mon Sep 17 00:00:00 2001 From: Philip James Date: Mon, 15 Jun 2026 21:28:42 -0700 Subject: [PATCH 10/11] Restore all explanatory comments removed from iOS backend Restore NSCameraUsageDescription, request_permission thread, take_photo configuration, delegate, and presentation comments that were lost during the rewrite of the iOS camera backend. --- iOS/src/toga_iOS/hardware/camera.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/iOS/src/toga_iOS/hardware/camera.py b/iOS/src/toga_iOS/hardware/camera.py index 3a658bf68d..67a31e4992 100644 --- a/iOS/src/toga_iOS/hardware/camera.py +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -135,6 +135,9 @@ def __init__(self, interface): else: self.native = None else: # pragma: no cover + # The app doesn't have the NSCameraUsageDescription key (e.g., via + # `permission.camera` in Briefcase). No-cover because we can't manufacture + # this condition in testing. raise RuntimeError( "Application metadata does not declare that the " "app will use the camera." @@ -155,7 +158,10 @@ def has_permission(self, allow_unknown=False): ) def request_permission(self, future): - def permission_complete(result) -> None: + # 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: # pragma: no cover future.set_result(result) iOS.AVCaptureDevice.requestAccessForMediaType( @@ -194,6 +200,7 @@ def take_photo(self, result, device, flash): warnings.warn("No camera is available", stacklevel=2) result.set_result(None) elif self.has_permission(allow_unknown=True): + # Configure the controller to take a photo self.native.cameraCaptureMode = ( UIImagePickerControllerCameraCaptureMode.Photo ) @@ -206,8 +213,10 @@ def take_photo(self, result, device, flash): ) self.native.cameraFlashMode = native_flash_mode(flash) + # Attach the result to the delegate self.native.delegate.result = result + # Show the pane ( toga.App.app.current_window._impl.native.rootViewController ).presentViewController(self.native, animated=True, completion=None) From c6e5035add6dcad57fdd9c9931f2dc00124a3d3f Mon Sep 17 00:00:00 2001 From: Philip James Date: Tue, 16 Jun 2026 15:42:33 -0700 Subject: [PATCH 11/11] Use PR-numbered changenote with reviewer's suggested wording --- changes/4458.feature.md | 1 + changes/camera-scanning.feature.md | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changes/4458.feature.md delete mode 100644 changes/camera-scanning.feature.md 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/changes/camera-scanning.feature.md b/changes/camera-scanning.feature.md deleted file mode 100644 index 1ad9d5c96d..0000000000 --- a/changes/camera-scanning.feature.md +++ /dev/null @@ -1 +0,0 @@ -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.