1 |
# Copyright (C) 2007, One Laptop Per Child |
2 |
# Copyright (C) 2009, Tomeu Vizoso, Lucian Branescu |
3 |
# |
4 |
# This program is free software; you can redistribute it and/or modify |
5 |
# it under the terms of the GNU General Public License as published by |
6 |
# the Free Software Foundation; either version 2 of the License, or |
7 |
# (at your option) any later version. |
8 |
# |
9 |
# This program is distributed in the hope that it will be useful, |
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 |
# GNU General Public License for more details. |
13 |
# |
14 |
# You should have received a copy of the GNU General Public License |
15 |
# along with this program; if not, write to the Free Software |
16 |
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA |
17 |
|
18 |
import os |
19 |
import logging |
20 |
from gettext import gettext as _ |
21 |
import tempfile |
22 |
import dbus |
23 |
import cairo |
24 |
import StringIO |
25 |
|
26 |
from gi.repository import Gtk |
27 |
from gi.repository import Gdk |
28 |
from gi.repository import WebKit |
29 |
from gi.repository import GdkPixbuf |
30 |
from gi.repository import GObject |
31 |
|
32 |
from sugar3.datastore import datastore |
33 |
from sugar3 import profile |
34 |
from sugar3 import mime |
35 |
from sugar3.graphics.alert import Alert, TimeoutAlert |
36 |
from sugar3.graphics.icon import Icon |
37 |
from sugar3.activity import activity |
38 |
|
39 |
DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' |
40 |
DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' |
41 |
DS_DBUS_PATH = '/org/laptop/sugar/DataStore' |
42 |
|
43 |
_active_downloads = [] |
44 |
_dest_to_window = {} |
45 |
|
46 |
PROGRESS_TIMEOUT = 3000 |
47 |
SPACE_THRESHOLD = 52428800 # 50 Mb |
48 |
|
49 |
def format_float(f): |
50 |
return "%0.2f" % f |
51 |
|
52 |
def can_quit(): |
53 |
return len(_active_downloads) == 0 |
54 |
|
55 |
|
56 |
def num_downloads(): |
57 |
return len(_active_downloads) |
58 |
|
59 |
|
60 |
def remove_all_downloads(): |
61 |
for download in _active_downloads: |
62 |
download.cancel() |
63 |
if download.dl_jobject is not None: |
64 |
datastore.delete(download.dl_jobject.object_id) |
65 |
download.cleanup() |
66 |
|
67 |
|
68 |
class Download(object): |
69 |
def __init__(self, download, browser): |
70 |
self._download = download |
71 |
self._activity = browser.get_toplevel() |
72 |
self._source = download.get_uri() |
73 |
|
74 |
self._download.connect('notify::status', self.__state_change_cb) |
75 |
self._download.connect('error', self.__error_cb) |
76 |
|
77 |
self.datastore_deleted_handler = None |
78 |
|
79 |
self.dl_jobject = None |
80 |
self._object_id = None |
81 |
self._stop_alert = None |
82 |
|
83 |
self._progress = 0 |
84 |
self._last_update_progress = 0 |
85 |
self._progress_sid = None |
86 |
|
87 |
# figure out download URI |
88 |
self.temp_path = os.path.join(activity.get_activity_root(), 'instance') |
89 |
if not os.path.exists(self.temp_path): |
90 |
os.makedirs(self.temp_path) |
91 |
|
92 |
fd, self._dest_path = tempfile.mkstemp(dir=self.temp_path, |
93 |
suffix=download.get_suggested_filename(), |
94 |
prefix='tmp') |
95 |
os.close(fd) |
96 |
logging.debug('Download destination path: %s' % self._dest_path) |
97 |
|
98 |
# We have to start the download to get 'total-size' |
99 |
# property. It not, 0 is returned |
100 |
self._download.set_destination_uri('file://' + self._dest_path) |
101 |
self._download.start() |
102 |
|
103 |
def _update_progress(self): |
104 |
if self._progress > self._last_update_progress: |
105 |
self._last_update_progress = self._progress |
106 |
self.dl_jobject.metadata['progress'] = str(self._progress) |
107 |
datastore.write(self.dl_jobject) |
108 |
|
109 |
self._progress_sid = None |
110 |
return False |
111 |
|
112 |
def __progress_change_cb(self, download, something): |
113 |
self._progress = int(self._download.get_progress() * 100) |
114 |
|
115 |
if self._progress_sid is None: |
116 |
self._progress_sid = GObject.timeout_add( |
117 |
PROGRESS_TIMEOUT, self._update_progress) |
118 |
|
119 |
def __current_size_changed_cb(self, download, something): |
120 |
current_size = self._download.get_current_size() |
121 |
total_size = self._download.get_total_size() |
122 |
self._progress = int(current_size * 100 / total_size) |
123 |
|
124 |
if self._progress_sid is None: |
125 |
self._progress_sid = GObject.timeout_add( |
126 |
PROGRESS_TIMEOUT, self._update_progress) |
127 |
|
128 |
def __state_change_cb(self, download, gparamspec): |
129 |
state = self._download.get_status() |
130 |
if state == WebKit.DownloadStatus.STARTED: |
131 |
# Check free space and cancel the download if there is not enough. |
132 |
total_size = self._download.get_total_size() |
133 |
logging.debug('Total size of the file: %s', total_size) |
134 |
enough_space = self.enough_space( |
135 |
total_size, path=self.temp_path) |
136 |
if not enough_space: |
137 |
logging.debug('Download canceled because of Disk Space') |
138 |
self.cancel() |
139 |
|
140 |
self._canceled_alert = Alert() |
141 |
self._canceled_alert.props.title = _('Not enough space ' |
142 |
'to download') |
143 |
|
144 |
total_size_mb = total_size / 1024.0 ** 2 |
145 |
free_space_mb = (self._free_available_space( |
146 |
path=self.temp_path) - SPACE_THRESHOLD) \ |
147 |
/ 1024.0 ** 2 |
148 |
filename = self._download.get_suggested_filename() |
149 |
self._canceled_alert.props.msg = \ |
150 |
_('Download "%{filename}" requires %{total_size_in_mb}' \ |
151 |
' MB of free space, only %{free_space_in_mb} MB' \ |
152 |
' is available' % \ |
153 |
{'filename': filename, |
154 |
'total_size_in_mb': format_float(total_size_mb), |
155 |
'free_space_in_mb': format_float(free_space_mb)}) |
156 |
ok_icon = Icon(icon_name='dialog-ok') |
157 |
self._canceled_alert.add_button(Gtk.ResponseType.OK, |
158 |
_('Ok'), ok_icon) |
159 |
ok_icon.show() |
160 |
self._canceled_alert.connect('response', |
161 |
self.__stop_response_cb) |
162 |
self._activity.add_alert(self._canceled_alert) |
163 |
else: |
164 |
# FIXME: workaround for SL #4385 |
165 |
# self._download.connect('notify::progress', |
166 |
# self.__progress_change_cb) |
167 |
self._download.connect('notify::current-size', |
168 |
self.__current_size_changed_cb) |
169 |
|
170 |
self._create_journal_object() |
171 |
self._object_id = self.dl_jobject.object_id |
172 |
|
173 |
alert = TimeoutAlert(9) |
174 |
alert.props.title = _('Download started') |
175 |
alert.props.msg = _('%s' % |
176 |
self._download.get_suggested_filename()) |
177 |
self._activity.add_alert(alert) |
178 |
alert.connect('response', self.__start_response_cb) |
179 |
alert.show() |
180 |
global _active_downloads |
181 |
_active_downloads.append(self) |
182 |
|
183 |
elif state == WebKit.DownloadStatus.FINISHED: |
184 |
self._stop_alert = Alert() |
185 |
self._stop_alert.props.title = _('Download completed') |
186 |
self._stop_alert.props.msg = \ |
187 |
_('%s' % self._download.get_suggested_filename()) |
188 |
open_icon = Icon(icon_name='zoom-activity') |
189 |
self._stop_alert.add_button(Gtk.ResponseType.APPLY, |
190 |
_('Show in Journal'), open_icon) |
191 |
open_icon.show() |
192 |
ok_icon = Icon(icon_name='dialog-ok') |
193 |
self._stop_alert.add_button(Gtk.ResponseType.OK, _('Ok'), ok_icon) |
194 |
ok_icon.show() |
195 |
self._activity.add_alert(self._stop_alert) |
196 |
self._stop_alert.connect('response', self.__stop_response_cb) |
197 |
self._stop_alert.show() |
198 |
|
199 |
if self._progress_sid is not None: |
200 |
GObject.source_remove(self._progress_sid) |
201 |
|
202 |
self.dl_jobject.metadata['title'] = \ |
203 |
self._download.get_suggested_filename() |
204 |
self.dl_jobject.metadata['description'] = _('From: %s') \ |
205 |
% self._source |
206 |
self.dl_jobject.metadata['progress'] = '100' |
207 |
self.dl_jobject.file_path = self._dest_path |
208 |
|
209 |
# sniff for a mime type, no way to get headers from WebKit |
210 |
sniffed_mime_type = mime.get_for_file(self._dest_path) |
211 |
self.dl_jobject.metadata['mime_type'] = sniffed_mime_type |
212 |
|
213 |
if sniffed_mime_type in ('image/bmp','image/gif','image/jpeg', |
214 |
'image/png','image/tiff'): |
215 |
preview = self._get_preview() |
216 |
if preview is not None: |
217 |
self.dl_jobject.metadata['preview'] = \ |
218 |
dbus.ByteArray(preview) |
219 |
|
220 |
datastore.write(self.dl_jobject, |
221 |
transfer_ownership=True, |
222 |
reply_handler=self.__internal_save_cb, |
223 |
error_handler=self.__internal_error_cb, |
224 |
timeout=360) |
225 |
|
226 |
elif state == WebKit.DownloadStatus.CANCELLED: |
227 |
self.cleanup() |
228 |
|
229 |
def __error_cb(self, download, err_code, err_detail, reason): |
230 |
logging.debug('Error downloading URI code %s, detail %s: %s' |
231 |
% (err_code, err_detail, reason)) |
232 |
|
233 |
def __internal_save_cb(self): |
234 |
logging.debug('Object saved succesfully to the datastore.') |
235 |
self.cleanup() |
236 |
|
237 |
def __internal_error_cb(self, err): |
238 |
logging.debug('Error saving activity object to datastore: %s' % err) |
239 |
self.cleanup() |
240 |
|
241 |
def __start_response_cb(self, alert, response_id): |
242 |
global _active_downloads |
243 |
if response_id is Gtk.ResponseType.CANCEL: |
244 |
logging.debug('Download Canceled') |
245 |
self.cancel() |
246 |
try: |
247 |
datastore.delete(self._object_id) |
248 |
except Exception, e: |
249 |
logging.warning('Object has been deleted already %s' % e) |
250 |
|
251 |
self.cleanup() |
252 |
if self._stop_alert is not None: |
253 |
self._activity.remove_alert(self._stop_alert) |
254 |
|
255 |
self._activity.remove_alert(alert) |
256 |
|
257 |
def __stop_response_cb(self, alert, response_id): |
258 |
global _active_downloads |
259 |
if response_id is Gtk.ResponseType.APPLY: |
260 |
logging.debug('Start application with downloaded object') |
261 |
activity.show_object_in_journal(self._object_id) |
262 |
self._activity.remove_alert(alert) |
263 |
|
264 |
def cleanup(self): |
265 |
global _active_downloads |
266 |
if self in _active_downloads: |
267 |
_active_downloads.remove(self) |
268 |
|
269 |
if self.datastore_deleted_handler is not None: |
270 |
self.datastore_deleted_handler.remove() |
271 |
self.datastore_deleted_handler = None |
272 |
|
273 |
if os.path.isfile(self._dest_path): |
274 |
os.remove(self._dest_path) |
275 |
|
276 |
if self.dl_jobject is not None: |
277 |
self.dl_jobject.destroy() |
278 |
self.dl_jobject = None |
279 |
|
280 |
def cancel(self): |
281 |
self._download.cancel() |
282 |
|
283 |
def enough_space(self, size, path='/'): |
284 |
"""Check if there is enough (size) free space on path |
285 |
|
286 |
size -- free space requested in Bytes |
287 |
|
288 |
path -- device where the check will be done. For example: '/tmp' |
289 |
|
290 |
This method is useful to check the free space, for example, |
291 |
before starting a download from internet, creating a big map |
292 |
in some game or whatever action that needs some space in the |
293 |
Hard Disk. |
294 |
""" |
295 |
|
296 |
free_space = self._free_available_space(path=path) |
297 |
return free_space - size > SPACE_THRESHOLD |
298 |
|
299 |
def _free_available_space(self, path='/'): |
300 |
"""Return available space in Bytes |
301 |
|
302 |
This method returns the available free space in the 'path' and |
303 |
returns this amount in Bytes. |
304 |
""" |
305 |
|
306 |
s = os.statvfs(path) |
307 |
return s.f_bavail * s.f_frsize |
308 |
|
309 |
def _create_journal_object(self): |
310 |
self.dl_jobject = datastore.create() |
311 |
self.dl_jobject.metadata['title'] = \ |
312 |
_('Downloading %(filename)s from \n%(source)s.') % \ |
313 |
{'filename': self._download.get_suggested_filename(), |
314 |
'source': self._source} |
315 |
|
316 |
self.dl_jobject.metadata['progress'] = '0' |
317 |
self.dl_jobject.metadata['keep'] = '0' |
318 |
self.dl_jobject.metadata['buddies'] = '' |
319 |
self.dl_jobject.metadata['preview'] = '' |
320 |
self.dl_jobject.metadata['icon-color'] = \ |
321 |
profile.get_color().to_string() |
322 |
self.dl_jobject.metadata['mime_type'] = '' |
323 |
self.dl_jobject.file_path = '' |
324 |
datastore.write(self.dl_jobject) |
325 |
|
326 |
bus = dbus.SessionBus() |
327 |
obj = bus.get_object(DS_DBUS_SERVICE, DS_DBUS_PATH) |
328 |
datastore_dbus = dbus.Interface(obj, DS_DBUS_INTERFACE) |
329 |
self.datastore_deleted_handler = datastore_dbus.connect_to_signal( |
330 |
'Deleted', self.__datastore_deleted_cb, |
331 |
arg0=self.dl_jobject.object_id) |
332 |
|
333 |
def _get_preview(self): |
334 |
# This code borrows from sugar3.activity.Activity.get_preview |
335 |
# to make the preview with cairo, and also uses GdkPixbuf to |
336 |
# load any GdkPixbuf supported format. |
337 |
pixbuf = GdkPixbuf.Pixbuf.new_from_file(self._dest_path) |
338 |
image_width = pixbuf.get_width() |
339 |
image_height = pixbuf.get_height() |
340 |
|
341 |
preview_width, preview_height = activity.PREVIEW_SIZE |
342 |
preview_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, |
343 |
preview_width, preview_height) |
344 |
cr = cairo.Context(preview_surface) |
345 |
|
346 |
scale_w = preview_width * 1.0 / image_width |
347 |
scale_h = preview_height * 1.0 / image_height |
348 |
scale = min(scale_w, scale_h) |
349 |
|
350 |
translate_x = int((preview_width - (image_width * scale)) / 2) |
351 |
translate_y = int((preview_height - (image_height * scale)) / 2) |
352 |
|
353 |
cr.translate(translate_x, translate_y) |
354 |
cr.scale(scale, scale) |
355 |
|
356 |
cr.set_source_rgba(1, 1, 1, 0) |
357 |
cr.set_operator(cairo.OPERATOR_SOURCE) |
358 |
cr.paint() |
359 |
Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0) |
360 |
cr.paint() |
361 |
|
362 |
preview_str = StringIO.StringIO() |
363 |
preview_surface.write_to_png(preview_str) |
364 |
return preview_str.getvalue() |
365 |
|
366 |
def __datastore_deleted_cb(self, uid): |
367 |
logging.debug('Downloaded entry has been deleted' \ |
368 |
' from the datastore: %r', uid) |
369 |
global _active_downloads |
370 |
if self in _active_downloads: |
371 |
self.cancel() |
372 |
self.cleanup() |
373 |
|
374 |
|
375 |
def add_download(download, browser): |
376 |
download = Download(download, browser) |