Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an implementation of Camera for Android. #2353

Merged
merged 6 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 78 additions & 35 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import sys
import warnings

from android.graphics.drawable import BitmapDrawable
from android.media import RingtoneManager
Expand All @@ -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):
Expand Down Expand Up @@ -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=} {resultCode=} {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
Expand Down Expand Up @@ -250,27 +255,65 @@ 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)
: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

Expand Down
3 changes: 3 additions & 0 deletions android/src/toga_android/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -44,6 +45,8 @@ def not_implemented(feature):
"Icon",
"Image",
"Paths",
# Hardware
"Camera",
# Widgets
# ActivityIndicator
"Box",
Expand Down
115 changes: 115 additions & 0 deletions android/src/toga_android/hardware/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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, FileProvider
from java.io import File

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.
# 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:
photo = toga.Image(Path(photo_file.getAbsolutePath()))
result.set_result(photo)
else:
result.set_result(None)

intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
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")
Empty file.
Loading