Add core Camera API tests.

This commit is contained in:
Russell Keith-Magee 2023-12-13 13:00:37 +08:00
parent 253b87e15a
commit ec4078a17a
No known key found for this signature in database
GPG key ID: 3D2DAB6A37BB5BC3
10 changed files with 289 additions and 103 deletions

View file

@ -37,24 +37,24 @@ class FlashMode(Enum):
ON = 1
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
# 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

View file

@ -1,40 +1,17 @@
from __future__ import annotations
from typing import Any, Protocol, TypeVar
import asyncio
import toga
from toga.constants import FlashMode
from toga.handlers import AsyncResult, wrapped_handler
from toga.handlers import AsyncResult
from toga.platform import get_platform_factory
class PhotoResult(AsyncResult):
RESULT_TYPE = "photo"
class PermissionResult(AsyncResult):
RESULT_TYPE = "permission"
# class VideoResult(AsyncResult):
# RESULT_TYPE = "video"
T = TypeVar("T")
class CameraResultHandler(Protocol[T]):
def __call__(self, camera: Camera, result: T, **kwargs: Any) -> None:
"""A handler to invoke when a camera returns an image or video.
:param camera: The camera
:param result: The content returned by the camera.
:param kwargs: Ensures compatibility with additional arguments introduced in
future versions.
"""
...
class Camera:
FRONT = "Front"
REAR = "Rear"
@ -53,39 +30,38 @@ class Camera:
"""
return self._impl.has_photo_permission()
def request_photo_permission(
self,
on_result: CameraResultHandler[toga.Image] | None = None,
) -> PermissionResult:
def request_photo_permission(self) -> PermissionResult:
"""Request sufficient permissions to capture photos.
If permission has already been granted, this will return immediately
without prompting the user.
If permission has already been granted, this will return immediately without
prompting the user.
:param on_result: A handler that will be invoked with the success or
failure of the request.
:returns: An awaitable PermissionResult object. The PermissionResult
object returns the success or failure of the permission request.
**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(wrapped_handler(self, on_result))
result = PermissionResult(None)
if has_permission := self.has_photo_permission:
result.set_result(has_permission)
return result
else:
return self._impl.request_photo_permission(result)
self._impl.request_photo_permission(result)
# def request_video_permission(
# self,
# on_result: CameraResultHandler[toga.Image] | None = None,
# ) -> PermissionResult:
# result = PermissionResult(wrapped_handler(self, on_result))
return result
# async def request_video_permission(self) -> bool:
# result = PermissionResult(None)
#
# if has_permission := self.has_video_permission:
# result.set_result(has_permission)
# return result
# else:
# return self._impl.request_video_permission(result)
# self._impl.request_video_permission(result)
#
# return result
@property
def devices(self) -> list[str]:
@ -98,14 +74,13 @@ class Camera:
:param device: The camera device to check. If a specific device is *not*
specified, a default camera will be used.
"""
return self.native.has_flash(device)
return self._impl.has_flash(device)
def take_photo(
async def take_photo(
self,
device: str | None = None,
flash: FlashMode = FlashMode.AUTO,
on_result: CameraResultHandler[toga.Image] | None = None,
) -> PhotoResult:
) -> toga.Image:
"""Capture a photo using one of the device's cameras.
If the platform requires permission to access the camera, and the user
@ -115,41 +90,29 @@ class Camera:
:param device: The camera device to use. If a specific device is *not*
specified, a default camera will be used.
:param flash: The flash mode to use; defaults to "auto"
:param on_result: A callback that will be invoked when the photo has
been taken (or the photo operation has been cancelled).
:returns: An awaitable CameraResult object. The CameraResult object
returns ``None`` when the user cancels the photo capture.
:returns: The :any:`toga.Image` captured by the camera.
"""
photo = PhotoResult(wrapped_handler(self, on_result))
self._impl.take_photo(photo, device=device, flash=flash)
return photo
future = asyncio.get_event_loop().create_future()
self._impl.take_photo(future, device=device, flash=flash)
return await future
# def record_video(
# async def record_video(
# self,
# device: str | None = None,
# flash: FlashMode = FlashMode.AUTO,
# quality: VideoQuality = VideoQuality.MEDIUM,
# on_result: CamreaResultHandler[toga.Video] | None = None,
# ) -> VideoResult:
# ) -> toga.Video:
# """Capture a video using one of the device's cameras.
#
# If the platform requires permission to access the camera and/or
# microphone, and the user hasn't previously provided that permission,
# this will cause permission to be requested.
#
# :param device: The camera device to use. If a specific device is *not*
# specified, a default camera will be used.
# :param flash: The flash mode to use; defaults to "auto"
# :param on_result: A callback that will be invoked when the photo has
# been taken (or the photo operation has been cancelled).
# :returns: An awaitable CameraResult object. The CameraResult object
# returns ``None`` when the user cancels the photo capture.
# :returns: The :any:`toga.Video` captured by the camera.
# """
# video = VideoResult()
# self._impl.record_video(
# video,
# device=device,
# flash=flash,
# on_result=wrapped_handler(self, on_result),
# )
# return video
# future = asyncio.get_event_loop().create_future()
# self._impl.record_video(future, device=device, flash=flash)
# return future

View file

View file

@ -0,0 +1,163 @@
import pytest
import toga
from toga.constants import FlashMode
from toga.hardware.camera import Camera
from toga_dummy import factory
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_photo_permission(app, initial, should_request, has_permission):
"""An app can request permission to take photos"""
# Set initial permission
app.camera._impl._has_photo_permission = initial
assert (
app.loop.run_until_complete(app.camera.request_photo_permission())
== has_permission
)
if should_request:
assert_action_performed(app.camera, "request photo permission")
else:
assert_action_not_performed(app.camera, "request photo permission")
# As a result of requesting, photo permission is as expected
assert app.camera.has_photo_permission == has_permission
def test_request_photo_permission_sync(app):
"""An app can synchronously request permission to take photos"""
# Set initial permission
app.camera._impl._has_photo_permission = -1
result = app.camera.request_photo_permission()
# This will cause a permission request to occur...
assert_action_performed(app.camera, "request photo 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,
"has_flash": app.camera.has_flash(device),
}
for device in app.camera.devices
] == [
{
"device": Camera.REAR,
"has_flash": True,
},
{
"device": Camera.FRONT,
"has_flash": False,
},
]
@pytest.mark.parametrize(
"device",
[None, Camera.FRONT, Camera.REAR],
)
@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_photo_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 photo 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_photo_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 photo 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_photo_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 photo permission")
assert_action_not_performed(app.camera, "take photo")

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

View file

@ -3,6 +3,7 @@ from .app import App, DocumentApp, MainWindow
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 @@ __all__ = [
"Image",
"Paths",
"dialogs",
# Hardware
"Camera",
# Widgets
"ActivityIndicator",
"Box",
@ -80,3 +83,7 @@ __all__ = [
# Real backends shouldn't expose Widget.
"Widget",
]
def __getattr__(name): # pragma: no cover
raise NotImplementedError(f"Toga's Dummy backend doesn't implement {name}")

View file

@ -0,0 +1,53 @@
from toga.hardware.camera import Camera as TogaCamera
from ..utils import LoggedObject
class Camera(LoggedObject):
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_photo_permission = -1
def has_photo_permission(self, allow_unknown=False):
self._action("has photo permission")
if allow_unknown:
return abs(self._has_photo_permission)
else:
return self._has_photo_permission > 0
def request_photo_permission(self, future):
self._action("request photo permission")
self._has_photo_permission = abs(self._has_photo_permission)
future.set_result(self._has_photo_permission != 0)
def get_devices(self):
self._action("get devices")
return [TogaCamera.REAR, TogaCamera.FRONT]
def has_flash(self, device):
self._action("has flash", device=device)
# Front camera doesn't have a flash.
return device == TogaCamera.REAR
def take_photo(self, future, device, flash):
if self.has_photo_permission(allow_unknown=True):
self._action(
"take photo",
permission_requested=self._has_photo_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

View file

@ -10,7 +10,7 @@ class ExampleHardwareApp(toga.App):
# If permission is denied, the app will continue.
self.camera.request_photo_permission()
except NotImplementedError:
print("The Camera API is not implemented")
print("The Camera API is not implemented on this platform")
self.photo = toga.ImageView(
image=toga.Image("resources/default.png"), style=Pack(width=200)

View file

@ -50,7 +50,7 @@ class TogaImagePickerController(UIImagePickerController):
picker.dismissViewControllerAnimated(True, completion=None)
image = toga.Image(info["UIImagePickerControllerOriginalImage"])
self.result.set_result(image)
self.future.set_result(image)
picker.delegate.release()
@ -58,7 +58,7 @@ class TogaImagePickerController(UIImagePickerController):
def imagePickerControllerDidCancel_(self, picker) -> None:
picker.dismissViewControllerAnimated(True, completion=None)
self.result.set_result(None)
self.future.set_result(None)
picker.delegate.release()
@ -93,9 +93,9 @@ class Camera:
# allow_unknown=allow_unknown,
# )
def request_photo_permission(self, result):
def request_photo_permission(self, future):
def video_complete(permission: bool) -> None:
result.set_result(permission)
future.set_result(permission)
AVCaptureDevice.requestAccessForMediaType(
AVMediaTypeVideo,
@ -120,7 +120,7 @@ class Camera:
def has_flash(self, device):
return self.native.isFlashAvailableForCameraDevice(native_device(device))
def take_photo(self, result, device, flash):
def take_photo(self, future, device, flash):
if self.has_photo_permission(allow_unknown=True):
# Configure the controller to take a photo
camera_session = TogaImagePickerController.alloc().init()
@ -134,7 +134,7 @@ class Camera:
camera_session.cameraFlashMode = native_flash_mode(flash)
# Create a delegate to handle the callback
camera_session.result = result
camera_session.future = future
camera_session.delegate = camera_session
# Show the pane
@ -144,7 +144,7 @@ class Camera:
else:
raise PermissionError("App does not have permission to take photos")
# def record_video(self, result, device, flash):
# def record_video(self, future, device, flash):
# if self.has_video_permission(allow_unknown=True):
# # Configure the controller to take a photo
# camera_session = TogaImagePickerController.alloc().init()
@ -158,7 +158,7 @@ class Camera:
# camera_session.cameraFlashMode = native_flash_mode(flash)
# # Create a delegate to handle the callback
# camera_session.result = result
# camera_session.future = future
# camera_session.delegate = camera_session
# # Show the pane