diff libervia/desktop_kivy/plugins/plugin_wid_file_sharing.py @ 493:b3cedbee561d

refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 18:26:16 +0200
parents cagou/plugins/plugin_wid_file_sharing.py@203755bbe0fe
children 196483685a63
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/plugins/plugin_wid_file_sharing.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,419 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# 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 libervia.backend.core import log as logging
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import files_utils
+from libervia.frontends.quick_frontend import quick_widgets
+from libervia.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 libervia.desktop_kivy 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 get_menu_choices(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.LiberviaDesktopKivyWidget, 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.LiberviaDesktopKivyWidget.__init__(self)
+        FilterBehavior.__init__(self)
+        TouchMenuBehavior.__init__(self)
+        self.mode_btn = ModeBtn(self)
+        self.mode_btn.bind(on_release=self.change_mode)
+        self.header_input_add_extra(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.register_signal("fis_shared_path_new",
+                                  handler=FileSharing.shared_path_new,
+                                  iface="plugin")
+            G.host.register_signal("fis_shared_path_removed",
+                                  handler=FileSharing.shared_path_removed,
+                                  iface="plugin")
+            FileSharing.signals_registered = True
+        G.host.bridge.fis_local_shares_get(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 on_header_wid_input(self):
+        if '/' in self.header_input.text or self.header_input.text == '~':
+            self.current_dir = expanduser(self.header_input.text)
+
+    def on_header_wid_input_complete(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 _disco_find_by_features_cb(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.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
+            return
+        self.host.bridge.disco_find_by_features(
+            [namespace], [], False, True, True, True, False, self.profile,
+            callback=self._disco_find_by_features_cb,
+            errback=partial(G.host.errback,
+                title=_("shared folder error"),
+                message=_("can't check sharing devices: {msg}")))
+
+    def fis_list_cb(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 fis_list_eb(self, failure_):
+        self.remote_dir = ''
+        G.host.add_note(
+            _("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.add_note(
+                    _("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.fis_list(
+                    str(self.remote_entity),
+                    self.remote_dir,
+                    {},
+                    self.profile,
+                    callback=self.fis_list_cb,
+                    errback=self.fis_list_eb)
+
+    ## Share methods ##
+
+    def do_share(self, entities_jids, item):
+        if entities_jids:
+            access = {'read': {'type': 'whitelist',
+                                'jids': entities_jids}}
+        else:
+            access = {}
+
+        G.host.bridge.fis_share_path(
+            item.name,
+            item.filepath,
+            json.dumps(access, ensure_ascii=False),
+            self.profile,
+            callback=lambda name: G.host.add_note(
+                _("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.fis_unshare_path(
+            item.filepath,
+            self.profile,
+            callback=lambda: G.host.add_note(
+                _("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 file_jingle_request_cb(self, progress_id, item, dest_path):
+        G.host.add_note(
+            _("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.file_jingle_request(str(self.remote_entity),
+                                        str(dest_path),
+                                        name,
+                                        '',
+                                        '',
+                                        extra,
+                                        self.profile,
+                                        callback=partial(self.file_jingle_request_cb,
+                                            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.get_visible_list(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.get_visible_list(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)))