Mercurial > libervia-desktop-kivy
comparison 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 |
comparison
equal
deleted
inserted
replaced
492:5114bbb5daa3 | 493:b3cedbee561d |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 #Libervia Desktop-Kivy | |
4 # Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 import sys | |
20 import os | |
21 import socket | |
22 import json | |
23 from functools import partial | |
24 from urllib.parse import urlparse | |
25 from pathlib import Path | |
26 import shutil | |
27 import mimetypes | |
28 from jnius import autoclass, cast, JavaException | |
29 from android import activity | |
30 from android.permissions import request_permissions, Permission | |
31 from kivy.clock import Clock | |
32 from kivy.uix.label import Label | |
33 from libervia.backend.core.i18n import _ | |
34 from libervia.backend.core import log as logging | |
35 from libervia.backend.tools.common import data_format | |
36 from libervia.frontends.tools import jid | |
37 from libervia.desktop_kivy.core.constants import Const as C | |
38 from libervia.desktop_kivy.core import dialog | |
39 from libervia.desktop_kivy import G | |
40 from .base import Platform as BasePlatform | |
41 | |
42 | |
43 log = logging.getLogger(__name__) | |
44 | |
45 # permission that are necessary to have LiberviaDesktopKivy running properly | |
46 PERMISSION_MANDATORY = [ | |
47 Permission.READ_EXTERNAL_STORAGE, | |
48 Permission.WRITE_EXTERNAL_STORAGE, | |
49 ] | |
50 | |
51 service = autoclass('org.libervia.libervia.desktop_kivy.ServiceBackend') | |
52 PythonActivity = autoclass('org.kivy.android.PythonActivity') | |
53 mActivity = PythonActivity.mActivity | |
54 Intent = autoclass('android.content.Intent') | |
55 AndroidString = autoclass('java.lang.String') | |
56 Uri = autoclass('android.net.Uri') | |
57 ImagesMedia = autoclass('android.provider.MediaStore$Images$Media') | |
58 AudioMedia = autoclass('android.provider.MediaStore$Audio$Media') | |
59 VideoMedia = autoclass('android.provider.MediaStore$Video$Media') | |
60 URLConnection = autoclass('java.net.URLConnection') | |
61 | |
62 DISPLAY_NAME = '_display_name' | |
63 DATA = '_data' | |
64 | |
65 | |
66 STATE_RUNNING = b"running" | |
67 STATE_PAUSED = b"paused" | |
68 STATE_STOPPED = b"stopped" | |
69 SOCKET_DIR = "/data/data/org.libervia.cagou/" | |
70 SOCKET_FILE = ".socket" | |
71 INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction") | |
72 | |
73 | |
74 class Platform(BasePlatform): | |
75 send_button_visible = True | |
76 | |
77 def __init__(self): | |
78 super().__init__() | |
79 # cache for callbacks to run when profile is plugged | |
80 self.cache = [] | |
81 | |
82 def init_platform(self): | |
83 # sys.platform is "linux" on android by default | |
84 # so we change it to allow backend to detect android | |
85 sys.platform = "android" | |
86 C.PLUGIN_EXT = 'pyc' | |
87 | |
88 def on_host_init(self, host): | |
89 argument = '' | |
90 service.start(mActivity, argument) | |
91 | |
92 activity.bind(on_new_intent=self.on_new_intent) | |
93 self.cache.append((self.on_new_intent, mActivity.getIntent())) | |
94 self.last_selected_wid = None | |
95 self.restore_selected_wid = True | |
96 host.addListener('profile_plugged', self.on_profile_plugged) | |
97 host.addListener('selected', self.on_selected_widget) | |
98 local_dir = Path(host.config_get('', 'local_dir')).resolve() | |
99 self.tmp_dir = local_dir / 'tmp' | |
100 # we assert to avoid disaster if `/ 'tmp'` is removed by mistake on the line | |
101 # above | |
102 assert self.tmp_dir.resolve() != local_dir | |
103 # we reset tmp dir on each run, to be sure that there is no residual file | |
104 if self.tmp_dir.exists(): | |
105 shutil.rmtree(self.tmp_dir) | |
106 self.tmp_dir.mkdir(0o700, parents=True) | |
107 | |
108 def on_init_frontend_state(self): | |
109 # XXX: we use a separated socket instead of bridge because if we | |
110 # try to call a bridge method in on_pause method, the call data | |
111 # is not written before the actual pause | |
112 s = self._frontend_status_socket = socket.socket( | |
113 socket.AF_UNIX, socket.SOCK_STREAM) | |
114 s.connect(os.path.join(SOCKET_DIR, SOCKET_FILE)) | |
115 s.sendall(STATE_RUNNING) | |
116 | |
117 def profile_autoconnect_get_cb(self, profile=None): | |
118 if profile is not None: | |
119 G.host.options.profile = profile | |
120 G.host.post_init() | |
121 | |
122 def profile_autoconnect_get_eb(self, failure_): | |
123 log.error(f"Error while getting profile to autoconnect: {failure_}") | |
124 G.host.post_init() | |
125 | |
126 def _show_perm_warning(self, permissions): | |
127 root_wid = G.host.app.root | |
128 perm_warning = Label( | |
129 size_hint=(1, 1), | |
130 text_size=(root_wid.width, root_wid.height), | |
131 font_size='22sp', | |
132 bold=True, | |
133 color=(0.67, 0, 0, 1), | |
134 halign='center', | |
135 valign='center', | |
136 text=_( | |
137 "Requested permissions are mandatory to run LiberviaDesktopKivy, if you don't " | |
138 "accept them, LiberviaDesktopKivy can't run properly. Please accept following " | |
139 "permissions, or set them in Android settings for LiberviaDesktopKivy:\n" | |
140 "{permissions}\n\nLiberviaDesktopKivy will be closed in 20 s").format( | |
141 permissions='\n'.join(p.split('.')[-1] for p in permissions))) | |
142 root_wid.clear_widgets() | |
143 root_wid.add_widget(perm_warning) | |
144 Clock.schedule_once(lambda *args: G.host.app.stop(), 20) | |
145 | |
146 def permission_cb(self, permissions, grant_results): | |
147 if not all(grant_results): | |
148 # we keep asking until they are accepted, as we can't run properly | |
149 # without them | |
150 # TODO: a message explaining why permission is needed should be printed | |
151 # TODO: the storage permission is mainly used to set download_dir, we should | |
152 # be able to run LiberviaDesktopKivy without it. | |
153 if not hasattr(self, 'perms_counter'): | |
154 self.perms_counter = 0 | |
155 self.perms_counter += 1 | |
156 if self.perms_counter > 5: | |
157 Clock.schedule_once( | |
158 lambda *args: self._show_perm_warning(permissions), | |
159 0) | |
160 return | |
161 | |
162 perm_dict = dict(zip(permissions, grant_results)) | |
163 log.warning( | |
164 f"not all mandatory permissions are granted, requesting again: " | |
165 f"{perm_dict}") | |
166 request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) | |
167 return | |
168 | |
169 Clock.schedule_once(lambda *args: G.host.bridge.profile_autoconnect_get( | |
170 callback=self.profile_autoconnect_get_cb, | |
171 errback=self.profile_autoconnect_get_eb), | |
172 0) | |
173 | |
174 def do_post_init(self): | |
175 request_permissions(PERMISSION_MANDATORY, callback=self.permission_cb) | |
176 return False | |
177 | |
178 def private_data_get_cb(self, data_s, profile): | |
179 data = data_format.deserialise(data_s, type_check=None) | |
180 if data is not None and self.restore_selected_wid: | |
181 log.debug(f"restoring previous widget {data}") | |
182 try: | |
183 name = data['name'] | |
184 target = data['target'] | |
185 except KeyError as e: | |
186 log.error(f"Bad data format for selected widget: {e}\ndata={data}") | |
187 return | |
188 if target: | |
189 target = jid.JID(data['target']) | |
190 plugin_info = G.host.get_plugin_info(name=name) | |
191 if plugin_info is None: | |
192 log.warning("Can't restore unknown plugin: {name}") | |
193 return | |
194 factory = plugin_info['factory'] | |
195 G.host.switch_widget( | |
196 None, | |
197 factory(plugin_info, target=target, profiles=[profile]) | |
198 ) | |
199 | |
200 def on_profile_plugged(self, profile): | |
201 log.debug("ANDROID profile_plugged") | |
202 G.host.bridge.param_set( | |
203 "autoconnect_backend", C.BOOL_TRUE, "Connection", -1, profile, | |
204 callback=lambda: log.info(f"profile {profile} autoconnection set"), | |
205 errback=lambda: log.error(f"can't set {profile} autoconnection")) | |
206 for method, *args in self.cache: | |
207 method(*args) | |
208 del self.cache | |
209 G.host.removeListener("profile_plugged", self.on_profile_plugged) | |
210 # we restore the stored widget if any | |
211 # user will then go back to where they was when the frontend was closed | |
212 G.host.bridge.private_data_get( | |
213 "cagou", "selected_widget", profile, | |
214 callback=partial(self.private_data_get_cb, profile=profile), | |
215 errback=partial( | |
216 G.host.errback, | |
217 title=_("can't get selected widget"), | |
218 message=_("error while retrieving selected widget: {msg}")) | |
219 ) | |
220 | |
221 def on_selected_widget(self, wid): | |
222 """Store selected widget in backend, to restore it on next startup""" | |
223 if self.last_selected_wid == None: | |
224 self.last_selected_wid = wid | |
225 # we skip the first selected widget, as we'll restore stored one if possible | |
226 return | |
227 | |
228 self.last_selected_wid = wid | |
229 | |
230 try: | |
231 plugin_info = wid.plugin_info | |
232 except AttributeError: | |
233 log.warning(f"No plugin info found for {wid}, can't store selected widget") | |
234 return | |
235 | |
236 try: | |
237 profile = next(iter(wid.profiles)) | |
238 except (AttributeError, StopIteration): | |
239 profile = None | |
240 | |
241 if profile is None: | |
242 try: | |
243 profile = next(iter(G.host.profiles)) | |
244 except StopIteration: | |
245 log.debug("No profile plugged yet, can't store selected widget") | |
246 return | |
247 try: | |
248 target = wid.target | |
249 except AttributeError: | |
250 target = None | |
251 | |
252 data = { | |
253 "name": plugin_info["name"], | |
254 "target": target, | |
255 } | |
256 | |
257 G.host.bridge.private_data_set( | |
258 "cagou", "selected_widget", data_format.serialise(data), profile, | |
259 errback=partial( | |
260 G.host.errback, | |
261 title=_("can set selected widget"), | |
262 message=_("error while setting selected widget: {msg}")) | |
263 ) | |
264 | |
265 def on_pause(self): | |
266 G.host.sync = False | |
267 self._frontend_status_socket.sendall(STATE_PAUSED) | |
268 return True | |
269 | |
270 def on_resume(self): | |
271 self._frontend_status_socket.sendall(STATE_RUNNING) | |
272 G.host.sync = True | |
273 | |
274 def on_stop(self): | |
275 self._frontend_status_socket.sendall(STATE_STOPPED) | |
276 self._frontend_status_socket.close() | |
277 | |
278 def on_key_back_root(self): | |
279 PythonActivity.moveTaskToBack(True) | |
280 return True | |
281 | |
282 def on_key_back_share(self, share_widget): | |
283 share_widget.close() | |
284 PythonActivity.moveTaskToBack(True) | |
285 return True | |
286 | |
287 def _disconnect(self, profile): | |
288 G.host.bridge.param_set( | |
289 "autoconnect_backend", C.BOOL_FALSE, "Connection", -1, profile, | |
290 callback=lambda: log.info(f"profile {profile} autoconnection unset"), | |
291 errback=lambda: log.error(f"can't unset {profile} autoconnection")) | |
292 G.host.profiles.unplug(profile) | |
293 G.host.bridge.disconnect(profile) | |
294 G.host.app.show_profile_manager() | |
295 G.host.close_ui() | |
296 | |
297 def _on_disconnect(self): | |
298 current_profile = next(iter(G.host.profiles)) | |
299 wid = dialog.ConfirmDialog( | |
300 title=_("Are you sure to disconnect?"), | |
301 message=_( | |
302 "If you disconnect the current user ({profile}), you won't receive " | |
303 "any notification until you connect it again, is this really what you " | |
304 "want?").format(profile=current_profile), | |
305 yes_cb=partial(self._disconnect, profile=current_profile), | |
306 no_cb=G.host.close_ui, | |
307 ) | |
308 G.host.show_extra_ui(wid) | |
309 | |
310 def on_extra_menu_init(self, extra_menu): | |
311 extra_menu.add_item(_('disconnect'), self._on_disconnect) | |
312 | |
313 def update_params_extra(self, extra): | |
314 # on Android, we handle autoconnection automatically, | |
315 # user must not modify those parameters | |
316 extra.update( | |
317 { | |
318 "ignore": [ | |
319 ["Connection", "autoconnect_backend"], | |
320 ["Connection", "autoconnect"], | |
321 ["Connection", "autodisconnect"], | |
322 ], | |
323 } | |
324 ) | |
325 | |
326 def get_col_data_from_uri(self, uri, col_name): | |
327 cursor = mActivity.getContentResolver().query(uri, None, None, None, None) | |
328 if cursor is None: | |
329 return None | |
330 try: | |
331 cursor.moveToFirst() | |
332 col_idx = cursor.getColumnIndex(col_name); | |
333 if col_idx == -1: | |
334 return None | |
335 return cursor.getString(col_idx) | |
336 finally: | |
337 cursor.close() | |
338 | |
339 def get_filename_from_uri(self, uri, media_type): | |
340 filename = self.get_col_data_from_uri(uri, DISPLAY_NAME) | |
341 if filename is None: | |
342 uri_p = Path(uri.toString()) | |
343 filename = uri_p.name or "unnamed" | |
344 if not uri_p.suffix and media_type: | |
345 suffix = mimetypes.guess_extension(media_type, strict=False) | |
346 if suffix: | |
347 filename = filename + suffix | |
348 return filename | |
349 | |
350 def get_path_from_uri(self, uri): | |
351 # FIXME: using DATA is not recommended (and DATA is deprecated) | |
352 # we should read directly the file with | |
353 # ContentResolver#openFileDescriptor(Uri, String) | |
354 path = self.get_col_data_from_uri(uri, DATA) | |
355 return uri.getPath() if path is None else path | |
356 | |
357 def on_new_intent(self, intent): | |
358 log.debug("on_new_intent") | |
359 action = intent.getAction(); | |
360 intent_type = intent.getType(); | |
361 if action == Intent.ACTION_MAIN: | |
362 action_str = intent.getStringExtra(INTENT_EXTRA_ACTION) | |
363 if action_str is not None: | |
364 action = json.loads(action_str) | |
365 log.debug(f"Extra action found: {action}") | |
366 action_type = action.get('type') | |
367 if action_type == "open": | |
368 try: | |
369 widget = action['widget'] | |
370 target = action['target'] | |
371 except KeyError as e: | |
372 log.warning(f"incomplete action {action}: {e}") | |
373 else: | |
374 # we don't want stored selected widget to be displayed after this | |
375 # one | |
376 log.debug("cancelling restoration of previous widget") | |
377 self.restore_selected_wid = False | |
378 # and now we open the widget linked to the intent | |
379 current_profile = next(iter(G.host.profiles)) | |
380 Clock.schedule_once( | |
381 lambda *args: G.host.do_action( | |
382 widget, jid.JID(target), [current_profile]), | |
383 0) | |
384 else: | |
385 log.warning(f"unexpected action: {action}") | |
386 | |
387 text = None | |
388 uri = None | |
389 path = None | |
390 elif action == Intent.ACTION_SEND: | |
391 # we have receiving data to share, we parse the intent data | |
392 # and show the share widget | |
393 data = {} | |
394 text = intent.getStringExtra(Intent.EXTRA_TEXT) | |
395 if text is not None: | |
396 data['text'] = text | |
397 | |
398 item = intent.getParcelableExtra(Intent.EXTRA_STREAM) | |
399 if item is not None: | |
400 uri = cast('android.net.Uri', item) | |
401 if uri.getScheme() == 'content': | |
402 # Android content, we'll dump it to a temporary file | |
403 filename = self.get_filename_from_uri(uri, intent_type) | |
404 filepath = self.tmp_dir / filename | |
405 input_stream = mActivity.getContentResolver().openInputStream(uri) | |
406 buff = bytearray(4096) | |
407 with open(filepath, 'wb') as f: | |
408 while True: | |
409 ret = input_stream.read(buff, 0, 4096) | |
410 if ret != -1: | |
411 f.write(buff[:ret]) | |
412 else: | |
413 break | |
414 input_stream.close() | |
415 data['path'] = path = str(filepath) | |
416 else: | |
417 data['uri'] = uri.toString() | |
418 path = self.get_path_from_uri(uri) | |
419 if path is not None and path not in data: | |
420 data['path'] = path | |
421 else: | |
422 uri = None | |
423 path = None | |
424 | |
425 | |
426 Clock.schedule_once(lambda *args: G.host.share(intent_type, data), 0) | |
427 else: | |
428 text = None | |
429 uri = None | |
430 path = None | |
431 | |
432 msg = (f"NEW INTENT RECEIVED\n" | |
433 f"type: {intent_type}\n" | |
434 f"action: {action}\n" | |
435 f"text: {text}\n" | |
436 f"uri: {uri}\n" | |
437 f"path: {path}") | |
438 | |
439 log.debug(msg) | |
440 | |
441 def check_plugin_permissions(self, plug_info, callback, errback): | |
442 perms = plug_info.get("android_permissons") | |
443 if not perms: | |
444 callback() | |
445 return | |
446 perms = [f"android.permission.{p}" if '.' not in p else p for p in perms] | |
447 | |
448 def request_permissions_cb(permissions, granted): | |
449 if all(granted): | |
450 Clock.schedule_once(lambda *args: callback()) | |
451 else: | |
452 Clock.schedule_once(lambda *args: errback()) | |
453 | |
454 request_permissions(perms, callback=request_permissions_cb) | |
455 | |
456 def open_url(self, url, wid=None): | |
457 parsed_url = urlparse(url) | |
458 if parsed_url.scheme == "aesgcm": | |
459 return super().open_url(url, wid) | |
460 else: | |
461 media_type = mimetypes.guess_type(url, strict=False)[0] | |
462 if media_type is None: | |
463 log.debug( | |
464 f"media_type for {url!r} not found with python mimetypes, trying " | |
465 f"guessContentTypeFromName") | |
466 media_type = URLConnection.guessContentTypeFromName(url) | |
467 intent = Intent(Intent.ACTION_VIEW) | |
468 if media_type is not None: | |
469 log.debug(f"file {url!r} is of type {media_type}") | |
470 intent.setDataAndType(Uri.parse(url), media_type) | |
471 else: | |
472 log.debug(f"can't guess media type for {url!r}") | |
473 intent.setData(Uri.parse(url)) | |
474 if mActivity.getPackageManager() is not None: | |
475 activity = cast('android.app.Activity', mActivity) | |
476 try: | |
477 activity.startActivity(intent) | |
478 except JavaException as e: | |
479 if e.classname != "android.content.ActivityNotFoundException": | |
480 raise e | |
481 log.debug( | |
482 f"activity not found for url {url!r}, we'll try generic opener") | |
483 else: | |
484 return | |
485 | |
486 # if nothing else worked, we default to base open_url | |
487 super().open_url(url, wid) |