Mercurial > libervia-desktop-kivy
diff libervia/desktop_kivy/core/platform_/android.py @ 493:b3cedbee561d
refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 18:26:16 +0200 |
parents | cagou/core/platform_/android.py@203755bbe0fe |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/desktop_kivy/core/platform_/android.py Fri Jun 02 18:26:16 2023 +0200 @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 + +#Libervia Desktop-Kivy +# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys +import os +import socket +import json +from functools import partial +from urllib.parse import urlparse +from pathlib import Path +import shutil +import mimetypes +from jnius import autoclass, cast, JavaException +from android import activity +from android.permissions import request_permissions, Permission +from kivy.clock import Clock +from kivy.uix.label import Label +from libervia.backend.core.i18n import _ +from libervia.backend.core import log as logging +from libervia.backend.tools.common import data_format +from libervia.frontends.tools import jid +from libervia.desktop_kivy.core.constants import Const as C +from libervia.desktop_kivy.core import dialog +from libervia.desktop_kivy import G +from .base import Platform as BasePlatform + + +log = logging.getLogger(__name__) + +# permission that are necessary to have LiberviaDesktopKivy running properly +PERMISSION_MANDATORY = [ + Permission.READ_EXTERNAL_STORAGE, + Permission.WRITE_EXTERNAL_STORAGE, +] + +service = autoclass('org.libervia.libervia.desktop_kivy.ServiceBackend') +PythonActivity = autoclass('org.kivy.android.PythonActivity') +mActivity = PythonActivity.mActivity +Intent = autoclass('android.content.Intent') +AndroidString = autoclass('java.lang.String') +Uri = autoclass('android.net.Uri') +ImagesMedia = autoclass('android.provider.MediaStore$Images$Media') +AudioMedia = autoclass('android.provider.MediaStore$Audio$Media') +VideoMedia = autoclass('android.provider.MediaStore$Video$Media') +URLConnection = autoclass('java.net.URLConnection') + +DISPLAY_NAME = '_display_name' +DATA = '_data' + + +STATE_RUNNING = b"running" +STATE_PAUSED = b"paused" +STATE_STOPPED = b"stopped" +SOCKET_DIR = "/data/data/org.libervia.cagou/" +SOCKET_FILE = ".socket" +INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction") + + +class Platform(BasePlatform): + send_button_visible = True + + def __init__(self): + super().__init__() + # cache for callbacks to run when profile is plugged + self.cache = [] + + def init_platform(self): + # sys.platform is "linux" on android by default + # so we change it to allow backend to detect android + sys.platform = "android" + C.PLUGIN_EXT = 'pyc' + + def on_host_init(self, host): + argument = '' + service.start(mActivity, argument) + + activity.bind(on_new_intent=self.on_new_intent) + self.cache.append((self.on_new_intent, mActivity.getIntent())) + self.last_selected_wid = None + self.restore_selected_wid = True + host.addListener('profile_plugged', self.on_profile_plugged) + host.addListener('selected', self.on_selected_widget) + local_dir = Path(host.config_get('', 'local_dir')).resolve() + self.tmp_dir = local_dir / 'tmp' + # we assert to avoid disaster if `/ 'tmp'` is removed by mistake on the line + # above + assert self.tmp_dir.resolve() != local_dir + # we reset tmp dir on each run, to be sure that there is no residual file + if self.tmp_dir.exists(): + shutil.rmtree(self.tmp_dir) + self.tmp_dir.mkdir(0o700, parents=True) + + def on_init_frontend_state(self): + # XXX: we use a separated socket instead of bridge because if we + # try to call a bridge method in on_pause method, the call data + # is not written before the actual pause + s = self._frontend_status_socket = socket.socket( + socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(os.path.join(SOCKET_DIR, SOCKET_FILE)) + s.sendall(STATE_RUNNING) + + def profile_autoconnect_get_cb(self, profile=None): + if profile is not None: + G.host.options.profile = profile + G.host.post_init() + + def profile_autoconnect_get_eb(self, failure_): + log.error(f"Error while getting profile to autoconnect: {failure_}") + G.host.post_init() + + def _show_perm_warning(self, permissions): + root_wid = G.host.app.root + perm_warning = Label( + size_hint=(1, 1), + text_size=(root_wid.width, root_wid.height), + font_size='22sp', + bold=True, + color=(0.67, 0, 0, 1), + halign='center', + valign='center', + text=_( + "Requested permissions are mandatory to run LiberviaDesktopKivy, if you don't " + "accept them, LiberviaDesktopKivy can't run properly. Please accept following " + "permissions, or set them in Android settings for LiberviaDesktopKivy:\n" + "{permissions}\n\nLiberviaDesktopKivy will be closed in 20 s").format( + permissions='\n'.join(p.split('.')[-1] for p in permissions))) + root_wid.clear_widgets() + root_wid.add_widget(perm_warning) + Clock.schedule_once(lambda *args: G.host.app.stop(), 20) + + def permission_cb(self, permissions, grant_results): + if not all(grant_results): + # we keep asking until they are accepted, as we can't run properly + # without them + # TODO: a message explaining why permission is needed should be printed + # TODO: the storage permission is mainly used to set download_dir, we should + # be able to run LiberviaDesktopKivy without it. + if not hasattr(self, 'perms_counter'): + self.perms_counter = 0 + self.perms_counter += 1 + if self.perms_counter > 5: + Clock.schedule_once( + lambda *args: self._show_perm_warning(permissions), + 0) + return + + perm_dict = dict(zip(permissions, grant_results)) + log.warning( + f"not all mandatory permissions are granted, requesting again: " + f"{perm_dict}") + request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) + return + + Clock.schedule_once(lambda *args: G.host.bridge.profile_autoconnect_get( + callback=self.profile_autoconnect_get_cb, + errback=self.profile_autoconnect_get_eb), + 0) + + def do_post_init(self): + request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) + return False + + def private_data_get_cb(self, data_s, profile): + data = data_format.deserialise(data_s, type_check=None) + if data is not None and self.restore_selected_wid: + log.debug(f"restoring previous widget {data}") + try: + name = data['name'] + target = data['target'] + except KeyError as e: + log.error(f"Bad data format for selected widget: {e}\ndata={data}") + return + if target: + target = jid.JID(data['target']) + plugin_info = G.host.get_plugin_info(name=name) + if plugin_info is None: + log.warning("Can't restore unknown plugin: {name}") + return + factory = plugin_info['factory'] + G.host.switch_widget( + None, + factory(plugin_info, target=target, profiles=[profile]) + ) + + def on_profile_plugged(self, profile): + log.debug("ANDROID profile_plugged") + G.host.bridge.param_set( + "autoconnect_backend", C.BOOL_TRUE, "Connection", -1, profile, + callback=lambda: log.info(f"profile {profile} autoconnection set"), + errback=lambda: log.error(f"can't set {profile} autoconnection")) + for method, *args in self.cache: + method(*args) + del self.cache + G.host.removeListener("profile_plugged", self.on_profile_plugged) + # we restore the stored widget if any + # user will then go back to where they was when the frontend was closed + G.host.bridge.private_data_get( + "cagou", "selected_widget", profile, + callback=partial(self.private_data_get_cb, profile=profile), + errback=partial( + G.host.errback, + title=_("can't get selected widget"), + message=_("error while retrieving selected widget: {msg}")) + ) + + def on_selected_widget(self, wid): + """Store selected widget in backend, to restore it on next startup""" + if self.last_selected_wid == None: + self.last_selected_wid = wid + # we skip the first selected widget, as we'll restore stored one if possible + return + + self.last_selected_wid = wid + + try: + plugin_info = wid.plugin_info + except AttributeError: + log.warning(f"No plugin info found for {wid}, can't store selected widget") + return + + try: + profile = next(iter(wid.profiles)) + except (AttributeError, StopIteration): + profile = None + + if profile is None: + try: + profile = next(iter(G.host.profiles)) + except StopIteration: + log.debug("No profile plugged yet, can't store selected widget") + return + try: + target = wid.target + except AttributeError: + target = None + + data = { + "name": plugin_info["name"], + "target": target, + } + + G.host.bridge.private_data_set( + "cagou", "selected_widget", data_format.serialise(data), profile, + errback=partial( + G.host.errback, + title=_("can set selected widget"), + message=_("error while setting selected widget: {msg}")) + ) + + def on_pause(self): + G.host.sync = False + self._frontend_status_socket.sendall(STATE_PAUSED) + return True + + def on_resume(self): + self._frontend_status_socket.sendall(STATE_RUNNING) + G.host.sync = True + + def on_stop(self): + self._frontend_status_socket.sendall(STATE_STOPPED) + self._frontend_status_socket.close() + + def on_key_back_root(self): + PythonActivity.moveTaskToBack(True) + return True + + def on_key_back_share(self, share_widget): + share_widget.close() + PythonActivity.moveTaskToBack(True) + return True + + def _disconnect(self, profile): + G.host.bridge.param_set( + "autoconnect_backend", C.BOOL_FALSE, "Connection", -1, profile, + callback=lambda: log.info(f"profile {profile} autoconnection unset"), + errback=lambda: log.error(f"can't unset {profile} autoconnection")) + G.host.profiles.unplug(profile) + G.host.bridge.disconnect(profile) + G.host.app.show_profile_manager() + G.host.close_ui() + + def _on_disconnect(self): + current_profile = next(iter(G.host.profiles)) + wid = dialog.ConfirmDialog( + title=_("Are you sure to disconnect?"), + message=_( + "If you disconnect the current user ({profile}), you won't receive " + "any notification until you connect it again, is this really what you " + "want?").format(profile=current_profile), + yes_cb=partial(self._disconnect, profile=current_profile), + no_cb=G.host.close_ui, + ) + G.host.show_extra_ui(wid) + + def on_extra_menu_init(self, extra_menu): + extra_menu.add_item(_('disconnect'), self._on_disconnect) + + def update_params_extra(self, extra): + # on Android, we handle autoconnection automatically, + # user must not modify those parameters + extra.update( + { + "ignore": [ + ["Connection", "autoconnect_backend"], + ["Connection", "autoconnect"], + ["Connection", "autodisconnect"], + ], + } + ) + + def get_col_data_from_uri(self, uri, col_name): + cursor = mActivity.getContentResolver().query(uri, None, None, None, None) + if cursor is None: + return None + try: + cursor.moveToFirst() + col_idx = cursor.getColumnIndex(col_name); + if col_idx == -1: + return None + return cursor.getString(col_idx) + finally: + cursor.close() + + def get_filename_from_uri(self, uri, media_type): + filename = self.get_col_data_from_uri(uri, DISPLAY_NAME) + if filename is None: + uri_p = Path(uri.toString()) + filename = uri_p.name or "unnamed" + if not uri_p.suffix and media_type: + suffix = mimetypes.guess_extension(media_type, strict=False) + if suffix: + filename = filename + suffix + return filename + + def get_path_from_uri(self, uri): + # FIXME: using DATA is not recommended (and DATA is deprecated) + # we should read directly the file with + # ContentResolver#openFileDescriptor(Uri, String) + path = self.get_col_data_from_uri(uri, DATA) + return uri.getPath() if path is None else path + + def on_new_intent(self, intent): + log.debug("on_new_intent") + action = intent.getAction(); + intent_type = intent.getType(); + if action == Intent.ACTION_MAIN: + action_str = intent.getStringExtra(INTENT_EXTRA_ACTION) + if action_str is not None: + action = json.loads(action_str) + log.debug(f"Extra action found: {action}") + action_type = action.get('type') + if action_type == "open": + try: + widget = action['widget'] + target = action['target'] + except KeyError as e: + log.warning(f"incomplete action {action}: {e}") + else: + # we don't want stored selected widget to be displayed after this + # one + log.debug("cancelling restoration of previous widget") + self.restore_selected_wid = False + # and now we open the widget linked to the intent + current_profile = next(iter(G.host.profiles)) + Clock.schedule_once( + lambda *args: G.host.do_action( + widget, jid.JID(target), [current_profile]), + 0) + else: + log.warning(f"unexpected action: {action}") + + text = None + uri = None + path = None + elif action == Intent.ACTION_SEND: + # we have receiving data to share, we parse the intent data + # and show the share widget + data = {} + text = intent.getStringExtra(Intent.EXTRA_TEXT) + if text is not None: + data['text'] = text + + item = intent.getParcelableExtra(Intent.EXTRA_STREAM) + if item is not None: + uri = cast('android.net.Uri', item) + if uri.getScheme() == 'content': + # Android content, we'll dump it to a temporary file + filename = self.get_filename_from_uri(uri, intent_type) + filepath = self.tmp_dir / filename + input_stream = mActivity.getContentResolver().openInputStream(uri) + buff = bytearray(4096) + with open(filepath, 'wb') as f: + while True: + ret = input_stream.read(buff, 0, 4096) + if ret != -1: + f.write(buff[:ret]) + else: + break + input_stream.close() + data['path'] = path = str(filepath) + else: + data['uri'] = uri.toString() + path = self.get_path_from_uri(uri) + if path is not None and path not in data: + data['path'] = path + else: + uri = None + path = None + + + Clock.schedule_once(lambda *args: G.host.share(intent_type, data), 0) + else: + text = None + uri = None + path = None + + msg = (f"NEW INTENT RECEIVED\n" + f"type: {intent_type}\n" + f"action: {action}\n" + f"text: {text}\n" + f"uri: {uri}\n" + f"path: {path}") + + log.debug(msg) + + def check_plugin_permissions(self, plug_info, callback, errback): + perms = plug_info.get("android_permissons") + if not perms: + callback() + return + perms = [f"android.permission.{p}" if '.' not in p else p for p in perms] + + def request_permissions_cb(permissions, granted): + if all(granted): + Clock.schedule_once(lambda *args: callback()) + else: + Clock.schedule_once(lambda *args: errback()) + + request_permissions(perms, callback=request_permissions_cb) + + def open_url(self, url, wid=None): + parsed_url = urlparse(url) + if parsed_url.scheme == "aesgcm": + return super().open_url(url, wid) + else: + media_type = mimetypes.guess_type(url, strict=False)[0] + if media_type is None: + log.debug( + f"media_type for {url!r} not found with python mimetypes, trying " + f"guessContentTypeFromName") + media_type = URLConnection.guessContentTypeFromName(url) + intent = Intent(Intent.ACTION_VIEW) + if media_type is not None: + log.debug(f"file {url!r} is of type {media_type}") + intent.setDataAndType(Uri.parse(url), media_type) + else: + log.debug(f"can't guess media type for {url!r}") + intent.setData(Uri.parse(url)) + if mActivity.getPackageManager() is not None: + activity = cast('android.app.Activity', mActivity) + try: + activity.startActivity(intent) + except JavaException as e: + if e.classname != "android.content.ActivityNotFoundException": + raise e + log.debug( + f"activity not found for url {url!r}, we'll try generic opener") + else: + return + + # if nothing else worked, we default to base open_url + super().open_url(url, wid)