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)