Bug 938023 - Implement the download API : Part 2, implementation. r=auswerk
authorFabrice Desré <fabrice@mozilla.com>
Wed, 27 Nov 2013 23:18:08 -0800
changeset 158080 4811dfce04077df38979fedab305d5adaa5fb16f
parent 158079 19fbd3fb0373b46b02c6fe79c56a867161255bcb
child 158081 401fdb5f8c768123096a2ef0f7ff7209437d4bcf
push id25733
push usercbook@mozilla.com
push dateFri, 29 Nov 2013 09:20:31 +0000
treeherdermozilla-central@1e3712793e7d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersauswerk
bugs938023
milestone28.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 938023 - Implement the download API : Part 2, implementation. r=auswerk
b2g/app/b2g.js
b2g/chrome/content/shell.js
b2g/confvars.sh
b2g/installer/package-manifest.in
content/events/test/test_all_synthetic_events.html
dom/apps/src/PermissionsTable.jsm
dom/base/DOMRequestHelper.jsm
dom/downloads/moz.build
dom/downloads/src/DownloadsAPI.js
dom/downloads/src/DownloadsAPI.jsm
dom/downloads/src/DownloadsAPI.manifest
dom/downloads/src/DownloadsIPC.jsm
dom/downloads/src/moz.build
dom/moz.build
dom/tests/mochitest/general/test_interfaces.html
toolkit/components/jsdownloads/src/DownloadIntegration.jsm
toolkit/components/jsdownloads/src/DownloadUIHelper.jsm
--- a/b2g/app/b2g.js
+++ b/b2g/app/b2g.js
@@ -836,8 +836,9 @@ pref("ril.cellbroadcast.disabled", false
 // The url of the page used to display network error details.
 pref("b2g.neterror.url", "app://system.gaiamobile.org/net_error.html");
 
 // Enable Web Speech synthesis API
 pref("media.webspeech.synth.enabled", true);
 
 // Downloads API
 pref("dom.mozDownloads.enabled", true);
+pref("dom.downloads.max_retention_days", 7);
--- a/b2g/chrome/content/shell.js
+++ b/b2g/chrome/content/shell.js
@@ -21,16 +21,18 @@ Cu.import('resource://gre/modules/ErrorP
 #ifdef MOZ_WIDGET_GONK
 Cu.import('resource://gre/modules/NetworkStatsService.jsm');
 #endif
 
 // identity
 Cu.import('resource://gre/modules/SignInToWebsite.jsm');
 SignInToWebsiteController.init();
 
+Cu.import('resource://gre/modules/DownloadsAPI.jsm');
+
 XPCOMUtils.defineLazyServiceGetter(Services, 'env',
                                    '@mozilla.org/process/environment;1',
                                    'nsIEnvironment');
 
 XPCOMUtils.defineLazyServiceGetter(Services, 'ss',
                                    '@mozilla.org/content/style-sheet-service;1',
                                    'nsIStyleSheetService');
 
@@ -1459,8 +1461,26 @@ Services.obs.addObserver(function resetP
 #endif
   },
   'profile-before-change2', false);
 
   let appStartup = Cc['@mozilla.org/toolkit/app-startup;1']
                      .getService(Ci.nsIAppStartup);
   appStartup.quit(Ci.nsIAppStartup.eForceQuit);
 }, 'b2g-reset-profile', false);
+
+/**
+  * CID of our implementation of nsIDownloadManagerUI.
+  */
+const kTransferCid = Components.ID("{1b4c85df-cbdd-4bb6-b04e-613caece083c}");
+
+/**
+  * Contract ID of the service implementing nsITransfer.
+  */
+const kTransferContractId = "@mozilla.org/transfer;1";
+
+// Override Toolkit's nsITransfer implementation with the one from the
+// JavaScript API for downloads.  This will eventually be removed when
+// nsIDownloadManager will not be available anymore (bug 851471).  The
+// old code in this module will be removed in bug 899110.
+Components.manager.QueryInterface(Ci.nsIComponentRegistrar)
+                  .registerFactory(kTransferCid, "",
+                                   kTransferContractId, null);
--- a/b2g/confvars.sh
+++ b/b2g/confvars.sh
@@ -53,8 +53,10 @@ MOZ_TIME_MANAGER=1
 MOZ_B2G_CERTDATA=1
 MOZ_PAY=1
 MOZ_TOOLKIT_SEARCH=
 MOZ_PLACES=
 MOZ_B2G=1
 
 #MOZ_NUWA_PROCESS=1
 MOZ_FOLD_LIBS=1
+
+MOZ_JSDOWNLOADS=1
--- a/b2g/installer/package-manifest.in
+++ b/b2g/installer/package-manifest.in
@@ -388,16 +388,18 @@
 @BINPATH@/components/SiteSpecificUserAgent.js
 @BINPATH@/components/SiteSpecificUserAgent.manifest
 @BINPATH@/components/storage-mozStorage.js
 @BINPATH@/components/crypto-SDR.js
 @BINPATH@/components/jsconsole-clhandler.manifest
 @BINPATH@/components/jsconsole-clhandler.js
 @BINPATH@/components/nsDownloadManagerUI.manifest
 @BINPATH@/components/nsDownloadManagerUI.js
+@BINPATH@/components/Downloads.manifest
+@BINPATH@/components/DownloadLegacy.js
 @BINPATH@/components/nsSidebar.manifest
 @BINPATH@/components/nsSidebar.js
 
 ; WiFi, NetworkManager, NetworkStats
 #ifdef MOZ_WIDGET_GONK
 @BINPATH@/components/DOMWifiManager.js
 @BINPATH@/components/DOMWifiManager.manifest
 @BINPATH@/components/NetworkInterfaceListService.js
@@ -557,16 +559,19 @@
 @BINPATH@/components/TCPSocketParentIntermediary.js
 @BINPATH@/components/TCPSocket.manifest
 
 @BINPATH@/components/Payment.js
 @BINPATH@/components/PaymentFlowInfo.js
 @BINPATH@/components/PaymentRequestInfo.js
 @BINPATH@/components/Payment.manifest
 
+@BINPATH@/components/DownloadsAPI.js
+@BINPATH@/components/DownloadsAPI.manifest
+
 ; InputMethod API
 @BINPATH@/components/MozKeyboard.js
 @BINPATH@/components/InputMethod.manifest
 
 ; Modules
 @BINPATH@/modules/*
 
 ; Safe Browsing
@@ -773,16 +778,17 @@ bin/components/@DLL_PREFIX@nkgnomevfs@DL
 @BINPATH@/components/YoutubeProtocolHandler.js
 @BINPATH@/components/RecoveryService.js
 @BINPATH@/components/MailtoProtocolHandler.js
 @BINPATH@/components/SmsProtocolHandler.js
 @BINPATH@/components/TelProtocolHandler.js
 @BINPATH@/components/B2GAboutRedirector.js
 @BINPATH@/components/FilePicker.js
 @BINPATH@/components/HelperAppDialog.js
+@BINPATH@/components/DownloadsUI.js
 
 @BINPATH@/components/DataStore.manifest
 @BINPATH@/components/DataStoreService.js
 @BINPATH@/components/dom_datastore.xpt
 
 #ifdef MOZ_WEBSPEECH
 @BINPATH@/components/dom_webspeechsynth.xpt
 #endif
--- a/content/events/test/test_all_synthetic_events.html
+++ b/content/events/test/test_all_synthetic_events.html
@@ -123,16 +123,20 @@ const kEventConstructors = {
   DeviceProximityEvent:                      { create: function (aName, aProps) {
                                                          return new DeviceProximityEvent(aName, aProps);
                                                        },
                                              },
   DeviceStorageChangeEvent:                  { create: function (aName, aProps) {
                                                          return new DeviceStorageChangeEvent(aName, aProps);
                                                        },
                                              },
+  DownloadEvent:                             { create: function (aName, aProps) {
+                                                         return new DownloadEvent(aName, aProps);
+                                                       },
+                                             },
   DOMTransactionEvent:                       { create: function (aName, aProps) {
                                                          return new DOMTransactionEvent(aName, aProps);
                                                        },
                                              },
   DragEvent:                                 { create: function (aName, aProps) {
                                                          var e = document.createEvent("dragevent");
                                                          e.initDragEvent(aName, aProps.bubbles, aProps.cancelable,
                                                                          aProps.view, aProps.detail,
--- a/dom/apps/src/PermissionsTable.jsm
+++ b/dom/apps/src/PermissionsTable.jsm
@@ -313,16 +313,21 @@ this.PermissionsTable =  { geolocation: 
                              privileged: DENY_ACTION,
                              certified: ALLOW_ACTION
                            },
                            "speaker-control": {
                              app: DENY_ACTION,
                              privileged: ALLOW_ACTION,
                              certified: ALLOW_ACTION
                            },
+                           "downloads": {
+                             app: DENY_ACTION,
+                             privileged: DENY_ACTION,
+                             certified: ALLOW_ACTION
+                           },
                          };
 
 /**
  * Append access modes to the permission name as suffixes.
  *   e.g. permission name 'contacts' with ['read', 'write'] =
  *   ['contacts-read', contacts-write']
  * @param string aPermName
  * @param array aAccess
--- a/dom/base/DOMRequestHelper.jsm
+++ b/dom/base/DOMRequestHelper.jsm
@@ -180,22 +180,23 @@ DOMRequestIpcHelper.prototype = {
         this._listeners[aName] ? cpmm.removeMessageListener(aName, this)
                                : cpmm.removeWeakMessageListener(aName, this);
         delete this._listeners[aName];
       });
     }
 
     this._listeners = null;
     this._requests = null;
-    this._window = null;
 
     // Objects inheriting from DOMRequestIPCHelper may have an uninit function.
     if (this.uninit) {
       this.uninit();
     }
+
+    this._window = null;
   },
 
   observe: function(aSubject, aTopic, aData) {
     if (aTopic !== "inner-window-destroyed") {
       return;
     }
 
     let wId = aSubject.QueryInterface(Ci.nsISupportsPRUint64).data;
new file mode 100644
--- /dev/null
+++ b/dom/downloads/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+PARALLEL_DIRS += ['src']
new file mode 100644
--- /dev/null
+++ b/dom/downloads/src/DownloadsAPI.js
@@ -0,0 +1,320 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/DOMRequestHelper.jsm");
+Cu.import("resource://gre/modules/DownloadsIPC.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "cpmm",
+                                   "@mozilla.org/childprocessmessagemanager;1",
+                                   "nsIMessageSender");
+
+function debug(aStr) {
+  dump("-*- DownloadsAPI.js : " + aStr + "\n");
+}
+
+function DOMDownloadManagerImpl() {
+  debug("DOMDownloadManagerImpl constructor");
+}
+
+DOMDownloadManagerImpl.prototype = {
+  __proto__: DOMRequestIpcHelper.prototype,
+
+  // nsIDOMGlobalPropertyInitializer implementation
+  init: function(aWindow) {
+    debug("DownloadsManager init");
+    this.initDOMRequestHelper(aWindow,
+                              ["Downloads:Added",
+                               "Downloads:Removed"]);
+  },
+
+  uninit: function() {
+    debug("uninit");
+    downloadsCache.evict(this._window);
+  },
+
+  set ondownloadstart(aHandler) {
+    this.__DOM_IMPL__.setEventHandler("ondownloadstart", aHandler);
+  },
+
+  get ondownloadstart() {
+    return this.__DOM_IMPL__.getEventHandler("ondownloadstart");
+  },
+
+  getDownloads: function() {
+    debug("getDownloads()");
+
+    return this.createPromise(function (aResolve, aReject) {
+      DownloadsIPC.getDownloads().then(
+        function(aDownloads) {
+          // Turn the list of download objects into DOM objects and
+          // send them.
+          let array = Cu.createArrayIn(this._window);
+          for (let id in aDownloads) {
+            let dom = createDOMDownloadObject(this._window, aDownloads[id]);
+            array.push(this._prepareForContent(dom));
+          }
+          aResolve(array);
+        }.bind(this),
+        function() {
+          aReject("GetDownloadsError");
+        }
+      );
+    }.bind(this));
+  },
+
+  clearAllDone: function() {
+    debug("clearAllDone()");
+    return this.createPromise(function (aResolve, aReject) {
+      DownloadsIPC.clearAllDone().then(
+        function(aDownloads) {
+          // Turn the list of download objects into DOM objects and
+          // send them.
+          let array = Cu.createArrayIn(this._window);
+          for (let id in aDownloads) {
+            let dom = createDOMDownloadObject(this._window, aDownloads[id]);
+            array.push(this._prepareForContent(dom));
+          }
+          aResolve(array);
+        }.bind(this),
+        function() {
+          aReject("ClearAllDoneError");
+        }
+      );
+    }.bind(this));
+  },
+
+  remove: function(aDownload) {
+    debug("remove " + aDownload.url + " " + aDownload.id);
+    return this.createPromise(function (aResolve, aReject) {
+      if (!downloadsCache.has(this._window, aDownload.id)) {
+        debug("no download " + aDownload.id);
+        aReject("InvalidDownload");
+        return;
+      }
+
+      DownloadsIPC.remove(aDownload.id).then(
+        function(aResult) {
+          let dom = createDOMDownloadObject(this._window, aResult);
+          // Change the state right away to not race against the update message.
+          dom.wrappedJSObject.state = "finalized";
+          aResolve(this._prepareForContent(dom));
+        }.bind(this),
+        function() {
+          aReject("RemoveError");
+        }
+      );
+    }.bind(this));
+  },
+
+  /**
+    * Turns a chrome download object into a content accessible one.
+    * When we have __DOM_IMPL__ available we just use that, otherwise
+    * we run _create() with the wrapped js object.
+    */
+  _prepareForContent: function(aChromeObject) {
+    if (aChromeObject.__DOM_IMPL__) {
+      return aChromeObject.__DOM_IMPL__;
+    }
+    let res = this._window.DOMDownload._create(this._window,
+                                            aChromeObject.wrappedJSObject);
+    return res;
+  },
+
+  receiveMessage: function(aMessage) {
+    let data = aMessage.data;
+    switch(aMessage.name) {
+      case "Downloads:Added":
+        debug("Adding " + uneval(data));
+        let event = new this._window.DownloadEvent("downloadstart", {
+          download:
+            this._prepareForContent(createDOMDownloadObject(this._window, data))
+        });
+        this.__DOM_IMPL__.dispatchEvent(event);
+        break;
+    }
+  },
+
+  classID: Components.ID("{c6587afa-0696-469f-9eff-9dac0dd727fe}"),
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports,
+                                         Ci.nsISupportsWeakReference,
+                                         Ci.nsIObserver,
+                                         Ci.nsIDOMGlobalPropertyInitializer]),
+
+};
+
+/**
+  * Keep track of download objects per window.
+  */
+let downloadsCache = {
+  init: function() {
+    this.cache = new WeakMap();
+  },
+
+  has: function(aWindow, aId) {
+    let downloads = this.cache.get(aWindow);
+    return !!(downloads && downloads[aId]);
+  },
+
+  get: function(aWindow, aDownload) {
+    let downloads = this.cache.get(aWindow);
+    if (!(downloads && downloads[aDownload.id])) {
+      debug("Adding download " + aDownload.id + " to cache.");
+      if (!downloads) {
+        this.cache.set(aWindow, {});
+        downloads = this.cache.get(aWindow);
+      }
+      // Create the object and add it to the cache.
+      let impl = Cc["@mozilla.org/downloads/download;1"]
+                   .createInstance(Ci.nsISupports);
+      impl.wrappedJSObject._init(aWindow, aDownload);
+      downloads[aDownload.id] = impl;
+    }
+    return downloads[aDownload.id];
+  },
+
+  evict: function(aWindow) {
+    this.cache.delete(aWindow);
+  }
+};
+
+downloadsCache.init();
+
+/**
+  * The DOM facade of a download object.
+  */
+
+function createDOMDownloadObject(aWindow, aDownload) {
+  return downloadsCache.get(aWindow, aDownload);
+}
+
+function DOMDownloadImpl() {
+  debug("DOMDownloadImpl constructor ");
+  this.wrappedJSObject = this;
+  this.totalBytes = 0;
+  this.currentBytes = 0;
+  this.url = null;
+  this.path = null;
+  this.state = "stopped";
+  this.contentType = null;
+  this.startTime = Date.now();
+  this.error = null;
+
+  /* private fields */
+  this.id = null;
+}
+
+DOMDownloadImpl.prototype = {
+
+  createPromise: function(aPromiseInit) {
+    return new this._window.Promise(aPromiseInit);
+  },
+
+  pause: function() {
+    debug("DOMDownloadImpl pause");
+    let id = this.id;
+    // We need to wrap the Promise.jsm promise in a "real" DOM promise...
+    return this.createPromise(function(aResolve, aReject) {
+      DownloadsIPC.pause(id).then(aResolve, aReject);
+    });
+  },
+
+  resume: function() {
+    debug("DOMDownloadImpl resume");
+    let id = this.id;
+    // We need to wrap the Promise.jsm promise in a "real" DOM promise...
+    return this.createPromise(function(aResolve, aReject) {
+      DownloadsIPC.resume(id).then(aResolve, aReject);
+    });
+  },
+
+  set onstatechange(aHandler) {
+    this.__DOM_IMPL__.setEventHandler("onstatechange", aHandler);
+  },
+
+  get onstatechange() {
+    return this.__DOM_IMPL__.getEventHandler("onstatechange");
+  },
+
+  _init: function(aWindow, aDownload) {
+    this._window = aWindow;
+    this.id = aDownload.id;
+    this._update(aDownload);
+    Services.obs.addObserver(this, "downloads-state-change-" + this.id,
+                             /* ownsWeak */ true);
+    debug("observer set for " + this.id);
+  },
+
+  /**
+    * Updates the state of the object and fires the statechange event.
+    */
+  _update: function(aDownload) {
+    debug("update " + uneval(aDownload));
+    if (this.id != aDownload.id) {
+      return;
+    }
+
+    let props = ["totalBytes", "currentBytes", "url", "path", "state",
+                 "contentType", "startTime"];
+    let changed = false;
+
+    props.forEach((prop) => {
+      if (aDownload[prop] && (aDownload[prop] != this[prop])) {
+        this[prop] = aDownload[prop];
+        changed = true;
+      }
+    });
+
+    if (aDownload.error) {
+      this.error = new this._window.DOMError("DownloadError", aDownload.error);
+    } else {
+      this.error = null;
+    }
+
+    // The visible state has not changed, so no need to fire an event.
+    if (!changed) {
+      return;
+    }
+
+    // __DOM_IMPL__ may not be available at first update.
+    if (this.__DOM_IMPL__) {
+      let event = new this._window.DownloadEvent("statechange", {
+        download: this.__DOM_IMPL__
+      });
+      debug("Dispatching statechange event. state=" + this.state);
+      this.__DOM_IMPL__.dispatchEvent(event);
+    }
+  },
+
+  observe: function(aSubject, aTopic, aData) {
+    debug("DOMDownloadImpl observe " + aTopic);
+    if (aTopic !== "downloads-state-change-" + this.id) {
+      return;
+    }
+
+    try {
+      let download = JSON.parse(aData);
+      // We get the start time as milliseconds, not as a Date object.
+      if (download.startTime) {
+        download.startTime = new Date(download.startTime);
+      }
+      this._update(download);
+    } catch(e) {}
+  },
+
+  classID: Components.ID("{96b81b99-aa96-439d-8c59-92eeed34705f}"),
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports,
+                                         Ci.nsIObserver,
+                                         Ci.nsISupportsWeakReference])
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DOMDownloadManagerImpl,
+                                                     DOMDownloadImpl]);
new file mode 100644
--- /dev/null
+++ b/dom/downloads/src/DownloadsAPI.jsm
@@ -0,0 +1,254 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = [];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Downloads.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "ppmm",
+                                   "@mozilla.org/parentprocessmessagemanager;1",
+                                   "nsIMessageBroadcaster");
+
+function debug(aStr) {
+  dump("-*- DownloadsAPI.jsm : " + aStr + "\n");
+}
+
+function sendPromiseMessage(aMm, aMessageName, aData, aError) {
+  let msg = {
+    id: aData.id,
+    promiseId: aData.promiseId
+  };
+
+  if (aError) {
+    msg.error = aError;
+  }
+
+  aMm.sendAsyncMessage(aMessageName, msg);
+}
+
+let DownloadsAPI = {
+  init: function() {
+    debug("init");
+
+    this._ids = new WeakMap(); // Maps toolkit download objects to ids.
+    this._index = {};          // Maps ids to downloads.
+
+    ["Downloads:GetList",
+     "Downloads:ClearAllDone",
+     "Downloads:Remove",
+     "Downloads:Pause",
+     "Downloads:Resume"].forEach((msgName) => {
+      ppmm.addMessageListener(msgName, this);
+    });
+
+    let self = this;
+    Task.spawn(function () {
+      let list = yield Downloads.getList(Downloads.ALL);
+      yield list.addView(self);
+
+      debug("view added to download list.");
+    }).then(null, Components.utils.reportError);
+
+    this._currentId = 0;
+  },
+
+  /**
+    * Returns a unique id for each download, hashing the url and the path.
+    */
+  downloadId: function(aDownload) {
+    let id = this._ids.get(aDownload, null);
+    if (!id) {
+      id = "download-" + this._currentId++;
+      this._ids.set(aDownload, id);
+      this._index[id] = aDownload;
+    }
+    return id;
+  },
+
+  getDownloadById: function(aId) {
+    return this._index[aId];
+  },
+
+  /**
+    * Converts a download object into a plain json object that we'll
+    * send to the DOM side.
+    */
+  jsonDownload: function(aDownload) {
+    let res = {
+      totalBytes: aDownload.totalBytes,
+      currentBytes: aDownload.currentBytes,
+      url: aDownload.source.url,
+      path: aDownload.target.path,
+      contentType: aDownload.contentType,
+      startTime: aDownload.startTime.getTime()
+    };
+
+    if (aDownload.error) {
+      res.error = aDownload.error.name;
+    }
+
+    res.id = this.downloadId(aDownload);
+
+    // The state of the download. Can be any of "downloading", "stopped",
+    // "succeeded", finalized".
+
+    // Default to "stopped"
+    res.state = "stopped";
+    if (!aDownload.stopped &&
+        !aDownload.canceled &&
+        !aDownload.succeeded &&
+        !aDownload.DownloadError) {
+      res.state = "downloading";
+    } else if (aDownload.succeeded) {
+      res.state = "succeeded";
+    }
+    return res;
+  },
+
+  /**
+    * download view methods.
+    */
+  onDownloadAdded: function(aDownload) {
+    let download = this.jsonDownload(aDownload);
+    debug("onDownloadAdded " + uneval(download));
+    ppmm.broadcastAsyncMessage("Downloads:Added", download);
+  },
+
+  onDownloadRemoved: function(aDownload) {
+    let download = this.jsonDownload(aDownload);
+    download.state = "finalized";
+    debug("onDownloadRemoved " + uneval(download));
+    ppmm.broadcastAsyncMessage("Downloads:Removed", download);
+    this._index[this._ids.get(aDownload)] = null;
+    this._ids.delete(aDownload);
+  },
+
+  onDownloadChanged: function(aDownload) {
+    let download = this.jsonDownload(aDownload);
+    debug("onDownloadChanged " + uneval(download));
+    ppmm.broadcastAsyncMessage("Downloads:Changed", download);
+  },
+
+  receiveMessage: function(aMessage) {
+    if (!aMessage.target.assertPermission("downloads")) {
+      debug("No 'downloads' permission!");
+      return;
+    }
+
+    debug("message: " + aMessage.name);
+    // Removing 'Downloads:' and turning first letter to lower case to
+    // build the function name from the message name.
+    let c = aMessage.name[10].toLowerCase();
+    let methodName = c + aMessage.name.substring(11);
+    if (this[methodName] && typeof this[methodName] === "function") {
+      this[methodName](aMessage.data, aMessage.target);
+    } else {
+      debug("Unimplemented method:  " + methodName);
+    }
+  },
+
+  getList: function(aData, aMm) {
+    debug("getList called!");
+    let self = this;
+    Task.spawn(function () {
+      let list = yield Downloads.getList(Downloads.ALL);
+      let downloads = yield list.getAll();
+      let res = [];
+      downloads.forEach((aDownload) => {
+        res.push(self.jsonDownload(aDownload));
+      });
+      aMm.sendAsyncMessage("Downloads:GetList:Return", res);
+    }).then(null, Components.utils.reportError);
+  },
+
+  clearAllDone: function(aData, aMm) {
+    debug("clearAllDone called!");
+    let self = this;
+    Task.spawn(function () {
+      let list = yield Downloads.getList(Downloads.ALL);
+      yield list.removeFinished();
+      list = yield Downloads.getList(Downloads.ALL);
+      let downloads = yield list.getAll();
+      let res = [];
+      downloads.forEach((aDownload) => {
+        res.push(self.jsonDownload(aDownload));
+      });
+      aMm.sendAsyncMessage("Downloads:ClearAllDone:Return", res);
+    }).then(null, Components.utils.reportError);
+  },
+
+  remove: function(aData, aMm) {
+    debug("remove id " + aData.id);
+    let download = this.getDownloadById(aData.id);
+    if (!download) {
+      sendPromiseMessage(aMm, "Downloads:Remove:Return",
+                         aData, "NoSuchDownload");
+      return;
+    }
+
+    Task.spawn(function() {
+      yield download.finalize(true);
+      let list = yield Downloads.getList(Downloads.ALL);
+      yield list.remove(download);
+    }).then(
+      function() {
+        sendPromiseMessage(aMm, "Downloads:Remove:Return", aData);
+      },
+      function() {
+        sendPromiseMessage(aMm, "Downloads:Remove:Return",
+                           aData, "RemoveError");
+      }
+    );
+  },
+
+  pause: function(aData, aMm) {
+    debug("pause id " + aData.id);
+    let download = this.getDownloadById(aData.id);
+    if (!download) {
+      sendPromiseMessage(aMm, "Downloads:Pause:Return",
+                         aData, "NoSuchDownload");
+      return;
+    }
+
+    download.cancel().then(
+      function() {
+        sendPromiseMessage(aMm, "Downloads:Pause:Return", aData);
+      },
+      function() {
+        sendPromiseMessage(aMm, "Downloads:Pause:Return",
+                           aData, "PauseError");
+      }
+    );
+  },
+
+  resume: function(aData, aMm) {
+    debug("resume id " + aData.id);
+    let download = this.getDownloadById(aData.id);
+    if (!download) {
+      sendPromiseMessage(aMm, "Downloads:Resume:Return",
+                         aData, "NoSuchDownload");
+      return;
+    }
+
+    download.start().then(
+      function() {
+        sendPromiseMessage(aMm, "Downloads:Resume:Return", aData);
+      },
+      function() {
+        sendPromiseMessage(aMm, "Downloads:Resume:Return",
+                           aData, "ResumeError");
+      }
+    );
+  }
+};
+
+DownloadsAPI.init();
new file mode 100644
--- /dev/null
+++ b/dom/downloads/src/DownloadsAPI.manifest
@@ -0,0 +1,6 @@
+# DownloadsAPI.js
+component {c6587afa-0696-469f-9eff-9dac0dd727fe} DownloadsAPI.js
+contract @mozilla.org/downloads/manager;1 {c6587afa-0696-469f-9eff-9dac0dd727fe}
+
+component {96b81b99-aa96-439d-8c59-92eeed34705f} DownloadsAPI.js
+contract @mozilla.org/downloads/download;1 {96b81b99-aa96-439d-8c59-92eeed34705f}
new file mode 100644
--- /dev/null
+++ b/dom/downloads/src/DownloadsIPC.jsm
@@ -0,0 +1,221 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = ["DownloadsIPC"];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "cpmm",
+                                   "@mozilla.org/childprocessmessagemanager;1",
+                                   "nsIMessageSender");
+
+/**
+  * This module lives in the child process and receives the ipc messages
+  * from the parent. It saves the download's state and redispatch changes
+  * to DOM objects using an observer notification.
+  *
+  * This module needs to be loaded once and only once per process.
+  */
+
+function debug(aStr) {
+  dump("-*- DownloadsIPC.jsm : " + aStr + "\n");
+}
+
+const ipcMessages = ["Downloads:Added",
+                     "Downloads:Removed",
+                     "Downloads:Changed",
+                     "Downloads:GetList:Return",
+                     "Downloads:ClearAllDone:Return",
+                     "Downloads:Remove:Return",
+                     "Downloads:Pause:Return",
+                     "Downloads:Resume:Return"];
+
+this.DownloadsIPC = {
+  downloads: {},
+
+  init: function() {
+    debug("init");
+    Services.obs.addObserver(this, "xpcom-shutdown", false);
+    ipcMessages.forEach((aMessage) => {
+      cpmm.addMessageListener(aMessage, this);
+    });
+
+    // We need to get the list of current downloads.
+    this.ready = false;
+    this.getListPromises = [];
+    this.clearAllPromises = [];
+    this.downloadPromises = {};
+    cpmm.sendAsyncMessage("Downloads:GetList", {});
+    this._promiseId = 0;
+  },
+
+  notifyChanges: function(aId) {
+    // TODO: use the subject instead of stringifying.
+    if (this.downloads[aId]) {
+      debug("notifyChanges notifying changes for " + aId);
+      Services.obs.notifyObservers(null, "downloads-state-change-" + aId,
+                                   JSON.stringify(this.downloads[aId]));
+    } else {
+      debug("notifyChanges failed for " + aId)
+    }
+  },
+
+  _updateDownloadsArray: function(aDownloads) {
+    this.downloads = [];
+    // We actually have an array of downloads.
+    aDownloads.forEach((aDownload) => {
+      this.downloads[aDownload.id] = aDownload;
+    });
+  },
+
+  receiveMessage: function(aMessage) {
+    let download = aMessage.data;
+    debug("message: " + aMessage.name + " " + download.id);
+    switch(aMessage.name) {
+      case "Downloads:GetList:Return":
+        this._updateDownloadsArray(download);
+
+        if (!this.ready) {
+          this.getListPromises.forEach(aPromise =>
+                                       aPromise.resolve(this.downloads));
+          this.getListPromises.length = 0;
+        }
+        this.ready = true;
+        break;
+      case "Downloads:ClearAllDone:Return":
+        this._updateDownloadsArray(download);
+        this.clearAllPromises.forEach(aPromise =>
+                                      aPromise.resolve(this.downloads));
+        this.clearAllPromises.length = 0;
+        break;
+      case "Downloads:Added":
+        this.downloads[download.id] = download;
+        this.notifyChanges(download.id);
+        break;
+      case "Downloads:Removed":
+        if (this.downloads[download.id]) {
+          this.downloads[download.id] = download;
+          this.notifyChanges(download.id);
+          delete this.downloads[download.id];
+        }
+        break;
+      case "Downloads:Changed":
+        // Only update properties that actually changed.
+        let cached = this.downloads[download.id];
+        if (!cached) {
+          debug("No download found for " + download.id);
+          return;
+        }
+        let props = ["totalBytes", "currentBytes", "url", "path", "state",
+                     "contentType", "startTime"];
+        let changed = false;
+
+        props.forEach((aProp) => {
+          if (download[aProp] && (download[aProp] != cached[aProp])) {
+            cached[aProp] = download[aProp];
+            changed = true;
+          }
+        });
+
+        // Updating the error property. We always get a 'state' change as
+        // well.
+        cached.error = download.error;
+
+        if (changed) {
+          this.notifyChanges(download.id);
+        }
+        break;
+      case "Downloads:Remove:Return":
+      case "Downloads:Pause:Return":
+      case "Downloads:Resume:Return":
+        if (this.downloadPromises[download.promiseId]) {
+          if (!download.error) {
+          this.downloadPromises[download.promiseId].resolve(download);
+          } else {
+            this.downloadPromises[download.promiseId].reject(download);
+          }
+          delete this.downloadPromises[download.promiseId];
+        }
+        break;
+    }
+  },
+
+  /**
+    * Returns a promise that is resolved with the list of current downloads.
+    */
+  getDownloads: function() {
+    debug("getDownloads()");
+    let deferred = Promise.defer();
+    if (this.ready) {
+      debug("Returning existing list.");
+      deferred.resolve(this.downloads);
+    } else {
+      this.getListPromises.push(deferred);
+    }
+    return deferred.promise;
+  },
+
+  /**
+    * Returns a promise that is resolved with the list of current downloads.
+    */
+  clearAllDone: function() {
+    debug("clearAllDone");
+    let deferred = Promise.defer();
+    this.clearAllPromises.push(deferred);
+    cpmm.sendAsyncMessage("Downloads:ClearAllDone", {});
+    return deferred.promise;
+  },
+
+  promiseId: function() {
+    return this._promiseId++;
+  },
+
+  remove: function(aId) {
+    debug("remove " + aId);
+    let deferred = Promise.defer();
+    let pId = this.promiseId();
+    this.downloadPromises[pId] = deferred;
+    cpmm.sendAsyncMessage("Downloads:Remove",
+                          { id: aId, promiseId: pId });
+    return deferred.promise;
+  },
+
+  pause: function(aId) {
+    debug("pause " + aId);
+    let deferred = Promise.defer();
+    let pId = this.promiseId();
+    this.downloadPromises[pId] = deferred;
+    cpmm.sendAsyncMessage("Downloads:Pause",
+                          { id: aId, promiseId: pId });
+    return deferred.promise;
+  },
+
+  resume: function(aId) {
+    debug("resume " + aId);
+    let deferred = Promise.defer();
+    let pId = this.promiseId();
+    this.downloadPromises[pId] = deferred;
+    cpmm.sendAsyncMessage("Downloads:Resume",
+                          { id: aId, promiseId: pId });
+    return deferred.promise;
+  },
+
+  observe: function(aSubject, aTopic, aData) {
+    if (aTopic == "xpcom-shutdown") {
+      ipcMessages.forEach((aMessage) => {
+        cpmm.removeMessageListener(aMessage, this);
+      });
+    }
+  }
+};
+
+DownloadsIPC.init();
new file mode 100644
--- /dev/null
+++ b/dom/downloads/src/moz.build
@@ -0,0 +1,15 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_COMPONENTS += [
+    'DownloadsAPI.js',
+    'DownloadsAPI.manifest',
+]
+
+EXTRA_JS_MODULES += [
+    'DownloadsAPI.jsm',
+    'DownloadsIPC.jsm',
+]
--- a/dom/moz.build
+++ b/dom/moz.build
@@ -99,16 +99,19 @@ if CONFIG['MOZ_PAY']:
     PARALLEL_DIRS += ['payment']
 
 if CONFIG['MOZ_GAMEPAD']:
     PARALLEL_DIRS += ['gamepad']
 
 if CONFIG['MOZ_NFC']:
     PARALLEL_DIRS += ['nfc']
 
+if CONFIG['MOZ_B2G']:
+    PARALLEL_DIRS += ['downloads']
+
 # bindings/test is here, because it needs to build after bindings/, and
 # we build subdirectories before ourselves.
 TEST_DIRS += [
     'tests',
     'imptests',
     'bindings/test',
 ]
 
--- a/dom/tests/mochitest/general/test_interfaces.html
+++ b/dom/tests/mochitest/general/test_interfaces.html
@@ -197,16 +197,19 @@ var interfaceNamesInGlobalScope =
     "DOMRect",
     "DOMRectList",
     "DOMRequest",
     "DOMSettableTokenList",
     "DOMStringList",
     "DOMStringMap",
     "DOMTokenList",
     "DOMTransactionEvent",
+    {name: "DOMDownload", b2g: true, pref: "dom.mozDownloads.enabled"},
+    {name: "DOMDownloadManager", b2g: true, pref: "dom.mozDownloads.enabled"},
+    {name: "DownloadEvent", b2g: true, pref: "dom.mozDownloads.enabled"},
     "DragEvent",
     "DynamicsCompressorNode",
     "Element",
     "ElementReplaceEvent",
     "ErrorEvent",
     "Event",
     "EventListenerInfo",
     "EventSource",
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -301,17 +301,26 @@ this.DownloadIntegration = {
    * @return True to save the download, false otherwise.
    */
   shouldPersistDownload: function (aDownload)
   {
     // In the default implementation, we save all the downloads currently in
     // progress, as well as stopped downloads for which we retained partially
     // downloaded data.  Stopped downloads for which we don't need to track the
     // presence of a ".part" file are only retained in the browser history.
+    // On b2g, we keep a few days of history.
+#ifdef MOZ_B2G
+    let maxTime = Date.now() -
+      Services.prefs.getIntPref("dom.downloads.max_retention_days") * 24 * 60 * 60 * 1000;
+    return (aDownload.startTime > maxTime) ||
+           aDownload.hasPartialData ||
+           !aDownload.stopped;
+#else
     return aDownload.hasPartialData || !aDownload.stopped;
+#endif
   },
 
   /**
    * Returns the system downloads directory asynchronously.
    *
    * @return {Promise}
    * @resolves The downloads directory string path.
    */
--- a/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadUIHelper.jsm
@@ -103,17 +103,17 @@ XPCOMUtils.defineLazyGetter(DownloadUIHe
  * Allows displaying prompts related to downloads.
  *
  * @param aParent
  *        The nsIDOMWindow to which prompts should be attached, or null to
  *        attach prompts to the most recently active window.
  */
 this.DownloadPrompter = function (aParent)
 {
-#ifdef MOZ_WIDGET_GONK
+#ifdef MOZ_B2G
   // On B2G there is no prompter implementation.
   this._prompter = null;
 #else
   this._prompter = Services.ww.getNewPrompter(aParent);
 #endif
 }
 
 this.DownloadPrompter.prototype = {