diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 767821f092..f4340bcbea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,6 +228,7 @@ jobs: - name: Test App working-directory: testbed + timeout-minutes: 15 run: ${{ matrix.briefcase-run-prefix }} briefcase run ${{ matrix.backend }} --test ${{ matrix.briefcase-run-args }} - name: Upload logs @@ -254,7 +255,6 @@ jobs: # only occur in CI, and can't be reproduced locally. When it runs, it will # open an SSH server (URL reported in the logs) so you can ssh into the CI # machine. - # - uses: actions/checkout@v3 # - name: Setup tmate session # uses: mxschmitt/action-tmate@v3 # if: failure() diff --git a/changes/2266.feature.rst b/changes/2266.feature.rst new file mode 100644 index 0000000000..b7155250b2 --- /dev/null +++ b/changes/2266.feature.rst @@ -0,0 +1 @@ +A cross-platform API for camera access was added. diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index cda6b628d1..cfe6a48d32 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/factory.py @@ -2,9 +2,8 @@ from .app import App, DocumentApp, MainWindow from .command import Command from .documents import Document - -# Resources from .fonts import Font +from .hardware.camera import Camera from .icons import Icon from .images import Image from .paths import Paths @@ -52,6 +51,8 @@ def not_implemented(feature): "Image", "Paths", "dialogs", + # Hardware + "Camera", # Widgets "ActivityIndicator", "Box", diff --git a/cocoa/src/toga_cocoa/hardware/camera.py b/cocoa/src/toga_cocoa/hardware/camera.py new file mode 100644 index 0000000000..37d242a31d --- /dev/null +++ b/cocoa/src/toga_cocoa/hardware/camera.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +from threading import Thread + +from rubicon.objc import Block, objc_method + +import toga +from toga.colors import BLACK, RED +from toga.constants import FlashMode +from toga.style import Pack +from toga.style.pack import COLUMN + +# for classes that need to be monkeypatched for testing +from toga_cocoa import libs as cocoa +from toga_cocoa.images import nsdata_to_bytes +from toga_cocoa.libs import ( + AVAuthorizationStatus, + AVCaptureFlashMode, + AVCapturePhotoOutput, + AVCaptureSession, + AVCaptureSessionPresetPhoto, + AVCaptureVideoPreviewLayer, + AVLayerVideoGravityResizeAspectFill, + AVMediaTypeVideo, +) + + +def native_flash_mode(flash): + return { + FlashMode.ON: AVCaptureFlashMode.On, + FlashMode.OFF: AVCaptureFlashMode.Off, + }.get(flash, AVCaptureFlashMode.Auto) + + +class CameraDevice: + def __init__(self, native): + self.native = native + + def id(self): + return str(self.native.uniqueID) + + def name(self): + return str(self.native.localizedName) + + def has_flash(self): + return self.native.isFlashAvailable() + + +# This is the native delegate, but we can't force the delegate to be invoked because we +# can't create a mock Photo; so we push all logic to the window, and mark this class no +# cover +class TogaCameraCaptureSession(AVCaptureSession): # pragma: no cover + @objc_method + def captureOutput_didFinishProcessingPhoto_error_( + self, output, photo, error + ) -> None: + # A photo has been taken. + self.window.photo_taken(photo) + + +class TogaCameraWindow(toga.Window): + def __init__(self, camera, device, flash, result): + super().__init__( + title="Camera", + on_close=self.close_window, + resizable=False, + # This size is too small by design; it will be expanded by the layout rules. + size=(640, 360), + ) + self.camera = camera + self.result = result + + self.create_preview_window() + self.create_camera_session(device, flash) + + def create_preview_window(self): + # A preview window, fixed 16:9 aspect ratio + self.preview = toga.Box(style=Pack(width=640, height=360)) + + # Set an initially empty list of devices. This will be populated once the window + # is shown, so that getting the list of devices doesn't slow down showing the + # capture window. + self.device_select = toga.Selection( + items=[], + on_change=self.change_camera, + style=Pack(width=200), + ) + + # The shutter button. Initially disabled until we know we have a camera available + self.shutter_button = toga.Button( + icon=toga.Icon("camera", system=True), + on_press=self.take_photo, + style=Pack(background_color=RED), + enabled=False, + ) + + # The flash mode. Initially disable the flash. + self.flash_mode = toga.Selection( + items=[], + style=Pack(width=75), + ) + + # Construct the overall layout + self.content = toga.Box( + children=[ + # The preview box will have its layer replaced by the the video preview. + # Put the preview box inside another box so that we have a surface that + # can show a black background while the camera is initializing. + toga.Box( + children=[self.preview], + style=Pack(background_color=BLACK), + ), + toga.Box( + # Put the controls in a ROW box; the shutter button is + # in the middle, non-flexible, so that it is centered. + children=[ + toga.Box( + children=[self.device_select], + style=Pack(flex=1), + ), + self.shutter_button, + toga.Box( + children=[ + toga.Box(style=Pack(flex=1)), + toga.Label("Flash:"), + self.flash_mode, + ], + style=Pack(flex=1), + ), + ], + style=Pack(padding=10), + ), + ], + style=Pack(direction=COLUMN), + ) + + # This is the method that creates the native camera session. Mocking these methods + # is extremely difficult (impossible?); plus we can't know what cameras the test + # machine will have. So - we mock this entire method, and mark it no-cover. + def create_camera_session(self, device, flash): # pragma: no cover + self.camera_session = TogaCameraCaptureSession.alloc().init() + self.camera_session.window = self + self.camera_session.beginConfiguration() + + # Create a preview layer, rendering into the preview box + 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) + + # Specify that we want photo output. + output = AVCapturePhotoOutput.alloc().init() + output.setHighResolutionCaptureEnabled(True) + self.camera_session.addOutput(output) + self.camera_session.setSessionPreset(AVCaptureSessionPresetPhoto) + + # Set a sentinel for the camera input; this won't be set until the user has + # selected a camera (either explicitly or implicitly) + self.camera_input = None + + # Apply the configuration + self.camera_session.commitConfiguration() + + # Polling camera devices and starting the camera session is a blocking activity. + # Start a background thread to populate the list of camera devices and start the + # camera session. + Thread( + target=self._enable_camera, + kwargs={"device": device, "flash": flash}, + ).start() + + def _enable_camera(self, device, flash): + self.camera_session.startRunning() + + # The GUI can only be modified from inside the GUI thread. Add a background task + # to apply the new device list. + self.camera.interface.app.loop.create_task( + self._update_camera_list(toga.App.app.camera.devices, device, flash) + ) + + async def _update_camera_list(self, devices, device, flash): + self.device_select.items = devices + if device: + self.device_select.value = device + + self._update_flash_mode(flash) + + def _update_flash_mode(self, flash=FlashMode.AUTO): + if device := self.device_select.value: + if device.has_flash: + self.flash_mode.items = [FlashMode.AUTO, FlashMode.OFF, FlashMode.ON] + self.flash_mode.value = flash + else: + self.flash_mode.items = [FlashMode.OFF] + else: + self.flash_mode.items = [] + + def change_camera(self, widget=None, **kwargs): + # Remove the existing camera input (if it exists) + 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) + self.shutter_button.enabled = True + else: + self.shutter_button.enabled = False + + self._update_flash_mode() + + def close_window(self, widget, **kwargs): + # Stop the camera session + self.camera_session.stopRunning() + + # Set the "no result" result + self.result.set_result(None) + + # Clear the reference to the preview window, and allow the window to close + self.camera.preview_windows.remove(self) + return True + + def take_photo(self, widget, **kwargs): + settings = cocoa.AVCapturePhotoSettings.photoSettings() + settings.flashMode = native_flash_mode(self.flash_mode.value) + + self.camera_session.outputs[0].capturePhotoWithSettings( + settings, + delegate=self.camera_session, + ) + self.close() + + def photo_taken(self, photo): + # Create the result image. + image = toga.Image(nsdata_to_bytes(photo.fileDataRepresentation())) + self.result.set_result(image) + + # Stop the camera session + self.camera_session.stopRunning() + + # Clear the reference to the preview window. + self.camera.preview_windows.remove(self) + + +class Camera: + def __init__(self, interface): + self.interface = interface + self.preview_windows = [] + + def has_permission(self, allow_unknown=False): + # To reset permissions to "factory" status, run: + # tccutil reset Camera + # + # To reset a single app: + # tccutil reset Camera + # + # e.g. + # tccutil reset Camera org.beeware.appname # for a bundled app + # tccutil reset Camera com.microsoft.VSCode # for code running in Visual Studio + # tccutil reset Camera com.apple.Terminal # for code running in the Apple terminal + + if allow_unknown: + valid_values = { + AVAuthorizationStatus.Authorized.value, + AVAuthorizationStatus.NotDetermined.value, + } + else: + valid_values = {AVAuthorizationStatus.Authorized.value} + + return ( + cocoa.AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) + in valid_values + ) + + 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: # pragma: no cover + future.set_result(result) + + cocoa.AVCaptureDevice.requestAccessForMediaType( + AVMediaTypeVideo, + completionHandler=Block(permission_complete, None, bool), + ) + + def get_devices(self): + return [ + CameraDevice(device) + for device in cocoa.AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo) + ] + + def take_photo(self, result, device, flash): + if self.has_permission(allow_unknown=True): + window = TogaCameraWindow(self, device, flash, result) + self.preview_windows.append(window) + window.show() + else: + raise PermissionError("App does not have permission to take photos") diff --git a/cocoa/src/toga_cocoa/libs/__init__.py b/cocoa/src/toga_cocoa/libs/__init__.py index 310642942a..018515a9b7 100644 --- a/cocoa/src/toga_cocoa/libs/__init__.py +++ b/cocoa/src/toga_cocoa/libs/__init__.py @@ -21,6 +21,7 @@ ) from .appkit import * # noqa: F401, F403 +from .av_foundation import * # noqa: F401, F403 from .core_graphics import * # noqa: F401, F403 from .core_text import * # noqa: F401, F403 from .foundation import * # noqa: F401, F403 diff --git a/cocoa/src/toga_cocoa/libs/av_foundation.py b/cocoa/src/toga_cocoa/libs/av_foundation.py new file mode 100644 index 0000000000..114a7ecf4a --- /dev/null +++ b/cocoa/src/toga_cocoa/libs/av_foundation.py @@ -0,0 +1,72 @@ +########################################################################## +# System/Library/Frameworks/AVFoundation.framework +########################################################################## +from ctypes import cdll, util +from enum import Enum + +from rubicon.objc import ObjCClass, objc_const + +###################################################################### +av_foundation = cdll.LoadLibrary(util.find_library("AVFoundation")) +###################################################################### + +###################################################################### +# AVAnimation.h + +AVLayerVideoGravityResize = objc_const(av_foundation, "AVLayerVideoGravityResize") +AVLayerVideoGravityResizeAspect = objc_const( + av_foundation, "AVLayerVideoGravityResizeAspect" +) +AVLayerVideoGravityResizeAspectFill = objc_const( + av_foundation, "AVLayerVideoGravityResizeAspectFill" +) + +###################################################################### +# AVMediaFormat.h + +AVMediaTypeAudio = objc_const(av_foundation, "AVMediaTypeAudio") +AVMediaTypeVideo = objc_const(av_foundation, "AVMediaTypeVideo") + +###################################################################### +# AVCaptureSessionPreset.h + +AVCaptureSessionPresetPhoto = objc_const(av_foundation, "AVCaptureSessionPresetPhoto") + +###################################################################### +# AVCaptureDevice.h + +AVCaptureDevice = ObjCClass("AVCaptureDevice") + + +class AVAuthorizationStatus(Enum): + NotDetermined = 0 + Restricted = 1 + Denied = 2 + Authorized = 3 + + +class AVCaptureFlashMode(Enum): + Off = 0 + On = 1 + Auto = 2 + + +###################################################################### +# AVCaptureDeviceInput.h +AVCaptureDeviceInput = ObjCClass("AVCaptureDeviceInput") + +###################################################################### +# AVCapturePhotoOutput.h + +AVCapturePhotoOutput = ObjCClass("AVCapturePhotoOutput") +AVCapturePhotoSettings = ObjCClass("AVCapturePhotoSettings") + +###################################################################### +# AVCaptureSession.h + +AVCaptureSession = ObjCClass("AVCaptureSession") + +###################################################################### +# AVCaptureVideoPreviewLayer.h + +AVCaptureVideoPreviewLayer = ObjCClass("AVCaptureVideoPreviewLayer") diff --git a/cocoa/src/toga_cocoa/resources/camera.png b/cocoa/src/toga_cocoa/resources/camera.png new file mode 100644 index 0000000000..dfaae89127 Binary files /dev/null and b/cocoa/src/toga_cocoa/resources/camera.png differ diff --git a/cocoa/tests_backend/hardware/__init__.py b/cocoa/tests_backend/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cocoa/tests_backend/hardware/camera.py b/cocoa/tests_backend/hardware/camera.py new file mode 100644 index 0000000000..61f520ad1d --- /dev/null +++ b/cocoa/tests_backend/hardware/camera.py @@ -0,0 +1,224 @@ +from unittest.mock import Mock + +from toga.constants import FlashMode +from toga_cocoa import libs as cocoa +from toga_cocoa.hardware.camera import TogaCameraWindow +from toga_cocoa.libs import ( + AVAuthorizationStatus, + AVCaptureFlashMode, + AVMediaTypeVideo, +) + +from ..app import AppProbe + + +class CameraProbe(AppProbe): + allow_no_camera = True + + def __init__(self, monkeypatch, app_probe): + super().__init__(app_probe.app) + + self.monkeypatch = monkeypatch + + # A mocked permissions table. The key is the media type; the value is True + # if permission has been granted, False if it has be denied. A missing value + # will be turned into a grant if permission is requested. + self._mock_permissions = {} + + # Mock AVCaptureDevice + # 2 devices installed. Camera 1 has a flash; camera 2 does not. + self._mock_AVCaptureDevice = Mock() + + self._mock_camera_1 = Mock(uniqueID="camera-1", localizedName="Camera 1") + self._mock_camera_1.isFlashAvailable.return_value = True + + self._mock_camera_2 = Mock(uniqueID="camera-2", localizedName="Camera 2") + self._mock_camera_2.isFlashAvailable.return_value = False + + self._mock_AVCaptureDevice.devicesWithMediaType.return_value = [ + self._mock_camera_1, + self._mock_camera_2, + ] + + def _mock_auth_status(media_type): + try: + return { + 1: AVAuthorizationStatus.Authorized.value, + 0: AVAuthorizationStatus.Denied.value, + }[self._mock_permissions[str(media_type)]] + except KeyError: + return AVAuthorizationStatus.NotDetermined.value + + self._mock_AVCaptureDevice.authorizationStatusForMediaType = _mock_auth_status + + def _mock_request_access(media_type, completionHandler): + # Fire completion handler + try: + result = self._mock_permissions[str(media_type)] + except KeyError: + # If there's no explicit permission, convert into a full grant + self._mock_permissions[str(media_type)] = True + result = True + completionHandler.func(result) + + self._mock_AVCaptureDevice.requestAccessForMediaType = _mock_request_access + + monkeypatch.setattr(cocoa, "AVCaptureDevice", self._mock_AVCaptureDevice) + + # Mock AVCaptureDeviceInput + self._mock_AVCaptureDeviceInput = Mock() + + def _mock_deviceInput(device, error): + return Mock(device=device) + + self._mock_AVCaptureDeviceInput.deviceInputWithDevice = _mock_deviceInput + + monkeypatch.setattr( + cocoa, "AVCaptureDeviceInput", self._mock_AVCaptureDeviceInput + ) + + # Mock AVCapturePhotoSettings + self._mock_AVCapturePhotoSettings = Mock() + + self._mock_photoSettings = Mock() + self._mock_AVCapturePhotoSettings.photoSettings.return_value = ( + self._mock_photoSettings + ) + + monkeypatch.setattr( + cocoa, "AVCapturePhotoSettings", self._mock_AVCapturePhotoSettings + ) + + # Mock creation of a camera Session + def _mock_camera_session(window, device, flash): + def _addInput(input): + window.camera_session.inputs.append(input) + + def _removeInput(input): + window.camera_session.inputs.remove(input) + + window.camera_session = Mock( + inputs=[], + outputs=[Mock()], + addInput=_addInput, + removeInput=_removeInput, + ) + + window._enable_camera(device, flash) + + monkeypatch.setattr( + TogaCameraWindow, "create_camera_session", _mock_camera_session + ) + + def cleanup(self): + # Ensure there are no open camrea preview windows at the end of a test. + for window in self.app.camera._impl.preview_windows: + window.cocoa_windowShouldClose() + + def known_cameras(self): + return { + "camera-1": ("Camera 1", True), + "camera-2": ("Camera 2", False), + } + + def select_other_camera(self): + device = self.app.camera.devices[1] + self.app.camera._impl.preview_windows[0].device_select.value = device + return device + + def disconnect_cameras(self): + self._mock_AVCaptureDevice.devicesWithMediaType.return_value = [] + + def reset_permission(self): + self._mock_permissions = {} + + def allow_permission(self): + self._mock_permissions[str(AVMediaTypeVideo)] = True + + def reject_permission(self): + self._mock_permissions[str(AVMediaTypeVideo)] = False + + async def wait_for_camera(self, device_count=2): + # A short delay is needed to ensure that the window fully creates. + await self.redraw("Camera view displayed", delay=0.1) + + # Session has been started, and there are devices + window = self.app.camera._impl.preview_windows[0] + window.camera_session.startRunning.assert_called_once_with() + + assert len(window.device_select.items) == device_count + + @property + def shutter_enabled(self): + return self.app.camera._impl.preview_windows[0].shutter_button.enabled + + async def press_shutter_button(self, photo): + window = self.app.camera._impl.preview_windows[0] + device_used = window.camera_session.inputs[0].device + # The shutter button should be enabled + assert self.shutter_enabled + + # Press the shutter button + window.take_photo(None) + await self.redraw("Shutter pressed") + + # Photo settings were created with the right flash mode + self._mock_AVCapturePhotoSettings.photoSettings.assert_called_once_with() + self._mock_AVCapturePhotoSettings.photoSettings.reset_mock() + flash_mode = self._mock_photoSettings.flashMode + + # The capture mechanism was invoked + output = window.camera_session.outputs[0] + output.capturePhotoWithSettings.assert_called_once_with( + self._mock_photoSettings, + delegate=window.camera_session, + ) + output.capturePhotoWithSettings.reset_mock() + + # Fake the result of a successful photo being taken + image_data = (self.app.paths.app / "resources/photo.png").read_bytes() + + result = Mock() + result.fileDataRepresentation.return_value = Mock( + bytes=image_data, length=len(image_data) + ) + + window.photo_taken(result) + + # The window has been closed and the session ended + assert window.closed + window.camera_session.stopRunning.assert_called_once_with() + window.camera_session.stopRunning.reset_mock() + assert window not in self.app.camera._impl.preview_windows + + return await photo, device_used, flash_mode + + async def cancel_photo(self, photo): + window = self.app.camera._impl.preview_windows[0] + + # Close the camera window. + window._impl.cocoa_windowShouldClose() + await self.redraw("Photo cancelled") + + # The window has been closed and the session ended + assert window.closed + window.camera_session.stopRunning.assert_called_once_with() + assert window not in self.app.camera._impl.preview_windows + + return await photo + + def same_device(self, device, native): + if device is None: + return self._mock_camera_1 == native + else: + return device._impl.native == native + + def same_flash_mode(self, expected, actual): + return ( + expected + == { + AVCaptureFlashMode.Auto: FlashMode.AUTO, + AVCaptureFlashMode.On: FlashMode.ON, + AVCaptureFlashMode.Off: FlashMode.OFF, + }[actual] + ) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index db95051bc3..b80ef34a9a 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -23,6 +23,7 @@ from toga.command import Command, CommandSet from toga.documents import Document from toga.handlers import wrapped_handler +from toga.hardware.camera import Camera from toga.icons import Icon from toga.paths import Paths from toga.platform import get_platform_factory @@ -505,6 +506,18 @@ def paths(self) -> Paths: """ return self._paths + @property + def camera(self) -> Camera: + """A representation of the device's camera (or cameras).""" + try: + return self._camera + except AttributeError: + # Instantiate the camera instance for this app on first access + # This will raise a exception if the platform doesn't implement + # the Camera API. + self._camera = Camera(self) + return self._camera + @property def name(self) -> str: """**DEPRECATED** – Use :any:`formal_name`.""" diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 98a8e302ad..1f7f3cf47b 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -21,3 +21,43 @@ class FillRule(Enum): "The rule to use when filling paths." EVENODD = 0 NONZERO = 1 + + +########################################################################## +# Camera +########################################################################## + + +class FlashMode(Enum): + """The flash mode to use when capturing photos or videos.""" + + # These constant values allow `flash=True` and `flash=False` to work + AUTO = -1 + OFF = 0 + ON = 1 + + def __str__(self): + return self.name.title() + + +# class VideoQuality(Enum): +# """The quality of the video recording. +# +# The values of ``LOW``, ``MEDIUM`` and ``HIGH`` represent specific (platform +# dependent) resolutions. These resolutions will remain the same over time. +# +# The values of ``CELLULAR`` and ``WIFI`` may change over time to reflect the +# capabilities of network hardware. +# +# ``HIGHEST`` will always refer to the highest quality that the device can +# record. +# """ +# +# LOW = 0 +# MEDIUM = 1 +# HIGH = 2 +# +# # Qualitative alternatives to these constants +# CELLULAR = 0 +# WIFI = 1 +# HIGHEST = 2 diff --git a/core/src/toga/hardware/camera.py b/core/src/toga/hardware/camera.py new file mode 100644 index 0000000000..0ff330c4c5 --- /dev/null +++ b/core/src/toga/hardware/camera.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from toga.constants import FlashMode +from toga.handlers import AsyncResult +from toga.platform import get_platform_factory + +if TYPE_CHECKING: + from toga.app import App + + +class PermissionResult(AsyncResult): + RESULT_TYPE = "permission" + + +class PhotoResult(AsyncResult): + RESULT_TYPE = "photo" + + +class CameraDevice: + def __init__(self, impl): + self._impl = impl + + @property + def id(self) -> str: + """A unique identifier for the device""" + return self._impl.id() + + @property + def name(self) -> str: + """A human-readable name for the device""" + return self._impl.name() + + @property + def has_flash(self) -> bool: + """Does the device have a flash?""" + return self._impl.has_flash() + + def __eq__(self, other) -> bool: + return self.id == other.id + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.name + + +class Camera: + def __init__(self, app: App): + self.factory = get_platform_factory() + self._app = app + self._impl = self.factory.Camera(self) + + @property + def app(self) -> App: + """The app with which the camera is associated""" + return self._app + + @property + def has_permission(self) -> bool: + """Does the user have permission to use camera devices? + + If the platform requires the user to explicitly confirm permission, and + the user has not yet given permission, this will return ``False``. + """ + return self._impl.has_permission() + + def request_permission(self) -> PermissionResult: + """Request sufficient permissions to capture photos. + + If permission has already been granted, this will return without prompting the + user. + + **This is an asynchronous method**. If you invoke this method in synchronous + context, it will start the process of requesting permissions, but will return + *immediately*. The return value can be awaited in an asynchronous context, but + cannot be compared directly. + + :returns: An asynchronous result; when awaited, returns True if the app has + permission to take a photo; False otherwise. + """ + result = PermissionResult(None) + + if has_permission := self.has_permission: + result.set_result(has_permission) + else: + self._impl.request_permission(result) + + return result + + @property + def devices(self) -> list[CameraDevice]: + """The list of available camera devices.""" + return [CameraDevice(impl) for impl in self._impl.get_devices()] + + def take_photo( + self, + device: CameraDevice | None = None, + flash: FlashMode = FlashMode.AUTO, + ) -> PhotoResult: + """Capture a photo using one of the device's cameras. + + 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 process of taking a photo, but will return + *immediately*. The return value can be awaited in an asynchronous context, but + cannot be compared directly. + + :param device: The initial camera device to use. If a device is *not* specified, + a default camera will be used. Depending on the hardware available, the user + may be able to change the camera used to capture the image at runtime. + :param flash: The initial flash mode to use; defaults to "auto". Depending on + the hardware available, this may be modified by the user at runtime. + :returns: An asynchronous result; when awaited, returns the :any:`toga.Image` + captured by the camera, or ``None`` if the photo was cancelled. + """ + photo = PhotoResult(None) + self._impl.take_photo(photo, device=device, flash=flash) + return photo diff --git a/core/tests/hardware/__init__.py b/core/tests/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/tests/hardware/test_camera.py b/core/tests/hardware/test_camera.py new file mode 100644 index 0000000000..a7731023d1 --- /dev/null +++ b/core/tests/hardware/test_camera.py @@ -0,0 +1,191 @@ +import pytest + +import toga +from toga.constants import FlashMode +from toga.hardware.camera import CameraDevice +from toga_dummy import factory +from toga_dummy.hardware.camera import ( + Camera as DummyCamera, + CameraDevice as DummyCameraDevice, +) +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) + + +@pytest.fixture +def photo(app): + return toga.Image("resources/photo.png") + + +def test_no_camera(monkeypatch, app): + """If there's no camera, and no factory implementation, accessing camera raises an exception""" + try: + monkeypatch.delattr(app, "_camera") + except AttributeError: + pass + monkeypatch.delattr(factory, "Camera") + + # Accessing the camera object should raise NotImplementedError + with pytest.raises(NotImplementedError): + app.camera + + +@pytest.mark.parametrize( + "initial, should_request, has_permission", + [ + (-1, True, True), + (0, True, False), + (1, False, True), + ], +) +def test_request_permission(app, initial, should_request, has_permission): + """An app can request permission to use the camera""" + # The camera instance round-trips the app instance + assert app.camera.app == app + + # Set initial permission + app.camera._impl._has_permission = initial + + assert ( + app.loop.run_until_complete(app.camera.request_permission()) == has_permission + ) + + if should_request: + assert_action_performed(app.camera, "request permission") + else: + assert_action_not_performed(app.camera, "request permission") + + # As a result of requesting, photo permission is as expected + assert app.camera.has_permission == has_permission + + +def test_request_permission_sync(app): + """An app can synchronously request permission to use the camera""" + # Set initial permission + app.camera._impl._has_permission = -1 + + result = app.camera.request_permission() + + # This will cause a permission request to occur... + assert_action_performed(app.camera, "request permission") + + # ... but the result won't be directly comparable + with pytest.raises(RuntimeError): + # == True isn't good python, but it's going to raise an exception anyway. + result == True # noqa: E712 + + +def test_device_properties(app): + """Device properties can be checked""" + + assert [ + { + "device": device, + "__repr__": repr(device), + "__str__": str(device), + "name": device.name, + "id": device.id, + "has_flash": device.has_flash, + } + for device in app.camera.devices + ] == [ + { + "device": CameraDevice(DummyCamera.CAMERA_1), + "__repr__": "", + "__str__": "Camera 1", + "name": "Camera 1", + "id": "camera-1", + "has_flash": True, + }, + { + "device": CameraDevice(DummyCamera.CAMERA_2), + "__repr__": "", + "__str__": "Camera 2", + "name": "Camera 2", + "id": "camera-2", + "has_flash": False, + }, + ] + + # Identity check + assert CameraDevice(DummyCamera.CAMERA_1) == CameraDevice(DummyCamera.CAMERA_1) + # A different instance with the same ID is equal + duplicate = CameraDevice( + DummyCameraDevice(id="camera-1", name="Duplicate Camera 1", has_flash=True) + ) + assert CameraDevice(DummyCamera.CAMERA_1) == duplicate + # Different cameras aren't equal + assert CameraDevice(DummyCamera.CAMERA_1) != CameraDevice(DummyCamera.CAMERA_2) + + +@pytest.mark.parametrize( + "device", + [None, CameraDevice(DummyCamera.CAMERA_1), CameraDevice(DummyCamera.CAMERA_2)], +) +@pytest.mark.parametrize( + "flash", + [FlashMode.AUTO, FlashMode.ON, FlashMode.OFF], +) +def test_take_photo_with_permission(app, device, flash, photo): + """If permission has not been previously requested, it is requested before a photo is taken.""" + # Set permission to potentially allowed + app.camera._impl._has_permission = -1 + + app.camera._impl.simulate_photo(photo) + + result = app.loop.run_until_complete( + app.camera.take_photo(device=device, flash=flash) + ) + + # Photo was returned + assert result == photo + + assert_action_performed(app.camera, "has permission") + assert_action_performed_with( + app.camera, + "take photo", + permission_requested=True, + device=device, + flash=flash, + ) + + +def test_take_photo_prior_permission(app, photo): + """If permission has been previously requested, a photo can be taken.""" + # Set permission + app.camera._impl._has_permission = 1 + + # Simulate the camera response + app.camera._impl.simulate_photo(photo) + + result = app.loop.run_until_complete(app.camera.take_photo()) + + # Photo was returned + assert result == photo + + assert_action_performed(app.camera, "has permission") + assert_action_performed_with( + app.camera, + "take photo", + permission_requested=False, + device=None, + flash=FlashMode.AUTO, + ) + + +def test_take_photo_no_permission(app, photo): + """If permission has been denied, an exception is raised""" + # Deny permission + 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.take_photo()) + + assert_action_performed(app.camera, "has permission") + assert_action_not_performed(app.camera, "take photo") diff --git a/core/tests/resources/photo.png b/core/tests/resources/photo.png new file mode 100644 index 0000000000..1e724cc3fc Binary files /dev/null and b/core/tests/resources/photo.png differ diff --git a/docs/reference/api/hardware/camera.rst b/docs/reference/api/hardware/camera.rst new file mode 100644 index 0000000000..c350e6e0b4 --- /dev/null +++ b/docs/reference/api/hardware/camera.rst @@ -0,0 +1,68 @@ +Camera +====== + +A sensor that can capture photos and/or video. + +.. rst-class:: widget-support +.. csv-filter:: Availability (:ref:`Key `) + :header-rows: 1 + :file: ../../data/widgets_by_platform.csv + :included_cols: 4,5,6,7,8,9,10 + :exclude: {0: '(?!(Camera|Hardware))'} + +Usage +----- + +Cameras attached to a device running an app can be accessed using the +:attr:`~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. + +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: + +.. code-block:: python + + import toga + + class MyApp(toga.App): + ... + async def time_for_a_selfie(self, widget, **kwargs): + photo = await self.camera.take_photo() + +Many 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 :any:`Camera.has_permission`, which you can request using +:any:`Camera.request_permission()`. + +The calls to request permissions *can* be invoked from a synchronous context (i.e., a +non ``async`` method); however, they are non-blocking when used in this way. Invoking a +method like :any:`Camera.request_permission()` will start the process of requesting +permission, but will return *immediately*, without waiting for the user's response. This +allows an app to *request* permissions as part of the startup process, prior to using +the camera APIs, without blocking the rest of app startup. + +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 requested, and the platform +allows, Toga will attempt to request permission at the time of first camera access. + +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. + +* 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. + +Reference +--------- + +.. autoclass:: toga.hardware.camera.Camera + +.. autoclass:: toga.hardware.camera.CameraDevice diff --git a/docs/reference/api/hardware/index.rst b/docs/reference/api/hardware/index.rst new file mode 100644 index 0000000000..de7644e304 --- /dev/null +++ b/docs/reference/api/hardware/index.rst @@ -0,0 +1,6 @@ +Device and Hardware +=================== + +.. toctree:: + + camera diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 3b46b6e653..7de538407c 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -89,6 +89,15 @@ Resources :doc:`ValueSource ` A data source describing a single value. ==================================================================== ======================================================================== +Device and Hardware +------------------- + +==================================================================== ======================================================================== + Usage Description +==================================================================== ======================================================================== + :doc:`Camera ` A sensor that can capture photos and/or video. +==================================================================== ======================================================================== + Other ----- @@ -107,6 +116,7 @@ Other window mainwindow containers/index + hardware/index resources/index widgets/index constants diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 78eb5a0496..aec6e3dbdb 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -28,6 +28,7 @@ Box,Layout Widget,:class:`~toga.Box`,Container for components,|y|,|y|,|y|,|y|,|y ScrollContainer,Layout Widget,:class:`~toga.ScrollContainer`,A container that can display a layout larger than the area of the container,|y|,|y|,|y|,|y|,|y|,, SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divides an area into two panels with a movable border,|y|,|y|,|y|,,,, OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,,,, +Camera,Hardware,:class:`~toga.hardware.camera.Camera`,A sensor that can capture photos and/or video.,|y|,,,|y|,,, App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|b| Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,, Command,Resource,:class:`~toga.Command`,Command,|y|,|y|,|y|,,|y|,, diff --git a/docs/reference/widgets_by_platform.rst b/docs/reference/widgets_by_platform.rst index 60f35a850b..4623e94868 100644 --- a/docs/reference/widgets_by_platform.rst +++ b/docs/reference/widgets_by_platform.rst @@ -53,6 +53,19 @@ Layout Widgets :stub-columns: 1 :widths: 3 1 1 1 1 1 1 1 +Device and Hardware +=================== + +.. rst-class:: widget-descriptions +.. csv-filter:: + :file: data/widgets_by_platform.csv + :header-rows: 1 + :exclude: {1: '(?!(Type|Hardware))'} + :included_cols: 2,4,5,6,7,8,9,10 + :class: longtable + :stub-columns: 1 + :widths: 3 1 1 1 1 1 1 1 + Resources ========= diff --git a/dummy/src/toga_dummy/factory.py b/dummy/src/toga_dummy/factory.py index 07a1d84113..aed45a0ae9 100644 --- a/dummy/src/toga_dummy/factory.py +++ b/dummy/src/toga_dummy/factory.py @@ -3,6 +3,7 @@ from .command import Command from .documents import Document from .fonts import Font +from .hardware.camera import Camera from .icons import Icon from .images import Image from .paths import Paths @@ -50,6 +51,8 @@ def not_implemented(feature): "Image", "Paths", "dialogs", + # Hardware + "Camera", # Widgets "ActivityIndicator", "Box", @@ -80,3 +83,7 @@ def not_implemented(feature): # Real backends shouldn't expose Widget. "Widget", ] + + +def __getattr__(name): # pragma: no cover + raise NotImplementedError(f"Toga's Dummy backend doesn't implement {name}") diff --git a/dummy/src/toga_dummy/hardware/__init__.py b/dummy/src/toga_dummy/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dummy/src/toga_dummy/hardware/camera.py b/dummy/src/toga_dummy/hardware/camera.py new file mode 100644 index 0000000000..00bcb24795 --- /dev/null +++ b/dummy/src/toga_dummy/hardware/camera.py @@ -0,0 +1,65 @@ +from ..utils import LoggedObject + + +class CameraDevice: + def __init__(self, id, name, has_flash): + self._id = id + self._name = name + self._has_flash = has_flash + + def id(self): + return self._id + + def name(self): + return self._name + + def has_flash(self): + return self._has_flash + + +class Camera(LoggedObject): + CAMERA_1 = CameraDevice(id="camera-1", name="Camera 1", has_flash=True) + CAMERA_2 = CameraDevice(id="camera-2", name="Camera 2", has_flash=False) + + def __init__(self, interface): + self.interface = interface + + # -1: permission *could* be granted, but hasn't been + # 1: permission has been granted + # 0: permission has been denied, or can't be granted + self._has_permission = -1 + + def has_permission(self, allow_unknown=False): + self._action("has permission") + if allow_unknown: + return abs(self._has_permission) + else: + return self._has_permission > 0 + + def request_permission(self, future): + self._action("request permission") + self._has_permission = abs(self._has_permission) + future.set_result(self._has_permission != 0) + + def get_devices(self): + self._action("get devices") + return [self.CAMERA_1, self.CAMERA_2] + + def take_photo(self, future, device, flash): + if self.has_permission(allow_unknown=True): + self._action( + "take photo", + permission_requested=self._has_permission < 0, + device=device, + 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: + raise PermissionError("App does not have permission to take photos") + + def simulate_photo(self, image): + self._photo = image diff --git a/examples/hardware/README.rst b/examples/hardware/README.rst new file mode 100644 index 0000000000..feb468d8b4 --- /dev/null +++ b/examples/hardware/README.rst @@ -0,0 +1,12 @@ +Hardware +======== + +Test app for the hardware device features. + +Quickstart +~~~~~~~~~~ + +To run this example: + + $ pip install toga + $ python -m hardware diff --git a/examples/hardware/hardware/__init__.py b/examples/hardware/hardware/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/hardware/hardware/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.1" diff --git a/examples/hardware/hardware/__main__.py b/examples/hardware/hardware/__main__.py new file mode 100644 index 0000000000..7e18bc11ef --- /dev/null +++ b/examples/hardware/hardware/__main__.py @@ -0,0 +1,4 @@ +from hardware.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/hardware/hardware/app.py b/examples/hardware/hardware/app.py new file mode 100644 index 0000000000..01a8d397b7 --- /dev/null +++ b/examples/hardware/hardware/app.py @@ -0,0 +1,71 @@ +import toga +from toga.constants import COLUMN +from toga.style import Pack + + +class ExampleHardwareApp(toga.App): + def startup(self): + try: + # This will provide a prompt for camera permissions at startup. + # If permission is denied, the app will continue. + self.camera.request_permission() + except NotImplementedError: + print("The Camera API is not implemented on this platform") + + self.photo = toga.ImageView( + image=toga.Image("resources/default.png"), style=Pack(width=200) + ) + + main_box = toga.Box( + children=[ + toga.Box( + children=[ + toga.Box(style=Pack(flex=1)), + self.photo, + toga.Box(style=Pack(flex=1)), + ] + ), + toga.Box( + children=[ + # Take a fresh photo + toga.Button( + "Take Photo", + on_press=self.take_photo, + style=Pack(flex=1, padding=5), + ), + # Select a photo from the photo library + # toga.Button( + # "Select Photo", + # on_press=self.select_photo, + # style=Pack(flex=1, padding=5), + # ), + ], + ), + ], + style=Pack(direction=COLUMN, padding_bottom=20), + ) + + self.main_window = toga.MainWindow(title=self.formal_name) + self.main_window.content = main_box + self.main_window.show() + + async def take_photo(self, widget, **kwargs): + try: + image = await self.camera.take_photo() + if image is None: + self.photo.image = "resources/default.png" + else: + self.photo.image = image + except PermissionError: + await self.main_window.info_dialog( + "Oh no!", "You have not granted permission to take photos" + ) + + +def main(): + return ExampleHardwareApp("Hardware", "org.beeware.widgets.hardware") + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/examples/hardware/hardware/resources/default.png b/examples/hardware/hardware/resources/default.png new file mode 100644 index 0000000000..d58757d179 Binary files /dev/null and b/examples/hardware/hardware/resources/default.png differ diff --git a/examples/hardware/pyproject.toml b/examples/hardware/pyproject.toml new file mode 100644 index 0000000000..63c8cf5292 --- /dev/null +++ b/examples/hardware/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["briefcase"] + +[tool.briefcase] +project_name = "Hardware" +bundle = "org.beeware" +version = "0.0.1" +url = "https://beeware.org" +license = "BSD license" +author = 'Tiberius Yak' +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.hardware] +formal_name = "Hardware" +description = "A testing app" +sources = ['hardware'] +requires = [ + '../../core', +] + + +[tool.briefcase.app.hardware.macOS] +requires = [ + '../../cocoa', + 'std-nslog>=1.0.0', +] + +[tool.briefcase.app.hardware.linux] +requires = [ + '../../gtk', +] + +[tool.briefcase.app.hardware.windows] +requires = [ + '../../winforms', +] + +# Mobile deployments +[tool.briefcase.app.hardware.iOS] +requires = [ + '../../iOS', + 'std-nslog>=1.0.0', +] + +[tool.briefcase.app.hardware.android] +requires = [ + '../../android', +] diff --git a/iOS/src/toga_iOS/factory.py b/iOS/src/toga_iOS/factory.py index c87afaad91..16c971ca85 100644 --- a/iOS/src/toga_iOS/factory.py +++ b/iOS/src/toga_iOS/factory.py @@ -3,9 +3,12 @@ from .colors import native_color from .command import Command from .fonts import Font +from .hardware.camera import Camera from .icons import Icon from .images import Image from .paths import Paths + +# Widgets from .widgets.box import Box from .widgets.button import Button from .widgets.canvas import Canvas @@ -48,6 +51,8 @@ def not_implemented(feature): "Image", "Paths", "dialogs", + # Hardware + "Camera", # Widgets "Box", "Button", diff --git a/iOS/src/toga_iOS/hardware/camera.py b/iOS/src/toga_iOS/hardware/camera.py new file mode 100644 index 0000000000..0ae0c355be --- /dev/null +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -0,0 +1,166 @@ +import warnings + +from rubicon.objc import Block, NSObject, objc_method + +import toga +from toga.constants import FlashMode + +# for classes that need to be monkeypatched for testing +from toga_iOS import libs as iOS +from toga_iOS.libs import ( + AVAuthorizationStatus, + AVMediaTypeVideo, + NSBundle, + UIImagePickerControllerCameraCaptureMode, + UIImagePickerControllerCameraDevice, + UIImagePickerControllerCameraFlashMode, + UIImagePickerControllerSourceTypeCamera, +) + + +class CameraDevice: + def __init__(self, id, name, native): + self._id = id + self._name = name + self.native = native + + def id(self): + return self._id + + def name(self): + return self._name + + def has_flash(self): + return iOS.UIImagePickerController.isFlashAvailableForCameraDevice(self.native) + + +def native_flash_mode(flash): + return { + FlashMode.ON: UIImagePickerControllerCameraFlashMode.On, + FlashMode.OFF: UIImagePickerControllerCameraFlashMode.Off, + }.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_( + self, picker, info + ) -> None: + picker.dismissViewControllerAnimated(True, completion=None) + + image = toga.Image(info["UIImagePickerControllerOriginalImage"]) + self.result.set_result(image) + + @objc_method + def imagePickerControllerDidCancel_(self, picker) -> None: + picker.dismissViewControllerAnimated(True, completion=None) + self.result.set_result(None) + + +class Camera: + def __init__(self, interface): + self.interface = interface + + if NSBundle.mainBundle.objectForInfoDictionaryKey("NSCameraUsageDescription"): + if iOS.UIImagePickerController.isSourceTypeAvailable( + UIImagePickerControllerSourceTypeCamera + ): + self.native = iOS.UIImagePickerController.new() + self.native.sourceType = UIImagePickerControllerSourceTypeCamera + self.native.delegate = TogaImagePickerDelegate.new() + 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." + ) + + def has_permission(self, allow_unknown=False): + if allow_unknown: + valid_values = { + AVAuthorizationStatus.Authorized.value, + AVAuthorizationStatus.NotDetermined.value, + } + else: + valid_values = {AVAuthorizationStatus.Authorized.value} + + return ( + iOS.AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) + in valid_values + ) + + 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) + + iOS.AVCaptureDevice.requestAccessForMediaType( + AVMediaTypeVideo, completionHandler=Block(permission_complete, None, bool) + ) + + def get_devices(self): + return ( + [ + CameraDevice( + id="Rear", + name="Rear", + native=UIImagePickerControllerCameraDevice.Rear, + ) + ] + if iOS.UIImagePickerController.isCameraDeviceAvailable( + UIImagePickerControllerCameraDevice.Rear + ) + else [] + ) + ( + [ + CameraDevice( + id="Front", + name="Front", + native=UIImagePickerControllerCameraDevice.Front, + ) + ] + if iOS.UIImagePickerController.isCameraDeviceAvailable( + UIImagePickerControllerCameraDevice.Front + ) + else [] + ) + + def take_photo(self, result, device, flash): + if self.native is None: + warnings.warn("No camera is available") + result.set_result(None) + elif self.has_permission(allow_unknown=True): + # Configure the controller to take a photo + self.native.cameraCaptureMode = ( + UIImagePickerControllerCameraCaptureMode.Photo + ) + + self.native.showsCameraControls = True + self.native.cameraDevice = ( + device._impl.native + if device + else UIImagePickerControllerCameraDevice.Rear + ) + 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") diff --git a/iOS/src/toga_iOS/libs/av_foundation.py b/iOS/src/toga_iOS/libs/av_foundation.py index 5367d885e5..25c2be2d50 100644 --- a/iOS/src/toga_iOS/libs/av_foundation.py +++ b/iOS/src/toga_iOS/libs/av_foundation.py @@ -2,6 +2,9 @@ # System/Library/Frameworks/AVFoundation.framework ########################################################################## from ctypes import c_uint32, cdll, util +from enum import Enum + +from rubicon.objc import ObjCClass, objc_const ###################################################################### av_foundation = cdll.LoadLibrary(util.find_library("AVFoundation")) @@ -11,3 +14,20 @@ av_foundation.AudioServicesPlayAlertSound.restype = None av_foundation.AudioServicesPlayAlertSound.argtypes = [SystemSoundID] + +###################################################################### +# AVCaptureDevice.h +AVCaptureDevice = ObjCClass("AVCaptureDevice") + + +class AVAuthorizationStatus(Enum): + NotDetermined = 0 + Restricted = 1 + Denied = 2 + Authorized = 3 + + +###################################################################### +# AVMediaFormat.h +AVMediaTypeAudio = objc_const(av_foundation, "AVMediaTypeAudio") +AVMediaTypeVideo = objc_const(av_foundation, "AVMediaTypeVideo") diff --git a/iOS/src/toga_iOS/libs/uikit.py b/iOS/src/toga_iOS/libs/uikit.py index ddeab96b40..8e1cd1ccc8 100644 --- a/iOS/src/toga_iOS/libs/uikit.py +++ b/iOS/src/toga_iOS/libs/uikit.py @@ -287,6 +287,38 @@ class UIContextualActionStyle(Enum): # UIImage.h UIImage = ObjCClass("UIImage") +###################################################################### +# UIImagePicker.h + +# Camera +UIImagePickerController = ObjCClass("UIImagePickerController") + +# PhotoLibrary and SavedPhotosAlbumn constants also exist, but they're marked as deprecated +UIImagePickerControllerSourceTypeCamera = 1 + + +class UIImagePickerControllerQualityType(Enum): + High = 0 + Medium = 1 + Low = 2 + + +class UIImagePickerControllerCameraCaptureMode(Enum): + Photo = 0 + Video = 1 + + +class UIImagePickerControllerCameraDevice(Enum): + Rear = 0 + Front = 1 + + +class UIImagePickerControllerCameraFlashMode(Enum): + Off = -1 + Auto = 0 + On = 1 + + ###################################################################### # UIImageView.h UIImageView = ObjCClass("UIImageView") diff --git a/iOS/tests_backend/hardware/__init__.py b/iOS/tests_backend/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/iOS/tests_backend/hardware/camera.py b/iOS/tests_backend/hardware/camera.py new file mode 100644 index 0000000000..3233f718b9 --- /dev/null +++ b/iOS/tests_backend/hardware/camera.py @@ -0,0 +1,183 @@ +from unittest.mock import Mock + +import toga +from toga.constants import FlashMode +from toga_iOS import libs as iOS +from toga_iOS.hardware.camera import Camera +from toga_iOS.libs import ( + AVAuthorizationStatus, + AVMediaTypeVideo, + UIImagePickerControllerCameraCaptureMode, + UIImagePickerControllerCameraDevice, + UIImagePickerControllerCameraFlashMode, + UIImagePickerControllerSourceTypeCamera, + UIViewController, +) + +from ..app import AppProbe + + +class CameraProbe(AppProbe): + allow_no_camera = False + + def __init__(self, monkeypatch, app_probe): + super().__init__(app_probe.app) + + self.monkeypatch = monkeypatch + + # A mocked permissions table. The key is the media type; the value is True + # if permission has been granted, False if it has be denied. A missing value + # will be turned into a grant if permission is requested. + self._mock_permissions = {} + + # Mock AVCaptureDevice + self._mock_AVCaptureDevice = Mock() + + def _mock_auth_status(media_type): + try: + return { + 1: AVAuthorizationStatus.Authorized.value, + 0: AVAuthorizationStatus.Denied.value, + }[self._mock_permissions[str(media_type)]] + except KeyError: + return AVAuthorizationStatus.NotDetermined.value + + self._mock_AVCaptureDevice.authorizationStatusForMediaType = _mock_auth_status + + def _mock_request_access(media_type, completionHandler): + # Fire completion handler + try: + result = self._mock_permissions[str(media_type)] + except KeyError: + # If there's no explicit permission, convert into a full grant + self._mock_permissions[str(media_type)] = True + result = True + completionHandler.func(result) + + self._mock_AVCaptureDevice.requestAccessForMediaType = _mock_request_access + + monkeypatch.setattr(iOS, "AVCaptureDevice", self._mock_AVCaptureDevice) + + # Mock UIImagePickerController + self._mock_UIImagePickerController = Mock() + + # On x86, the simulator crashes if you try to set the sourceType + # for the picker, because the simulator doesn't support that source type. + # On ARM, the hardware will let you show the dialog, but logs multiple errors. + # Avoid the problem by using a neutral UIViewController. This also allows + # us to mock behaviors that we can't do programmatically, like changing + # the camera while the view is displayed. + self._mock_picker = UIViewController.new() + self._mock_UIImagePickerController.new.return_value = self._mock_picker + + # Simulate both cameras being available + self._mock_UIImagePickerController.isCameraDeviceAvailable.return_value = True + + # Ensure the controller says that the camera source type is available. + self._mock_UIImagePickerController.isSourceTypeAvailable.return_value = True + + # Flash is available on the rear camera + def _mock_flash_available(device): + return device == UIImagePickerControllerCameraDevice.Rear + + self._mock_UIImagePickerController.isFlashAvailableForCameraDevice = ( + _mock_flash_available + ) + + monkeypatch.setattr( + iOS, "UIImagePickerController", self._mock_UIImagePickerController + ) + + def cleanup(self): + try: + picker = self.app.camera._impl.native + result = picker.delegate.result + if not result.future.done(): + picker.delegate.imagePickerControllerDidCancel(picker) + except AttributeError: + pass + + def known_cameras(self): + return { + "Rear": ("Rear", True), + "Front": ("Front", False), + } + + def select_other_camera(self): + other = self.app.camera.devices[1] + self.app.camera._impl.native.cameraDevice = other._impl.native + 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) + + def reset_permission(self): + self._mock_permissions = {} + + def allow_permission(self): + self._mock_permissions[str(AVMediaTypeVideo)] = True + + def reject_permission(self): + self._mock_permissions[str(AVMediaTypeVideo)] = False + + async def wait_for_camera(self, device_count=0): + await self.redraw("Camera view displayed", delay=0.5) + + @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 + image = toga.Image("resources/photo.png") + picker.delegate.imagePickerController( + picker, + didFinishPickingMediaWithInfo={ + "UIImagePickerControllerOriginalImage": image._impl.native + }, + ) + + await self.redraw("Photo taken", delay=0.5) + + 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) + + return await photo + + def same_device(self, device, native): + if device is None: + return native == UIImagePickerControllerCameraDevice.Rear + else: + return device._impl.native == native + + def same_flash_mode(self, expected, actual): + return ( + expected + == { + UIImagePickerControllerCameraFlashMode.Auto: FlashMode.AUTO, + UIImagePickerControllerCameraFlashMode.On: FlashMode.ON, + UIImagePickerControllerCameraFlashMode.Off: FlashMode.OFF, + }[actual] + ) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 9107fb175b..44fac0c0eb 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -28,6 +28,8 @@ test_requires = [ "pillow==9.2.0", ] +permission.camera = "The testbed needs to exercise Camera APIs" + [tool.briefcase.app.testbed.macOS] requires = [ "../cocoa", diff --git a/testbed/src/testbed/resources/photo.png b/testbed/src/testbed/resources/photo.png new file mode 100644 index 0000000000..1e724cc3fc Binary files /dev/null and b/testbed/src/testbed/resources/photo.png differ diff --git a/testbed/tests/hardware/__init__.py b/testbed/tests/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testbed/tests/hardware/probe.py b/testbed/tests/hardware/probe.py new file mode 100644 index 0000000000..aec675ceda --- /dev/null +++ b/testbed/tests/hardware/probe.py @@ -0,0 +1,6 @@ +from importlib import import_module + + +def get_probe(monkeypatch, app_probe, name): + module = import_module(f"tests_backend.hardware.{name.lower()}") + return getattr(module, f"{name}Probe")(monkeypatch, app_probe) diff --git a/testbed/tests/hardware/test_camera.py b/testbed/tests/hardware/test_camera.py new file mode 100644 index 0000000000..9548e5f0a4 --- /dev/null +++ b/testbed/tests/hardware/test_camera.py @@ -0,0 +1,177 @@ +import warnings + +import pytest + +from toga.constants import FlashMode + +from ..conftest import skip_on_platforms +from .probe import get_probe + + +@pytest.fixture +async def camera_probe(monkeypatch, app_probe): + skip_on_platforms("android", "linux", "windows") + probe = get_probe(monkeypatch, app_probe, "Camera") + yield probe + probe.cleanup() + + +async def test_camera_properties(app, camera_probe): + assert { + device.id: (device.name, device.has_flash) for device in app.camera.devices + } == camera_probe.known_cameras() + + +async def test_grant_permission(app, camera_probe): + """A user can grant permission to use the camera""" + # Reset camera permissions + camera_probe.reset_permission() + + # Initiate the permission request. Since there hasn't been an explicit + # allow or deny, this will allow access. + assert await app.camera.request_permission() + + # Permission now exists + assert app.camera.has_permission + + # A second request to grant permissions is a no-op + assert await app.camera.request_permission() + + # Permission still exists + assert app.camera.has_permission + + +async def test_take_photo(app, camera_probe): + """A user can take a photo with the all the available cameras""" + + # Ensure the camera has permissions + camera_probe.allow_permission() + + for camera in [None] + app.camera.devices: + # Trigger taking a photo + photo = app.camera.take_photo(device=camera) + await camera_probe.wait_for_camera() + + # Simulate pressing the shutter on the camera + image, device_used, _ = await camera_probe.press_shutter_button(photo) + + # The image exists, and has the expected size, using the requested camera + assert image.size == (512, 512) + assert camera_probe.same_device(camera, device_used) + + +async def test_flash_mode(app, camera_probe): + """A user can take a photo with all the flash modes""" + + # Ensure the camera has permissions + camera_probe.allow_permission() + + for flash_mode in [FlashMode.AUTO, FlashMode.ON, FlashMode.OFF]: + # Trigger taking a photo with the default device + photo = app.camera.take_photo(flash=flash_mode) + await camera_probe.wait_for_camera() + + # Simulate pressing the shutter on the camera + image, _, flash_mode_used = await camera_probe.press_shutter_button(photo) + + # The image exists, and has the expected size. + assert image.size == (512, 512) + assert camera_probe.same_flash_mode(flash_mode, flash_mode_used) + + +async def test_take_photo_unknown_permission(app, camera_probe): + """If a user hasn't explicitly granted permissions, they can take a photo with the camera""" + # Don't pre-grant permission; use default grant. + + # Trigger taking a photo + photo = app.camera.take_photo() + await camera_probe.wait_for_camera() + + # Simulate pressing the shutter on the camera + image, _, _ = await camera_probe.press_shutter_button(photo) + + # The image exists, and has the expected size. + assert image.size == (512, 512) + + +async def test_cancel_photo(app, camera_probe): + """A user can cancel taking a photo""" + + # Ensure the camera has permissions + camera_probe.allow_permission() + + # Trigger taking a photo + photo = app.camera.take_photo() + await camera_probe.wait_for_camera() + + # Simulate pressing the shutter on the camer + image = await camera_probe.cancel_photo(photo) + + # No image was returned + assert image is None + + +async def test_take_photo_no_permission(app, camera_probe): + """If the user doesn't have camera permission, an error is raised""" + # Revoke camera permission + camera_probe.reject_permission() + + with pytest.raises(PermissionError): + await app.camera.take_photo() + + +async def test_change_camera(app, camera_probe): + """The currently selected camera can be changed""" + + # Ensure the camera has permissions + camera_probe.allow_permission() + + # Trigger taking a photo + photo = app.camera.take_photo() + await camera_probe.wait_for_camera() + + # Select the second camera + selected_device = camera_probe.select_other_camera() + await camera_probe.redraw("New camera selected") + + # The shutter is enabled + assert camera_probe.shutter_enabled + + # Simulate pressing the shutter on the camer + image, used_device, _ = await camera_probe.press_shutter_button(photo) + + # The camera used the selected device + assert camera_probe.same_device(selected_device, used_device) + assert image is not None + + +async def test_no_cameras(app, camera_probe): + """If there are no cameras attached, the only option is cancelling.""" + # Disconnect the cameras + camera_probe.disconnect_cameras() + + # Ensure the camera has permissions + camera_probe.allow_permission() + + # Trigger taking a photo. This may raise a warning. + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "No camera is available") + photo = app.camera.take_photo() + + # Some platforms (e.g., macOS) can't know ahead of time that there are no cameras, + # so they show the camera dialog, but disable the shutter until a camera is + # available, leaving cancel as the only option. Other platforms know ahead of time + # that there are no cameras, so they can short cut and cancel the photo request. + if camera_probe.allow_no_camera: + await camera_probe.wait_for_camera(device_count=0) + + # The shutter is *not* enabled + assert not camera_probe.shutter_enabled + + # Simulate pressing the shutter on the camera + image = await camera_probe.cancel_photo(photo) + else: + image = await photo + + # No image was returned + assert image is None