view cagou/plugins/plugin_wid_file_sharing.py @ 466:cd448b877d1d

install: update requirements with alabaster==0.7.12 alembic==1.4.3 anki @ file:///build/anki/src/anki/dist/anki-2.1.35-py3-none-any.whl ankirspy @ file:///build/anki/src/anki/dist/ankirspy-2.1.35-cp39-cp39-manylinux1_x86_64.whl ansi2html==1.6.0 anytree==2.8.0 apipkg==1.5 apparmor==3.0.1 appdirs==1.4.4 appimage-builder==0.8.5 aqt @ file:///build/anki/src/anki/dist/aqt-2.1.35-py3-none-any.whl argcomplete==1.12.1 argon2-cffi==20.1.0 asn1crypto==1.4.0 async-generator==1.10 attrs==20.3.0 autobahn==21.3.1 Automat==20.2.0 autopep8==1.5.5 Babel==2.9.0 backcall==0.2.0 bcrypt==3.2.0 Beaker==1.11.0 beautifulsoup4==4.9.3 black==20.8b1 bleach==3.3.0 blinker==1.4 bmap-tools==3.5 breezy==3.1.0 Brlapi==0.8.2 btrfsutil==5.11 CacheControl==0.12.6 cached-property==1.5.2 cagou==0.8.0.dev0+83c67b093350.153 cairocffi==1.2.0 CairoSVG==2.5.2 certifi==2020.6.20 cffi==1.14.5 chardet==3.0.4 click==7.1.2 colorama==0.4.4 commonmark==0.9.1 configobj==5.1.0.dev0 constantly==15.1.0 contextlib2==0.6.0.post1 coverage==5.5 cryptography==3.4.6 css-parser==1.0.6 cssselect2==0.4.1 cycler==0.10.0 Cython==0.29.22 decorator==4.4.2 defusedxml==0.6.0 diffoscope==169 distlib==0.3.1 distro==1.5.0 docker==4.4.4 docker-compose==1.28.5 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 docutils==0.16 dulwich==0.20.20 emrichen==0.2.3 entrypoints==0.3 extras==1.0.0 fastimport==0.9.8 file-magic==0.4.0 filelock==3.0.12 fixtures==3.0.0 flake8==3.8.4 Flask==1.1.2 Flask-BabelEx==0.9.4 Flask-Compress==1.8.0 Flask-Cors==3.0.9 Flask-Gravatar==0.5.0 Flask-Login==0.5.0 Flask-Mail==0.9.1 Flask-Migrate==2.7.0 Flask-Paranoid==0.2.0 Flask-Principal==0.4.0 Flask-Script==2.0.6 Flask-Security-Too==3.3.3 Flask-SQLAlchemy==2.4.4 Flask-WTF==0.14.3 future==0.18.2 gajim==1.2.2 gssapi==1.6.12 html2text==2020.1.16 html5lib==1.1 httpie==2.4.0 httplib2==0.19.0 hyperlink==21.0.0 idna==2.10 imagesize==1.2.0 img2pdf==0.4.0 importlib-metadata==3.4.0 incremental==17.5.0 inflect==5.3.0 iniconfig==1.1.1 ipdb==0.13.6 ipykernel==5.4.2 ipython==7.19.0 ipython-genutils==0.2.0 ipywidgets==7.6.2 isc==2.0 itsdangerous==1.1.0 jedi==0.17.2 jeepney==0.6.0 Jinja2==2.11.3 joblib==1.0.0 jsonpath-rw==1.4.0 jsonpickle==1.5.1 jsonschema==3.2.0 jupyter-client==6.1.7 jupyter-console==6.2.0 jupyter-core==4.6.3 jupyterlab-pygments==0.1.2 keyring==23.0.0 Kivy==2.0.0 kiwisolver==1.3.1 langdetect==1.0.8 ldap3==2.9.dev0 lensfun==0.3.95 LibAppArmor==3.0.1 libarchive-c==2.9 libfdt==1.6.0 libnacl==1.7.2 lockfile==0.12.2 louis==3.17.0 lxml==4.6.2 Mako==1.1.4 Markdown==3.3.3 MarkupSafe==1.1.1 matplotlib==3.3.4 mccabe==0.6.1 meld==3.20.3 mercurial==5.7.1 meson==0.57.1 miniupnpc==2.1 mistune==0.8.4 more-itertools==8.6.0 msgpack==1.0.2 mypy==0.812 mypy-extensions==0.4.3 natsort==7.1.1 nbclient==0.5.1 nbconvert==6.0.7 nbformat==5.0.8 nbxmpp==1.0.2 nest-asyncio==1.4.3 netifaces==0.10.9 netsnmp-python==1.0a1 networkx==2.5 nltk==3.5 nose==1.3.7 notebook==6.2.0 Nuitka==0.6.12.3 numpy==1.20.1 openshot-qt==2.5.1 ordered-set==4.0.2 orjson @ file:///build/python-orjson/src/python-orjson-3.5.1/target/wheels/orjson-3.5.1-cp39-cp39-manylinux2010_x86_64.whl packaging==20.9 pandas==1.2.3 pandocfilters==1.4.3 paramiko==2.7.2 parso==0.7.1 passlib==1.7.4 path==15.1.2 pathspec==0.8.1 patiencediff==0.2.1 pbr==5.5.1 pdfarranger==1.7.0 pep517==0.9.1 pexpect==4.8.0 pickleshare==0.7.5 pikepdf==2.8.0.post2 Pillow==8.1.0 pluggy==0.13.1 ply==3.11 precis-i18n==1.0.3 progress==1.5 progressbar2==3.53.1 prometheus-client==0.9.0 prompt-toolkit==3.0.17 protobuf==3.12.4 psutil==5.8.0 psycopg2==2.8.6 ptyprocess==0.7.0 pudb==2020.1 pwquality==1.4.4 py==1.10.0 pyaml==20.4.0 pyasn1==0.4.8 pyasn1-modules==0.2.8 PyAudio==0.2.11 pybind11==2.6.2 pycairo==1.20.0 pycodestyle==2.6.0 pycountry==20.7.3 pycparser==2.20 pydocstyle==5.1.1 pyenchant==3.2.0 pyflakes==2.2.0 Pygments==2.8.1 PyGObject==3.38.0 PyHamcrest==1.9.0 pyinotify==0.9.6 pymediainfo==5.0.3 PyNaCl==1.4.0 PyOpenGL==3.1.5 pyOpenSSL==20.0.1 pyparsing==2.4.7 pyPEG2==2.15.2 PyQt5==5.15.4 PyQt5-sip==12.8.1 PyQtWebEngine==5.15.4 pyrsistent==0.17.3 PySocks==1.7.1 pytest==6.2.2 python-dateutil==2.8.1 python-dotenv==0.15.0 python-editor==1.0.4 python-Levenshtein==0.12.2 python-mimeparse==1.6.0 python-utils==2.5.6 python-xlib==0.29 pytoml==0.1.21 pytz==2021.1 pyxdg==0.26 PyYAML==5.3.1 pyzmq==20.0.0 questionary==1.9.0 qutebrowser==2.1.0 Reflector==2021.1.10.0.6.34 regex==2020.11.13 requests==2.25.1 requests-toolbelt==0.9.1 requirements-parser==0.2.0 resolvelib==0.5.4 retrying==1.3.3 s3cmd==2.1.0 schema==0.7.4 scikit-learn==0.24.1 scipy==1.6.1 scons==3.1.2 screenkey==1.4 SecretStorage==3.3.1 Send2Trash==1.5.0 service-identity==18.1.0 sh==1.14.1 shortuuid==1.0.1 simplejson==3.17.2 sip==4.19.25 six==1.15.0 snowballstemmer==2.1.0 soupsieve==2.2 speaklater==1.3 Sphinx==3.5.2 sphinx-rtd-theme==0.5.1 sphinxcontrib-applehelp==1.0.2 sphinxcontrib-devhelp==1.0.2 sphinxcontrib-htmlhelp==1.0.3 sphinxcontrib-jsmath==1.0.1 sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 sqlacodegen==2.3.0 SQLAlchemy==1.3.23 sqlparse==0.4.1 sshtunnel==0.1.5 subdownloader==2.1.0 systemd-python==234 team==1.0 termcolor==1.1.0 terminado==0.9.2 terminator==2.1.0 testpath==0.4.4 testtools==2.4.0 texttable==1.6.3 threadpoolctl==2.1.0 tinycss2==1.1.0 tlsh==0.2.0 toml==0.10.2 tornado==6.1 traitlets==5.0.5 treq==21.1.0 Twisted==20.3.0 txaio==21.2.1 typed-ast==1.4.2 typing-extensions==3.7.4.3 tzlocal==2.1 urllib3==1.26.3 urwid==2.1.1 validate==5.1.0.dev0 virtualenv==20.4.2 waitress==1.4.4 wcwidth==0.2.5 webencodings==0.5.1 websocket-client==0.58.0 Werkzeug==1.0.1 Whoosh==2.7.4 widgetsnbextension==3.5.1 wsaccel==0.6.3 WTForms==2.2.1 xcffib==0.11.1 youtube-dl==2021.3.3 zipp==3.4.1 zope.interface==5.2.0
author Goffi <goffi@goffi.org>
date Sat, 20 Mar 2021 14:26:33 +0100
parents 3c9ba4a694ef
children 203755bbe0fe
line wrap: on
line source

#!/usr/bin/env python3

# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
# Copyright (C) 2016-2021 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 functools import partial
import os.path
import json
from sat.core import log as logging
from sat.core import exceptions
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 ..core.constants import Const as C
from ..core import cagou_widget
from ..core.menu import EntitiesSelectorMenu
from ..core.behaviors import TouchMenuBehavior, FilterBehavior
from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget,
                                       CategorySeparator)
from cagou import G
from kivy import properties
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy import utils as kivy_utils

log = logging.getLogger(__name__)


PLUGIN_INFO = {
    "name": _("file sharing"),
    "main": "FileSharing",
    "description": _("share/transfer files between devices"),
    "icon_symbol": "exchange",
}
MODE_VIEW = "view"
MODE_LOCAL = "local"
SELECT_INSTRUCTIONS = _("Please select entities to share with")

if kivy_utils.platform == "android":
    from jnius import autoclass
    Environment = autoclass("android.os.Environment")
    base_dir = Environment.getExternalStorageDirectory().getAbsolutePath()
    def expanduser(path):
        if path == '~' or path.startswith('~/'):
            return path.replace('~', base_dir, 1)
        return path
else:
    expanduser = os.path.expanduser


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 = _("view shared files")
        elif new_mode == MODE_LOCAL:
            self.text = _("share local files")
        else:
            exceptions.InternalError("Unknown mode: {mode}".format(mode=new_mode))


class PathWidget(ItemWidget):

    def __init__(self, filepath, main_wid, **kw):
        name = os.path.basename(filepath)
        self.filepath = os.path.normpath(filepath)
        if self.filepath == '.':
            self.filepath = ''
        super(PathWidget, self).__init__(name=name, main_wid=main_wid, **kw)

    @property
    def is_dir(self):
        raise NotImplementedError

    def do_item_action(self, touch):
        if self.is_dir:
            self.main_wid.current_dir = self.filepath

    def open_menu(self, touch, dt):
        log.debug(_("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=_('unshare'),
                                index=len(choices)+1,
                                callback=self.main_wid.unshare))
        else:
            choices.append(dict(text=_('share'),
                                index=len(choices)+1,
                                callback=self.main_wid.share))
        return choices


class RemotePathWidget(PathWidget):

    def __init__(self, main_wid, filepath, type_, **kw):
        self.type_ = type_
        super(RemotePathWidget, self).__init__(filepath, main_wid=main_wid, **kw)

    @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 == '..':
                self.main_wid.remote_entity = ''
            else:
                super(RemotePathWidget, self).do_item_action(touch)
        else:
            self.main_wid.request_item(self)
            return True

class SharingDeviceWidget(DeviceWidget):

    def do_item_action(self, touch):
        self.main_wid.remote_entity = self.entity_jid
        self.main_wid.remote_dir = ''


class FileSharing(quick_widgets.QuickWidget, cagou_widget.CagouWidget, FilterBehavior,
                  TouchMenuBehavior):
    SINGLE=False
    layout = properties.ObjectProperty()
    mode = properties.OptionProperty(MODE_VIEW, options=[MODE_VIEW, MODE_LOCAL])
    local_dir = properties.StringProperty(expanduser('~'))
    remote_dir = properties.StringProperty('')
    remote_entity = properties.StringProperty('')
    shared_paths = properties.ListProperty()
    use_header_input = True
    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)
        TouchMenuBehavior.__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()
        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):
        self.update_view(None, self.local_dir)

    def onHeaderInput(self):
        if '/' in self.header_input.text or self.header_input.text == '~':
            self.current_dir = expanduser(self.header_input.text)

    def onHeaderInputComplete(self, wid, text, **kwargs):
        """we filter items when text is entered in input box"""
        if '/' in text:
            return
        self.do_filter(self.layout,
                       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 == '..'])


    ## remote sharing callback ##

    def _discoFindByFeaturesCb(self, data):
        entities_services, entities_own, entities_roster = data
        for entities_map, title in ((entities_services,
                                     _('services')),
                                    (entities_own,
                                     _('your devices')),
                                    (entities_roster,
                                     _('your contacts devices'))):
            if entities_map:
                self.layout.add_widget(CategorySeparator(text=title))
                for entity_str, entity_ids in entities_map.items():
                    entity_jid = jid.JID(entity_str)
                    item = SharingDeviceWidget(
                        self, entity_jid, Identities(entity_ids))
                    self.layout.add_widget(item)
        if not entities_services and not entities_own and not entities_roster:
            self.layout.add_widget(Label(
                size_hint=(1, 1),
                halign='center',
                text_size=self.size,
                text=_("No sharing device found")))

    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 = _("can't find file information sharing namespace, "
                    "is the plugin running?")
            log.warning(msg)
            G.host.addNote(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
            return
        self.host.bridge.discoFindByFeatures(
            [namespace], [], False, True, True, True, False, self.profile,
            callback=self._discoFindByFeaturesCb,
            errback=partial(G.host.errback,
                title=_("shared folder error"),
                message=_("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['name'])
            item = RemotePathWidget(
                filepath=filepath,
                main_wid=self,
                type_=file_data['type'])
            self.layout.add_widget(item)

    def FISListEb(self, failure_):
        self.remote_dir = ''
        G.host.addNote(
            _("shared folder error"),
            _("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('updating {}, {}'.format(self.current_dir, args))
        self.layout.clear_widgets()
        self.header_input.text = ''
        self.header_input.hint_text = self.current_dir

        if self.mode == MODE_LOCAL:
            filepath = os.path.join(self.local_dir, '..')
            self.layout.add_widget(LocalPathWidget(filepath=filepath, main_wid=self))
            try:
                files = sorted(os.listdir(self.local_dir))
            except OSError as e:
                msg = _("can't list files in \"{local_dir}\": {msg}").format(
                    local_dir=self.local_dir,
                    msg=e)
                G.host.addNote(
                    _("shared folder error"),
                    msg,
                    level=C.XMLUI_DATA_LVL_WARNING)
                self.local_dir = expanduser('~')
                return
            for f in files:
                filepath = os.path.join(self.local_dir, f)
                self.layout.add_widget(LocalPathWidget(filepath=filepath,
                                                       main_wid=self))
        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, '..')
                item = RemotePathWidget(
                    filepath = parent_path,
                    main_wid=self,
                    type_ = C.FILE_TYPE_DIRECTORY)
                self.layout.add_widget(item)
                self.host.bridge.FISList(
                    str(self.remote_entity),
                    self.remote_dir,
                    {},
                    self.profile,
                    callback=self.FISListCb,
                    errback=self.FISListEb)

    ## Share methods ##

    def do_share(self, entities_jids, item):
        if entities_jids:
            access = {'read': {'type': 'whitelist',
                                '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(
                _("sharing folder"),
                _("{name} is now shared").format(name=name)),
            errback=partial(G.host.errback,
                title=_("sharing folder"),
                message=_("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(
                _("sharing folder"),
                _("{name} is not shared anymore").format(name=item.name)),
            errback=partial(G.host.errback,
                title=_("sharing folder"),
                message=_("can't unshare folder: {msg}")))

    def fileJingleRequestCb(self, progress_id, item, dest_path):
        G.host.addNote(
            _("file request"),
            _("{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(str(self.remote_entity),
                                        str(dest_path),
                                        name,
                                        '',
                                        '',
                                        extra,
                                        self.profile,
                                        callback=partial(self.fileJingleRequestCb,
                                            item=item,
                                            dest_path=dest_path),
                                        errback=partial(G.host.errback,
                                            title = _("file request error"),
                                            message = _("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(_("shared path {path} not found in {widget}".format(
                    path = shared_path, widget = wid)))