view src/cagou/core/cagou_main.py @ 74:88e3071079a7

buildozer: added plyer and VIBRATE permission for backend's android plugin
author Goffi <goffi@goffi.org>
date Mon, 19 Dec 2016 23:50:19 +0100
parents 674b1fa3c945
children b84dadbab62b
line wrap: on
line source

#!/usr//bin/env python2
# -*- coding: utf-8 -*-

# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
# Copyright (C) 2016 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 _
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.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 .menu import MenusWidget
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


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 CagouRootWidget(BoxLayout):

    def __init__(self, main_widget):
        super(CagouRootWidget, self).__init__(orientation=("vertical"))
        # general menus
        self.menus_widget = MenusWidget()
        self.add_widget(self.menus_widget)
        # header
        self._head_widget = RootHeadWidget()
        self.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)
        self.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):
        # 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):
        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._plg_wids = []  # widget 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.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 _defaultFactory(self, plugin_info, target, profiles):
        """factory used to create widget instance when PLUGIN_INFO["factory"] is not set"""
        main_cls = plugin_info['main']
        return self.widgets.getOrCreateWidget(main_cls, target, on_new_widget=None, profiles=iter(self.profiles))

    ## 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
        if sys.platform == "android":
            # FIXME: android hack, need to be moved to separate file
            plugins_path = "/data/data/org.goffi.cagou.cagou/files/cagou/plugins"
            plugin_glob = "plugin*.pyo"
        else:
            plugins_path = os.path.dirname(cagou.plugins.__file__)
            plugin_glob = "plugin*.py"
        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 = set()  # use to avoid loading 2 times plugin with same import name
        for plug in plug_lst:
            plugin_path = 'cagou.plugins.' + plug
            mod = import_module(plugin_path)
            try:
                plugin_info = mod.PLUGIN_INFO
            except AttributeError:
                plugin_info = {}

            # import name is used to differentiate plugins
            if 'import_name' not in plugin_info:
                plugin_info['import_name'] = plug
            if '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 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:
                plugin_info['name'] = plug[plug.rfind('_')+1:]

            # 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'])
            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'] = self._defaultFactory

            # 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

            self._plg_wids.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())

        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 getPluggedWidgets(self, except_cls=None):
        """get available widgets plugin infos

        @param except_cls(None, class): if not None,
            widgets from this class will be excluded
        @return (iter[dict]): available widgets plugin infos
        """
        for plugin_data in self._plg_wids:
            if plugin_data['main'] == except_cls:
                continue
            yield plugin_data

    def getPluginInfo(self, **kwargs):
        """get first plugin info corresponding to filters

        @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
        """
        for plugin_info in self._plg_wids:
            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 _getMenusCb(self, backend_menus):
        main_menu = self.app.root.menus_widget
        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)

    ## misc ##

    def plugging_profiles(self):
        self.app.root.changeWidget(WidgetsHandler())
        self.bridge.getMenus("", C.NO_SECURITY_LIMIT, callback=self._getMenusCb)

    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):
        self.app.root.changeWidget(ui, "xmlui")
        self.app.root.show("xmlui")

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