From 0d14ff7fc477a41401e013941835d642efe069a4 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 20 Jan 2024 08:48:16 +0800 Subject: [PATCH 1/4] Added an implementation of Camera for Android. --- android/src/toga_android/app.py | 106 ++++++++++---- android/src/toga_android/factory.py | 3 + android/src/toga_android/hardware/camera.py | 102 ++++++++++++++ android/tests_backend/hardware/__init__.py | 0 android/tests_backend/hardware/camera.py | 145 ++++++++++++++++++++ cocoa/tests_backend/hardware/camera.py | 16 ++- docs/reference/api/hardware/camera.rst | 8 +- docs/reference/data/widgets_by_platform.csv | 2 +- examples/hardware/pyproject.toml | 8 ++ iOS/tests_backend/hardware/camera.py | 16 ++- testbed/pyproject.toml | 8 ++ testbed/tests/hardware/test_camera.py | 42 ++++-- 12 files changed, 397 insertions(+), 59 deletions(-) create mode 100644 android/src/toga_android/hardware/camera.py create mode 100644 android/tests_backend/hardware/__init__.py create mode 100644 android/tests_backend/hardware/camera.py diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index d3bbeeb299..3612e66455 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -1,5 +1,6 @@ import asyncio import sys +import warnings from android.graphics.drawable import BitmapDrawable from android.media import RingtoneManager @@ -18,10 +19,9 @@ class MainWindow(Window): class TogaApp(dynamic_proxy(IPythonApp)): - last_intent_requestcode = ( - -1 - ) # always increment before using it for invoking new Intents + last_requestcode = -1 # A unique ID for native background requests running_intents = {} # dictionary for currently running Intents + permission_requests = {} # dictionary for outstanding permission requests menuitem_mapping = {} # dictionary for mapping menuitems to commands def __init__(self, app): @@ -52,28 +52,33 @@ def onDestroy(self): def onRestart(self): print("Toga app: onRestart") # pragma: no cover - # TODO #1798: document and test this somehow - def onActivityResult(self, requestCode, resultCode, resultData): # pragma: no cover - """Callback method, called from MainActivity when an Intent ends. - - :param int requestCode: The integer request code originally supplied to startActivityForResult(), - allowing you to identify who this result came from. - :param int resultCode: The integer result code returned by the child activity through its setResult(). - :param Intent resultData: An Intent, which can return result data to the caller (various data can be attached - to Intent "extras"). - """ + def onActivityResult(self, requestCode, resultCode, resultData): + print(f"Toga app: onActivityResult, {requestCode=}, {resultData=}") + try: + # Retrieve the completion callback; if non-none, invoke it. + callback = self.running_intents.pop(requestCode) + # In theory, the callback can be empty; however, we don't + # have any practical use for this at present, so the branch + # is marked no-cover + if callback: # pragma: no branch + callback(resultCode, resultData) + except KeyError: # pragma: no cover + # This shouldn't happen; we shouldn't get notified of an + # intent that we didn't start + print(f"No intent matching request code {requestCode}") + + def onRequestPermissionsResult(self, requestCode, permissions, grantResults): print( - f"Toga app: onActivityResult, requestCode={requestCode}, resultData={resultData}" + f"Toga app: onRequestPermissionsResult, {requestCode=}, {permissions=} {grantResults=}" ) try: - # remove Intent from the list of running Intents, - # and set the result of the intent. - result_future = self.running_intents.pop(requestCode) - result_future.set_result( - {"resultCode": resultCode, "resultData": resultData} - ) - except KeyError: - print("No intent matching request code {requestCode}") + # Retrieve the completion callback and invoke it. + callback = self.permission_requests.pop(requestCode) + callback(permissions, grantResults) + except KeyError: # pragma: no cover + # This shouldn't happen; we shouldn't get notified of an + # permission that we didn't request. + print(f"No permission request matching request code {requestCode}") def onConfigurationChanged(self, new_config): pass # pragma: no cover @@ -256,21 +261,66 @@ async def intent_result(self, intent): # pragma: no cover :param Intent intent: The Intent to call :returns: A Dictionary containing "resultCode" (int) and "resultData" (Intent or None) - :rtype: dict """ + warnings.warn( + "intent_result has been deprecated; use start_activity", + DeprecationWarning, + ) try: - self._listener.last_intent_requestcode += 1 - code = self._listener.last_intent_requestcode - result_future = asyncio.Future() - self._listener.running_intents[code] = result_future - self.native.startActivityForResult(intent, code) + def complete_handler(code, data): + result_future.set_result({"resultCode": code, "resultData": data}) + + self.start_activity(intent, on_complete=complete_handler) + await result_future return result_future.result() except AttributeError: raise RuntimeError("No appropriate Activity found to handle this intent.") + def _native_startActivityForResult( + self, activity, code, *options + ): # pragma: no cover + # A wrapper around the native method so that it can be mocked during testing. + self.native.startActivityForResult(activity, code, *options) + + def start_activity(self, activity, *options, on_complete=None): + """Start a native Android activity. + + :param activity: The Intent/Activity to start + :param options: Any additional arguments to pass to the native + ``startActivityForResult`` call. + :param on_complete: The callback to invoke when the activity + completes. The callback will be invoked with 2 arguments: + the result code, and the result data. + """ + self._listener.last_requestcode += 1 + code = self._listener.last_requestcode + + self._listener.running_intents[code] = on_complete + + self._native_startActivityForResult(activity, code, *options) + + def _native_requestPermissions(self, permissions, code): # pragma: no cover + # A wrapper around the native method so that it can be mocked during testing. + self.native.requestPermissions(permissions, code) + + def request_permissions(self, permissions, on_complete): + """Request a set of permissions from the user. + + :param permissions: The list of permissions to request. + :param on_complete: The callback to invoke when the permission request + completes. The callback will be invoked with 2 arguments: the list of + permissions that were processed, and a second list of the same size, + containing the grant status of each of those permissions. + """ + self._listener.last_requestcode += 1 + code = self._listener.last_requestcode + + self._listener.permission_requests[code] = on_complete + self._native_requestPermissions(permissions, code) + def enter_full_screen(self, windows): pass diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index 9171ea4766..b0e2ca7295 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -2,6 +2,7 @@ from .app import App, MainWindow 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 @@ -43,6 +44,8 @@ def not_implemented(feature): "Icon", "Image", "Paths", + # Hardware + "Camera", # Widgets # ActivityIndicator "Box", diff --git a/android/src/toga_android/hardware/camera.py b/android/src/toga_android/hardware/camera.py new file mode 100644 index 0000000000..7485318f07 --- /dev/null +++ b/android/src/toga_android/hardware/camera.py @@ -0,0 +1,102 @@ +import warnings + +from android.content import Context, Intent +from android.content.pm import PackageManager +from android.hardware.camera2 import CameraCharacteristics +from android.provider import MediaStore +from androidx.core.content import ContextCompat + +import toga + + +class CameraDevice: + def __init__(self, manager, id): + self._manager = manager + self._id = id + + def id(self): + return self._id + + def name(self): + return f"Camera {self._id}" + + def has_flash(self): + characteristics = self._manager.getCameraCharacteristics(self._id) + return characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) + + +class Camera: + CAMERA_PERMISSION = "android.permission.CAMERA" + + def __init__(self, interface): + self.interface = interface + + # Does the device have a camera? + self.context = self.interface.app._impl.native.getApplicationContext() + self.has_camera = self.context.getPackageManager().hasSystemFeature( + PackageManager.FEATURE_CAMERA + ) + + def _native_checkSelfPermission(self, context, permission): # pragma: no cover + # A wrapper around the native call so it can be mocked. + return ContextCompat.checkSelfPermission(context, Camera.CAMERA_PERMISSION) + + def has_permission(self): + result = self._native_checkSelfPermission( + self.context, Camera.CAMERA_PERMISSION + ) + return result == PackageManager.PERMISSION_GRANTED + + def request_permission(self, future): + def request_complete(permissions, results): + # Map the permissions to their result + perms = dict(zip(permissions, results)) + try: + result = ( + perms[Camera.CAMERA_PERMISSION] == PackageManager.PERMISSION_GRANTED + ) + except KeyError: # pragma: no cover + # This shouldn't ever happen - we shouldn't get a completion of a camera + # permission request that doesn't include the camera permission - but + # just in case, we'll assume if it's not there, it failed. + result = False + future.set_result(result) + + self.interface.app._impl.request_permissions( + [Camera.CAMERA_PERMISSION], + on_complete=request_complete, + ) + + def get_devices(self): + manager = self.interface.app._impl.native.getSystemService( + Context.CAMERA_SERVICE + ) + + return [ + CameraDevice(manager=manager, id=ident) + for ident in manager.getCameraIdList() + ] + + def take_photo(self, result, device, flash): + if not self.has_camera: + warnings.warn("No camera is available") + result.set_result(None) + elif self.has_permission(): + # We have permission; go directly to taking the photo + def photo_taken(code, data): + # Activity.RESULT_CANCELED == 0 + if code: + bundle = data.getExtras() + bitmap = bundle.get("data") + thumb = toga.Image(bitmap) + result.set_result(thumb) + else: + result.set_result(None) + + intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + # TODO: The image returned by default is only a thumbnail. There's some sort + # of jiggery-pokery needed to return an actual image. + # intent.putExtra(MediaStore.EXTRA_OUTPUT, ...) + self.interface.app._impl.start_activity(intent, on_complete=photo_taken) + else: + raise PermissionError("App does not have permission to take photos") diff --git a/android/tests_backend/hardware/__init__.py b/android/tests_backend/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/tests_backend/hardware/camera.py b/android/tests_backend/hardware/camera.py new file mode 100644 index 0000000000..165b2fd82d --- /dev/null +++ b/android/tests_backend/hardware/camera.py @@ -0,0 +1,145 @@ +from unittest.mock import Mock + +import pytest +from android.content.pm import PackageManager +from android.provider import MediaStore + +import toga +from toga_android.app import App +from toga_android.hardware.camera import Camera + +from ..app import AppProbe + + +class CameraProbe(AppProbe): + allow_no_camera = False + request_permission_on_first_use = 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 App.startActivityForResult + self._mock_startActivityForResult = Mock() + monkeypatch.setattr( + App, "_native_startActivityForResult", self._mock_startActivityForResult + ) + + # Mock App.requestPermissions + def request_permissions(permissions, code): + grants = [] + for permission in permissions: + status = self._mock_permissions.get(permission, 0) + self._mock_permissions[permission] = abs(status) + grants.append( + PackageManager.PERMISSION_GRANTED + if status + else PackageManager.PERMISSION_DENIED + ) + + app_probe.app._impl._listener.onRequestPermissionsResult( + code, permissions, grants + ) + + self._mock_requestPermissions = Mock(side_effect=request_permissions) + monkeypatch.setattr( + App, "_native_requestPermissions", self._mock_requestPermissions + ) + + # Mock ContextCompat.checkSelfPermission + def has_permission(context, permission): + return ( + PackageManager.PERMISSION_GRANTED + if self._mock_permissions.get(permission, 0) == 1 + else PackageManager.PERMISSION_DENIED + ) + + self._mock_checkSelfPermission = Mock(side_effect=has_permission) + monkeypatch.setattr( + Camera, "_native_checkSelfPermission", self._mock_checkSelfPermission + ) + + def cleanup(self): + pass + + def known_cameras(self): + # The Android emulator has a single camera. Physical devices will have other + # properties, this test result won't be accurate. + return { + "1": ("Camera 1", True), + } + + def select_other_camera(self): + pytest.xfail("Android can't programmativally select other cameras") + + def disconnect_cameras(self): + # native=False + self.app.camera._impl.has_camera = False + + def reset_permission(self): + self._mock_permissions = {} + + def grant_permission(self): + self._mock_permissions[Camera.CAMERA_PERMISSION] = 1 + + def allow_permission(self): + self._mock_permissions[Camera.CAMERA_PERMISSION] = -1 + + def reject_permission(self): + self._mock_permissions[Camera.CAMERA_PERMISSION] = 0 + + async def wait_for_camera(self, device_count=0): + await self.redraw("Camera view displayed") + + @property + def shutter_enabled(self): + # Shutter can't be disabled + return True + + async def press_shutter_button(self, photo): + # The activity was started + self._mock_startActivityForResult.assert_called_once() + (intent, code), _ = self._mock_startActivityForResult.call_args + assert intent.getAction() == MediaStore.ACTION_IMAGE_CAPTURE + self._mock_startActivityForResult.reset_mock() + + # Fake the result of a successful photo being taken + image = toga.Image("resources/photo.png") + data = Mock() + data.getExtras.return_value = {"data": image._impl.native} + # Activity.RESULT_OK = -1 + self.app._impl._listener.onActivityResult(code, -1, data) + + await self.redraw("Photo taken") + + return await photo, None, None + + async def cancel_photo(self, photo): + # The activity was started + self._mock_startActivityForResult.assert_called_once() + (intent, code), _ = self._mock_startActivityForResult.call_args + assert intent.getAction() == MediaStore.ACTION_IMAGE_CAPTURE + self._mock_startActivityForResult.reset_mock() + + # Fake the result of a cancelled photo. + data = Mock() + # Activity.RESULT_CANCELLED = 0 + self.app._impl._listener.onActivityResult(code, 0, data) + + await self.redraw("Photo cancelled") + + return await photo + + def same_device(self, device, native): + # Android provides no real camera control; so return all devices as a match + return True + + def same_flash_mode(self, expected, actual): + # Android provides no real camera control; so return all devices as a match + return True diff --git a/cocoa/tests_backend/hardware/camera.py b/cocoa/tests_backend/hardware/camera.py index 61f520ad1d..ff03209ad4 100644 --- a/cocoa/tests_backend/hardware/camera.py +++ b/cocoa/tests_backend/hardware/camera.py @@ -14,6 +14,7 @@ class CameraProbe(AppProbe): allow_no_camera = True + request_permission_on_first_use = True def __init__(self, monkeypatch, app_probe): super().__init__(app_probe.app) @@ -54,11 +55,11 @@ def _mock_auth_status(media_type): def _mock_request_access(media_type, completionHandler): # Fire completion handler try: - result = self._mock_permissions[str(media_type)] + result = bool(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 + # If there's no explicit permission, it's a denial + self._mock_permissions[str(media_type)] = 0 + result = False completionHandler.func(result) self._mock_AVCaptureDevice.requestAccessForMediaType = _mock_request_access @@ -132,11 +133,14 @@ def disconnect_cameras(self): def reset_permission(self): self._mock_permissions = {} + def grant_permission(self): + self._mock_permissions[str(AVMediaTypeVideo)] = -1 + def allow_permission(self): - self._mock_permissions[str(AVMediaTypeVideo)] = True + self._mock_permissions[str(AVMediaTypeVideo)] = 1 def reject_permission(self): - self._mock_permissions[str(AVMediaTypeVideo)] = False + self._mock_permissions[str(AVMediaTypeVideo)] = 0 async def wait_for_camera(self, device_count=2): # A short delay is needed to ensure that the window fully creates. diff --git a/docs/reference/api/hardware/camera.rst b/docs/reference/api/hardware/camera.rst index c350e6e0b4..c45d3503c2 100644 --- a/docs/reference/api/hardware/camera.rst +++ b/docs/reference/api/hardware/camera.rst @@ -32,7 +32,7 @@ handler: 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 +Most platforms will require some form of device permission to access the camera. The permission APIs are paired with the specific actions performed on those APIs - that is, to take a photo, you require :any:`Camera.has_permission`, which you can request using :any:`Camera.request_permission()`. @@ -45,8 +45,9 @@ allows an app to *request* permissions as part of the startup process, prior to 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. +invoking any camera API. If permission has not yet been granted, the platform *may* +request access at the time of first camera access; however, this is not guaranteed to be +the behavior on all platforms. Notes ----- @@ -56,6 +57,7 @@ Notes * iOS: ``NSCameraUsageDescription`` must be defined in the app's ``Info.plist`` file. * macOS: The ``com.apple.security.device.camera`` entitlement must be enabled. + * Android: The ``android.permission.CAMERA`` permission must be declared. * The iOS simulator implements the iOS Camera APIs, but is not able to take photographs. To test your app's Camera usage, you must use a physical iOS device. diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index aec6e3dbdb..390dcde50e 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -28,7 +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|,,, +Camera,Hardware,:class:`~toga.hardware.camera.Camera`,A sensor that can capture photos and/or video.,|y|,,,|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/examples/hardware/pyproject.toml b/examples/hardware/pyproject.toml index 63c8cf5292..2672066bfd 100644 --- a/examples/hardware/pyproject.toml +++ b/examples/hardware/pyproject.toml @@ -18,6 +18,7 @@ requires = [ '../../core', ] +permission.camera = "This app demonstrates camera capabilities" [tool.briefcase.app.hardware.macOS] requires = [ @@ -46,3 +47,10 @@ requires = [ requires = [ '../../android', ] + +base_theme = "Theme.MaterialComponents.Light.DarkActionBar" + +build_gradle_dependencies = [ + "com.google.android.material:material:1.11.0", + "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", +] diff --git a/iOS/tests_backend/hardware/camera.py b/iOS/tests_backend/hardware/camera.py index 3233f718b9..45a93e14a7 100644 --- a/iOS/tests_backend/hardware/camera.py +++ b/iOS/tests_backend/hardware/camera.py @@ -19,6 +19,7 @@ class CameraProbe(AppProbe): allow_no_camera = False + request_permission_on_first_use = True def __init__(self, monkeypatch, app_probe): super().__init__(app_probe.app) @@ -47,11 +48,11 @@ def _mock_auth_status(media_type): def _mock_request_access(media_type, completionHandler): # Fire completion handler try: - result = self._mock_permissions[str(media_type)] + result = bool(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 + # If there's no explicit permission, it's a denial + self._mock_permissions[str(media_type)] = 0 + result = False completionHandler.func(result) self._mock_AVCaptureDevice.requestAccessForMediaType = _mock_request_access @@ -116,11 +117,14 @@ def disconnect_cameras(self): def reset_permission(self): self._mock_permissions = {} + def grant_permission(self): + self._mock_permissions[str(AVMediaTypeVideo)] = -1 + def allow_permission(self): - self._mock_permissions[str(AVMediaTypeVideo)] = True + self._mock_permissions[str(AVMediaTypeVideo)] = 1 def reject_permission(self): - self._mock_permissions[str(AVMediaTypeVideo)] = False + self._mock_permissions[str(AVMediaTypeVideo)] = 0 async def wait_for_camera(self, device_count=0): await self.redraw("Camera view displayed", delay=0.5) diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 44fac0c0eb..35a8e97b31 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -93,6 +93,14 @@ requires = [ test_requires = [ "fonttools==4.42.1", ] + +base_theme = "Theme.MaterialComponents.Light.DarkActionBar" + +build_gradle_dependencies = [ + "com.google.android.material:material:1.11.0", + "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", +] + build_gradle_extra_content = """\ android.defaultConfig.python { // Coverage requires access to individual .py files. diff --git a/testbed/tests/hardware/test_camera.py b/testbed/tests/hardware/test_camera.py index 9548e5f0a4..2ec782109b 100644 --- a/testbed/tests/hardware/test_camera.py +++ b/testbed/tests/hardware/test_camera.py @@ -10,7 +10,7 @@ @pytest.fixture async def camera_probe(monkeypatch, app_probe): - skip_on_platforms("android", "linux", "windows") + skip_on_platforms("linux", "windows") probe = get_probe(monkeypatch, app_probe, "Camera") yield probe probe.cleanup() @@ -24,11 +24,10 @@ async def test_camera_properties(app, camera_probe): async def test_grant_permission(app, camera_probe): """A user can grant permission to use the camera""" - # Reset camera permissions - camera_probe.reset_permission() + # Prime the permission system to approve permission requests + camera_probe.allow_permission() - # Initiate the permission request. Since there hasn't been an explicit - # allow or deny, this will allow access. + # Initiate the permission request. As permissions are primed, they will be approved. assert await app.camera.request_permission() # Permission now exists @@ -41,11 +40,25 @@ async def test_grant_permission(app, camera_probe): assert app.camera.has_permission +async def test_deny_permission(app, camera_probe): + """A user can deny permission to use the camera""" + # Initiate the permission request. As permissions are not primed, they will be denied. + assert not await app.camera.request_permission() + + # Permission has been denied + assert not app.camera.has_permission + + # A second request to request permissions is a no-op + assert not await app.camera.request_permission() + + # Permission is still denied + assert not 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() + camera_probe.grant_permission() for camera in [None] + app.camera.devices: # Trigger taking a photo @@ -62,9 +75,8 @@ async def test_take_photo(app, camera_probe): 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() + camera_probe.grant_permission() for flash_mode in [FlashMode.AUTO, FlashMode.ON, FlashMode.OFF]: # Trigger taking a photo with the default device @@ -81,8 +93,10 @@ async def test_flash_mode(app, camera_probe): 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. + if not camera_probe.request_permission_on_first_use: + pytest.xfail("Platform does not request permission on first use") + # Don't pre-grant permission; use default grant. # Trigger taking a photo photo = app.camera.take_photo() await camera_probe.wait_for_camera() @@ -96,9 +110,8 @@ async def test_take_photo_unknown_permission(app, camera_probe): async def test_cancel_photo(app, camera_probe): """A user can cancel taking a photo""" - # Ensure the camera has permissions - camera_probe.allow_permission() + camera_probe.grant_permission() # Trigger taking a photo photo = app.camera.take_photo() @@ -122,9 +135,8 @@ async def test_take_photo_no_permission(app, camera_probe): async def test_change_camera(app, camera_probe): """The currently selected camera can be changed""" - # Ensure the camera has permissions - camera_probe.allow_permission() + camera_probe.grant_permission() # Trigger taking a photo photo = app.camera.take_photo() @@ -151,7 +163,7 @@ async def test_no_cameras(app, camera_probe): camera_probe.disconnect_cameras() # Ensure the camera has permissions - camera_probe.allow_permission() + camera_probe.grant_permission() # Trigger taking a photo. This may raise a warning. with warnings.catch_warnings(): From a335b0d21d010925a1c9698be37f40d3fa0c709f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 20 Jan 2024 15:51:07 +0800 Subject: [PATCH 2/4] Add changenotes. --- changes/2353.feature.rst | 1 + changes/2353.removal.rst | 1 + 2 files changed, 2 insertions(+) create mode 100644 changes/2353.feature.rst create mode 100644 changes/2353.removal.rst diff --git a/changes/2353.feature.rst b/changes/2353.feature.rst new file mode 100644 index 0000000000..b7679413cf --- /dev/null +++ b/changes/2353.feature.rst @@ -0,0 +1 @@ +An Android implementation of the Camera API was added. diff --git a/changes/2353.removal.rst b/changes/2353.removal.rst new file mode 100644 index 0000000000..1caef1aa16 --- /dev/null +++ b/changes/2353.removal.rst @@ -0,0 +1 @@ +The internal Android method ``intent_result`` has been deprecated. This was an internal API, and not formally documented, but it was the easiest mechanism for invoking Intents on the Android backend. It has been replaced by the synchronous ``start_activity`` method that allows you to register a callback when the intent completes. From 39c927d3adea7f9c8dc0af90a20f0d47a7e3289c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 20 Jan 2024 16:40:47 +0800 Subject: [PATCH 3/4] Add documentation of start_activity. --- android/src/toga_android/app.py | 7 ----- changes/1798.doc.rst | 1 + docs/how-to/backends/android.rst | 45 +++++++++++++++++++++++++++++++ docs/how-to/backends/cocoa.rst | 9 +++++++ docs/how-to/backends/gtk.rst | 8 ++++++ docs/how-to/backends/iOS.rst | 8 ++++++ docs/how-to/backends/index.rst | 27 +++++++++++++++++++ docs/how-to/backends/textual.rst | 8 ++++++ docs/how-to/backends/web.rst | 7 +++++ docs/how-to/backends/winforms.rst | 8 ++++++ docs/how-to/index.rst | 3 ++- 11 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 changes/1798.doc.rst create mode 100644 docs/how-to/backends/android.rst create mode 100644 docs/how-to/backends/cocoa.rst create mode 100644 docs/how-to/backends/gtk.rst create mode 100644 docs/how-to/backends/iOS.rst create mode 100644 docs/how-to/backends/index.rst create mode 100644 docs/how-to/backends/textual.rst create mode 100644 docs/how-to/backends/web.rst create mode 100644 docs/how-to/backends/winforms.rst diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 3612e66455..063e9cfa2c 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -255,13 +255,6 @@ def set_current_window(self, window): # TODO #1798: document and test this somehow async def intent_result(self, intent): # pragma: no cover - """Calls an Intent and waits for its result. - - A RuntimeError will be raised when the Intent cannot be invoked. - - :param Intent intent: The Intent to call - :returns: A Dictionary containing "resultCode" (int) and "resultData" (Intent or None) - """ warnings.warn( "intent_result has been deprecated; use start_activity", DeprecationWarning, diff --git a/changes/1798.doc.rst b/changes/1798.doc.rst new file mode 100644 index 0000000000..13811e6e17 --- /dev/null +++ b/changes/1798.doc.rst @@ -0,0 +1 @@ +Initial documentation of backend-specific features has been added. diff --git a/docs/how-to/backends/android.rst b/docs/how-to/backends/android.rst new file mode 100644 index 0000000000..cb75794649 --- /dev/null +++ b/docs/how-to/backends/android.rst @@ -0,0 +1,45 @@ +======= +Android +======= + +The Android backend uses `Material3 widgets `__. + +The native APIs are accessed using `Chaquopy +`__. + +Activities and Intents +====================== + +On Android, some interactions are managed using Activities, which are started using +Intents. + +Android's implementation of the :any:`toga.App` class includes the method +:meth:`~toga_android.App.start_activity()`, which can be used to start an activity. + +.. py:method:: toga_android.App.start_activity(self, activity, *options, on_complete=None) + + Start a native Android activity. + + :param activity: The ``android.content.Intent`` instance to start + :param options: Any additional arguments to pass to the native + ``android.app.Activity.startActivityForResult()`` call. + :param on_complete: A callback to invoke when the activity completes. The callback + will be invoked with 2 arguments: the result code, and the result data. + +To use this method, instantiate an instance of ``android.content.Intent``; optionally, +provide additional arguments, and a callback that will be invoked when the activity +completes. For example, to dial a phone number with the ``Intent.ACTION_DIAL`` intent:: + + from android.content import Intent + from android.net import Uri + + intent = new Intent(Intent.ACTION_DIAL) + intent.setData(Uri.parse("tel:0123456789")) + + def number_dialed(result, data): + # result is the status code (e.g., Activity.RESULT_OK) + # data is the value returned by the activity. + ... + + # Assuming your toga.App app instance is called `app` + app._impl.start_activity(intent, on_complete=number_dialed) diff --git a/docs/how-to/backends/cocoa.rst b/docs/how-to/backends/cocoa.rst new file mode 100644 index 0000000000..bfabec60b2 --- /dev/null +++ b/docs/how-to/backends/cocoa.rst @@ -0,0 +1,9 @@ +============= +Cocoa (macOS) +============= + +The backend used on macOS uses the `AppKit API +`__, also known as Cocoa. + +The native APIs are accessed using `Rubicon Objective C +`__. diff --git a/docs/how-to/backends/gtk.rst b/docs/how-to/backends/gtk.rst new file mode 100644 index 0000000000..830701f724 --- /dev/null +++ b/docs/how-to/backends/gtk.rst @@ -0,0 +1,8 @@ +=== +GTK +=== + +The GTK backend uses the `GTK3 API `__. + +The native APIs are accessed using the `PyGObject binding +`__. diff --git a/docs/how-to/backends/iOS.rst b/docs/how-to/backends/iOS.rst new file mode 100644 index 0000000000..16a7d0ff79 --- /dev/null +++ b/docs/how-to/backends/iOS.rst @@ -0,0 +1,8 @@ +=== +iOS +=== + +The iOS backend uses `UIKit `. + +The native APIs are accessed using `Rubicon Objective C +`__. diff --git a/docs/how-to/backends/index.rst b/docs/how-to/backends/index.rst new file mode 100644 index 0000000000..0ff242452e --- /dev/null +++ b/docs/how-to/backends/index.rst @@ -0,0 +1,27 @@ +============================== +Backend implementation details +============================== + +Although Toga is a cross-platform toolkit, it is sometimes necessary to invoke +platform-specific logic. These guides provide information on how platform-specific +features map onto the Toga backend API. + +Accessing these APIs will result in an application that is no longer cross-platform +(unless you gate the usage of these APIs with ``sys.platform`` or +``toga.platform.current_platform`` checks); however, accessing a backend API may be the +only way to implement a feature that Toga doesn't provide. + +For details on how to access the the backend implementations, see the documentation on +:ref:`Toga's three-layer architecture `. + +.. toctree:: + :maxdepth: 1 + :glob: + + android + gtk + iOS + cocoa + textual + web + winforms diff --git a/docs/how-to/backends/textual.rst b/docs/how-to/backends/textual.rst new file mode 100644 index 0000000000..20e73c2c17 --- /dev/null +++ b/docs/how-to/backends/textual.rst @@ -0,0 +1,8 @@ +======= +Textual +======= + +The Textual backend uses the `Textual API `__. + +The native APIs are accessed using `Rubicon Objective C +`__. diff --git a/docs/how-to/backends/web.rst b/docs/how-to/backends/web.rst new file mode 100644 index 0000000000..859c91b28c --- /dev/null +++ b/docs/how-to/backends/web.rst @@ -0,0 +1,7 @@ +=== +Web +=== + +The Web backend uses `Shoelace web components `__. + +The DOM is accessed using `PyScript `__. diff --git a/docs/how-to/backends/winforms.rst b/docs/how-to/backends/winforms.rst new file mode 100644 index 0000000000..72b9419151 --- /dev/null +++ b/docs/how-to/backends/winforms.rst @@ -0,0 +1,8 @@ +================== +Winforms (Windows) +================== + +The backend used on Windows uses the `Windows Forms API +`__. + +The native .NET APIs are accessed using `Python.NET `__. diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 93fba538e3..237e365e95 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -10,10 +10,11 @@ already knows than tutorials do, and unlike documents in the tutorial they can stand alone. .. toctree:: - :maxdepth: 1 + :maxdepth: 2 :glob: Get started Contribute code to Toga Contribute documentation to Toga + backends/index internal/index From cba1e969acf7470989c2fa88aa07772e46dbcce3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 30 Jan 2024 10:50:37 +0800 Subject: [PATCH 4/4] Use a file provider to get the full-resolution file from the camera intent. --- android/src/toga_android/app.py | 4 +-- android/src/toga_android/hardware/camera.py | 31 +++++++++++++++------ android/tests_backend/hardware/camera.py | 22 +++++++++++---- examples/hardware/hardware/app.py | 2 +- examples/hardware/pyproject.toml | 3 +- testbed/pyproject.toml | 1 + 6 files changed, 44 insertions(+), 19 deletions(-) diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 063e9cfa2c..1ee8663341 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -53,7 +53,7 @@ def onRestart(self): print("Toga app: onRestart") # pragma: no cover def onActivityResult(self, requestCode, resultCode, resultData): - print(f"Toga app: onActivityResult, {requestCode=}, {resultData=}") + print(f"Toga app: onActivityResult {requestCode=} {resultCode=} {resultData=}") try: # Retrieve the completion callback; if non-none, invoke it. callback = self.running_intents.pop(requestCode) @@ -69,7 +69,7 @@ def onActivityResult(self, requestCode, resultCode, resultData): def onRequestPermissionsResult(self, requestCode, permissions, grantResults): print( - f"Toga app: onRequestPermissionsResult, {requestCode=}, {permissions=} {grantResults=}" + f"Toga app: onRequestPermissionsResult {requestCode=} {permissions=} {grantResults=}" ) try: # Retrieve the completion callback and invoke it. diff --git a/android/src/toga_android/hardware/camera.py b/android/src/toga_android/hardware/camera.py index 7485318f07..5e7c86f4c3 100644 --- a/android/src/toga_android/hardware/camera.py +++ b/android/src/toga_android/hardware/camera.py @@ -1,10 +1,12 @@ import warnings +from pathlib import Path from android.content import Context, Intent from android.content.pm import PackageManager from android.hardware.camera2 import CameraCharacteristics from android.provider import MediaStore -from androidx.core.content import ContextCompat +from androidx.core.content import ContextCompat, FileProvider +from java.io import File import toga @@ -82,21 +84,32 @@ def take_photo(self, result, device, flash): warnings.warn("No camera is available") result.set_result(None) elif self.has_permission(): - # We have permission; go directly to taking the photo + # We have permission; go directly to taking the photo. + # The "shared" subfolder of the app's cache folder is + # marked as a file provider. Ensure the folder exists. + shared_folder = File(self.context.getCacheDir(), "shared") + if not shared_folder.exists(): + shared_folder.mkdirs() + + # Create a temporary file in the shared folder, + # and convert it to a URI using the app's fileprovider. + photo_file = File.createTempFile("camera", ".jpg", shared_folder) + photo_uri = FileProvider.getUriForFile( + self.context, + f"{self.interface.app.app_id}.fileprovider", + photo_file, + ) + def photo_taken(code, data): # Activity.RESULT_CANCELED == 0 if code: - bundle = data.getExtras() - bitmap = bundle.get("data") - thumb = toga.Image(bitmap) - result.set_result(thumb) + photo = toga.Image(Path(photo_file.getAbsolutePath())) + result.set_result(photo) else: result.set_result(None) intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - # TODO: The image returned by default is only a thumbnail. There's some sort - # of jiggery-pokery needed to return an actual image. - # intent.putExtra(MediaStore.EXTRA_OUTPUT, ...) + intent.putExtra(MediaStore.EXTRA_OUTPUT, photo_uri) self.interface.app._impl.start_activity(intent, on_complete=photo_taken) else: raise PermissionError("App does not have permission to take photos") diff --git a/android/tests_backend/hardware/camera.py b/android/tests_backend/hardware/camera.py index 165b2fd82d..9bc2066953 100644 --- a/android/tests_backend/hardware/camera.py +++ b/android/tests_backend/hardware/camera.py @@ -1,10 +1,10 @@ +import shutil from unittest.mock import Mock import pytest from android.content.pm import PackageManager from android.provider import MediaStore -import toga from toga_android.app import App from toga_android.hardware.camera import Camera @@ -66,7 +66,8 @@ def has_permission(context, permission): ) def cleanup(self): - pass + # Ensure that after a test runs, there's no shared files. + shutil.rmtree(self.app.paths.cache / "shared", ignore_errors=True) def known_cameras(self): # The Android emulator has a single camera. Physical devices will have other @@ -76,7 +77,7 @@ def known_cameras(self): } def select_other_camera(self): - pytest.xfail("Android can't programmativally select other cameras") + pytest.xfail("Android can't programmatically select other cameras") def disconnect_cameras(self): # native=False @@ -109,10 +110,19 @@ async def press_shutter_button(self, photo): assert intent.getAction() == MediaStore.ACTION_IMAGE_CAPTURE self._mock_startActivityForResult.reset_mock() - # Fake the result of a successful photo being taken - image = toga.Image("resources/photo.png") + # Fake the result of a successful photo being taken. + output_uri = intent.getExtras().get(MediaStore.EXTRA_OUTPUT) + shared_suffix = output_uri.getPath()[1:] + + # The shared folder *must* exist as a result of the camera being triggered. + assert (self.app.paths.cache / shared_suffix).parent.is_dir() + # Copy the reference file to the location that the camera intent would have + # populated. + shutil.copy( + self.app.paths.app / "resources/photo.png", + self.app.paths.cache / shared_suffix, + ) data = Mock() - data.getExtras.return_value = {"data": image._impl.native} # Activity.RESULT_OK = -1 self.app._impl._listener.onActivityResult(code, -1, data) diff --git a/examples/hardware/hardware/app.py b/examples/hardware/hardware/app.py index 01a8d397b7..5b0f26b88e 100644 --- a/examples/hardware/hardware/app.py +++ b/examples/hardware/hardware/app.py @@ -63,7 +63,7 @@ async def take_photo(self, widget, **kwargs): def main(): - return ExampleHardwareApp("Hardware", "org.beeware.widgets.hardware") + return ExampleHardwareApp("Hardware", "org.beeware.examples.hardware") if __name__ == "__main__": diff --git a/examples/hardware/pyproject.toml b/examples/hardware/pyproject.toml index 2672066bfd..5903923d7e 100644 --- a/examples/hardware/pyproject.toml +++ b/examples/hardware/pyproject.toml @@ -3,7 +3,7 @@ requires = ["briefcase"] [tool.briefcase] project_name = "Hardware" -bundle = "org.beeware" +bundle = "org.beeware.examples" version = "0.0.1" url = "https://beeware.org" license = "BSD license" @@ -51,6 +51,7 @@ requires = [ base_theme = "Theme.MaterialComponents.Light.DarkActionBar" build_gradle_dependencies = [ + "androidx.appcompat:appcompat:1.6.1", "com.google.android.material:material:1.11.0", "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", ] diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 35a8e97b31..734ecad203 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -97,6 +97,7 @@ test_requires = [ base_theme = "Theme.MaterialComponents.Light.DarkActionBar" build_gradle_dependencies = [ + "androidx.appcompat:appcompat:1.6.1", "com.google.android.material:material:1.11.0", "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", ]