Mercurial > libervia-desktop-kivy
comparison cagou/plugins/plugin_wid_file_sharing.py @ 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 | |
children | a68c9baa6694 |
comparison
equal
deleted
inserted
replaced
191:fda3f22aa3ce | 192:62198e00a2b7 |
---|---|
1 #!/usr/bin/python | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # Cagou: desktop/mobile frontend for Salut à Toi XMPP client | |
5 # Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 | |
21 from sat.core import log as logging | |
22 from sat.core import exceptions | |
23 log = logging.getLogger(__name__) | |
24 from sat.core.i18n import _ | |
25 from sat.tools.common import files_utils | |
26 from sat_frontends.quick_frontend import quick_widgets | |
27 from sat_frontends.tools import jid | |
28 from cagou.core.constants import Const as C | |
29 from cagou.core import cagou_widget | |
30 from cagou import G | |
31 from kivy import properties | |
32 from kivy.uix.label import Label | |
33 from kivy.uix.button import Button | |
34 from kivy.uix.boxlayout import BoxLayout | |
35 from kivy.garden import modernmenu | |
36 from kivy.clock import Clock | |
37 from kivy.metrics import dp | |
38 from kivy.animation import Animation | |
39 from functools import partial | |
40 import os.path | |
41 import json | |
42 | |
43 | |
44 PLUGIN_INFO = { | |
45 "name": _(u"file sharing"), | |
46 "main": "FileSharing", | |
47 "description": _(u"share/transfer files between devices"), | |
48 "icon_symbol": u"exchange", | |
49 } | |
50 MODE_VIEW = u"view" | |
51 MODE_LOCAL = u"local" | |
52 | |
53 | |
54 dist = modernmenu.dist | |
55 | |
56 | |
57 class ModeBtn(Button): | |
58 | |
59 def __init__(self, parent, **kwargs): | |
60 super(ModeBtn, self).__init__(**kwargs) | |
61 parent.bind(mode=self.on_mode) | |
62 self.on_mode(parent, parent.mode) | |
63 | |
64 def on_mode(self, parent, new_mode): | |
65 if new_mode == MODE_VIEW: | |
66 self.text = _(u"view shared files") | |
67 elif new_mode == MODE_LOCAL: | |
68 self.text = _(u"share local files") | |
69 else: | |
70 exceptions.InternalError(u"Unknown mode: {mode}".format(mode=new_mode)) | |
71 | |
72 | |
73 class Identities(object): | |
74 | |
75 def __init__(self, entity_ids): | |
76 identities = {} | |
77 for cat, type_, name in entity_ids: | |
78 identities.setdefault(cat, {}).setdefault(type_, []).append(name) | |
79 self.identities = identities | |
80 | |
81 @property | |
82 def name(self): | |
83 return self.identities.values()[0].values()[0][0] | |
84 | |
85 | |
86 class ItemWidget(BoxLayout): | |
87 click_timeout = properties.NumericProperty(0.4) | |
88 base_width = properties.NumericProperty(dp(100)) | |
89 | |
90 def __init__(self, sharing_wid, name): | |
91 self.sharing_wid = sharing_wid | |
92 self.name = name | |
93 super(ItemWidget, self).__init__() | |
94 | |
95 def on_touch_down(self, touch): | |
96 if not self.collide_point(*touch.pos): | |
97 return | |
98 t = partial(self.open_menu, touch) | |
99 touch.ud['menu_timeout'] = t | |
100 Clock.schedule_once(t, self.click_timeout) | |
101 return super(ItemWidget, self).on_touch_down(touch) | |
102 | |
103 def do_item_action(self, touch): | |
104 pass | |
105 | |
106 def on_touch_up(self, touch): | |
107 if touch.ud.get('menu_timeout'): | |
108 Clock.unschedule(touch.ud['menu_timeout']) | |
109 if self.collide_point(*touch.pos) and self.sharing_wid.menu is None: | |
110 self.do_item_action(touch) | |
111 return super(ItemWidget, self).on_touch_up(touch) | |
112 | |
113 def open_menu(self, touch, dt): | |
114 self.sharing_wid.open_menu(self, touch) | |
115 del touch.ud['menu_timeout'] | |
116 | |
117 def getMenuChoices(self): | |
118 """return choice adapted to selected item | |
119 | |
120 @return (list[dict]): choices ad expected by ModernMenu | |
121 """ | |
122 return [] | |
123 | |
124 | |
125 class PathWidget(ItemWidget): | |
126 | |
127 def __init__(self, sharing_wid, filepath): | |
128 name = os.path.basename(filepath) | |
129 self.filepath = os.path.normpath(filepath) | |
130 if self.filepath == u'.': | |
131 self.filepath = u'' | |
132 super(PathWidget, self).__init__(sharing_wid, name) | |
133 | |
134 @property | |
135 def is_dir(self): | |
136 raise NotImplementedError | |
137 | |
138 def do_item_action(self, touch): | |
139 if self.is_dir: | |
140 self.sharing_wid.current_dir = self.filepath | |
141 | |
142 def open_menu(self, touch, dt): | |
143 log.debug(_(u"opening menu for {path}").format(path=self.filepath)) | |
144 super(PathWidget, self).open_menu(touch, dt) | |
145 | |
146 | |
147 class LocalPathWidget(PathWidget): | |
148 | |
149 @property | |
150 def is_dir(self): | |
151 return os.path.isdir(self.filepath) | |
152 | |
153 def getMenuChoices(self): | |
154 choices = [] | |
155 if self.shared: | |
156 choices.append(dict(text=_(u'unshare'), | |
157 index=len(choices)+1, | |
158 callback=self.sharing_wid.unshare)) | |
159 else: | |
160 choices.append(dict(text=_(u'share'), | |
161 index=len(choices)+1, | |
162 callback=self.sharing_wid.share)) | |
163 return choices | |
164 | |
165 | |
166 class RemotePathWidget(PathWidget): | |
167 | |
168 def __init__(self, sharing_wid, filepath, type_): | |
169 self.type_ = type_ | |
170 super(RemotePathWidget, self).__init__(sharing_wid, filepath) | |
171 | |
172 @property | |
173 def is_dir(self): | |
174 return self.type_ == C.FILE_TYPE_DIRECTORY | |
175 | |
176 def do_item_action(self, touch): | |
177 if self.is_dir: | |
178 if self.filepath == u'..': | |
179 self.sharing_wid.remote_entity = u'' | |
180 else: | |
181 super(RemotePathWidget, self).do_item_action(touch) | |
182 else: | |
183 self.sharing_wid.request_item(self) | |
184 return True | |
185 | |
186 | |
187 class DeviceWidget(ItemWidget): | |
188 | |
189 def __init__(self, sharing_wid, entity_jid, identities): | |
190 self.entity_jid = entity_jid | |
191 self.identities = identities | |
192 self.own_device = entity_jid.bare == next(G.host.profiles.itervalues()).whoami | |
193 name = self.identities.name if self.own_device else self.entity_jid.node | |
194 super(DeviceWidget, self).__init__(sharing_wid, name) | |
195 | |
196 def do_item_action(self, touch): | |
197 self.sharing_wid.remote_entity = self.entity_jid | |
198 self.sharing_wid.remote_dir = u'' | |
199 | |
200 | |
201 class CategorySeparator(Label): | |
202 pass | |
203 | |
204 | |
205 class Menu(modernmenu.ModernMenu): | |
206 pass | |
207 | |
208 | |
209 class FileSharing(quick_widgets.QuickWidget, cagou_widget.CagouWidget): | |
210 SINGLE=False | |
211 float_layout = properties.ObjectProperty() | |
212 layout = properties.ObjectProperty() | |
213 mode = properties.OptionProperty(MODE_LOCAL, options=[MODE_VIEW, MODE_LOCAL]) | |
214 local_dir = properties.StringProperty(os.path.expanduser(u'~')) | |
215 remote_dir = properties.StringProperty(u'') | |
216 remote_entity = properties.StringProperty(u'') | |
217 shared_paths = properties.ListProperty() | |
218 signals_registered = False | |
219 | |
220 def __init__(self, host, target, profiles): | |
221 self._filter_last = u'' | |
222 self._filter_anim = Animation(width = 0, | |
223 height = 0, | |
224 opacity = 0, | |
225 d = 0.5) | |
226 quick_widgets.QuickWidget.__init__(self, host, target, profiles) | |
227 cagou_widget.CagouWidget.__init__(self) | |
228 self.mode_btn = ModeBtn(self) | |
229 self.mode_btn.bind(on_release=self.change_mode) | |
230 self.headerInputAddExtra(self.mode_btn) | |
231 self.bind(local_dir=self.update_view, | |
232 remote_dir=self.update_view, | |
233 remote_entity=self.update_view) | |
234 self.update_view() | |
235 self.menu = None | |
236 self.menu_item = None | |
237 self.float_layout.bind(children=self.clean_fl_children) | |
238 if not FileSharing.signals_registered: | |
239 # FIXME: we use this hack (registering the signal for the whole class) now | |
240 # as there is currently no unregisterSignal available in bridges | |
241 G.host.registerSignal("FISSharedPathNew", handler=FileSharing.shared_path_new, iface="plugin") | |
242 G.host.registerSignal("FISSharedPathRemoved", handler=FileSharing.shared_path_removed, iface="plugin") | |
243 FileSharing.signals_registered = True | |
244 G.host.bridge.FISLocalSharesGet(self.profile, | |
245 callback=self.fill_paths, | |
246 errback=G.host.errback) | |
247 | |
248 @property | |
249 def current_dir(self): | |
250 return self.local_dir if self.mode == MODE_LOCAL else self.remote_dir | |
251 | |
252 @current_dir.setter | |
253 def current_dir(self, new_dir): | |
254 if self.mode == MODE_LOCAL: | |
255 self.local_dir = new_dir | |
256 else: | |
257 self.remote_dir = new_dir | |
258 | |
259 def fill_paths(self, shared_paths): | |
260 self.shared_paths.extend(shared_paths) | |
261 | |
262 def change_mode(self, mode_btn): | |
263 self.clear_menu() | |
264 opt = self.__class__.mode.options | |
265 new_idx = (opt.index(self.mode)+1) % len(opt) | |
266 self.mode = opt[new_idx] | |
267 | |
268 def on_mode(self, instance, new_mode): | |
269 print(instance) | |
270 self.update_view(None, self.local_dir) | |
271 | |
272 def onHeaderInputComplete(self, wid, text): | |
273 """we filter items when text is entered in input box""" | |
274 text = text.strip().lower() | |
275 filtering = len(text)>len(self._filter_last) | |
276 self._filter_last = text | |
277 for child in self.layout.children: | |
278 if not isinstance(child, ItemWidget): | |
279 continue | |
280 if child.name == u'..': | |
281 continue | |
282 if text in child.name.lower(): | |
283 self._filter_anim.cancel(child) | |
284 child.width = child.base_width | |
285 child.height = child.minimum_height | |
286 child.opacity = 1 | |
287 elif (filtering | |
288 and child.opacity > 0 | |
289 and not self._filter_anim.have_properties_to_animate(child)): | |
290 self._filter_anim.start(child) | |
291 | |
292 ## remote sharing callback ## | |
293 | |
294 def _discoFindByFeaturesCb(self, data): | |
295 entities_services, entities_own, entities_roster = data | |
296 for entities_map, title in ((entities_services, | |
297 _(u'services')), | |
298 (entities_own, | |
299 _(u'your devices')), | |
300 (entities_roster, | |
301 _(u'your contacts devices'))): | |
302 if entities_map: | |
303 self.layout.add_widget(CategorySeparator(text=title)) | |
304 for entity_str, entity_ids in entities_map.iteritems(): | |
305 entity_jid = jid.JID(entity_str) | |
306 item = DeviceWidget(self, | |
307 entity_jid, | |
308 Identities(entity_ids)) | |
309 self.layout.add_widget(item) | |
310 | |
311 def discover_devices(self): | |
312 """Looks for devices handling file "File Information Sharing" and display them""" | |
313 try: | |
314 namespace = self.host.ns_map['fis'] | |
315 except KeyError: | |
316 msg = _(u"can't find file information sharing namespace, is the plugin running?") | |
317 log.warning(msg) | |
318 G.host.addNote(_(u"missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR) | |
319 return | |
320 self.host.bridge.discoFindByFeatures( | |
321 [namespace], [], False, True, True, True, self.profile, | |
322 callback=self._discoFindByFeaturesCb, | |
323 errback=partial(G.host.errback, | |
324 title=_(u"shared folder error"), | |
325 message=_(u"can't check sharing devices: {msg}"))) | |
326 | |
327 def FISListCb(self, files_data): | |
328 for file_data in files_data: | |
329 filepath = os.path.join(self.current_dir, file_data[u'name']) | |
330 item = RemotePathWidget( | |
331 self, | |
332 filepath=filepath, | |
333 type_=file_data[u'type']) | |
334 self.layout.add_widget(item) | |
335 | |
336 def FISListEb(self, failure_): | |
337 self.remote_dir = u'' | |
338 G.host.addNote( | |
339 _(u"shared folder error"), | |
340 _(u"can't list files for {remote_entity}: {msg}").format( | |
341 remote_entity=self.remote_entity, | |
342 msg=failure_), | |
343 level=C.XMLUI_DATA_LVL_WARNING) | |
344 | |
345 ## view generation ## | |
346 | |
347 def update_view(self, *args): | |
348 """update items according to current mode, entity and dir""" | |
349 log.debug(u'updating {}, {}'.format(self.current_dir, args)) | |
350 self.layout.clear_widgets() | |
351 self.header_input.text = u'' | |
352 if self.mode == MODE_LOCAL: | |
353 filepath = os.path.join(self.local_dir, u'..') | |
354 self.layout.add_widget(LocalPathWidget(sharing_wid=self, filepath=filepath)) | |
355 files = sorted(os.listdir(self.local_dir)) | |
356 for f in files: | |
357 filepath = os.path.join(self.local_dir, f) | |
358 self.layout.add_widget(LocalPathWidget(sharing_wid=self, filepath=filepath)) | |
359 elif self.mode == MODE_VIEW: | |
360 if not self.remote_entity: | |
361 self.discover_devices() | |
362 else: | |
363 # we always a way to go back | |
364 # so user can return to previous list even in case of error | |
365 parent_path = os.path.join(self.remote_dir, u'..') | |
366 item = RemotePathWidget( | |
367 self, | |
368 filepath = parent_path, | |
369 type_ = C.FILE_TYPE_DIRECTORY) | |
370 self.layout.add_widget(item) | |
371 self.host.bridge.FISList( | |
372 self.remote_entity, | |
373 self.remote_dir, | |
374 {}, | |
375 self.profile, | |
376 callback=self.FISListCb, | |
377 errback=self.FISListEb) | |
378 | |
379 ## menu methods ## | |
380 | |
381 def clean_fl_children(self, layout, children): | |
382 """insure that self.menu and self.menu_item are None when menu is dimissed""" | |
383 if self.menu is not None and self.menu not in children: | |
384 self.menu = self.menu_item = None | |
385 | |
386 def clear_menu(self): | |
387 """remove menu if there is one""" | |
388 if self.menu is not None: | |
389 self.menu.dismiss() | |
390 self.menu = None | |
391 self.menu_item = None | |
392 | |
393 def open_menu(self, item, touch): | |
394 """open menu for item | |
395 | |
396 @param item(PathWidget): item when the menu has been requested | |
397 @param touch(kivy.input.MotionEvent): touch data | |
398 """ | |
399 if self.menu_item == item: | |
400 return | |
401 self.clear_menu() | |
402 pos = self.to_widget(*touch.pos) | |
403 choices = item.getMenuChoices() | |
404 if not choices: | |
405 return | |
406 self.menu = Menu(choices=choices, | |
407 center=pos, | |
408 size_hint=(None, None)) | |
409 self.float_layout.add_widget(self.menu) | |
410 self.menu.start_display(touch) | |
411 self.menu_item = item | |
412 | |
413 ## Share methods ## | |
414 | |
415 def share(self, menu): | |
416 item = self.menu_item | |
417 self.clear_menu() | |
418 G.host.bridge.FISSharePath( | |
419 item.name, | |
420 item.filepath, | |
421 json.dumps({}, ensure_ascii=False), | |
422 self.profile, | |
423 callback=lambda name: G.host.addNote( | |
424 _(u"sharing folder"), | |
425 _(u"{name} is now shared").format(name=name)), | |
426 errback=partial(G.host.errback, | |
427 title=_(u"sharing folder"), | |
428 message=_(u"can't share folder: {msg}"))) | |
429 | |
430 def unshare(self, menu): | |
431 item = self.menu_item | |
432 self.clear_menu() | |
433 G.host.bridge.FISUnsharePath( | |
434 item.filepath, | |
435 self.profile, | |
436 callback=lambda: G.host.addNote( | |
437 _(u"sharing folder"), | |
438 _(u"{name} is not shared anymore").format(name=item.name)), | |
439 errback=partial(G.host.errback, | |
440 title=_(u"sharing folder"), | |
441 message=_(u"can't unshare folder: {msg}"))) | |
442 | |
443 def fileJingleRequestCb(self, progress_id, item, dest_path): | |
444 G.host.addNote( | |
445 _(u"file request"), | |
446 _(u"{name} download started at {dest_path}").format( | |
447 name = item.name, | |
448 dest_path = dest_path)) | |
449 | |
450 def request_item(self, item): | |
451 """Retrieve an item from remote entity | |
452 | |
453 @param item(RemotePathWidget): item to retrieve | |
454 """ | |
455 path, name = os.path.split(item.filepath) | |
456 assert name | |
457 assert self.remote_entity | |
458 extra = {'path': path} | |
459 dest_path = files_utils.get_unique_name(os.path.join(G.host.downloads_dir, name)) | |
460 G.host.bridge.fileJingleRequest(self.remote_entity, | |
461 dest_path, | |
462 name, | |
463 u'', | |
464 u'', | |
465 extra, | |
466 self.profile, | |
467 callback=partial(self.fileJingleRequestCb, | |
468 item=item, | |
469 dest_path=dest_path), | |
470 errback=partial(G.host.errback, | |
471 title = _(u"file request error"), | |
472 message = _(u"can't request file: {msg}"))) | |
473 | |
474 @classmethod | |
475 def shared_path_new(cls, shared_path, name, profile): | |
476 for wid in G.host.getVisibleList(cls): | |
477 if shared_path not in wid.shared_paths: | |
478 wid.shared_paths.append(shared_path) | |
479 | |
480 @classmethod | |
481 def shared_path_removed(cls, shared_path, profile): | |
482 for wid in G.host.getVisibleList(cls): | |
483 if shared_path in wid.shared_paths: | |
484 wid.shared_paths.remove(shared_path) | |
485 else: | |
486 log.warning(_(u"shared path {path} not found in {widget}".format( | |
487 path = shared_path, widget = wid))) |