changeset 192:62198e00a2b7

plugin file sharing: first draft: new file sharing plugin, which allow to share local file or retrieve remote ones.
author Goffi <goffi@goffi.org>
date Tue, 22 May 2018 19:25:23 +0200
parents fda3f22aa3ce
children 284cb5c467b0
files cagou/plugins/plugin_wid_file_sharing.kv cagou/plugins/plugin_wid_file_sharing.py
diffstat 2 files changed, 589 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cagou/plugins/plugin_wid_file_sharing.kv	Tue May 22 19:25:23 2018 +0200
@@ -0,0 +1,102 @@
+# 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/>.
+
+#:import ModernMenu kivy.garden.modernmenu.ModernMenu
+
+
+<ModeBtn>:
+    width: self.texture_size[0] + sp(20)
+    size_hint: None, 1
+
+
+<FileSharing>:
+    float_layout: float_layout
+    layout: layout
+    FloatLayout:
+        id: float_layout
+        ScrollView:
+            size_hint: 1, 1
+            pos_hint: {'x': 0, 'y': 0}
+            do_scroll_x: False
+            scroll_type: ['bars', 'content']
+            bar_width: dp(6)
+            StackLayout:
+                id: layout
+                size_hint: 1, None
+                height: self.minimum_height
+                spacing: 0
+
+
+<ItemWidget>:
+    size_hint: None, None
+    width: self.base_width
+    height: self.minimum_height
+    orientation: 'vertical'
+
+
+<PathWidget>:
+    shared: False
+    Symbol:
+        size_hint: 1, None
+        height: dp(80)
+        symbol: 'folder-open-empty' if root.is_dir else 'doc'
+        margin: dp(40)
+        color: (1, 0, 0, 1) if root.shared else (0, 0, 0, 1) if root.is_dir else app.c_prim_dark
+    Label:
+        size_hint: None, None
+        width: dp(100)
+        font_size: sp(14)
+        text_size: dp(95), None
+        size: self.texture_size
+        text: root.name
+        halign: 'center'
+
+
+<LocalPathWidget>:
+    shared: root.filepath in root.sharing_wid.shared_paths
+
+
+<DeviceWidget>:
+    Symbol:
+        size_hint: 1, None
+        height: dp(80)
+        symbol: 'desktop'
+        margin: dp(40)
+        color: 0, 0, 0, 1
+    Label:
+        size_hint: None, None
+        width: dp(100)
+        font_size: sp(14)
+        text_size: dp(95), None
+        size: self.texture_size
+        text: root.name
+        halign: 'center'
+
+
+<CategorySeparator>:
+    size_hint: 1, None
+    height: sp(20)
+
+
+<Menu>:
+    creation_direction: -1
+    radius: 25
+    creation_timeout: .4
+    cancel_color: app.c_sec_light[:3] + [0.3]
+    color: app.c_sec
+
+<ModernMenuLabel>:
+    bg_color: app.c_sec[:3] + [0.9]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cagou/plugins/plugin_wid_file_sharing.py	Tue May 22 19:25:23 2018 +0200
@@ -0,0 +1,487 @@
+#!/usr/bin/python
+# -*- 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 import log as logging
+from sat.core import exceptions
+log = logging.getLogger(__name__)
+from sat.core.i18n import _
+from sat.tools.common import files_utils
+from sat_frontends.quick_frontend import quick_widgets
+from sat_frontends.tools import jid
+from cagou.core.constants import Const as C
+from cagou.core import cagou_widget
+from cagou import G
+from kivy import properties
+from kivy.uix.label import Label
+from kivy.uix.button import Button
+from kivy.uix.boxlayout import BoxLayout
+from kivy.garden import modernmenu
+from kivy.clock import Clock
+from kivy.metrics import dp
+from kivy.animation import Animation
+from functools import partial
+import os.path
+import json
+
+
+PLUGIN_INFO = {
+    "name": _(u"file sharing"),
+    "main": "FileSharing",
+    "description": _(u"share/transfer files between devices"),
+    "icon_symbol": u"exchange",
+}
+MODE_VIEW = u"view"
+MODE_LOCAL = u"local"
+
+
+dist = modernmenu.dist
+
+
+class ModeBtn(Button):
+
+    def __init__(self, parent, **kwargs):
+        super(ModeBtn, self).__init__(**kwargs)
+        parent.bind(mode=self.on_mode)
+        self.on_mode(parent, parent.mode)
+
+    def on_mode(self, parent, new_mode):
+        if new_mode == MODE_VIEW:
+            self.text = _(u"view shared files")
+        elif new_mode == MODE_LOCAL:
+            self.text = _(u"share local files")
+        else:
+            exceptions.InternalError(u"Unknown mode: {mode}".format(mode=new_mode))
+
+
+class Identities(object):
+
+    def __init__(self, entity_ids):
+        identities = {}
+        for cat, type_, name in entity_ids:
+            identities.setdefault(cat, {}).setdefault(type_, []).append(name)
+        self.identities = identities
+
+    @property
+    def name(self):
+        return self.identities.values()[0].values()[0][0]
+
+
+class ItemWidget(BoxLayout):
+    click_timeout = properties.NumericProperty(0.4)
+    base_width = properties.NumericProperty(dp(100))
+
+    def __init__(self, sharing_wid, name):
+        self.sharing_wid = sharing_wid
+        self.name = name
+        super(ItemWidget, self).__init__()
+
+    def on_touch_down(self, touch):
+        if not self.collide_point(*touch.pos):
+            return
+        t = partial(self.open_menu, touch)
+        touch.ud['menu_timeout'] = t
+        Clock.schedule_once(t, self.click_timeout)
+        return super(ItemWidget, self).on_touch_down(touch)
+
+    def do_item_action(self, touch):
+        pass
+
+    def on_touch_up(self, touch):
+        if touch.ud.get('menu_timeout'):
+            Clock.unschedule(touch.ud['menu_timeout'])
+            if self.collide_point(*touch.pos) and self.sharing_wid.menu is None:
+                self.do_item_action(touch)
+        return super(ItemWidget, self).on_touch_up(touch)
+
+    def open_menu(self, touch, dt):
+        self.sharing_wid.open_menu(self, touch)
+        del touch.ud['menu_timeout']
+
+    def getMenuChoices(self):
+        """return choice adapted to selected item
+
+        @return (list[dict]): choices ad expected by ModernMenu
+        """
+        return []
+
+
+class PathWidget(ItemWidget):
+
+    def __init__(self, sharing_wid, filepath):
+        name = os.path.basename(filepath)
+        self.filepath = os.path.normpath(filepath)
+        if self.filepath == u'.':
+            self.filepath = u''
+        super(PathWidget, self).__init__(sharing_wid, name)
+
+    @property
+    def is_dir(self):
+        raise NotImplementedError
+
+    def do_item_action(self, touch):
+        if self.is_dir:
+            self.sharing_wid.current_dir = self.filepath
+
+    def open_menu(self, touch, dt):
+        log.debug(_(u"opening menu for {path}").format(path=self.filepath))
+        super(PathWidget, self).open_menu(touch, dt)
+
+
+class LocalPathWidget(PathWidget):
+
+    @property
+    def is_dir(self):
+        return os.path.isdir(self.filepath)
+
+    def getMenuChoices(self):
+        choices = []
+        if self.shared:
+            choices.append(dict(text=_(u'unshare'),
+                                index=len(choices)+1,
+                                callback=self.sharing_wid.unshare))
+        else:
+            choices.append(dict(text=_(u'share'),
+                                index=len(choices)+1,
+                                callback=self.sharing_wid.share))
+        return choices
+
+
+class RemotePathWidget(PathWidget):
+
+    def __init__(self, sharing_wid, filepath, type_):
+        self.type_ = type_
+        super(RemotePathWidget, self).__init__(sharing_wid, filepath)
+
+    @property
+    def is_dir(self):
+        return self.type_ == C.FILE_TYPE_DIRECTORY
+
+    def do_item_action(self, touch):
+        if self.is_dir:
+            if self.filepath == u'..':
+                self.sharing_wid.remote_entity = u''
+            else:
+                super(RemotePathWidget, self).do_item_action(touch)
+        else:
+            self.sharing_wid.request_item(self)
+            return True
+
+
+class DeviceWidget(ItemWidget):
+
+    def __init__(self, sharing_wid, entity_jid, identities):
+        self.entity_jid = entity_jid
+        self.identities = identities
+        self.own_device = entity_jid.bare == next(G.host.profiles.itervalues()).whoami
+        name = self.identities.name if self.own_device else self.entity_jid.node
+        super(DeviceWidget, self).__init__(sharing_wid, name)
+
+    def do_item_action(self, touch):
+        self.sharing_wid.remote_entity = self.entity_jid
+        self.sharing_wid.remote_dir = u''
+
+
+class CategorySeparator(Label):
+    pass
+
+
+class Menu(modernmenu.ModernMenu):
+    pass
+
+
+class FileSharing(quick_widgets.QuickWidget, cagou_widget.CagouWidget):
+    SINGLE=False
+    float_layout = properties.ObjectProperty()
+    layout = properties.ObjectProperty()
+    mode = properties.OptionProperty(MODE_LOCAL, options=[MODE_VIEW, MODE_LOCAL])
+    local_dir = properties.StringProperty(os.path.expanduser(u'~'))
+    remote_dir = properties.StringProperty(u'')
+    remote_entity = properties.StringProperty(u'')
+    shared_paths = properties.ListProperty()
+    signals_registered = False
+
+    def __init__(self, host, target, profiles):
+        self._filter_last = u''
+        self._filter_anim = Animation(width = 0,
+                                      height = 0,
+                                      opacity = 0,
+                                      d = 0.5)
+        quick_widgets.QuickWidget.__init__(self, host, target, profiles)
+        cagou_widget.CagouWidget.__init__(self)
+        self.mode_btn = ModeBtn(self)
+        self.mode_btn.bind(on_release=self.change_mode)
+        self.headerInputAddExtra(self.mode_btn)
+        self.bind(local_dir=self.update_view,
+                  remote_dir=self.update_view,
+                  remote_entity=self.update_view)
+        self.update_view()
+        self.menu = None
+        self.menu_item = None
+        self.float_layout.bind(children=self.clean_fl_children)
+        if not FileSharing.signals_registered:
+            # FIXME: we use this hack (registering the signal for the whole class) now
+            #        as there is currently no unregisterSignal available in bridges
+            G.host.registerSignal("FISSharedPathNew", handler=FileSharing.shared_path_new, iface="plugin")
+            G.host.registerSignal("FISSharedPathRemoved", handler=FileSharing.shared_path_removed, iface="plugin")
+            FileSharing.signals_registered = True
+        G.host.bridge.FISLocalSharesGet(self.profile,
+                                        callback=self.fill_paths,
+                                        errback=G.host.errback)
+
+    @property
+    def current_dir(self):
+        return self.local_dir if self.mode == MODE_LOCAL else self.remote_dir
+
+    @current_dir.setter
+    def current_dir(self, new_dir):
+        if self.mode == MODE_LOCAL:
+            self.local_dir = new_dir
+        else:
+            self.remote_dir = new_dir
+
+    def fill_paths(self, shared_paths):
+        self.shared_paths.extend(shared_paths)
+
+    def change_mode(self, mode_btn):
+        self.clear_menu()
+        opt = self.__class__.mode.options
+        new_idx = (opt.index(self.mode)+1) % len(opt)
+        self.mode = opt[new_idx]
+
+    def on_mode(self, instance, new_mode):
+        print(instance)
+        self.update_view(None, self.local_dir)
+
+    def onHeaderInputComplete(self, wid, text):
+        """we filter items when text is entered in input box"""
+        text = text.strip().lower()
+        filtering = len(text)>len(self._filter_last)
+        self._filter_last = text
+        for child in self.layout.children:
+            if not isinstance(child, ItemWidget):
+                continue
+            if child.name == u'..':
+                continue
+            if text in child.name.lower():
+                self._filter_anim.cancel(child)
+                child.width = child.base_width
+                child.height = child.minimum_height
+                child.opacity = 1
+            elif (filtering
+                  and child.opacity > 0
+                  and not self._filter_anim.have_properties_to_animate(child)):
+                self._filter_anim.start(child)
+
+    ## remote sharing callback ##
+
+    def _discoFindByFeaturesCb(self, data):
+        entities_services, entities_own, entities_roster = data
+        for entities_map, title in ((entities_services,
+                                     _(u'services')),
+                                    (entities_own,
+                                     _(u'your devices')),
+                                    (entities_roster,
+                                     _(u'your contacts devices'))):
+            if entities_map:
+                self.layout.add_widget(CategorySeparator(text=title))
+                for entity_str, entity_ids in entities_map.iteritems():
+                    entity_jid = jid.JID(entity_str)
+                    item = DeviceWidget(self,
+                                        entity_jid,
+                                        Identities(entity_ids))
+                    self.layout.add_widget(item)
+
+    def discover_devices(self):
+        """Looks for devices handling file "File Information Sharing" and display them"""
+        try:
+            namespace = self.host.ns_map['fis']
+        except KeyError:
+            msg = _(u"can't find file information sharing namespace, is the plugin running?")
+            log.warning(msg)
+            G.host.addNote(_(u"missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
+            return
+        self.host.bridge.discoFindByFeatures(
+            [namespace], [], False, True, True, True, self.profile,
+            callback=self._discoFindByFeaturesCb,
+            errback=partial(G.host.errback,
+                title=_(u"shared folder error"),
+                message=_(u"can't check sharing devices: {msg}")))
+
+    def FISListCb(self, files_data):
+        for file_data in files_data:
+            filepath = os.path.join(self.current_dir, file_data[u'name'])
+            item = RemotePathWidget(
+                self,
+                filepath=filepath,
+                type_=file_data[u'type'])
+            self.layout.add_widget(item)
+
+    def FISListEb(self, failure_):
+        self.remote_dir = u''
+        G.host.addNote(
+            _(u"shared folder error"),
+            _(u"can't list files for {remote_entity}: {msg}").format(
+                remote_entity=self.remote_entity,
+                msg=failure_),
+            level=C.XMLUI_DATA_LVL_WARNING)
+
+    ## view generation ##
+
+    def update_view(self, *args):
+        """update items according to current mode, entity and dir"""
+        log.debug(u'updating {}, {}'.format(self.current_dir, args))
+        self.layout.clear_widgets()
+        self.header_input.text = u''
+        if self.mode == MODE_LOCAL:
+            filepath = os.path.join(self.local_dir, u'..')
+            self.layout.add_widget(LocalPathWidget(sharing_wid=self, filepath=filepath))
+            files = sorted(os.listdir(self.local_dir))
+            for f in files:
+                filepath = os.path.join(self.local_dir, f)
+                self.layout.add_widget(LocalPathWidget(sharing_wid=self, filepath=filepath))
+        elif self.mode == MODE_VIEW:
+            if not self.remote_entity:
+                self.discover_devices()
+            else:
+                # we always a way to go back
+                # so user can return to previous list even in case of error
+                parent_path = os.path.join(self.remote_dir, u'..')
+                item = RemotePathWidget(
+                    self,
+                    filepath = parent_path,
+                    type_ = C.FILE_TYPE_DIRECTORY)
+                self.layout.add_widget(item)
+                self.host.bridge.FISList(
+                    self.remote_entity,
+                    self.remote_dir,
+                    {},
+                    self.profile,
+                    callback=self.FISListCb,
+                    errback=self.FISListEb)
+
+    ## menu methods ##
+
+    def clean_fl_children(self, layout, children):
+        """insure that self.menu and self.menu_item are None when menu is dimissed"""
+        if self.menu is not None and self.menu not in children:
+            self.menu = self.menu_item = None
+
+    def clear_menu(self):
+        """remove menu if there is one"""
+        if self.menu is not None:
+            self.menu.dismiss()
+            self.menu = None
+            self.menu_item = None
+
+    def open_menu(self, item, touch):
+        """open menu for item
+
+        @param item(PathWidget): item when the menu has been requested
+        @param touch(kivy.input.MotionEvent): touch data
+        """
+        if self.menu_item == item:
+            return
+        self.clear_menu()
+        pos = self.to_widget(*touch.pos)
+        choices = item.getMenuChoices()
+        if not choices:
+            return
+        self.menu = Menu(choices=choices,
+                         center=pos,
+                         size_hint=(None, None))
+        self.float_layout.add_widget(self.menu)
+        self.menu.start_display(touch)
+        self.menu_item = item
+
+    ## Share methods ##
+
+    def share(self, menu):
+        item = self.menu_item
+        self.clear_menu()
+        G.host.bridge.FISSharePath(
+            item.name,
+            item.filepath,
+            json.dumps({}, ensure_ascii=False),
+            self.profile,
+            callback=lambda name: G.host.addNote(
+                _(u"sharing folder"),
+                _(u"{name} is now shared").format(name=name)),
+            errback=partial(G.host.errback,
+                title=_(u"sharing folder"),
+                message=_(u"can't share folder: {msg}")))
+
+    def unshare(self, menu):
+        item = self.menu_item
+        self.clear_menu()
+        G.host.bridge.FISUnsharePath(
+            item.filepath,
+            self.profile,
+            callback=lambda: G.host.addNote(
+                _(u"sharing folder"),
+                _(u"{name} is not shared anymore").format(name=item.name)),
+            errback=partial(G.host.errback,
+                title=_(u"sharing folder"),
+                message=_(u"can't unshare folder: {msg}")))
+
+    def fileJingleRequestCb(self, progress_id, item, dest_path):
+        G.host.addNote(
+            _(u"file request"),
+            _(u"{name} download started at {dest_path}").format(
+                name = item.name,
+                dest_path = dest_path))
+
+    def request_item(self, item):
+        """Retrieve an item from remote entity
+
+        @param item(RemotePathWidget): item to retrieve
+        """
+        path, name = os.path.split(item.filepath)
+        assert name
+        assert self.remote_entity
+        extra = {'path': path}
+        dest_path = files_utils.get_unique_name(os.path.join(G.host.downloads_dir, name))
+        G.host.bridge.fileJingleRequest(self.remote_entity,
+                                        dest_path,
+                                        name,
+                                        u'',
+                                        u'',
+                                        extra,
+                                        self.profile,
+                                        callback=partial(self.fileJingleRequestCb,
+                                            item=item,
+                                            dest_path=dest_path),
+                                        errback=partial(G.host.errback,
+                                            title = _(u"file request error"),
+                                            message = _(u"can't request file: {msg}")))
+
+    @classmethod
+    def shared_path_new(cls, shared_path, name, profile):
+        for wid in G.host.getVisibleList(cls):
+            if shared_path not in wid.shared_paths:
+                wid.shared_paths.append(shared_path)
+
+    @classmethod
+    def shared_path_removed(cls, shared_path, profile):
+        for wid in G.host.getVisibleList(cls):
+            if shared_path in wid.shared_paths:
+                wid.shared_paths.remove(shared_path)
+            else:
+                log.warning(_(u"shared path {path} not found in {widget}".format(
+                    path = shared_path, widget = wid)))