view cagou/plugins/plugin_wid_file_sharing.py @ 198:60b63c3e63a1

plugin file sharing: use new EntitiesSelectorMenu to select entities which can access shared files
author Goffi <goffi@goffi.org>
date Wed, 23 May 2018 21:28:16 +0200
parents 519b3a29743c
children b80d275e437f
line wrap: on
line source

#!/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.core.menu import EntitiesSelectorMenu
from cagou.core.utils import FilterBehavior
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 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"
SELECT_INSTRUCTIONS = _(u"Please select entities to share with")


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, FilterBehavior):
    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):
        quick_widgets.QuickWidget.__init__(self, host, target, profiles)
        cagou_widget.CagouWidget.__init__(self)
        FilterBehavior.__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 onHeaderInput(self):
        if u'/' in self.header_input.text or self.header_input.text == u'~':
            self.current_dir = os.path.expanduser(self.header_input.text)

    def onHeaderInputComplete(self, wid, text, **kwargs):
        """we filter items when text is entered in input box"""
        if u'/' in text:
            return
        self.do_filter(self.layout.children,
                       text,
                       lambda c: c.name,
                       width_cb=lambda c: c.base_width,
                       height_cb=lambda c: c.minimum_height,
                       continue_tests=[lambda c: not isinstance(c, ItemWidget),
                                       lambda c: c.name == u'..'])


    ## 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''
        self.header_input.hint_text = self.current_dir

        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 do_share(self, entities_jids, item):
        if entities_jids:
            access = {u'read': {u'type': 'whitelist',
                                u'jids': entities_jids}}
        else:
            access = {}

        G.host.bridge.FISSharePath(
            item.name,
            item.filepath,
            json.dumps(access, 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 share(self, menu):
        item = self.menu_item
        self.clear_menu()
        EntitiesSelectorMenu(instructions=SELECT_INSTRUCTIONS,
                             callback=partial(self.do_share, item=item)).show()

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