Web · Wiki · Activities · Blog · Lists · Chat · Meeting · Bugs · Git · Translate · Archive · People · Donate
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)