# HG changeset patch # User Goffi # Date 1527009923 -7200 # Node ID 62198e00a2b78b17644173989d9fbdf46bfb8e9b # Parent fda3f22aa3ce5b0dee937cc1ff8b1866ebf5d76f plugin file sharing: first draft: new file sharing plugin, which allow to share local file or retrieve remote ones. diff -r fda3f22aa3ce -r 62198e00a2b7 cagou/plugins/plugin_wid_file_sharing.kv --- /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 . + +#:import ModernMenu kivy.garden.modernmenu.ModernMenu + + +: + width: self.texture_size[0] + sp(20) + size_hint: None, 1 + + +: + 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 + + +: + size_hint: None, None + width: self.base_width + height: self.minimum_height + orientation: 'vertical' + + +: + 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' + + +: + shared: root.filepath in root.sharing_wid.shared_paths + + +: + 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' + + +: + size_hint: 1, None + height: sp(20) + + +: + creation_direction: -1 + radius: 25 + creation_timeout: .4 + cancel_color: app.c_sec_light[:3] + [0.3] + color: app.c_sec + +: + bg_color: app.c_sec[:3] + [0.9] diff -r fda3f22aa3ce -r 62198e00a2b7 cagou/plugins/plugin_wid_file_sharing.py --- /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 . + + +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)))