Add core Camera API tests.
This commit is contained in:
parent
253b87e15a
commit
ec4078a17a
10 changed files with 289 additions and 103 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
core/tests/hardware/__init__.py
Normal file
0
core/tests/hardware/__init__.py
Normal file
163
core/tests/hardware/test_camera.py
Normal file
163
core/tests/hardware/test_camera.py
Normal 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")
|
||||
BIN
core/tests/resources/photo.png
Normal file
BIN
core/tests/resources/photo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 754 KiB |
|
|
@ -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}")
|
||||
|
|
|
|||
0
dummy/src/toga_dummy/hardware/__init__.py
Normal file
0
dummy/src/toga_dummy/hardware/__init__.py
Normal file
53
dummy/src/toga_dummy/hardware/camera.py
Normal file
53
dummy/src/toga_dummy/hardware/camera.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue