view 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 source

#!/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))