diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b82ce5c5f..f4340bcbea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -255,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/cocoa/tests_backend/hardware/camera.py b/cocoa/tests_backend/hardware/camera.py index e8ee9b1408..61f520ad1d 100644 --- a/cocoa/tests_backend/hardware/camera.py +++ b/cocoa/tests_backend/hardware/camera.py @@ -13,6 +13,8 @@ class CameraProbe(AppProbe): + allow_no_camera = True + def __init__(self, monkeypatch, app_probe): super().__init__(app_probe.app) diff --git a/core/src/toga/hardware/camera.py b/core/src/toga/hardware/camera.py index 12d247b807..0ff330c4c5 100644 --- a/core/src/toga/hardware/camera.py +++ b/core/src/toga/hardware/camera.py @@ -121,24 +121,3 @@ def take_photo( photo = PhotoResult(None) self._impl.take_photo(photo, device=device, flash=flash) return photo - - # async def record_video( - # self, - # device: CameraDevice | None = None, - # flash: FlashMode = FlashMode.AUTO, - # quality: VideoQuality = VideoQuality.MEDIUM, - # ) -> 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" - # :returns: The :any:`toga.Video` captured by the camera. - # """ - # future = asyncio.get_event_loop().create_future() - # self._impl.record_video(future, device=device, flash=flash) - # return future diff --git a/iOS/src/toga_iOS/hardware/camera.py b/iOS/src/toga_iOS/hardware/camera.py index 56cfb132e7..0ae0c355be 100644 --- a/iOS/src/toga_iOS/hardware/camera.py +++ b/iOS/src/toga_iOS/hardware/camera.py @@ -1,12 +1,16 @@ -from rubicon.objc import Block, objc_method +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, - AVCaptureDevice, AVMediaTypeVideo, - UIImagePickerController, + NSBundle, UIImagePickerControllerCameraCaptureMode, UIImagePickerControllerCameraDevice, UIImagePickerControllerCameraFlashMode, @@ -27,7 +31,7 @@ def name(self): return self._name def has_flash(self): - return UIImagePickerController.isFlashAvailableForCameraDevice(self.native) + return iOS.UIImagePickerController.isFlashAvailableForCameraDevice(self.native) def native_flash_mode(flash): @@ -44,7 +48,7 @@ def native_flash_mode(flash): # }.get(quality, UIImagePickerControllerQualityType.Medium) -class TogaImagePickerController(UIImagePickerController): +class TogaImagePickerDelegate(NSObject): @objc_method def imagePickerController_didFinishPickingMediaWithInfo_( self, picker, info @@ -57,7 +61,6 @@ def imagePickerController_didFinishPickingMediaWithInfo_( @objc_method def imagePickerControllerDidCancel_(self, picker) -> None: picker.dismissViewControllerAnimated(True, completion=None) - self.result.set_result(None) @@ -65,9 +68,22 @@ class Camera: def __init__(self, interface): self.interface = interface - self.native = TogaImagePickerController.alloc().init() - self.native.sourceType = UIImagePickerControllerSourceTypeCamera - self.native.delegate = self.native + 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: @@ -79,7 +95,7 @@ def has_permission(self, allow_unknown=False): valid_values = {AVAuthorizationStatus.Authorized.value} return ( - AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) + iOS.AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) in valid_values ) @@ -87,10 +103,10 @@ 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 + def permission_complete(result) -> None: future.set_result(result) - AVCaptureDevice.requestAccessForMediaType( + iOS.AVCaptureDevice.requestAccessForMediaType( AVMediaTypeVideo, completionHandler=Block(permission_complete, None, bool) ) @@ -103,7 +119,7 @@ def get_devices(self): native=UIImagePickerControllerCameraDevice.Rear, ) ] - if UIImagePickerController.isCameraDeviceAvailable( + if iOS.UIImagePickerController.isCameraDeviceAvailable( UIImagePickerControllerCameraDevice.Rear ) else [] @@ -115,14 +131,17 @@ def get_devices(self): native=UIImagePickerControllerCameraDevice.Front, ) ] - if UIImagePickerController.isCameraDeviceAvailable( + if iOS.UIImagePickerController.isCameraDeviceAvailable( UIImagePickerControllerCameraDevice.Front ) else [] ) def take_photo(self, result, device, flash): - if self.has_permission(allow_unknown=True): + 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 @@ -136,8 +155,8 @@ def take_photo(self, result, device, flash): ) self.native.cameraFlashMode = native_flash_mode(flash) - # Attach the result to the picker - self.native.result = result + # Attach the result to the delegate + self.native.delegate.result = result # Show the pane toga.App.app.current_window._impl.native.rootViewController.presentViewController( @@ -145,28 +164,3 @@ def take_photo(self, result, device, flash): ) else: raise PermissionError("App does not have permission to take photos") - - # def record_video(self, result, device, flash): - # if self.has_video_permission(allow_unknown=True): - # # Configure the controller to take a photo - # self.native.cameraCaptureMode = ( - # UIImagePickerControllerCameraCaptureMode.Video - # ) - - # 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 picker - # self.native.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/tests_backend/hardware/camera.py b/iOS/tests_backend/hardware/camera.py index d6fcc46673..3233f718b9 100644 --- a/iOS/tests_backend/hardware/camera.py +++ b/iOS/tests_backend/hardware/camera.py @@ -1,78 +1,99 @@ -import sqlite3 -import sys -from pathlib import Path - -import pytest +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 ( - NSBundle, - UIImagePickerController, + AVAuthorizationStatus, + AVMediaTypeVideo, + UIImagePickerControllerCameraCaptureMode, UIImagePickerControllerCameraDevice, + UIImagePickerControllerCameraFlashMode, + UIImagePickerControllerSourceTypeCamera, + UIViewController, ) from ..app import AppProbe class CameraProbe(AppProbe): - def __init__(self, monkeypatch, app_probe): - if not sys.implementation._simulator: - pytest.skip("Can't run Camera tests on physical hardware") + allow_no_camera = False + def __init__(self, monkeypatch, app_probe): super().__init__(app_probe.app) self.monkeypatch = monkeypatch - # iOS doesn't allow for permissions to be changed once they're initially set. - # Since we need permissions to be enabled to test most features, set the - # state of the TCC database to enable camera permissions when they're actually - # interrogated by the UIKit APIs. - tcc_db = sqlite3.connect( - str( - Path(NSBundle.mainBundle.resourcePath) - / "../../../../../Library/TCC/TCC.db" - ), - ) - cursor = tcc_db.cursor() - cursor.execute( - ( - "REPLACE INTO access " - "(service, client, client_type, auth_value, auth_reason, auth_version, flags) " - "VALUES (?, ?, ?, ?, ?, ?, ?)" - ), - ("kTCCServiceCamera", "org.beeware.toga.testbed", 0, 2, 2, 1, 0), - ) - tcc_db.commit() - tcc_db.close() - - # iPhone simulator has no camera devices. Mock the response of the camera - # identifiers to report a rear camera with a flash, and a front camera with - # no flash. - - def _is_available(self, device): - return True - - def _has_flash(self, device): + # 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 - monkeypatch.setitem( - UIImagePickerController.objc_class.__dict__["instance_methods"], - "isCameraDeviceAvailable:", - _is_available, + self._mock_UIImagePickerController.isFlashAvailableForCameraDevice = ( + _mock_flash_available ) - monkeypatch.setitem( - UIImagePickerController.objc_class.__dict__["instance_methods"], - "isFlashAvailableForCameraDevice:", - _has_flash, + + monkeypatch.setattr( + iOS, "UIImagePickerController", self._mock_UIImagePickerController ) def cleanup(self): - picker = self.app.camera._impl.native try: - result = picker.result + picker = self.app.camera._impl.native + result = picker.delegate.result if not result.future.done(): - picker.imagePickerControllerDidCancel(picker) + picker.delegate.imagePickerControllerDidCancel(picker) except AttributeError: pass @@ -83,43 +104,43 @@ def known_cameras(self): } def select_other_camera(self): - raise pytest.xfail("Cannot programmatically change camera on iOS") + other = self.app.camera.devices[1] + self.app.camera._impl.native.cameraDevice = other._impl.native + return other def disconnect_cameras(self): - raise pytest.xfail("Cameras cannot be removed on iOS") + # 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): - # Mock the *next* call to retrieve photo permission. - orig = Camera.has_permission - - def reset_permission(mock, allow_unknown=False): - self.monkeypatch.setattr(Camera, "has_permission", orig) - return allow_unknown - - self.monkeypatch.setattr(Camera, "has_permission", reset_permission) + self._mock_permissions = {} def allow_permission(self): - # Mock the result of has_permission to allow - def grant_permission(mock, allow_unknown=False): - return True - - self.monkeypatch.setattr(Camera, "has_permission", grant_permission) + self._mock_permissions[str(AVMediaTypeVideo)] = True def reject_permission(self): - # Mock the result of has_permission to deny - def deny_permission(mock, allow_unknown=False): - return False - - self.monkeypatch.setattr(Camera, "has_permission", deny_permission) + self._mock_permissions[str(AVMediaTypeVideo)] = False - async def wait_for_camera(self): + 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 = self.app.camera._impl.native - picker.imagePickerController( + picker.delegate.imagePickerController( picker, didFinishPickingMediaWithInfo={ "UIImagePickerControllerOriginalImage": image._impl.native @@ -128,23 +149,35 @@ async def press_shutter_button(self, photo): await self.redraw("Photo taken", delay=0.5) - return await photo, None, None + return await photo, picker.cameraDevice, picker.cameraFlashMode async def cancel_photo(self, photo): - # Fake the result of a cancelling the photo + # The camera picker was correctly configured picker = self.app.camera._impl.native - picker.imagePickerControllerDidCancel(picker) + 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): - # As the iOS camera is an external UI, we can't programmatically influence or - # modify it; so we make all device checks pass. - return True + if device is None: + return native == UIImagePickerControllerCameraDevice.Rear + else: + return device._impl.native == native def same_flash_mode(self, expected, actual): - # As the iOS camera is an external UI, we can't programmatically influence or - # modify it; so we make all device checks pass. - return True + return ( + expected + == { + UIImagePickerControllerCameraFlashMode.Auto: FlashMode.AUTO, + UIImagePickerControllerCameraFlashMode.On: FlashMode.ON, + UIImagePickerControllerCameraFlashMode.Off: FlashMode.OFF, + }[actual] + ) diff --git a/testbed/tests/hardware/test_camera.py b/testbed/tests/hardware/test_camera.py index e484c62cc4..9548e5f0a4 100644 --- a/testbed/tests/hardware/test_camera.py +++ b/testbed/tests/hardware/test_camera.py @@ -1,3 +1,5 @@ +import warnings + import pytest from toga.constants import FlashMode @@ -144,22 +146,32 @@ async def test_change_camera(app, camera_probe): async def test_no_cameras(app, camera_probe): - """If there are no cameras attached, the dialog is displayed, but the button is disabled""" + """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 - photo = app.camera.take_photo() - await camera_probe.wait_for_camera(device_count=0) + # 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() - # The shutter is *not* enabled - assert not camera_probe.shutter_enabled + # 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) - # Simulate pressing the shutter on the camer - image = await camera_probe.cancel_photo(photo) + # 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