From f36a54628c368f48e9581945a916ed11b8170a97 Mon Sep 17 00:00:00 2001 From: Gabriel Pettier Date: Mon, 14 Jun 2021 01:11:48 +0200 Subject: [PATCH 1/2] Add a `require_permissions` decorator to use on android methods adding the decorator with permissions on a method or function will automatically require the permissions when the method is called. tested on the gps example, but as it worked, added on the other android implementations using the permissions requested in the buildozer.spec of their example. --- examples/gps/main.py | 32 ------------ plyer/platforms/android/__init__.py | 73 +++++++++++++++++++++++++++ plyer/platforms/android/audio.py | 2 + plyer/platforms/android/battery.py | 3 +- plyer/platforms/android/brightness.py | 5 +- plyer/platforms/android/call.py | 4 +- plyer/platforms/android/flash.py | 3 +- plyer/platforms/android/gps.py | 5 +- plyer/platforms/android/sms.py | 2 + plyer/platforms/android/stt.py | 3 +- plyer/platforms/android/vibrator.py | 5 +- 11 files changed, 96 insertions(+), 41 deletions(-) diff --git a/examples/gps/main.py b/examples/gps/main.py index eba684019..ddad8e7fb 100644 --- a/examples/gps/main.py +++ b/examples/gps/main.py @@ -33,34 +33,6 @@ class GpsTest(App): gps_location = StringProperty() gps_status = StringProperty('Click Start to get GPS location updates') - def request_android_permissions(self): - """ - Since API 23, Android requires permission to be requested at runtime. - This function requests permission and handles the response via a - callback. - - The request will produce a popup if permissions have not already been - been granted, otherwise it will do nothing. - """ - from android.permissions import request_permissions, Permission - - def callback(permissions, results): - """ - Defines the callback to be fired when runtime permission - has been granted or denied. This is not strictly required, - but added for the sake of completeness. - """ - if all([res for res in results]): - print("callback. All permissions granted.") - else: - print("callback. Some permissions refused.") - - request_permissions([Permission.ACCESS_COARSE_LOCATION, - Permission.ACCESS_FINE_LOCATION], callback) - # # To request permissions without a callback, do: - # request_permissions([Permission.ACCESS_COARSE_LOCATION, - # Permission.ACCESS_FINE_LOCATION]) - def build(self): try: gps.configure(on_location=self.on_location, @@ -70,10 +42,6 @@ def build(self): traceback.print_exc() self.gps_status = 'GPS is not implemented for your platform' - if platform == "android": - print("gps.py: Android detected. Requesting permissions") - self.request_android_permissions() - return Builder.load_string(kv) def start(self, minTime, minDistance): diff --git a/plyer/platforms/android/__init__.py b/plyer/platforms/android/__init__.py index 8fe126353..ddd4f7b81 100644 --- a/plyer/platforms/android/__init__.py +++ b/plyer/platforms/android/__init__.py @@ -1,8 +1,13 @@ from os import environ +from logging import getLogger + +from functools import wraps from jnius import autoclass ANDROID_VERSION = autoclass('android.os.Build$VERSION') SDK_INT = ANDROID_VERSION.SDK_INT +LOG = getLogger(__name__) + try: from android import config @@ -10,9 +15,77 @@ except (ImportError, AttributeError): ns = 'org.renpy.android' + if 'PYTHON_SERVICE_ARGUMENT' in environ: PythonService = autoclass(ns + '.PythonService') activity = PythonService.mService else: PythonActivity = autoclass(ns + '.PythonActivity') activity = PythonActivity.mActivity + + +def resolve_permission(permission): + """Helper method to allow passing a permission by name + """ + from android.permissions import Permission + if hasattr(Permission, permission): + return getattr(Permission, permission) + return permission + + +def require_permissions(*permissions, handle_denied=None): + """ + A decorator for android plyer functions allowing to automatically request + necessary permissions when a method is called. + + usage: + @require_permissions(Permission.ACCESS_COARSE_LOCATION, Permission.ACCESS_FINE_LOCATION) + def start_gps(...): + ... + + + if the permissions haven't been granted yet, the require_permissions method + will be called first, and the actual method will be set as a callback to + execute when the user accept or refuse permissions, if you want to handle + the cases where some of the permissions are denied, you can set a callback + method to `handle_denied`. When set, and if some permissions are refused + this function will be called with the list of permissions that were refused + as a parameter. If you don't set such a handler, the decorated method will + be called in all the cases. + """ + + def decorator(function): + LOG.debug(f"decorating function {function.__name__}") + @wraps(function) + def wrapper(*args, **kwargs): + nonlocal permissions + from android.permissions import request_permissions, check_permission + + def callback(permissions, grant_results): + LOG.debug(f"callback called with {dict(zip(permissions, grant_results))}") + if handle_denied and not all(grant_results): + handle_denied([ + permission + for (granted, permission) in zip(grant_results, permissions) + if granted + ]) + else: + function(*args, **kwargs) + + permissions = [resolve_permission(permission) for permission in permissions] + permissions = [ + permission + for permission in permissions + if not check_permission(permission) + ] + LOG.debug(f"needed permissions: {permissions}") + + if permissions: + LOG.debug("calling request_permissions with callback") + request_permissions(permissions, callback) + else: + LOG.debug("no missing permissiong calling function directly") + function(*args, **kwargs) + + return wrapper + return decorator diff --git a/plyer/platforms/android/audio.py b/plyer/platforms/android/audio.py index 9f000ff41..959cd684b 100644 --- a/plyer/platforms/android/audio.py +++ b/plyer/platforms/android/audio.py @@ -1,6 +1,7 @@ from jnius import autoclass from plyer.facades.audio import Audio +from plyer.platforms.android import require_permissions # Recorder Classes MediaRecorder = autoclass('android.media.MediaRecorder') @@ -26,6 +27,7 @@ def __init__(self, file_path=None): self._recorder = None self._player = None + @require_permissions("RECORD_AUDIO") def _start(self): self._recorder = MediaRecorder() self._recorder.setAudioSource(AudioSource.DEFAULT) diff --git a/plyer/platforms/android/battery.py b/plyer/platforms/android/battery.py index 4a58f1f0b..20fe9e856 100644 --- a/plyer/platforms/android/battery.py +++ b/plyer/platforms/android/battery.py @@ -3,7 +3,7 @@ ''' from jnius import autoclass, cast -from plyer.platforms.android import activity +from plyer.platforms.android import activity, require_permissions from plyer.facades import Battery Intent = autoclass('android.content.Intent') @@ -16,6 +16,7 @@ class AndroidBattery(Battery): Implementation of Android battery API. ''' + @require_permissions('BATTERY_STATS') def _get_state(self): status = {"isCharging": None, "percentage": None} diff --git a/plyer/platforms/android/brightness.py b/plyer/platforms/android/brightness.py index d0a1157fd..fd12f3b9d 100755 --- a/plyer/platforms/android/brightness.py +++ b/plyer/platforms/android/brightness.py @@ -5,7 +5,7 @@ from jnius import autoclass from plyer.facades import Brightness -from android import mActivity +from plyer.platform.android import activity, require_permissions System = autoclass('android.provider.Settings$System') @@ -15,7 +15,7 @@ class AndroidBrightness(Brightness): def _current_level(self): System.putInt( - mActivity.getContentResolver(), + activity.getContentResolver(), System.SCREEN_BRIGHTNESS_MODE, System.SCREEN_BRIGHTNESS_MODE_MANUAL) cr_level = System.getInt( @@ -23,6 +23,7 @@ def _current_level(self): System.SCREEN_BRIGHTNESS) return (cr_level / 255.) * 100 + @require_permissions("WRITE_SETTINGS") def _set_level(self, level): System.putInt( mActivity.getContentResolver(), diff --git a/plyer/platforms/android/call.py b/plyer/platforms/android/call.py index 2a1388c7d..18a80ba2f 100644 --- a/plyer/platforms/android/call.py +++ b/plyer/platforms/android/call.py @@ -5,7 +5,7 @@ from jnius import autoclass from plyer.facades import Call -from plyer.platforms.android import activity +from plyer.platforms.android import activity, require_permissions Intent = autoclass('android.content.Intent') uri = autoclass('android.net.Uri') @@ -13,6 +13,7 @@ class AndroidCall(Call): + @require_permissions("CALL_PHONE") def _makecall(self, **kwargs): intent = Intent(Intent.ACTION_CALL) @@ -20,6 +21,7 @@ def _makecall(self, **kwargs): intent.setData(uri.parse("tel:{}".format(tel))) activity.startActivity(intent) + @require_permissions("CALL_PHONE") def _dialcall(self, **kwargs): intent_ = Intent(Intent.ACTION_DIAL) activity.startActivity(intent_) diff --git a/plyer/platforms/android/flash.py b/plyer/platforms/android/flash.py index eec1ae385..b53bcbef2 100644 --- a/plyer/platforms/android/flash.py +++ b/plyer/platforms/android/flash.py @@ -6,7 +6,7 @@ from plyer.facades import Flash from jnius import autoclass -from plyer.platforms.android import activity +from plyer.platforms.android import activity, require_permissions Camera = autoclass("android.hardware.Camera") CameraParameters = autoclass("android.hardware.Camera$Parameters") @@ -38,6 +38,7 @@ def _release(self): self._camera.release() self._camera = None + @require_permissions("CAMERA", "FLASHLIGHT") def _camera_open(self): if not flash_available: return diff --git a/plyer/platforms/android/gps.py b/plyer/platforms/android/gps.py index 17fd86e97..ba07721eb 100644 --- a/plyer/platforms/android/gps.py +++ b/plyer/platforms/android/gps.py @@ -4,7 +4,9 @@ ''' from plyer.facades import GPS -from plyer.platforms.android import activity +from plyer.platforms.android import activity, require_permissions +from android.permissions import Permission + from jnius import autoclass, java_method, PythonJavaClass Looper = autoclass('android.os.Looper') @@ -62,6 +64,7 @@ def _configure(self): ) self._location_listener = _LocationListener(self) + @require_permissions("ACCESS_COARSE_LOCATION", "ACCESS_FINE_LOCATION") def _start(self, **kwargs): min_time = kwargs.get('minTime') min_distance = kwargs.get('minDistance') diff --git a/plyer/platforms/android/sms.py b/plyer/platforms/android/sms.py index 8650968cf..43e1302d5 100644 --- a/plyer/platforms/android/sms.py +++ b/plyer/platforms/android/sms.py @@ -5,12 +5,14 @@ from jnius import autoclass from plyer.facades import Sms +from plyer.platform.android import require_permissions SmsManager = autoclass('android.telephony.SmsManager') class AndroidSms(Sms): + @require_permissions("SEND_SMS") def _send(self, **kwargs): sms = SmsManager.getDefault() diff --git a/plyer/platforms/android/stt.py b/plyer/platforms/android/stt.py index 51681e049..be83f05bb 100644 --- a/plyer/platforms/android/stt.py +++ b/plyer/platforms/android/stt.py @@ -5,7 +5,7 @@ from jnius import PythonJavaClass from plyer.facades import STT -from plyer.platforms.android import activity +from plyer.platforms.android import activity, require_permission ArrayList = autoclass('java.util.ArrayList') Bundle = autoclass('android.os.Bundle') @@ -197,6 +197,7 @@ def _on_partial(self, messages): self.partial_results.extend(messages) @run_on_ui_thread + @require_permission("RECORD_AUDIO") def _start(self): intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) intent.putExtra( diff --git a/plyer/platforms/android/vibrator.py b/plyer/platforms/android/vibrator.py index a318c3c43..a414ee29e 100644 --- a/plyer/platforms/android/vibrator.py +++ b/plyer/platforms/android/vibrator.py @@ -2,8 +2,7 @@ from jnius import autoclass, cast from plyer.facades import Vibrator -from plyer.platforms.android import activity -from plyer.platforms.android import SDK_INT +from plyer.platforms.android import activity, SDK_INT, require_permission Context = autoclass("android.content.Context") vibrator_service = activity.getSystemService(Context.VIBRATOR_SERVICE) @@ -22,6 +21,7 @@ class AndroidVibrator(Vibrator): * check whether Vibrator exists. """ + @require_permissions("VIBRATE") def _vibrate(self, time=None, **kwargs): if vibrator: if SDK_INT >= 26: @@ -33,6 +33,7 @@ def _vibrate(self, time=None, **kwargs): else: vibrator.vibrate(int(1000 * time)) + @require_permissions("VIBRATE") def _pattern(self, pattern=None, repeat=None, **kwargs): pattern = [int(1000 * time) for time in pattern] From d690755739dcc7880160cc31d7722c7b4a5a66f6 Mon Sep 17 00:00:00 2001 From: Gabriel Pettier Date: Wed, 16 Jun 2021 01:15:49 +0200 Subject: [PATCH 2/2] Slight improve to documentation, and add note about android permissions + some cleanup --- docs/source/index.rst | 33 +++++++++++++++++++++++++++++++ plyer/platforms/android/camera.py | 1 - plyer/platforms/android/stt.py | 4 ++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 20ae9d6f9..41d799a6e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,12 +8,45 @@ Welcome to Plyer Plyer is a Python library for accessing features of your hardware / platforms. +Each feature is defined by a facade, and provided by platform specific +implementations, they are used by importing them directly from the `plyer` +package. + +For example, to get an implementation of the `gps` facade, and start it you can do: + +```python +from plyer import gps + +gps.start() +``` + +Please consult the :mod:`plyer.facades` documentation for the available methods. + +.. note:: + + Android manage permissions at runtime, and in granular way. Each feature + can require one or multiple permissions. Plyer will try to ask for the + necessary permissions the moment they are needed, but they still need to be + declared at compile time through python-for-android command line, or in + buildozer.spec. + + Also, there are implications to requesting a permission, as it will briefly + pause your application. For this reason, it's advised to avoid: + - starting a plyer feature that require permissions before the app is done + starting + - calling multiple features that require different permissions in the same + frame, unless you previously requested all the necessary permissions. + + If needed, you can normally import the `android` module to manually request + permissions. Make sure this import is only done when running on Android. + .. automodule:: plyer :members: .. automodule:: plyer.facades :members: + Indices and tables ================== diff --git a/plyer/platforms/android/camera.py b/plyer/platforms/android/camera.py index be259aa01..e1e83e0b3 100644 --- a/plyer/platforms/android/camera.py +++ b/plyer/platforms/android/camera.py @@ -6,7 +6,6 @@ from plyer.platforms.android import activity Intent = autoclass('android.content.Intent') -PythonActivity = autoclass('org.renpy.android.PythonActivity') MediaStore = autoclass('android.provider.MediaStore') Uri = autoclass('android.net.Uri') diff --git a/plyer/platforms/android/stt.py b/plyer/platforms/android/stt.py index be83f05bb..18bedb691 100644 --- a/plyer/platforms/android/stt.py +++ b/plyer/platforms/android/stt.py @@ -5,7 +5,7 @@ from jnius import PythonJavaClass from plyer.facades import STT -from plyer.platforms.android import activity, require_permission +from plyer.platforms.android import activity, require_permissions ArrayList = autoclass('java.util.ArrayList') Bundle = autoclass('android.os.Bundle') @@ -197,7 +197,7 @@ def _on_partial(self, messages): self.partial_results.extend(messages) @run_on_ui_thread - @require_permission("RECORD_AUDIO") + @require_permissions("RECORD_AUDIO") def _start(self): intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) intent.putExtra(