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