Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_misc_android.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_misc_android.py@c23cad65ae99 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # SAT plugin for file tansfer | |
4 # Copyright (C) 2009-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 os.path | |
22 import json | |
23 from pathlib import Path | |
24 from zope.interface import implementer | |
25 from twisted.names import client as dns_client | |
26 from twisted.python.procutils import which | |
27 from twisted.internet import defer | |
28 from twisted.internet import reactor | |
29 from twisted.internet import protocol | |
30 from twisted.internet import abstract | |
31 from twisted.internet import error as int_error | |
32 from twisted.internet import _sslverify | |
33 from libervia.backend.core.i18n import _, D_ | |
34 from libervia.backend.core.constants import Const as C | |
35 from libervia.backend.core.log import getLogger | |
36 from libervia.backend.core import exceptions | |
37 from libervia.backend.tools.common import async_process | |
38 from libervia.backend.memory import params | |
39 | |
40 | |
41 log = getLogger(__name__) | |
42 | |
43 PLUGIN_INFO = { | |
44 C.PI_NAME: "Android", | |
45 C.PI_IMPORT_NAME: "android", | |
46 C.PI_TYPE: C.PLUG_TYPE_MISC, | |
47 C.PI_RECOMMENDATIONS: ["XEP-0352"], | |
48 C.PI_MAIN: "AndroidPlugin", | |
49 C.PI_HANDLER: "no", | |
50 C.PI_DESCRIPTION: D_( | |
51 """Manage Android platform specificities, like pause or notifications""" | |
52 ), | |
53 } | |
54 | |
55 if sys.platform != "android": | |
56 raise exceptions.CancelError("this module is not needed on this platform") | |
57 | |
58 | |
59 import re | |
60 import certifi | |
61 from plyer import vibrator | |
62 from android import api_version | |
63 from plyer.platforms.android import activity | |
64 from plyer.platforms.android.notification import AndroidNotification | |
65 from jnius import autoclass | |
66 from android.broadcast import BroadcastReceiver | |
67 from android import python_act | |
68 | |
69 | |
70 Context = autoclass('android.content.Context') | |
71 ConnectivityManager = autoclass('android.net.ConnectivityManager') | |
72 MediaPlayer = autoclass('android.media.MediaPlayer') | |
73 AudioManager = autoclass('android.media.AudioManager') | |
74 | |
75 # notifications | |
76 AndroidString = autoclass('java.lang.String') | |
77 PendingIntent = autoclass('android.app.PendingIntent') | |
78 Intent = autoclass('android.content.Intent') | |
79 | |
80 # DNS | |
81 # regex to find dns server prop with "getprop" | |
82 RE_DNS = re.compile(r"^\[net\.[a-z0-9]+\.dns[0-4]\]: \[(.*)\]$", re.MULTILINE) | |
83 SystemProperties = autoclass('android.os.SystemProperties') | |
84 | |
85 #: delay between a pause event and sending the inactive indication to server, in seconds | |
86 #: we don't send the indication immediately because user can be just checking something | |
87 #: quickly on an other app. | |
88 CSI_DELAY = 30 | |
89 | |
90 PARAM_RING_CATEGORY = "Notifications" | |
91 PARAM_RING_NAME = "sound" | |
92 PARAM_RING_LABEL = D_("sound on notifications") | |
93 RING_OPTS = { | |
94 "normal": D_("Normal"), | |
95 "never": D_("Never"), | |
96 } | |
97 PARAM_VIBRATE_CATEGORY = "Notifications" | |
98 PARAM_VIBRATE_NAME = "vibrate" | |
99 PARAM_VIBRATE_LABEL = D_("Vibrate on notifications") | |
100 VIBRATION_OPTS = { | |
101 "always": D_("Always"), | |
102 "vibrate": D_("In vibrate mode"), | |
103 "never": D_("Never"), | |
104 } | |
105 SOCKET_DIR = "/data/data/org.libervia.cagou/" | |
106 SOCKET_FILE = ".socket" | |
107 STATE_RUNNING = b"running" | |
108 STATE_PAUSED = b"paused" | |
109 STATE_STOPPED = b"stopped" | |
110 STATES = (STATE_RUNNING, STATE_PAUSED, STATE_STOPPED) | |
111 NET_TYPE_NONE = "no network" | |
112 NET_TYPE_WIFI = "wifi" | |
113 NET_TYPE_MOBILE = "mobile" | |
114 NET_TYPE_OTHER = "other" | |
115 INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction") | |
116 | |
117 | |
118 @implementer(_sslverify.IOpenSSLTrustRoot) | |
119 class AndroidTrustPaths: | |
120 | |
121 def _addCACertsToContext(self, context): | |
122 # twisted doesn't have access to Android root certificates | |
123 # we use certifi to work around that (same thing is done in Kivy) | |
124 context.load_verify_locations(certifi.where()) | |
125 | |
126 | |
127 def platformTrust(): | |
128 return AndroidTrustPaths() | |
129 | |
130 | |
131 class Notification(AndroidNotification): | |
132 # We extend plyer's AndroidNotification instead of creating directly with jnius | |
133 # because it already handles issues like backward compatibility, and we just want to | |
134 # slightly modify the behaviour. | |
135 | |
136 @staticmethod | |
137 def _set_open_behavior(notification, sat_action): | |
138 # we reproduce plyer's AndroidNotification._set_open_behavior | |
139 # bu we add SàT specific extra action data | |
140 | |
141 app_context = activity.getApplication().getApplicationContext() | |
142 notification_intent = Intent(app_context, python_act) | |
143 | |
144 notification_intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) | |
145 notification_intent.setAction(Intent.ACTION_MAIN) | |
146 notification_intent.add_category(Intent.CATEGORY_LAUNCHER) | |
147 if sat_action is not None: | |
148 action_data = AndroidString(json.dumps(sat_action).encode()) | |
149 log.debug(f"adding extra {INTENT_EXTRA_ACTION} ==> {action_data}") | |
150 notification_intent = notification_intent.putExtra( | |
151 INTENT_EXTRA_ACTION, action_data) | |
152 | |
153 # we use PendingIntent.FLAG_UPDATE_CURRENT here, otherwise extra won't be set | |
154 # in the new intent (the old ACTION_MAIN intent will be reused). This differs | |
155 # from plyers original behaviour which set no flag here | |
156 pending_intent = PendingIntent.getActivity( | |
157 app_context, 0, notification_intent, PendingIntent.FLAG_UPDATE_CURRENT | |
158 ) | |
159 | |
160 notification.setContentIntent(pending_intent) | |
161 notification.setAutoCancel(True) | |
162 | |
163 def _notify(self, **kwargs): | |
164 # we reproduce plyer's AndroidNotification._notify behaviour here | |
165 # and we add handling of "sat_action" attribute (SàT specific). | |
166 # we also set, where suitable, default values to empty string instead of | |
167 # original None, as a string is expected (in plyer the empty string is used | |
168 # in the generic "notify" method). | |
169 sat_action = kwargs.pop("sat_action", None) | |
170 noti = None | |
171 message = kwargs.get('message', '').encode('utf-8') | |
172 ticker = kwargs.get('ticker', '').encode('utf-8') | |
173 title = AndroidString( | |
174 kwargs.get('title', '').encode('utf-8') | |
175 ) | |
176 icon = kwargs.get('app_icon', '') | |
177 | |
178 if kwargs.get('toast', False): | |
179 self._toast(message) | |
180 return | |
181 else: | |
182 noti = self._build_notification(title) | |
183 | |
184 noti.setContentTitle(title) | |
185 noti.setContentText(AndroidString(message)) | |
186 noti.setTicker(AndroidString(ticker)) | |
187 | |
188 self._set_icons(noti, icon=icon) | |
189 self._set_open_behavior(noti, sat_action) | |
190 | |
191 self._open_notification(noti) | |
192 | |
193 | |
194 class FrontendStateProtocol(protocol.Protocol): | |
195 | |
196 def __init__(self, android_plugin): | |
197 self.android_plugin = android_plugin | |
198 | |
199 def dataReceived(self, data): | |
200 if data in STATES: | |
201 self.android_plugin.state = data | |
202 else: | |
203 log.warning("Unexpected data: {data}".format(data=data)) | |
204 | |
205 | |
206 class FrontendStateFactory(protocol.Factory): | |
207 | |
208 def __init__(self, android_plugin): | |
209 self.android_plugin = android_plugin | |
210 | |
211 def buildProtocol(self, addr): | |
212 return FrontendStateProtocol(self.android_plugin) | |
213 | |
214 | |
215 | |
216 class AndroidPlugin(object): | |
217 | |
218 params = """ | |
219 <params> | |
220 <individual> | |
221 <category name="{category_name}" label="{category_label}"> | |
222 <param name="{ring_param_name}" label="{ring_param_label}" type="list" security="0"> | |
223 {ring_options} | |
224 </param> | |
225 <param name="{vibrate_param_name}" label="{vibrate_param_label}" type="list" security="0"> | |
226 {vibrate_options} | |
227 </param> | |
228 </category> | |
229 </individual> | |
230 </params> | |
231 """.format( | |
232 category_name=PARAM_VIBRATE_CATEGORY, | |
233 category_label=D_(PARAM_VIBRATE_CATEGORY), | |
234 vibrate_param_name=PARAM_VIBRATE_NAME, | |
235 vibrate_param_label=PARAM_VIBRATE_LABEL, | |
236 vibrate_options=params.make_options(VIBRATION_OPTS, "always"), | |
237 ring_param_name=PARAM_RING_NAME, | |
238 ring_param_label=PARAM_RING_LABEL, | |
239 ring_options=params.make_options(RING_OPTS, "normal"), | |
240 ) | |
241 | |
242 def __init__(self, host): | |
243 log.info(_("plugin Android initialization")) | |
244 log.info(f"using Android API {api_version}") | |
245 self.host = host | |
246 self._csi = host.plugins.get('XEP-0352') | |
247 self._csi_timer = None | |
248 host.memory.update_params(self.params) | |
249 try: | |
250 os.mkdir(SOCKET_DIR, 0o700) | |
251 except OSError as e: | |
252 if e.errno == 17: | |
253 # dir already exists | |
254 pass | |
255 else: | |
256 raise e | |
257 self._state = None | |
258 factory = FrontendStateFactory(self) | |
259 socket_path = os.path.join(SOCKET_DIR, SOCKET_FILE) | |
260 try: | |
261 reactor.listenUNIX(socket_path, factory) | |
262 except int_error.CannotListenError as e: | |
263 if e.socketError.errno == 98: | |
264 # the address is already in use, we need to remove it | |
265 os.unlink(socket_path) | |
266 reactor.listenUNIX(socket_path, factory) | |
267 else: | |
268 raise e | |
269 # we set a low priority because we want the notification to be sent after all | |
270 # plugins have done their job | |
271 host.trigger.add("message_received", self.message_received_trigger, priority=-1000) | |
272 | |
273 # profiles autoconnection | |
274 host.bridge.add_method( | |
275 "profile_autoconnect_get", | |
276 ".plugin", | |
277 in_sign="", | |
278 out_sign="s", | |
279 method=self._profile_autoconnect_get, | |
280 async_=True, | |
281 ) | |
282 | |
283 # audio manager, to get ring status | |
284 self.am = activity.getSystemService(Context.AUDIO_SERVICE) | |
285 | |
286 # sound notification | |
287 media_dir = Path(host.memory.config_get("", "media_dir")) | |
288 assert media_dir is not None | |
289 notif_path = media_dir / "sounds" / "notifications" / "music-box.mp3" | |
290 self.notif_player = MediaPlayer() | |
291 self.notif_player.setDataSource(str(notif_path)) | |
292 self.notif_player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION) | |
293 self.notif_player.prepare() | |
294 | |
295 # SSL fix | |
296 _sslverify.platformTrust = platformTrust | |
297 log.info("SSL Android patch applied") | |
298 | |
299 # DNS fix | |
300 defer.ensureDeferred(self.update_resolver()) | |
301 | |
302 # Connectivity handling | |
303 self.cm = activity.getSystemService(Context.CONNECTIVITY_SERVICE) | |
304 self._net_type = None | |
305 d = defer.ensureDeferred(self._check_connectivity()) | |
306 d.addErrback(host.log_errback) | |
307 | |
308 # XXX: we need to keep a reference to BroadcastReceiver to avoid | |
309 # "XXX has no attribute 'invoke'" error (looks like the same issue as | |
310 # https://github.com/kivy/pyjnius/issues/59) | |
311 self.br = BroadcastReceiver( | |
312 callback=lambda *args, **kwargs: reactor.callFromThread( | |
313 self.on_connectivity_change | |
314 ), | |
315 actions=["android.net.conn.CONNECTIVITY_CHANGE"] | |
316 ) | |
317 self.br.start() | |
318 | |
319 @property | |
320 def state(self): | |
321 return self._state | |
322 | |
323 @state.setter | |
324 def state(self, new_state): | |
325 log.debug(f"frontend state has changed: {new_state.decode()}") | |
326 previous_state = self._state | |
327 self._state = new_state | |
328 if new_state == STATE_RUNNING: | |
329 self._on_running(previous_state) | |
330 elif new_state == STATE_PAUSED: | |
331 self._on_paused(previous_state) | |
332 elif new_state == STATE_STOPPED: | |
333 self._on_stopped(previous_state) | |
334 | |
335 @property | |
336 def cagou_active(self): | |
337 return self._state == STATE_RUNNING | |
338 | |
339 def _on_running(self, previous_state): | |
340 if previous_state is not None: | |
341 self.host.bridge.bridge_reactivate_signals() | |
342 self.set_active() | |
343 | |
344 def _on_paused(self, previous_state): | |
345 self.host.bridge.bridge_deactivate_signals() | |
346 self.set_inactive() | |
347 | |
348 def _on_stopped(self, previous_state): | |
349 self.set_inactive() | |
350 | |
351 def _notify_message(self, mess_data, client): | |
352 """Send notification when suitable | |
353 | |
354 notification is sent if: | |
355 - there is a message and it is not a groupchat | |
356 - message is not coming from ourself | |
357 """ | |
358 if (mess_data["message"] and mess_data["type"] != C.MESS_TYPE_GROUPCHAT | |
359 and not mess_data["from"].userhostJID() == client.jid.userhostJID()): | |
360 message = next(iter(mess_data["message"].values())) | |
361 try: | |
362 subject = next(iter(mess_data["subject"].values())) | |
363 except StopIteration: | |
364 subject = D_("new message from {contact}").format( | |
365 contact = mess_data['from']) | |
366 | |
367 notification = Notification() | |
368 notification._notify( | |
369 title=subject, | |
370 message=message, | |
371 sat_action={ | |
372 "type": "open", | |
373 "widget": "chat", | |
374 "target": mess_data["from"].userhost(), | |
375 }, | |
376 ) | |
377 | |
378 ringer_mode = self.am.getRingerMode() | |
379 vibrate_mode = ringer_mode == AudioManager.RINGER_MODE_VIBRATE | |
380 | |
381 ring_setting = self.host.memory.param_get_a( | |
382 PARAM_RING_NAME, | |
383 PARAM_RING_CATEGORY, | |
384 profile_key=client.profile | |
385 ) | |
386 | |
387 if ring_setting != 'never' and ringer_mode == AudioManager.RINGER_MODE_NORMAL: | |
388 self.notif_player.start() | |
389 | |
390 vibration_setting = self.host.memory.param_get_a( | |
391 PARAM_VIBRATE_NAME, | |
392 PARAM_VIBRATE_CATEGORY, | |
393 profile_key=client.profile | |
394 ) | |
395 if (vibration_setting == 'always' | |
396 or vibration_setting == 'vibrate' and vibrate_mode): | |
397 try: | |
398 vibrator.vibrate() | |
399 except Exception as e: | |
400 log.warning("Can't use vibrator: {e}".format(e=e)) | |
401 return mess_data | |
402 | |
403 def message_received_trigger(self, client, message_elt, post_treat): | |
404 if not self.cagou_active: | |
405 # we only send notification is the frontend is not displayed | |
406 post_treat.addCallback(self._notify_message, client) | |
407 | |
408 return True | |
409 | |
410 # Profile autoconnection | |
411 | |
412 def _profile_autoconnect_get(self): | |
413 return defer.ensureDeferred(self.profile_autoconnect_get()) | |
414 | |
415 async def _get_profiles_autoconnect(self): | |
416 autoconnect_dict = await self.host.memory.storage.get_ind_param_values( | |
417 category='Connection', name='autoconnect_backend', | |
418 ) | |
419 return [p for p, v in autoconnect_dict.items() if C.bool(v)] | |
420 | |
421 async def profile_autoconnect_get(self): | |
422 """Return profile to connect automatically by frontend, if any""" | |
423 profiles_autoconnect = await self._get_profiles_autoconnect() | |
424 if not profiles_autoconnect: | |
425 return None | |
426 if len(profiles_autoconnect) > 1: | |
427 log.warning( | |
428 f"More that one profiles with backend autoconnection set found, picking " | |
429 f"up first one (full list: {profiles_autoconnect!r})") | |
430 return profiles_autoconnect[0] | |
431 | |
432 # CSI | |
433 | |
434 def _set_inactive(self): | |
435 self._csi_timer = None | |
436 for client in self.host.get_clients(C.PROF_KEY_ALL): | |
437 self._csi.set_inactive(client) | |
438 | |
439 def set_inactive(self): | |
440 if self._csi is None or self._csi_timer is not None: | |
441 return | |
442 self._csi_timer = reactor.callLater(CSI_DELAY, self._set_inactive) | |
443 | |
444 def set_active(self): | |
445 if self._csi is None: | |
446 return | |
447 if self._csi_timer is not None: | |
448 self._csi_timer.cancel() | |
449 self._csi_timer = None | |
450 for client in self.host.get_clients(C.PROF_KEY_ALL): | |
451 self._csi.set_active(client) | |
452 | |
453 # Connectivity | |
454 | |
455 async def _handle_network_change(self, net_type): | |
456 """Notify the clients about network changes. | |
457 | |
458 This way the client can disconnect/reconnect transport, or change delays | |
459 """ | |
460 log.debug(f"handling network change ({net_type})") | |
461 if net_type == NET_TYPE_NONE: | |
462 for client in self.host.get_clients(C.PROF_KEY_ALL): | |
463 client.network_disabled() | |
464 else: | |
465 # DNS servers may have changed | |
466 await self.update_resolver() | |
467 # client may be there but disabled (e.g. with stream management) | |
468 for client in self.host.get_clients(C.PROF_KEY_ALL): | |
469 log.debug(f"enabling network for {client.profile}") | |
470 client.network_enabled() | |
471 | |
472 # profiles may have been disconnected and then purged, we try | |
473 # to reconnect them in case | |
474 profiles_autoconnect = await self._get_profiles_autoconnect() | |
475 for profile in profiles_autoconnect: | |
476 if not self.host.is_connected(profile): | |
477 log.info(f"{profile} is not connected, reconnecting it") | |
478 try: | |
479 await self.host.connect(profile) | |
480 except Exception as e: | |
481 log.error(f"Can't connect profile {profile}: {e}") | |
482 | |
483 async def _check_connectivity(self): | |
484 active_network = self.cm.getActiveNetworkInfo() | |
485 if active_network is None: | |
486 net_type = NET_TYPE_NONE | |
487 else: | |
488 net_type_android = active_network.getType() | |
489 if net_type_android == ConnectivityManager.TYPE_WIFI: | |
490 net_type = NET_TYPE_WIFI | |
491 elif net_type_android == ConnectivityManager.TYPE_MOBILE: | |
492 net_type = NET_TYPE_MOBILE | |
493 else: | |
494 net_type = NET_TYPE_OTHER | |
495 | |
496 if net_type != self._net_type: | |
497 log.info("connectivity has changed") | |
498 self._net_type = net_type | |
499 if net_type == NET_TYPE_NONE: | |
500 log.info("no network active") | |
501 elif net_type == NET_TYPE_WIFI: | |
502 log.info("WIFI activated") | |
503 elif net_type == NET_TYPE_MOBILE: | |
504 log.info("mobile data activated") | |
505 else: | |
506 log.info("network activated (type={net_type_android})" | |
507 .format(net_type_android=net_type_android)) | |
508 else: | |
509 log.debug("_check_connectivity called without network change ({net_type})" | |
510 .format(net_type = net_type)) | |
511 | |
512 # we always call _handle_network_change even if there is not connectivity change | |
513 # to be sure to reconnect when necessary | |
514 await self._handle_network_change(net_type) | |
515 | |
516 | |
517 def on_connectivity_change(self): | |
518 log.debug("on_connectivity_change called") | |
519 d = defer.ensureDeferred(self._check_connectivity()) | |
520 d.addErrback(self.host.log_errback) | |
521 | |
522 async def update_resolver(self): | |
523 # There is no "/etc/resolv.conf" on Android, which confuse Twisted and makes | |
524 # SRV record checking unusable. We fixe that by checking DNS server used, and | |
525 # updating Twisted's resolver accordingly | |
526 dns_servers = await self.get_dns_servers() | |
527 | |
528 log.info( | |
529 "Patching Twisted to use Android DNS resolver ({dns_servers})".format( | |
530 dns_servers=', '.join([s[0] for s in dns_servers])) | |
531 ) | |
532 dns_client.theResolver = dns_client.createResolver(servers=dns_servers) | |
533 | |
534 async def get_dns_servers(self): | |
535 servers = [] | |
536 | |
537 if api_version < 26: | |
538 # thanks to A-IV at https://stackoverflow.com/a/11362271 for the way to go | |
539 log.debug("Old API, using SystemProperties to find DNS") | |
540 for idx in range(1, 5): | |
541 addr = SystemProperties.get(f'net.dns{idx}') | |
542 if abstract.isIPAddress(addr): | |
543 servers.append((addr, 53)) | |
544 else: | |
545 log.debug(f"API {api_version} >= 26, using getprop to find DNS") | |
546 # use of getprop inspired by various solutions at | |
547 # https://stackoverflow.com/q/3070144 | |
548 # it's the most simple option, and it fit wells with async_process | |
549 getprop_paths = which('getprop') | |
550 if getprop_paths: | |
551 try: | |
552 getprop_path = getprop_paths[0] | |
553 props = await async_process.run(getprop_path) | |
554 servers = [(ip, 53) for ip in RE_DNS.findall(props.decode()) | |
555 if abstract.isIPAddress(ip)] | |
556 except Exception as e: | |
557 log.warning(f"Can't use \"getprop\" to find DNS server: {e}") | |
558 if not servers: | |
559 # FIXME: Cloudflare's 1.1.1.1 seems to have a better privacy policy, to be | |
560 # checked. | |
561 log.warning( | |
562 "no server found, we have to use factory Google DNS, this is not ideal " | |
563 "for privacy" | |
564 ) | |
565 servers.append(('8.8.8.8', 53), ('8.8.4.4', 53)) | |
566 return servers |