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))