comparison 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
comparison
equal deleted inserted replaced
492:5114bbb5daa3 493:b3cedbee561d
1 #!/usr/bin/env python3
2
3 #Libervia Desktop-Kivy
4 # Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19
20 from functools import partial
21 import os.path
22 import json
23 from libervia.backend.core import log as logging
24 from libervia.backend.core import exceptions
25 from libervia.backend.core.i18n import _
26 from libervia.backend.tools.common import files_utils
27 from libervia.frontends.quick_frontend import quick_widgets
28 from libervia.frontends.tools import jid
29 from ..core.constants import Const as C
30 from ..core import cagou_widget
31 from ..core.menu import EntitiesSelectorMenu
32 from ..core.behaviors import TouchMenuBehavior, FilterBehavior
33 from ..core.common_widgets import (Identities, ItemWidget, DeviceWidget,
34 CategorySeparator)
35 from libervia.desktop_kivy import G
36 from kivy import properties
37 from kivy.uix.label import Label
38 from kivy.uix.button import Button
39 from kivy import utils as kivy_utils
40
41 log = logging.getLogger(__name__)
42
43
44 PLUGIN_INFO = {
45 "name": _("file sharing"),
46 "main": "FileSharing",
47 "description": _("share/transfer files between devices"),
48 "icon_symbol": "exchange",
49 }
50 MODE_VIEW = "view"
51 MODE_LOCAL = "local"
52 SELECT_INSTRUCTIONS = _("Please select entities to share with")
53
54 if kivy_utils.platform == "android":
55 from jnius import autoclass
56 Environment = autoclass("android.os.Environment")
57 base_dir = Environment.getExternalStorageDirectory().getAbsolutePath()
58 def expanduser(path):
59 if path == '~' or path.startswith('~/'):
60 return path.replace('~', base_dir, 1)
61 return path
62 else:
63 expanduser = os.path.expanduser
64
65
66 class ModeBtn(Button):
67
68 def __init__(self, parent, **kwargs):
69 super(ModeBtn, self).__init__(**kwargs)
70 parent.bind(mode=self.on_mode)
71 self.on_mode(parent, parent.mode)
72
73 def on_mode(self, parent, new_mode):
74 if new_mode == MODE_VIEW:
75 self.text = _("view shared files")
76 elif new_mode == MODE_LOCAL:
77 self.text = _("share local files")
78 else:
79 exceptions.InternalError("Unknown mode: {mode}".format(mode=new_mode))
80
81
82 class PathWidget(ItemWidget):
83
84 def __init__(self, filepath, main_wid, **kw):
85 name = os.path.basename(filepath)
86 self.filepath = os.path.normpath(filepath)
87 if self.filepath == '.':
88 self.filepath = ''
89 super(PathWidget, self).__init__(name=name, main_wid=main_wid, **kw)
90
91 @property
92 def is_dir(self):
93 raise NotImplementedError
94
95 def do_item_action(self, touch):
96 if self.is_dir:
97 self.main_wid.current_dir = self.filepath
98
99 def open_menu(self, touch, dt):
100 log.debug(_("opening menu for {path}").format(path=self.filepath))
101 super(PathWidget, self).open_menu(touch, dt)
102
103
104 class LocalPathWidget(PathWidget):
105
106 @property
107 def is_dir(self):
108 return os.path.isdir(self.filepath)
109
110 def get_menu_choices(self):
111 choices = []
112 if self.shared:
113 choices.append(dict(text=_('unshare'),
114 index=len(choices)+1,
115 callback=self.main_wid.unshare))
116 else:
117 choices.append(dict(text=_('share'),
118 index=len(choices)+1,
119 callback=self.main_wid.share))
120 return choices
121
122
123 class RemotePathWidget(PathWidget):
124
125 def __init__(self, main_wid, filepath, type_, **kw):
126 self.type_ = type_
127 super(RemotePathWidget, self).__init__(filepath, main_wid=main_wid, **kw)
128
129 @property
130 def is_dir(self):
131 return self.type_ == C.FILE_TYPE_DIRECTORY
132
133 def do_item_action(self, touch):
134 if self.is_dir:
135 if self.filepath == '..':
136 self.main_wid.remote_entity = ''
137 else:
138 super(RemotePathWidget, self).do_item_action(touch)
139 else:
140 self.main_wid.request_item(self)
141 return True
142
143 class SharingDeviceWidget(DeviceWidget):
144
145 def do_item_action(self, touch):
146 self.main_wid.remote_entity = self.entity_jid
147 self.main_wid.remote_dir = ''
148
149
150 class FileSharing(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior,
151 TouchMenuBehavior):
152 SINGLE=False
153 layout = properties.ObjectProperty()
154 mode = properties.OptionProperty(MODE_VIEW, options=[MODE_VIEW, MODE_LOCAL])
155 local_dir = properties.StringProperty(expanduser('~'))
156 remote_dir = properties.StringProperty('')
157 remote_entity = properties.StringProperty('')
158 shared_paths = properties.ListProperty()
159 use_header_input = True
160 signals_registered = False
161
162 def __init__(self, host, target, profiles):
163 quick_widgets.QuickWidget.__init__(self, host, target, profiles)
164 cagou_widget.LiberviaDesktopKivyWidget.__init__(self)
165 FilterBehavior.__init__(self)
166 TouchMenuBehavior.__init__(self)
167 self.mode_btn = ModeBtn(self)
168 self.mode_btn.bind(on_release=self.change_mode)
169 self.header_input_add_extra(self.mode_btn)
170 self.bind(local_dir=self.update_view,
171 remote_dir=self.update_view,
172 remote_entity=self.update_view)
173 self.update_view()
174 if not FileSharing.signals_registered:
175 # FIXME: we use this hack (registering the signal for the whole class) now
176 # as there is currently no unregisterSignal available in bridges
177 G.host.register_signal("fis_shared_path_new",
178 handler=FileSharing.shared_path_new,
179 iface="plugin")
180 G.host.register_signal("fis_shared_path_removed",
181 handler=FileSharing.shared_path_removed,
182 iface="plugin")
183 FileSharing.signals_registered = True
184 G.host.bridge.fis_local_shares_get(self.profile,
185 callback=self.fill_paths,
186 errback=G.host.errback)
187
188 @property
189 def current_dir(self):
190 return self.local_dir if self.mode == MODE_LOCAL else self.remote_dir
191
192 @current_dir.setter
193 def current_dir(self, new_dir):
194 if self.mode == MODE_LOCAL:
195 self.local_dir = new_dir
196 else:
197 self.remote_dir = new_dir
198
199 def fill_paths(self, shared_paths):
200 self.shared_paths.extend(shared_paths)
201
202 def change_mode(self, mode_btn):
203 self.clear_menu()
204 opt = self.__class__.mode.options
205 new_idx = (opt.index(self.mode)+1) % len(opt)
206 self.mode = opt[new_idx]
207
208 def on_mode(self, instance, new_mode):
209 self.update_view(None, self.local_dir)
210
211 def on_header_wid_input(self):
212 if '/' in self.header_input.text or self.header_input.text == '~':
213 self.current_dir = expanduser(self.header_input.text)
214
215 def on_header_wid_input_complete(self, wid, text, **kwargs):
216 """we filter items when text is entered in input box"""
217 if '/' in text:
218 return
219 self.do_filter(self.layout,
220 text,
221 lambda c: c.name,
222 width_cb=lambda c: c.base_width,
223 height_cb=lambda c: c.minimum_height,
224 continue_tests=[lambda c: not isinstance(c, ItemWidget),
225 lambda c: c.name == '..'])
226
227
228 ## remote sharing callback ##
229
230 def _disco_find_by_features_cb(self, data):
231 entities_services, entities_own, entities_roster = data
232 for entities_map, title in ((entities_services,
233 _('services')),
234 (entities_own,
235 _('your devices')),
236 (entities_roster,
237 _('your contacts devices'))):
238 if entities_map:
239 self.layout.add_widget(CategorySeparator(text=title))
240 for entity_str, entity_ids in entities_map.items():
241 entity_jid = jid.JID(entity_str)
242 item = SharingDeviceWidget(
243 self, entity_jid, Identities(entity_ids))
244 self.layout.add_widget(item)
245 if not entities_services and not entities_own and not entities_roster:
246 self.layout.add_widget(Label(
247 size_hint=(1, 1),
248 halign='center',
249 text_size=self.size,
250 text=_("No sharing device found")))
251
252 def discover_devices(self):
253 """Looks for devices handling file "File Information Sharing" and display them"""
254 try:
255 namespace = self.host.ns_map['fis']
256 except KeyError:
257 msg = _("can't find file information sharing namespace, "
258 "is the plugin running?")
259 log.warning(msg)
260 G.host.add_note(_("missing plugin"), msg, C.XMLUI_DATA_LVL_ERROR)
261 return
262 self.host.bridge.disco_find_by_features(
263 [namespace], [], False, True, True, True, False, self.profile,
264 callback=self._disco_find_by_features_cb,
265 errback=partial(G.host.errback,
266 title=_("shared folder error"),
267 message=_("can't check sharing devices: {msg}")))
268
269 def fis_list_cb(self, files_data):
270 for file_data in files_data:
271 filepath = os.path.join(self.current_dir, file_data['name'])
272 item = RemotePathWidget(
273 filepath=filepath,
274 main_wid=self,
275 type_=file_data['type'])
276 self.layout.add_widget(item)
277
278 def fis_list_eb(self, failure_):
279 self.remote_dir = ''
280 G.host.add_note(
281 _("shared folder error"),
282 _("can't list files for {remote_entity}: {msg}").format(
283 remote_entity=self.remote_entity,
284 msg=failure_),
285 level=C.XMLUI_DATA_LVL_WARNING)
286
287 ## view generation ##
288
289 def update_view(self, *args):
290 """update items according to current mode, entity and dir"""
291 log.debug('updating {}, {}'.format(self.current_dir, args))
292 self.layout.clear_widgets()
293 self.header_input.text = ''
294 self.header_input.hint_text = self.current_dir
295
296 if self.mode == MODE_LOCAL:
297 filepath = os.path.join(self.local_dir, '..')
298 self.layout.add_widget(LocalPathWidget(filepath=filepath, main_wid=self))
299 try:
300 files = sorted(os.listdir(self.local_dir))
301 except OSError as e:
302 msg = _("can't list files in \"{local_dir}\": {msg}").format(
303 local_dir=self.local_dir,
304 msg=e)
305 G.host.add_note(
306 _("shared folder error"),
307 msg,
308 level=C.XMLUI_DATA_LVL_WARNING)
309 self.local_dir = expanduser('~')
310 return
311 for f in files:
312 filepath = os.path.join(self.local_dir, f)
313 self.layout.add_widget(LocalPathWidget(filepath=filepath,
314 main_wid=self))
315 elif self.mode == MODE_VIEW:
316 if not self.remote_entity:
317 self.discover_devices()
318 else:
319 # we always a way to go back
320 # so user can return to previous list even in case of error
321 parent_path = os.path.join(self.remote_dir, '..')
322 item = RemotePathWidget(
323 filepath = parent_path,
324 main_wid=self,
325 type_ = C.FILE_TYPE_DIRECTORY)
326 self.layout.add_widget(item)
327 self.host.bridge.fis_list(
328 str(self.remote_entity),
329 self.remote_dir,
330 {},
331 self.profile,
332 callback=self.fis_list_cb,
333 errback=self.fis_list_eb)
334
335 ## Share methods ##
336
337 def do_share(self, entities_jids, item):
338 if entities_jids:
339 access = {'read': {'type': 'whitelist',
340 'jids': entities_jids}}
341 else:
342 access = {}
343
344 G.host.bridge.fis_share_path(
345 item.name,
346 item.filepath,
347 json.dumps(access, ensure_ascii=False),
348 self.profile,
349 callback=lambda name: G.host.add_note(
350 _("sharing folder"),
351 _("{name} is now shared").format(name=name)),
352 errback=partial(G.host.errback,
353 title=_("sharing folder"),
354 message=_("can't share folder: {msg}")))
355
356 def share(self, menu):
357 item = self.menu_item
358 self.clear_menu()
359 EntitiesSelectorMenu(instructions=SELECT_INSTRUCTIONS,
360 callback=partial(self.do_share, item=item)).show()
361
362 def unshare(self, menu):
363 item = self.menu_item
364 self.clear_menu()
365 G.host.bridge.fis_unshare_path(
366 item.filepath,
367 self.profile,
368 callback=lambda: G.host.add_note(
369 _("sharing folder"),
370 _("{name} is not shared anymore").format(name=item.name)),
371 errback=partial(G.host.errback,
372 title=_("sharing folder"),
373 message=_("can't unshare folder: {msg}")))
374
375 def file_jingle_request_cb(self, progress_id, item, dest_path):
376 G.host.add_note(
377 _("file request"),
378 _("{name} download started at {dest_path}").format(
379 name = item.name,
380 dest_path = dest_path))
381
382 def request_item(self, item):
383 """Retrieve an item from remote entity
384
385 @param item(RemotePathWidget): item to retrieve
386 """
387 path, name = os.path.split(item.filepath)
388 assert name
389 assert self.remote_entity
390 extra = {'path': path}
391 dest_path = files_utils.get_unique_name(os.path.join(G.host.downloads_dir, name))
392 G.host.bridge.file_jingle_request(str(self.remote_entity),
393 str(dest_path),
394 name,
395 '',
396 '',
397 extra,
398 self.profile,
399 callback=partial(self.file_jingle_request_cb,
400 item=item,
401 dest_path=dest_path),
402 errback=partial(G.host.errback,
403 title = _("file request error"),
404 message = _("can't request file: {msg}")))
405
406 @classmethod
407 def shared_path_new(cls, shared_path, name, profile):
408 for wid in G.host.get_visible_list(cls):
409 if shared_path not in wid.shared_paths:
410 wid.shared_paths.append(shared_path)
411
412 @classmethod
413 def shared_path_removed(cls, shared_path, profile):
414 for wid in G.host.get_visible_list(cls):
415 if shared_path in wid.shared_paths:
416 wid.shared_paths.remove(shared_path)
417 else:
418 log.warning(_("shared path {path} not found in {widget}".format(
419 path = shared_path, widget = wid)))