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