Mercurial > libervia-desktop-kivy
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cagou/core/cagou_main.py Thu Apr 05 17:11:21 2018 +0200 @@ -0,0 +1,666 @@ +#!/usr//bin/env python2 +# -*- coding: utf-8 -*- + +# Cagou: desktop/mobile frontend for Salut à Toi XMPP client +# Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from sat.core.i18n import _ +from . import kivy_hack +kivy_hack.do_hack() +from constants import Const as C +from sat.core import log as logging +log = logging.getLogger(__name__) +from sat.core import exceptions +from sat_frontends.quick_frontend.quick_app import QuickApp +from sat_frontends.quick_frontend import quick_widgets +from sat_frontends.quick_frontend import quick_chat +from sat_frontends.quick_frontend import quick_utils +from sat.tools import config +from sat.tools.common import dynamic_import +import kivy +kivy.require('1.9.1') +import kivy.support +main_config = config.parseMainConf() +bridge_name = config.getConfig(main_config, '', 'bridge', 'dbus') +# FIXME: event loop is choosen according to bridge_name, a better way should be used +if 'dbus' in bridge_name: + kivy.support.install_gobject_iteration() +elif bridge_name in ('pb', 'embedded'): + kivy.support.install_twisted_reactor() +from kivy.app import App +from kivy.lang import Builder +from kivy import properties +import xmlui +from profile_manager import ProfileManager +from widgets_handler import WidgetsHandler +from kivy.clock import Clock +from kivy.uix.label import Label +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.floatlayout import FloatLayout +from kivy.uix.screenmanager import ScreenManager, Screen, FallOutTransition, RiseInTransition +from kivy.uix.dropdown import DropDown +from cagou_widget import CagouWidget +from . import widgets_handler +from .common import IconButton +from . import menu +from importlib import import_module +import os.path +import glob +import cagou.plugins +import cagou.kv +from kivy import utils as kivy_utils +import sys +if kivy_utils.platform == "android": + # FIXME: move to separate android module + kivy.support.install_android() + # sys.platform is "linux" on android by default + # so we change it to allow backend to detect android + sys.platform = "android" + import mmap + C.PLUGIN_EXT = 'pyo' + + +class NotifsIcon(IconButton): + notifs = properties.ListProperty() + + def on_release(self): + callback, args, kwargs = self.notifs.pop(0) + callback(*args, **kwargs) + + def addNotif(self, callback, *args, **kwargs): + self.notifs.append((callback, args, kwargs)) + + +class Note(Label): + title = properties.StringProperty() + message = properties.StringProperty() + level = properties.OptionProperty(C.XMLUI_DATA_LVL_DEFAULT, options=list(C.XMLUI_DATA_LVLS)) + + +class NoteDrop(Note): + pass + + +class NotesDrop(DropDown): + clear_btn = properties.ObjectProperty() + + def __init__(self, notes): + super(NotesDrop, self).__init__() + self.notes = notes + + def open(self, widget): + self.clear_widgets() + for n in self.notes: + self.add_widget(NoteDrop(title=n.title, message=n.message, level=n.level)) + self.add_widget(self.clear_btn) + super(NotesDrop, self).open(widget) + + +class RootHeadWidget(BoxLayout): + """Notifications widget""" + manager = properties.ObjectProperty() + notifs_icon = properties.ObjectProperty() + notes = properties.ListProperty() + + def __init__(self): + super(RootHeadWidget, self).__init__() + self.notes_last = None + self.notes_event = None + self.notes_drop = NotesDrop(self.notes) + + def addNotif(self, callback, *args, **kwargs): + self.notifs_icon.addNotif(callback, *args, **kwargs) + + def addNote(self, title, message, level): + note = Note(title=title, message=message, level=level) + self.notes.append(note) + if len(self.notes) > 10: + del self.notes[:-10] + if self.notes_event is None: + self.notes_event = Clock.schedule_interval(self._displayNextNote, 5) + self._displayNextNote() + + def addNotifUI(self, ui): + self.notifs_icon.addNotif(ui.show, force=True) + + def _displayNextNote(self, dummy=None): + screen = Screen() + try: + idx = self.notes.index(self.notes_last) + 1 + except ValueError: + idx = 0 + try: + note = self.notes_last = self.notes[idx] + except IndexError: + self.notes_event.cancel() + self.notes_event = None + else: + screen.add_widget(note) + self.manager.switch_to(screen) + + +class RootMenus(menu.MenusWidget): + pass + + +class RootBody(BoxLayout): + pass + + +class CagouRootWidget(FloatLayout): + root_menus = properties.ObjectProperty() + root_body = properties.ObjectProperty + + def __init__(self, main_widget): + super(CagouRootWidget, self).__init__() + # header + self._head_widget = RootHeadWidget() + self.root_body.add_widget(self._head_widget) + # body + self._manager = ScreenManager() + # main widgets + main_screen = Screen(name='main') + main_screen.add_widget(main_widget) + self._manager.add_widget(main_screen) + # backend XMLUI (popups, forms, etc) + xmlui_screen = Screen(name='xmlui') + self._manager.add_widget(xmlui_screen) + # extra (file chooser, audio record, etc) + extra_screen = Screen(name='extra') + self._manager.add_widget(extra_screen) + self.root_body.add_widget(self._manager) + + def changeWidget(self, widget, screen_name="main"): + """change main widget""" + if self._manager.transition.is_active: + # FIXME: workaround for what seems a Kivy bug + # TODO: report this upstream + self._manager.transition.stop() + screen = self._manager.get_screen(screen_name) + screen.clear_widgets() + screen.add_widget(widget) + + def show(self, screen="main"): + if self._manager.transition.is_active: + # FIXME: workaround for what seems a Kivy bug + # TODO: report this upstream + self._manager.transition.stop() + if self._manager.current == screen: + return + if screen == "main": + self._manager.transition = FallOutTransition() + else: + self._manager.transition = RiseInTransition() + self._manager.current = screen + + def newAction(self, handler, action_data, id_, security_limit, profile): + """Add a notification for an action""" + self._head_widget.addNotif(handler, action_data, id_, security_limit, profile) + + def addNote(self, title, message, level): + self._head_widget.addNote(title, message, level) + + def addNotifUI(self, ui): + self._head_widget.addNotifUI(ui) + + +class CagouApp(App): + """Kivy App for Cagou""" + + def build(self): + return CagouRootWidget(Label(text=u"Loading please wait")) + + def showWidget(self): + self._profile_manager = ProfileManager() + self.root.changeWidget(self._profile_manager) + + def expand(self, path, *args, **kwargs): + """expand path and replace known values + + useful in kv. Values which can be used: + - {media}: media dir + @param path(unicode): path to expand + @param *args: additional arguments used in format + @param **kwargs: additional keyword arguments used in format + """ + return os.path.expanduser(path).format(*args, media=self.host.media_dir, **kwargs) + + def on_start(self): + if sys.platform == "android": + # XXX: we use memory map instead of bridge because if we try to call a bridge method + # in on_pause method, the call data is not written before the actual pause + # we create a memory map on .cagou_status file with a 1 byte status + # status is: + # R => running + # P => paused + # S => stopped + self._first_pause = True + self.cagou_status_fd = open('.cagou_status', 'wb+') + self.cagou_status_fd.write('R') + self.cagou_status_fd.flush() + self.cagou_status = mmap.mmap(self.cagou_status_fd.fileno(), 1, prot=mmap.PROT_WRITE) + + def on_pause(self): + self.cagou_status[0] = 'P' + return True + + def on_resume(self): + self.cagou_status[0] = 'R' + + def on_stop(self): + if sys.platform == "android": + self.cagou_status[0] = 'S' + self.cagou_status.flush() + self.cagou_status_fd.close() + + +class Cagou(QuickApp): + MB_HANDLE = False + + def __init__(self): + if bridge_name == 'embedded': + from sat.core import sat_main + self.sat = sat_main.SAT() + if sys.platform == 'android': + from android import AndroidService + service = AndroidService(u'Cagou (SàT)'.encode('utf-8'), u'Salut à Toi backend'.encode('utf-8')) + service.start(u'service started') + self.service = service + + bridge_module = dynamic_import.bridge(bridge_name, 'sat_frontends.bridge') + if bridge_module is None: + log.error(u"Can't import {} bridge".format(bridge_name)) + sys.exit(3) + else: + log.debug(u"Loading {} bridge".format(bridge_name)) + super(Cagou, self).__init__(bridge_factory=bridge_module.Bridge, xmlui=xmlui, check_options=quick_utils.check_options, connect_bridge=False) + self._import_kv() + self.app = CagouApp() + self.app.host = self + self.media_dir = self.app.media_dir = config.getConfig(main_config, '', 'media_dir') + self.app.default_avatar = os.path.join(self.media_dir, "misc/default_avatar.png") + self.app.icon = os.path.join(self.media_dir, "icons/muchoslava/png/cagou_profil_bleu_96.png") + self._plg_wids = [] # main widgets plugins + self._plg_wids_transfer = [] # transfer widgets plugins + self._import_plugins() + self._visible_widgets = {} # visible widgets by classes + + @property + def visible_widgets(self): + for w_list in self._visible_widgets.itervalues(): + for w in w_list: + yield w + + def onBridgeConnected(self): + self.registerSignal("otrState", iface="plugin") + self.bridge.getReady(self.onBackendReady) + + def _bridgeEb(self, failure): + if bridge_name == "pb" and sys.platform == "android": + try: + self.retried += 1 + except AttributeError: + self.retried = 1 + from twisted.internet.error import ConnectionRefusedError + if failure.check(ConnectionRefusedError) and self.retried < 100: + if self.retried % 20 == 0: + log.debug("backend not ready, retrying ({})".format(self.retried)) + Clock.schedule_once(lambda dummy: self.connectBridge(), 0.05) + return + super(Cagou, self)._bridgeEb(failure) + + def run(self): + self.connectBridge() + self.app.bind(on_stop=self.onStop) + self.app.run() + + def onStop(self, obj): + try: + sat_instance = self.sat + except AttributeError: + pass + else: + sat_instance.stopService() + + def onBackendReady(self): + self.app.showWidget() + self.postInit() + + def postInit(self, dummy=None): + # FIXME: resize seem to bug on android, so we use below_target for now + self.app.root_window.softinput_mode = "below_target" + profile_manager = self.app._profile_manager + del self.app._profile_manager + super(Cagou, self).postInit(profile_manager) + + def _defaultFactoryMain(self, plugin_info, target, profiles): + """default factory used to create main widgets instances + + used when PLUGIN_INFO["factory"] is not set + @param plugin_info(dict): plugin datas + @param target: QuickWidget target + @param profiles(iterable): list of profiles + """ + main_cls = plugin_info['main'] + return self.widgets.getOrCreateWidget(main_cls, target, on_new_widget=None, profiles=iter(self.profiles)) + + def _defaultFactoryTransfer(self, plugin_info, callback, cancel_cb, profiles): + """default factory used to create transfer widgets instances + + @param plugin_info(dict): plugin datas + @param callback(callable): method to call with path to file to transfer + @param cancel_cb(callable): call when transfer is cancelled + transfer widget must be used as first argument + @param profiles(iterable): list of profiles + None if not specified + """ + main_cls = plugin_info['main'] + return main_cls(callback=callback, cancel_cb=cancel_cb) + + ## plugins & kv import ## + + def _import_kv(self): + """import all kv files in cagou.kv""" + path = os.path.dirname(cagou.kv.__file__) + for kv_path in glob.glob(os.path.join(path, "*.kv")): + Builder.load_file(kv_path) + log.debug(u"kv file {} loaded".format(kv_path)) + + def _import_plugins(self): + """import all plugins""" + self.default_wid = None + plugins_path = os.path.dirname(cagou.plugins.__file__) + plugin_glob = u"plugin*." + C.PLUGIN_EXT + plug_lst = [os.path.splitext(p)[0] for p in map(os.path.basename, glob.glob(os.path.join(plugins_path, plugin_glob)))] + + imported_names_main = set() # used to avoid loading 2 times plugin with same import name + imported_names_transfer = set() + for plug in plug_lst: + plugin_path = 'cagou.plugins.' + plug + + # we get type from plugin name + suff = plug[7:] + if u'_' not in suff: + log.error(u"invalid plugin name: {}, skipping".format(plug)) + continue + plugin_type = suff[:suff.find(u'_')] + + # and select the variable to use according to type + if plugin_type == C.PLUG_TYPE_WID: + imported_names = imported_names_main + default_factory = self._defaultFactoryMain + elif plugin_type == C.PLUG_TYPE_TRANSFER: + imported_names = imported_names_transfer + default_factory = self._defaultFactoryTransfer + else: + log.error(u"unknown plugin type {type_} for plugin {file_}, skipping".format( + type_ = plugin_type, + file_ = plug + )) + continue + plugins_set = self._getPluginsSet(plugin_type) + + mod = import_module(plugin_path) + try: + plugin_info = mod.PLUGIN_INFO + except AttributeError: + plugin_info = {} + + plugin_info['plugin_file'] = plug + plugin_info['plugin_type'] = plugin_type + + if 'platforms' in plugin_info: + if sys.platform not in plugin_info['platforms']: + log.info(u"{plugin_file} is not used on this platform, skipping".format(**plugin_info)) + continue + + # import name is used to differentiate plugins + if 'import_name' not in plugin_info: + plugin_info['import_name'] = plug + if plugin_info['import_name'] in imported_names: + log.warning(_(u"there is already a plugin named {}, ignoring new one").format(plugin_info['import_name'])) + continue + if plugin_info['import_name'] == C.WID_SELECTOR: + if plugin_type != C.PLUG_TYPE_WID: + log.error(u"{import_name} import name can only be used with {type_} type, skipping {name}".format(type_=C.PLUG_TYPE_WID, **plugin_info)) + continue + # if WidgetSelector exists, it will be our default widget + self.default_wid = plugin_info + + # we want everything optional, so we use plugin file name + # if actual name is not found + if 'name' not in plugin_info: + name_start = 8 + len(plugin_type) + plugin_info['name'] = plug[name_start:] + + # we need to load the kv file + if 'kv_file' not in plugin_info: + plugin_info['kv_file'] = u'{}.kv'.format(plug) + kv_path = os.path.join(plugins_path, plugin_info['kv_file']) + if not os.path.exists(kv_path): + log.debug(u"no kv found for {plugin_file}".format(**plugin_info)) + else: + Builder.load_file(kv_path) + + # what is the main class ? + main_cls = getattr(mod, plugin_info['main']) + plugin_info['main'] = main_cls + + # factory is used to create the instance + # if not found, we use a defaut one with getOrCreateWidget + if 'factory' not in plugin_info: + plugin_info['factory'] = default_factory + + # icons + for size in ('small', 'medium'): + key = u'icon_{}'.format(size) + try: + path = plugin_info[key] + except KeyError: + path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir) + else: + path = path.format(media=self.media_dir) + if not os.path.isfile(path): + path = C.DEFAULT_WIDGET_ICON.format(media=self.media_dir) + plugin_info[key] = path + + plugins_set.append(plugin_info) + if not self._plg_wids: + log.error(_(u"no widget plugin found")) + return + + # we want widgets sorted by names + self._plg_wids.sort(key=lambda p: p['name'].lower()) + self._plg_wids_transfer.sort(key=lambda p: p['name'].lower()) + + if self.default_wid is None: + # we have no selector widget, we use the first widget as default + self.default_wid = self._plg_wids[0] + + def _getPluginsSet(self, type_): + if type_ == C.PLUG_TYPE_WID: + return self._plg_wids + elif type_ == C.PLUG_TYPE_TRANSFER: + return self._plg_wids_transfer + else: + raise KeyError(u"{} plugin type is unknown".format(type_)) + + def getPluggedWidgets(self, type_=C.PLUG_TYPE_WID, except_cls=None): + """get available widgets plugin infos + + @param type_(unicode): type of widgets to get + one of C.PLUG_TYPE_* constant + @param except_cls(None, class): if not None, + widgets from this class will be excluded + @return (iter[dict]): available widgets plugin infos + """ + plugins_set = self._getPluginsSet(type_) + for plugin_data in plugins_set: + if plugin_data['main'] == except_cls: + continue + yield plugin_data + + def getPluginInfo(self, type_=C.PLUG_TYPE_WID, **kwargs): + """get first plugin info corresponding to filters + + @param type_(unicode): type of widgets to get + one of C.PLUG_TYPE_* constant + @param **kwargs: filter(s) to use, each key present here must also + exist and be of the same value in requested plugin info + @return (dict, None): found plugin info or None + """ + plugins_set = self._getPluginsSet(type_) + for plugin_info in plugins_set: + for k, w in kwargs.iteritems(): + try: + if plugin_info[k] != w: + continue + except KeyError: + continue + return plugin_info + + ## widgets handling + + def newWidget(self, widget): + log.debug(u"new widget created: {}".format(widget)) + if isinstance(widget, quick_chat.QuickChat) and widget.type == C.CHAT_GROUP: + self.addNote(u"", _(u"room {} has been joined").format(widget.target)) + + def getParentHandler(self, widget): + """Return handler holding this widget + + @return (WidgetsHandler): handler + """ + w_handler = widget.parent + while w_handler and not(isinstance(w_handler, widgets_handler.WidgetsHandler)): + w_handler = w_handler.parent + return w_handler + + def switchWidget(self, old, new): + """Replace old widget by new one + + old(CagouWidget): CagouWidget instance or a child + new(CagouWidget): new widget instance + """ + to_change = None + if isinstance(old, CagouWidget): + to_change = old + else: + for w in old.walk_reverse(): + if isinstance(w, CagouWidget): + to_change = w + break + + if to_change is None: + raise exceptions.InternalError(u"no CagouWidget found when trying to switch widget") + handler = self.getParentHandler(to_change) + handler.changeWidget(new) + + def addVisibleWidget(self, widget): + """declare a widget visible + + for internal use only! + """ + assert isinstance(widget, quick_widgets.QuickWidget) + self._visible_widgets.setdefault(widget.__class__, []).append(widget) + + def removeVisibleWidget(self, widget): + """declare a widget not visible anymore + + for internal use only! + """ + self._visible_widgets[widget.__class__].remove(widget) + self.widgets.deleteWidget(widget) + + def getVisibleList(self, cls): + """get list of visible widgets for a given class + + @param cls(QuickWidget class): type of widgets to get + @return (list[QuickWidget class]): visible widgets of this class + """ + try: + return self._visible_widgets[cls] + except KeyError: + return [] + + def getOrClone(self, widget): + """Get a QuickWidget if it has not parent set else clone it""" + if widget.parent is None: + return widget + targets = list(widget.targets) + w = self.widgets.getOrCreateWidget(widget.__class__, targets[0], on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=widget.profiles) + for t in targets[1:]: + w.addTarget(t) + return w + + ## menus ## + + def _menusGetCb(self, backend_menus): + main_menu = self.app.root.root_menus + self.menus.addMenus(backend_menus) + self.menus.addMenu(C.MENU_GLOBAL, (_(u"Help"), _(u"About")), callback=main_menu.onAbout) + main_menu.update(C.MENU_GLOBAL) + + ## bridge handlers ## + + def otrStateHandler(self, state, dest_jid, profile): + """OTR state has changed for on destinee""" + # XXX: this method could be in QuickApp but it's here as + # it's only used by Cagou so far + for widget in self.widgets.getWidgets(quick_chat.QuickChat, profiles=(profile,)): + widget.onOTRState(state, dest_jid, profile) + + ## misc ## + + def plugging_profiles(self): + self.app.root.changeWidget(WidgetsHandler()) + self.bridge.menusGet("", C.NO_SECURITY_LIMIT, callback=self._menusGetCb) + + def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE): + log.info(u"Profile presence status set to {show}/{status}".format(show=show, status=status)) + + def addNote(self, title, message, level=C.XMLUI_DATA_LVL_INFO): + """add a note (message which disappear) to root widget's header""" + self.app.root.addNote(title, message, level) + + def addNotifUI(self, ui): + """add a notification with a XMLUI attached + + @param ui(xmlui.XMLUIPanel): XMLUI instance to show when notification is selected + """ + self.app.root.addNotifUI(ui) + + def showUI(self, ui): + """show a XMLUI""" + self.app.root.changeWidget(ui, "xmlui") + self.app.root.show("xmlui") + + def showExtraUI(self, widget): + """show any extra widget""" + self.app.root.changeWidget(widget, "extra") + self.app.root.show("extra") + + def closeUI(self): + self.app.root.show() + + def getDefaultAvatar(self, entity=None): + return self.app.default_avatar + + def showDialog(self, message, title, type="info", answer_cb=None, answer_data=None): + # TODO + log.info(u"FIXME: showDialog not implemented") + log.info(u"message: {} -- {}".format(title, message))