comparison cagou/core/cagou_main.py @ 126:cd99f70ea592

global file reorganisation: - follow common convention by puttin cagou in "cagou" instead of "src/cagou" - added VERSION in cagou with current version - updated dates - moved main executable in /bin - moved buildozer files in root directory - temporary moved platform to assets/platform
author Goffi <goffi@goffi.org>
date Thu, 05 Apr 2018 17:11:21 +0200
parents src/cagou/core/cagou_main.py@dcd6fbb3f010
children 0ec3c3c0ed92
comparison
equal deleted inserted replaced
125:b6e6afb0dc46 126:cd99f70ea592
1 #!/usr//bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # Cagou: desktop/mobile frontend for Salut à Toi XMPP client
5 # Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20
21 from sat.core.i18n import _
22 from . import kivy_hack
23 kivy_hack.do_hack()
24 from constants import Const as C
25 from sat.core import log as logging
26 log = logging.getLogger(__name__)
27 from sat.core import exceptions
28 from sat_frontends.quick_frontend.quick_app import QuickApp
29 from sat_frontends.quick_frontend import quick_widgets
30 from sat_frontends.quick_frontend import quick_chat
31 from sat_frontends.quick_frontend import quick_utils
32 from sat.tools import config
33 from sat.tools.common import dynamic_import
34 import kivy
35 kivy.require('1.9.1')
36 import kivy.support
37 main_config = config.parseMainConf()
38 bridge_name = config.getConfig(main_config, '', 'bridge', 'dbus')
39 # FIXME: event loop is choosen according to bridge_name, a better way should be used
40 if 'dbus' in bridge_name:
41 kivy.support.install_gobject_iteration()
42 elif bridge_name in ('pb', 'embedded'):
43 kivy.support.install_twisted_reactor()
44 from kivy.app import App
45 from kivy.lang import Builder
46 from kivy import properties
47 import xmlui
48 from profile_manager import ProfileManager
49 from widgets_handler import WidgetsHandler
50 from kivy.clock import Clock
51 from kivy.uix.label import Label
52 from kivy.uix.boxlayout import BoxLayout
53 from kivy.uix.floatlayout import FloatLayout
54 from kivy.uix.screenmanager import ScreenManager, Screen, FallOutTransition, RiseInTransition
55 from kivy.uix.dropdown import DropDown
56 from cagou_widget import CagouWidget
57 from . import widgets_handler
58 from .common import IconButton
59 from . import menu
60 from importlib import import_module
61 import os.path
62 import glob
63 import cagou.plugins
64 import cagou.kv
65 from kivy import utils as kivy_utils
66 import sys
67 if kivy_utils.platform == "android":
68 # FIXME: move to separate android module
69 kivy.support.install_android()
70 # sys.platform is "linux" on android by default
71 # so we change it to allow backend to detect android
72 sys.platform = "android"
73 import mmap
74 C.PLUGIN_EXT = 'pyo'
75
76
77 class NotifsIcon(IconButton):
78 notifs = properties.ListProperty()
79
80 def on_release(self):
81 callback, args, kwargs = self.notifs.pop(0)
82 callback(*args, **kwargs)
83
84 def addNotif(self, callback, *args, **kwargs):
85 self.notifs.append((callback, args, kwargs))
86
87
88 class Note(Label):
89 title = properties.StringProperty()
90 message = properties.StringProperty()
91 level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT, options=list(C.XMLUI_DATA_LVLS))
92
93
94 class NoteDrop(Note):
95 pass
96
97
98 class NotesDrop(DropDown):
99 clear_btn = properties.ObjectProperty()
100
101 def __init__(self, notes):
102 super(NotesDrop, self).__init__()
103 self.notes = notes
104
105 def open(self, widget):
106 self.clear_widgets()
107 for n in self.notes:
108 self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level))
109 self.add_widget(self.clear_btn)
110 super(NotesDrop, self).open(widget)
111
112
113 class RootHeadWidget(BoxLayout):
114 """Notifications widget"""
115 manager = properties.ObjectProperty()
116 notifs_icon = properties.ObjectProperty()
117 notes = properties.ListProperty()
118
119 def __init__(self):
120 super(RootHeadWidget, self).__init__()
121 self.notes_last = None
122 self.notes_event = None
123 self.notes_drop = NotesDrop(self.notes)
124
125 def addNotif(self, callback, *args, **kwargs):
126 self.notifs_icon.addNotif(callback, *args, **kwargs)
127
128 def addNote(self, title, message, level):
129 note = Note(title=title, message=message, level=level)
130 self.notes.append(note)
131 if len(self.notes) > 10:
132 del self.notes[:-10]
133 if self.notes_event is None:
134 self.notes_event = Clock.schedule_interval(self._displayNextNote, 5)
135 self._displayNextNote()
136
137 def addNotifUI(self, ui):
138 self.notifs_icon.addNotif(ui.show, force=True)
139
140 def _displayNextNote(self, dummy=None):
141 screen = Screen()
142 try:
143 idx = self.notes.index(self.notes_last) + 1
144 except ValueError:
145 idx = 0
146 try:
147 note = self.notes_last = self.notes[idx]
148 except IndexError:
149 self.notes_event.cancel()
150 self.notes_event = None
151 else:
152 screen.add_widget(note)
153 self.manager.switch_to(screen)
154
155
156 class RootMenus(menu.MenusWidget):
157 pass
158
159
160 class RootBody(BoxLayout):
161 pass
162
163
164 class CagouRootWidget(FloatLayout):
165 root_menus = properties.ObjectProperty()
166 root_body = properties.ObjectProperty
167
168 def __init__(self, main_widget):
169 super(CagouRootWidget, self).__init__()
170 # header
171 self._head_widget = RootHeadWidget()
172 self.root_body.add_widget(self._head_widget)
173 # body
174 self._manager = ScreenManager()
175 # main widgets
176 main_screen = Screen(name='main')
177 main_screen.add_widget(main_widget)
178 self._manager.add_widget(main_screen)
179 # backend XMLUI (popups, forms, etc)
180 xmlui_screen = Screen(name='xmlui')
181 self._manager.add_widget(xmlui_screen)
182 # extra (file chooser, audio record, etc)
183 extra_screen = Screen(name='extra')
184 self._manager.add_widget(extra_screen)
185 self.root_body.add_widget(self._manager)
186
187 def changeWidget(self, widget, screen_name="main"):
188 """change main widget"""
189 if self._manager.transition.is_active:
190 # FIXME: workaround for what seems a Kivy bug
191 # TODO: report this upstream
192 self._manager.transition.stop()
193 screen = self._manager.get_screen(screen_name)
194 screen.clear_widgets()
195 screen.add_widget(widget)
196
197 def show(self, screen="main"):
198 if self._manager.transition.is_active:
199 # FIXME: workaround for what seems a Kivy bug
200 # TODO: report this upstream
201 self._manager.transition.stop()
202 if self._manager.current == screen:
203 return
204 if screen == "main":
205 self._manager.transition = FallOutTransition()
206 else:
207 self._manager.transition = RiseInTransition()
208 self._manager.current = screen
209
210 def newAction(self, handler, action_data, id_, security_limit, profile):
211 """Add a notification for an action"""
212 self._head_widget.addNotif(handler, action_data, id_, security_limit, profile)
213
214 def addNote(self, title, message, level):
215 self._head_widget.addNote(title, message, level)
216
217 def addNotifUI(self, ui):
218 self._head_widget.addNotifUI(ui)
219
220
221 class CagouApp(App):
222 """Kivy App for Cagou"""
223
224 def build(self):
225 return CagouRootWidget(Label(text=u"Loading please wait"))
226
227 def showWidget(self):
228 self._profile_manager = ProfileManager()
229 self.root.changeWidget(self._profile_manager)
230
231 def expand(self, path, *args, **kwargs):
232 """expand path and replace known values
233
234 useful in kv. Values which can be used:
235 - {media}: media dir
236 @param path(unicode): path to expand
237 @param *args: additional arguments used in format
238 @param **kwargs: additional keyword arguments used in format
239 """
240 return os.path.expanduser(path).format(*args, media=self.host.media_dir, **kwargs)
241
242 def on_start(self):
243 if sys.platform == "android":
244 # XXX: we use memory map instead of bridge because if we try to call a bridge method
245 # in on_pause method, the call data is not written before the actual pause
246 # we create a memory map on .cagou_status file with a 1 byte status
247 # status is:
248 # R => running
249 # P => paused
250 # S => stopped
251 self._first_pause = True
252 self.cagou_status_fd = open('.cagou_status', 'wb+')
253 self.cagou_status_fd.write('R')
254 self.cagou_status_fd.flush()
255 self.cagou_status = mmap.mmap(self.cagou_status_fd.fileno(), 1, prot=mmap.PROT_WRITE)
256
257 def on_pause(self):
258 self.cagou_status[0] = 'P'
259 return True
260
261 def on_resume(self):
262 self.cagou_status[0] = 'R'
263
264 def on_stop(self):
265 if sys.platform == "android":
266 self.cagou_status[0] = 'S'
267 self.cagou_status.flush()
268 self.cagou_status_fd.close()
269
270
271 class Cagou(QuickApp):
272 MB_HANDLE = False
273
274 def __init__(self):
275 if bridge_name == 'embedded':
276 from sat.core import sat_main
277 self.sat = sat_main.SAT()
278 if sys.platform == 'android':
279 from android import AndroidService
280 service = AndroidService(u'Cagou (SàT)'.encode('utf-8'), u'Salut à Toi backend'.encode('utf-8'))
281 service.start(u'service started')
282 self.service = service
283
284 bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge')
285 if bridge_module is None:
286 log.error(u"Can't import {} bridge".format(bridge_name))
287 sys.exit(3)
288 else:
289 log.debug(u"Loading {} bridge".format(bridge_name))
290 super(Cagou, self).__init__(bridge_factory=bridge_module.Bridge, xmlui=xmlui, check_options=quick_utils.check_options, connect_bridge=False)
291 self._import_kv()
292 self.app = CagouApp()
293 self.app.host = self
294 self.media_dir = self.app.media_dir = config.getConfig(main_config, '', 'media_dir')
295 self.app.default_avatar = os.path.join(self.media_dir, "misc/default_avatar.png")
296 self.app.icon = os.path.join(self.media_dir, "icons/muchoslava/png/cagou_profil_bleu_96.png")
297 self._plg_wids = [] # main widgets plugins
298 self._plg_wids_transfer = [] # transfer widgets plugins
299 self._import_plugins()
300 self._visible_widgets = {} # visible widgets by classes
301
302 @property
303 def visible_widgets(self):
304 for w_list in self._visible_widgets.itervalues():
305 for w in w_list:
306 yield w
307
308 def onBridgeConnected(self):
309 self.registerSignal("otrState", iface="plugin")
310 self.bridge.getReady(self.onBackendReady)
311
312 def _bridgeEb(self, failure):
313 if bridge_name == "pb" and sys.platform == "android":
314 try:
315 self.retried += 1
316 except AttributeError:
317 self.retried = 1
318 from twisted.internet.error import ConnectionRefusedError
319 if failure.check(ConnectionRefusedError) and self.retried < 100:
320 if self.retried % 20 == 0:
321 log.debug("backend not ready, retrying ({})".format(self.retried))
322 Clock.schedule_once(lambda dummy: self.connectBridge(), 0.05)
323 return
324 super(Cagou, self)._bridgeEb(failure)
325
326 def run(self):
327 self.connectBridge()
328 self.app.bind(on_stop=self.onStop)
329 self.app.run()
330
331 def onStop(self, obj):
332 try:
333 sat_instance = self.sat
334 except AttributeError:
335 pass
336 else:
337 sat_instance.stopService()
338
339 def onBackendReady(self):
340 self.app.showWidget()
341 self.postInit()
342
343 def postInit(self, dummy=None):
344 # FIXME: resize seem to bug on android, so we use below_target for now
345 self.app.root_window.softinput_mode = "below_target"
346 profile_manager = self.app._profile_manager
347 del self.app._profile_manager
348 super(Cagou, self).postInit(profile_manager)
349
350 def _defaultFactoryMain(self, plugin_info, target, profiles):
351 """default factory used to create main widgets instances
352
353 used when PLUGIN_INFO["factory"] is not set
354 @param plugin_info(dict): plugin datas
355 @param target: QuickWidget target
356 @param profiles(iterable): list of profiles
357 """
358 main_cls = plugin_info['main']
359 return self.widgets.getOrCreateWidget(main_cls, target, on_new_widget=None, profiles=iter(self.profiles))
360
361 def _defaultFactoryTransfer(self, plugin_info, callback, cancel_cb, profiles):
362 """default factory used to create transfer widgets instances
363
364 @param plugin_info(dict): plugin datas
365 @param callback(callable): method to call with path to file to transfer
366 @param cancel_cb(callable): call when transfer is cancelled
367 transfer widget must be used as first argument
368 @param profiles(iterable): list of profiles
369 None if not specified
370 """
371 main_cls = plugin_info['main']
372 return main_cls(callback=callback, cancel_cb=cancel_cb)
373
374 ## plugins & kv import ##
375
376 def _import_kv(self):
377 """import all kv files in cagou.kv"""
378 path = os.path.dirname(cagou.kv.__file__)
379 for kv_path in glob.glob(os.path.join(path, "*.kv")):
380 Builder.load_file(kv_path)
381 log.debug(u"kv file {} loaded".format(kv_path))
382
383 def _import_plugins(self):
384 """import all plugins"""
385 self.default_wid = None
386 plugins_path = os.path.dirname(cagou.plugins.__file__)
387 plugin_glob = u"plugin*." + C.PLUGIN_EXT
388 plug_lst = [os.path.splitext(p)[0] for p in map(os.path.basename, glob.glob(os.path.join(plugins_path, plugin_glob)))]
389
390 imported_names_main = set() # used to avoid loading 2 times plugin with same import name
391 imported_names_transfer = set()
392 for plug in plug_lst:
393 plugin_path = 'cagou.plugins.' + plug
394
395 # we get type from plugin name
396 suff = plug[7:]
397 if u'_' not in suff:
398 log.error(u"invalid plugin name: {}, skipping".format(plug))
399 continue
400 plugin_type = suff[:suff.find(u'_')]
401
402 # and select the variable to use according to type
403 if plugin_type == C.PLUG_TYPE_WID:
404 imported_names = imported_names_main
405 default_factory = self._defaultFactoryMain
406 elif plugin_type == C.PLUG_TYPE_TRANSFER:
407 imported_names = imported_names_transfer
408 default_factory = self._defaultFactoryTransfer
409 else:
410 log.error(u"unknown plugin type {type_} for plugin {file_}, skipping".format(
411 type_ = plugin_type,
412 file_ = plug
413 ))
414 continue
415 plugins_set = self._getPluginsSet(plugin_type)
416
417 mod = import_module(plugin_path)
418 try:
419 plugin_info = mod.PLUGIN_INFO
420 except AttributeError:
421 plugin_info = {}
422
423 plugin_info['plugin_file'] = plug
424 plugin_info['plugin_type'] = plugin_type
425
426 if 'platforms' in plugin_info:
427 if sys.platform not in plugin_info['platforms']:
428 log.info(u"{plugin_file} is not used on this platform, skipping".format(**plugin_info))
429 continue
430
431 # import name is used to differentiate plugins
432 if 'import_name' not in plugin_info:
433 plugin_info['import_name'] = plug
434 if plugin_info['import_name'] in imported_names:
435 log.warning(_(u"there is already a plugin named {}, ignoring new one").format(plugin_info['import_name']))
436 continue
437 if plugin_info['import_name'] == C.WID_SELECTOR:
438 if plugin_type != C.PLUG_TYPE_WID:
439 log.error(u"{import_name} import name can only be used with {type_} type, skipping {name}".format(type_=C.PLUG_TYPE_WID, **plugin_info))
440 continue
441 # if WidgetSelector exists, it will be our default widget
442 self.default_wid = plugin_info
443
444 # we want everything optional, so we use plugin file name
445 # if actual name is not found
446 if 'name' not in plugin_info:
447 name_start = 8 + len(plugin_type)
448 plugin_info['name'] = plug[name_start:]
449
450 # we need to load the kv file
451 if 'kv_file' not in plugin_info:
452 plugin_info['kv_file'] = u'{}.kv'.format(plug)
453 kv_path = os.path.join(plugins_path, plugin_info['kv_file'])
454 if not os.path.exists(kv_path):
455 log.debug(u"no kv found for {plugin_file}".format(**plugin_info))
456 else:
457 Builder.load_file(kv_path)
458
459 # what is the main class ?
460 main_cls = getattr(mod, plugin_info['main'])
461 plugin_info['main'] = main_cls
462
463 # factory is used to create the instance
464 # if not found, we use a defaut one with getOrCreateWidget
465 if 'factory' not in plugin_info:
466 plugin_info['factory'] = default_factory
467
468 # icons
469 for size in ('small', 'medium'):
470 key = u'icon_{}'.format(size)
471 try:
472 path = plugin_info[key]
473 except KeyError:
474 path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir)
475 else:
476 path = path.format(media=self.media_dir)
477 if not os.path.isfile(path):
478 path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir)
479 plugin_info[key] = path
480
481 plugins_set.append(plugin_info)
482 if not self._plg_wids:
483 log.error(_(u"no widget plugin found"))
484 return
485
486 # we want widgets sorted by names
487 self._plg_wids.sort(key=lambda p: p['name'].lower())
488 self._plg_wids_transfer.sort(key=lambda p: p['name'].lower())
489
490 if self.default_wid is None:
491 # we have no selector widget, we use the first widget as default
492 self.default_wid = self._plg_wids[0]
493
494 def _getPluginsSet(self, type_):
495 if type_ == C.PLUG_TYPE_WID:
496 return self._plg_wids
497 elif type_ == C.PLUG_TYPE_TRANSFER:
498 return self._plg_wids_transfer
499 else:
500 raise KeyError(u"{} plugin type is unknown".format(type_))
501
502 def getPluggedWidgets(self, type_=C.PLUG_TYPE_WID, except_cls=None):
503 """get available widgets plugin infos
504
505 @param type_(unicode): type of widgets to get
506 one of C.PLUG_TYPE_* constant
507 @param except_cls(None, class): if not None,
508 widgets from this class will be excluded
509 @return (iter[dict]): available widgets plugin infos
510 """
511 plugins_set = self._getPluginsSet(type_)
512 for plugin_data in plugins_set:
513 if plugin_data['main'] == except_cls:
514 continue
515 yield plugin_data
516
517 def getPluginInfo(self, type_=C.PLUG_TYPE_WID, **kwargs):
518 """get first plugin info corresponding to filters
519
520 @param type_(unicode): type of widgets to get
521 one of C.PLUG_TYPE_* constant
522 @param **kwargs: filter(s) to use, each key present here must also
523 exist and be of the same value in requested plugin info
524 @return (dict, None): found plugin info or None
525 """
526 plugins_set = self._getPluginsSet(type_)
527 for plugin_info in plugins_set:
528 for k, w in kwargs.iteritems():
529 try:
530 if plugin_info[k] != w:
531 continue
532 except KeyError:
533 continue
534 return plugin_info
535
536 ## widgets handling
537
538 def newWidget(self, widget):
539 log.debug(u"new widget created: {}".format(widget))
540 if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP:
541 self.addNote(u"", _(u"room {} has been joined").format(widget.target))
542
543 def getParentHandler(self, widget):
544 """Return handler holding this widget
545
546 @return (WidgetsHandler): handler
547 """
548 w_handler = widget.parent
549 while w_handler and not(isinstance(w_handler, widgets_handler.WidgetsHandler)):
550 w_handler = w_handler.parent
551 return w_handler
552
553 def switchWidget(self, old, new):
554 """Replace old widget by new one
555
556 old(CagouWidget): CagouWidget instance or a child
557 new(CagouWidget): new widget instance
558 """
559 to_change = None
560 if isinstance(old, CagouWidget):
561 to_change = old
562 else:
563 for w in old.walk_reverse():
564 if isinstance(w, CagouWidget):
565 to_change = w
566 break
567
568 if to_change is None:
569 raise exceptions.InternalError(u"no CagouWidget found when trying to switch widget")
570 handler = self.getParentHandler(to_change)
571 handler.changeWidget(new)
572
573 def addVisibleWidget(self, widget):
574 """declare a widget visible
575
576 for internal use only!
577 """
578 assert isinstance(widget, quick_widgets.QuickWidget)
579 self._visible_widgets.setdefault(widget.__class__, []).append(widget)
580
581 def removeVisibleWidget(self, widget):
582 """declare a widget not visible anymore
583
584 for internal use only!
585 """
586 self._visible_widgets[widget.__class__].remove(widget)
587 self.widgets.deleteWidget(widget)
588
589 def getVisibleList(self, cls):
590 """get list of visible widgets for a given class
591
592 @param cls(QuickWidget class): type of widgets to get
593 @return (list[QuickWidget class]): visible widgets of this class
594 """
595 try:
596 return self._visible_widgets[cls]
597 except KeyError:
598 return []
599
600 def getOrClone(self, widget):
601 """Get a QuickWidget if it has not parent set else clone it"""
602 if widget.parent is None:
603 return widget
604 targets = list(widget.targets)
605 w = self.widgets.getOrCreateWidget(widget.__class__, targets[0], on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=widget.profiles)
606 for t in targets[1:]:
607 w.addTarget(t)
608 return w
609
610 ## menus ##
611
612 def _menusGetCb(self, backend_menus):
613 main_menu = self.app.root.root_menus
614 self.menus.addMenus(backend_menus)
615 self.menus.addMenu(C.MENU_GLOBAL, (_(u"Help"), _(u"About")), callback=main_menu.onAbout)
616 main_menu.update(C.MENU_GLOBAL)
617
618 ## bridge handlers ##
619
620 def otrStateHandler(self, state, dest_jid, profile):
621 """OTR state has changed for on destinee"""
622 # XXX: this method could be in QuickApp but it's here as
623 # it's only used by Cagou so far
624 for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)):
625 widget.onOTRState(state, dest_jid, profile)
626
627 ## misc ##
628
629 def plugging_profiles(self):
630 self.app.root.changeWidget(WidgetsHandler())
631 self.bridge.menusGet("", C.NO_SECURITY_LIMIT, callback=self._menusGetCb)
632
633 def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE):
634 log.info(u"Profile presence status set to {show}/{status}".format(show=show, status=status))
635
636 def addNote(self, title, message, level=C.XMLUI_DATA_LVL_INFO):
637 """add a note (message which disappear) to root widget's header"""
638 self.app.root.addNote(title, message, level)
639
640 def addNotifUI(self, ui):
641 """add a notification with a XMLUI attached
642
643 @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected
644 """
645 self.app.root.addNotifUI(ui)
646
647 def showUI(self, ui):
648 """show a XMLUI"""
649 self.app.root.changeWidget(ui, "xmlui")
650 self.app.root.show("xmlui")
651
652 def showExtraUI(self, widget):
653 """show any extra widget"""
654 self.app.root.changeWidget(widget, "extra")
655 self.app.root.show("extra")
656
657 def closeUI(self):
658 self.app.root.show()
659
660 def getDefaultAvatar(self, entity=None):
661 return self.app.default_avatar
662
663 def showDialog(self, message, title, type="info", answer_cb=None, answer_data=None):
664 # TODO
665 log.info(u"FIXME: showDialog not implemented")
666 log.info(u"message: {} -- {}".format(title, message))