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