Bug 825318 - Implement adoptDownload for mozDownloadManager, r=aus, r=sicking
authorAndrew Sutherland <asutherland@asutherland.org>
Tue, 24 Feb 2015 11:06:59 -0500
changeset 261456 4d0ef24554cac4d2be608674fe82835c8c8fd185
parent 261378 d0266a1f909ef9d4544ad384c2138cd660bdf1b2
child 261457 426a926167103bc2fb6e0ac0ff686c08e5d07d99
push id830
push userraliiev@mozilla.com
push dateFri, 19 Jun 2015 19:24:37 +0000
treeherdermozilla-release@932614382a68 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaus, sicking
bugs825318, 979446
milestone39.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 825318 - Implement adoptDownload for mozDownloadManager, r=aus, r=sicking Implement mozDownloadManager.adoptDownload as a certified-only API. This also fixes and re-enables many of the existing dom/downloads tests failures by virtue of cleanup and not running them on non-gonk toolkits where exceptions will be thrown and things will fail. This should resolve bug 979446 about re-enabling the tests.
dom/downloads/DownloadsAPI.js
dom/downloads/DownloadsAPI.jsm
dom/downloads/DownloadsIPC.jsm
dom/downloads/tests/clear_all_done_helper.js
dom/downloads/tests/common_app.js
dom/downloads/tests/file_app.sjs
dom/downloads/tests/file_app.template.webapp
dom/downloads/tests/mochitest.ini
dom/downloads/tests/shim_app_as_test.js
dom/downloads/tests/shim_app_as_test_chrome.js
dom/downloads/tests/test_downloads_adopt_download.html
dom/downloads/tests/test_downloads_basic.html
dom/downloads/tests/test_downloads_large.html
dom/downloads/tests/test_downloads_pause_remove.html
dom/downloads/tests/test_downloads_pause_resume.html
dom/downloads/tests/testapp_downloads_adopt_download.html
dom/downloads/tests/testapp_downloads_adopt_download.js
dom/downloads/tests/testapp_downloads_adopt_download.manifest
dom/webidl/Downloads.webidl
--- a/dom/downloads/DownloadsAPI.js
+++ b/dom/downloads/DownloadsAPI.js
@@ -16,16 +16,22 @@ Cu.import("resource://gre/modules/Downlo
 
 XPCOMUtils.defineLazyServiceGetter(this, "cpmm",
                                    "@mozilla.org/childprocessmessagemanager;1",
                                    "nsIMessageSender");
 XPCOMUtils.defineLazyServiceGetter(this, "volumeService",
                                    "@mozilla.org/telephony/volume-service;1",
                                     "nsIVolumeService");
 
+/**
+  * The content process implementations of navigator.mozDownloadManager and its
+  * DOMDownload download objects.  Uses DownloadsIPC.jsm to communicate with
+  * DownloadsAPI.jsm in the parent process.
+  */
+
 function debug(aStr) {
 #ifdef MOZ_DEBUG
   dump("-*- DownloadsAPI.js : " + aStr + "\n");
 #endif
 }
 
 function DOMDownloadManagerImpl() {
   debug("DOMDownloadManagerImpl constructor");
@@ -35,16 +41,25 @@ DOMDownloadManagerImpl.prototype = {
   __proto__: DOMRequestIpcHelper.prototype,
 
   // nsIDOMGlobalPropertyInitializer implementation
   init: function(aWindow) {
     debug("DownloadsManager init");
     this.initDOMRequestHelper(aWindow,
                               ["Downloads:Added",
                                "Downloads:Removed"]);
+
+    // Get the manifest URL if this is an installed app
+    let appsService = Cc["@mozilla.org/AppsService;1"]
+                        .getService(Ci.nsIAppsService);
+    let principal = aWindow.document.nodePrincipal;
+    // This returns the empty string if we're not an installed app.  Coerce to
+    // null.
+    this._manifestURL = appsService.getManifestURLByLocalId(principal.appId) ||
+                          null;
   },
 
   uninit: function() {
     debug("uninit");
     downloadsCache.evict(this._window);
   },
 
   set ondownloadstart(aHandler) {
@@ -74,33 +89,18 @@ DOMDownloadManagerImpl.prototype = {
           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 = new this._window.Array();
-          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));
+    // This is a void function; we just kick it off.  No promises, etc.
+    DownloadsIPC.clearAllDone();
   },
 
   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");
@@ -116,16 +116,77 @@ DOMDownloadManagerImpl.prototype = {
         }.bind(this),
         function() {
           aReject("RemoveError");
         }
       );
     }.bind(this));
   },
 
+  adoptDownload: function(aAdoptDownloadDict) {
+    // Our AdoptDownloadDict only includes simple types, which WebIDL enforces.
+    // We have no object/any types so we do not need to worry about invoking
+    // JSON.stringify (and it inheriting our security privileges).
+    debug("adoptDownload");
+    return this.createPromise(function (aResolve, aReject) {
+      if (!aAdoptDownloadDict) {
+        debug("Download dictionary is required!");
+        aReject("InvalidDownload");
+        return;
+      }
+      if (!aAdoptDownloadDict.storageName || !aAdoptDownloadDict.storagePath ||
+          !aAdoptDownloadDict.contentType) {
+        debug("Missing one of: storageName, storagePath, contentType");
+        aReject("InvalidDownload");
+        return;
+      }
+
+      // Convert storageName/storagePath to a local filesystem path.
+      let volume;
+      // getVolumeByName throws if you give it something it doesn't like
+      // because XPConnect converts the NS_ERROR_NOT_AVAILABLE to an
+      // exception.  So catch it.
+      try {
+        volume = volumeService.getVolumeByName(aAdoptDownloadDict.storageName);
+      } catch (ex) {}
+      if (!volume) {
+        debug("Invalid storage name: " + aAdoptDownloadDict.storageName);
+        aReject("InvalidDownload");
+        return;
+      }
+      let computedPath = volume.mountPoint + '/' +
+                           aAdoptDownloadDict.storagePath;
+      // We validate that there is actually a file at the given path in the
+      // parent process in DownloadsAPI.js because that's where the file
+      // access would actually occur either way.
+
+      // Create a DownloadsAPI.jsm 'jsonDownload' style representation.
+      let jsonDownload = {
+        url: aAdoptDownloadDict.url,
+        path: computedPath,
+        contentType: aAdoptDownloadDict.contentType,
+        startTime: aAdoptDownloadDict.startTime.valueOf() || Date.now(),
+        sourceAppManifestURL: this._manifestURL
+      };
+
+      DownloadsIPC.adoptDownload(jsonDownload).then(
+        function(aResult) {
+          let domDownload = createDOMDownloadObject(this._window, aResult);
+          aResolve(this._prepareForContent(domDownload));
+        }.bind(this),
+        function(aResult) {
+          // This will be one of: AdoptError (generic catch-all),
+          // AdoptNoSuchFile, AdoptFileIsDirectory
+          aReject(aResult.error);
+        }
+      );
+    }.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__;
@@ -290,16 +351,21 @@ DOMDownloadImpl.prototype = {
     if (["downloading",
          "stopped",
          "succeeded",
          "finalized"].indexOf(aState) != -1) {
       this._state = aState;
     }
   },
 
+  /**
+    * Initialize a DOMDownload instance for the given window using the
+    * 'jsonDownload' serialized format of the download encoded by
+    * DownloadsAPI.jsm.
+    */
   _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);
   },
@@ -309,22 +375,23 @@ DOMDownloadImpl.prototype = {
     */
   _update: function(aDownload) {
     debug("update " + uneval(aDownload));
     if (this.id != aDownload.id) {
       return;
     }
 
     let props = ["totalBytes", "currentBytes", "url", "path", "storageName",
-                 "storagePath", "state", "contentType", "startTime"];
+                 "storagePath", "state", "contentType", "startTime",
+                 "sourceAppManifestURL"];
     let changed = false;
     let changedProps = {};
 
     props.forEach((prop) => {
-      if (aDownload[prop] && (aDownload[prop] != this[prop])) {
+      if (prop in aDownload && (aDownload[prop] != this[prop])) {
         this[prop] = aDownload[prop];
         changedProps[prop] = changed = true;
       }
     });
 
     // When the path changes, we should update the storage name and
     // storage path used for our downloaded file in case our download
     // was re-targetted to a different storage and/or filename.
--- a/dom/downloads/DownloadsAPI.jsm
+++ b/dom/downloads/DownloadsAPI.jsm
@@ -8,21 +8,29 @@ 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");
+Cu.import("resource://gre/modules/osfile.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "ppmm",
                                    "@mozilla.org/parentprocessmessagemanager;1",
                                    "nsIMessageBroadcaster");
 
+/**
+  * Parent process logic that services download API requests from the
+  * DownloadAPI.js instances in content processeses.  The actual work of managing
+  * downloads is done by Toolkit's Downloads.jsm.  This module is loaded by B2G's
+  * shell.js
+  */
+
 function debug(aStr) {
 #ifdef MOZ_DEBUG
   dump("-*- DownloadsAPI.jsm : " + aStr + "\n");
 #endif
 }
 
 function sendPromiseMessage(aMm, aMessageName, aData, aError) {
   debug("sendPromiseMessage " + aMessageName);
@@ -44,17 +52,18 @@ let DownloadsAPI = {
 
     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) => {
+     "Downloads:Resume",
+     "Downloads:Adopt"].forEach((msgName) => {
       ppmm.addMessageListener(msgName, this);
     });
 
     let self = this;
     Task.spawn(function () {
       let list = yield Downloads.getList(Downloads.ALL);
       yield list.addView(self);
 
@@ -87,17 +96,19 @@ let DownloadsAPI = {
     */
   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()
+      startTime: aDownload.startTime.getTime(),
+      sourceAppManifestURL: aDownload._unknownProperties &&
+                              aDownload._unknownProperties.sourceAppManifestURL
     };
 
     if (aDownload.error) {
       res.error = aDownload.error;
     }
 
     res.id = this.downloadId(aDownload);
 
@@ -160,16 +171,19 @@ let DownloadsAPI = {
       this.remove(aMessage.data, aMessage.target);
       break;
     case "Downloads:Pause":
       this.pause(aMessage.data, aMessage.target);
       break;
     case "Downloads:Resume":
       this.resume(aMessage.data, aMessage.target);
       break;
+    case "Downloads:Adopt":
+      this.adoptDownload(aMessage.data, aMessage.target);
+      break;
     default:
       debug("Invalid message: " + aMessage.name);
     }
   },
 
   getList: function(aData, aMm) {
     debug("getList called!");
     let self = this;
@@ -181,27 +195,19 @@ let DownloadsAPI = {
         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);
+      list.removeFinished();
     }).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",
@@ -257,12 +263,103 @@ let DownloadsAPI = {
       function() {
         sendPromiseMessage(aMm, "Downloads:Resume:Return", aData);
       },
       function() {
         sendPromiseMessage(aMm, "Downloads:Resume:Return",
                            aData, "ResumeError");
       }
     );
+  },
+
+  /**
+    * Receive a download to adopt in the same representation we produce from
+    * our "jsonDownload" normalizer and add it to the list of downloads.
+    */
+  adoptDownload: function(aData, aMm) {
+    let adoptJsonRep = aData.jsonDownload;
+    debug("adoptDownload " + uneval(adoptJsonRep));
+
+    Task.spawn(function* () {
+      // Verify that the file exists on disk.  This will result in a rejection
+      // if the file does not exist.  We will also use this information for the
+      // file size to avoid weird inconsistencies.  We ignore the filesystem
+      // timestamp in favor of whatever the caller is telling us.
+      let fileInfo = yield OS.File.stat(adoptJsonRep.path);
+
+      // We also require that the file is not a directory.
+      if (fileInfo.isDir) {
+        throw new Error("AdoptFileIsDirectory");
+      }
+
+      // We need to create a Download instance to add to the list.  Create a
+      // serialized representation and then from there the instance.
+      let serializedRep = {
+        // explicit initializations in toSerializable
+        source: {
+          url: adoptJsonRep.url
+          // This is where isPrivate would go if adoption supported private
+          // browsing.
+        },
+        target: {
+          path: adoptJsonRep.path,
+        },
+        startTime: adoptJsonRep.startTime,
+        // kPlainSerializableDownloadProperties propagations
+        succeeded: true, // (all adopted downloads are required to be completed)
+        totalBytes: fileInfo.size,
+        contentType: adoptJsonRep.contentType,
+        // unknown properties added/used by the DownloadsAPI
+        currentBytes: fileInfo.size,
+        sourceAppManifestURL: adoptJsonRep.sourceAppManifestURL
+      };
+
+      let download = yield Downloads.createDownload(serializedRep);
+
+      // The ALL list is a DownloadCombinedList instance that combines the
+      // PUBLIC (persisted to disk) and PRIVATE (ephemeral) download lists..
+      // When we call add on it, it dispatches to the appropriate list based on
+      // the 'isPrivate' field of the source.  (Which we don't initialize and
+      // defaults to false.)
+      let allDownloadList = yield Downloads.getList(Downloads.ALL);
+
+      // This add will automatically notify all views of the added download,
+      // including DownloadsAPI instances and the DownloadAutoSaveView that's
+      // subscribed to the PUBLIC list and will save the download.
+      yield allDownloadList.add(download);
+
+      debug("download adopted");
+      // The notification above occurred synchronously, and so we will have
+      // already dispatched an added notification for our download to the child
+      // process in question.  As such, we only need to relay the download id
+      // since the download will already have been cached.
+      return download;
+    }.bind(this)).then(
+      (download) => {
+        sendPromiseMessage(aMm, "Downloads:Adopt:Return",
+                           {
+                             id: this.downloadId(download),
+                             promiseId: aData.promiseId
+                           });
+      },
+      (ex) => {
+        let reportAs = "AdoptError";
+        // Provide better error codes for expected errors.
+        if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
+          reportAs = "AdoptNoSuchFile";
+        } else if (ex.message === "AdoptFileIsDirectory") {
+          reportAs = ex.message;
+        } else {
+          // Anything else is unexpected and should be reported to help track
+          // down what's going wrong.
+          debug("unexpected download error: " + ex);
+          Cu.reportError(ex);
+        }
+        sendPromiseMessage(aMm, "Downloads:Adopt:Return",
+                           {
+                             promiseId: aData.promiseId
+                           },
+                           reportAs);
+    });
   }
 };
 
 DownloadsAPI.init();
--- a/dom/downloads/DownloadsIPC.jsm
+++ b/dom/downloads/DownloadsIPC.jsm
@@ -31,35 +31,34 @@ function debug(aStr) {
   dump("-*- DownloadsIPC.jsm : " + aStr + "\n");
 #endif
 }
 
 const ipcMessages = ["Downloads:Added",
                      "Downloads:Removed",
                      "Downloads:Changed",
                      "Downloads:GetList:Return",
-                     "Downloads:ClearAllDone:Return",
                      "Downloads:Remove:Return",
                      "Downloads:Pause:Return",
-                     "Downloads:Resume:Return"];
+                     "Downloads:Resume:Return",
+                     "Downloads:Adopt: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]) {
@@ -88,22 +87,16 @@ this.DownloadsIPC = {
 
         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);
@@ -134,16 +127,17 @@ this.DownloadsIPC = {
 
         if (changed) {
           this.notifyChanges(download.id);
         }
         break;
       case "Downloads:Remove:Return":
       case "Downloads:Pause:Return":
       case "Downloads:Resume:Return":
+      case "Downloads:Adopt: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];
         }
@@ -162,24 +156,21 @@ this.DownloadsIPC = {
       deferred.resolve(this.downloads);
     } else {
       this.getListPromises.push(deferred);
     }
     return deferred.promise;
   },
 
   /**
-    * Returns a promise that is resolved with the list of current downloads.
-    */
+   * Void function to trigger removal of completed 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);
@@ -206,16 +197,26 @@ this.DownloadsIPC = {
     let deferred = Promise.defer();
     let pId = this.promiseId();
     this.downloadPromises[pId] = deferred;
     cpmm.sendAsyncMessage("Downloads:Resume",
                           { id: aId, promiseId: pId });
     return deferred.promise;
   },
 
+  adoptDownload: function(aJsonDownload) {
+    debug("adoptDownload");
+    let deferred = Promise.defer();
+    let pId = this.promiseId();
+    this.downloadPromises[pId] = deferred;
+    cpmm.sendAsyncMessage("Downloads:Adopt",
+                          { jsonDownload: aJsonDownload, promiseId: pId });
+    return deferred.promise;
+  },
+
   observe: function(aSubject, aTopic, aData) {
     if (aTopic == "xpcom-shutdown") {
       ipcMessages.forEach((aMessage) => {
         cpmm.removeMessageListener(aMessage, this);
       });
     }
   }
 };
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/clear_all_done_helper.js
@@ -0,0 +1,67 @@
+/**
+ * A helper to clear out the existing downloads known to the mozDownloadManager
+ * / downloads.js.
+ *
+ * It exists because previously mozDownloadManager.clearAllDone() thought that
+ * when it returned that all the completed downloads would be cleared out.  It
+ * was wrong and this led to various intermittent test failurse.  In discussion
+ * on https://bugzil.la/979446#c13 and onwards, it was decided that
+ * clearAllDone() was in the wrong and that the jsdownloads API it depends on
+ * was not going to change to make it be in the right.
+ *
+ * The existing uses of clearAllDone() in tests seemed to be about:
+ * - Exploding if there was somehow still a download in progress
+ * - Clearing out the download list at the start of a test so that calls to
+ *   getDownloads() wouldn't have to worry about existing downloads, etc.
+ *
+ * From discussion, the right way to handle clearing is to wait for the expected
+ * removal events to occur for the existing downloads.  So that's what we do.
+ * We still generate a test failure if there are any in-progress downloads.
+ *
+ * @param {Boolean} [getDownloads=false]
+ *   If true, invoke getDownloads after clearing the download list and return
+ *   its value.
+ */
+function clearAllDoneHelper(getDownloads) {
+  var clearedPromise = new Promise(function(resolve, reject) {
+    function gotDownloads(downloads) {
+      // If there are no downloads, we're already done.
+      if (downloads.length === 0) {
+        resolve();
+        return;
+      }
+
+      // Track the set of expected downloads that will be finalized.
+      var expectedIds = new Set();
+      function changeHandler(evt) {
+        var download = evt.download;
+        if (download.state === "finalized") {
+          expectedIds.delete(download.id);
+          if (expectedIds.size === 0) {
+            resolve();
+          }
+        }
+      }
+      downloads.forEach(function(download) {
+        if (download.state === "downloading") {
+          ok(false, "A download is still active: " + download.path);
+          reject("Active download");
+        }
+        download.onstatechange = changeHandler;
+        expectedIds.add(download.id);
+      });
+      navigator.mozDownloadManager.clearAllDone();
+    }
+    function gotBadNews(err) {
+      ok(false, "Problem clearing all downloads: " + err);
+      reject(err);
+    }
+    navigator.mozDownloadManager.getDownloads().then(gotDownloads, gotBadNews);
+ });
+ if (!getDownloads) {
+   return clearedPromise;
+ }
+ return clearedPromise.then(function() {
+   return navigator.mozDownloadManager.getDownloads();
+ });
+}
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/common_app.js
@@ -0,0 +1,19 @@
+function is(a, b, msg) {
+  alert((a === b ? 'OK' : 'KO') + ' ' + a + ' should equal ' + b + ': ' + msg);
+}
+
+function ok(a, msg) {
+  alert((a ? 'OK' : 'KO')+ ' ' + msg);
+}
+
+function info(msg) {
+  alert('INFO ' + msg);
+}
+
+function cbError() {
+  alert('KO error');
+}
+
+function finish() {
+  alert('DONE');
+}
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/file_app.sjs
@@ -0,0 +1,55 @@
+var gBasePath = "tests/dom/downloads/tests/";
+var gTemplate = "file_app.template.webapp";
+
+function handleRequest(request, response) {
+  var query = getQuery(request);
+
+  var testToken = query.testToken || '';
+  var appType = query.appType || 'web';
+
+  var template = gBasePath + gTemplate;
+  response.setHeader("Content-Type", "application/x-web-app-manifest+json", false);
+  var body = readTemplate(template)
+               .replace(/TESTTOKEN/g, testToken)
+               .replace(/APPTYPE/g, appType);
+  response.write();
+}
+
+// Copy-pasted incantations. There ought to be a better way to synchronously read
+// a file into a string, but I guess we're trying to discourage that.
+function readTemplate(path) {
+  var file = Components.classes["@mozilla.org/file/directory_service;1"].
+                        getService(Components.interfaces.nsIProperties).
+                        get("CurWorkD", Components.interfaces.nsILocalFile);
+  var fis  = Components.classes['@mozilla.org/network/file-input-stream;1'].
+                        createInstance(Components.interfaces.nsIFileInputStream);
+  var cis = Components.classes["@mozilla.org/intl/converter-input-stream;1"].
+                       createInstance(Components.interfaces.nsIConverterInputStream);
+  var split = path.split("/");
+  for(var i = 0; i < split.length; ++i) {
+    file.append(split[i]);
+  }
+  fis.init(file, -1, -1, false);
+  cis.init(fis, "UTF-8", 0, 0);
+
+  var data = "";
+  let (str = {}) {
+    let read = 0;
+    do {
+      read = cis.readString(0xffffffff, str); // read as much as we can and put it in str.value
+      data += str.value;
+    } while (read != 0);
+  }
+  cis.close();
+  return data;
+}
+
+function getQuery(request) {
+  var query = {};
+  request.queryString.split('&').forEach(function (val) {
+    var [name, value] = val.split('=');
+    query[name] = unescape(value);
+  });
+  return query;
+}
+
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/file_app.template.webapp
@@ -0,0 +1,7 @@
+{
+  "name": "Really Rapid Release (hosted)",
+  "description": "Updated even faster than <a href='http://mozilla.org'>Firefox</a>, just to annoy slashdotters.",
+  "type": "APPTYPE",
+  "launch_path": "/tests/dom/downloads/tests/TESTTOKEN",
+  "icons": { "128": "default_icon" }
+}
--- a/dom/downloads/tests/mochitest.ini
+++ b/dom/downloads/tests/mochitest.ini
@@ -1,12 +1,24 @@
 [DEFAULT]
-skip-if = buildapp == 'mulet' || buildapp == 'b2g' # bug 979446, frequent failures
+# The actual requirement for mozDownloadManager is MOZ_GONK because of
+# the nsIVolumeService dependency.  Until https://bugzil.la/1130264 is
+# addressed, there is no way for mulet to run these tests.
+run-if = toolkit == 'gonk'
 support-files =
   serve_file.sjs
+  clear_all_done_helper.js
+  file_app.template.webapp
+  file_app.sjs
+  common_app.js
+  shim_app_as_test.js
+  shim_app_as_test_chrome.js
+  testapp_downloads_adopt_download.html
+  testapp_downloads_adopt_download.js
+  testapp_downloads_adopt_download.manifest
 
 [test_downloads_navigator_object.html]
 [test_downloads_basic.html]
 [test_downloads_large.html]
+[test_downloads_adopt_download.html]
 [test_downloads_bad_file.html]
 [test_downloads_pause_remove.html]
 [test_downloads_pause_resume.html]
-skip-if = toolkit=='gonk' # b2g(bug 947167) b2g-debug(bug 947167)
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/shim_app_as_test.js
@@ -0,0 +1,210 @@
+/**
+ * Support logic to run a test file as an installed app.  This file is derived
+ * from dom/requestsync/tests/test_basic_app.html but uses
+ * DOMApplicationRegistry in a chrome script (shim_app_as_test_chrome.js) to
+ * directly install the apps instead of mozApps.install because mozApps.install
+ * can't install privileged/certified apps.  (This is the same mechanism used by
+ * the Firefox OS Gaia email app's backend test runner.)
+ *
+ * You really only want to do this if your test cares about the app's origin
+ * or you REALLY want to double-check AvailableIn and other WebIDL-provided
+ * security mechanisms.
+ *
+ * If you trust WebIDL, your life may be made significantly easier by just
+ * setting the pref "dom.ignore_webidl_scope_checks" to true, which makes
+ * BindingUtils.cpp's IsInPrivilegedApp and IsInCertifiedApp return true no
+ * matter what *on the main thread*.  You are potentially out of luck on
+ * workers since at the time of writing this since the values stored on
+ * WorkerPrivateParent are based on the app status and ignore the pref.
+ *
+ * TO USE THIS:
+ *
+ * Make sure you have the usual header boilerplate:
+ *   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ *   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ *
+ * You also want to add this file!
+ *   <script type="application/javascript" src="shim_app_as_test.js"></script>
+ *
+ * In your script body, issue a call like so:
+ * runAppTest({
+ *   appFile: 'testapp_downloads_adopt_download.html',
+ *   appManifest: 'testapp_downloads_adopt_download.manifest',
+ *   appType: 'certified',
+ *   extraPrefs: {
+ *     set: [["dom.mozDownloads.enabled", true]]
+ *   }
+ * });
+ *
+ * You shouldn't be adding other stuff to that file.  Instead, you want
+ * everything in your testapp_*.html file.  And you probably just want to copy
+ * and paste from an existing one of those...
+ */
+
+  var gManifestURL;
+  var gApp;
+  var gOptions;
+
+  // Load the chrome script.
+  var gChromeHelper = SpecialPowers.loadChromeScript(
+                        SimpleTest.getTestFileURL('shim_app_as_test_chrome.js'));
+
+  function installApp() {
+    info("installing app");
+    var useOrigin = document.location.origin;
+    gChromeHelper.sendAsyncMessage(
+      'install',
+      {
+        origin: useOrigin,
+        manifestURL: SimpleTest.getTestFileURL(gOptions.appManifest),
+      });
+  }
+
+  function installedApp(appInfo) {
+    gApp = appInfo;
+    ok(!!appInfo, 'installed app');
+    runTests();
+  }
+  gChromeHelper.addMessageListener('installed', installedApp);
+
+  function uninstallApp() {
+    info('uninstalling app');
+    gChromeHelper.sendAsyncMessage('uninstall', gApp);
+  }
+
+  function uninstalledApp(success) {
+    ok(success, 'uninstalled app');
+    runTests();
+  }
+  gChromeHelper.addMessageListener('uninstalled', uninstalledApp);
+
+  function testApp() {
+    var cleanupFrame;
+    var handleTestMessage = function(message) {
+      if (/^OK/.exec(message)) {
+        ok(true, "Message from app: " + message);
+      } else if (/^KO/.exec(message)) {
+        ok(false, "Message from app: " + message);
+      } else if (/^INFO/.exec(message)) {
+        info("Message from app: " + message.substring(5));
+      } else if (/^DONE$/.exec(message)) {
+        ok(true, "Messaging from app complete");
+        cleanupFrame();
+        runTests();
+      }
+    };
+
+    // Bug 1097479 means that embed-webapps does not work if you are already
+    // OOP, as we are for b2g.  So we need to have the chrome script run our
+    // app in a sibling iframe to the one we're living in.  When that bug is
+    // fixed or we are run in a non-b2g context, we can set this value to false
+    // or otherwise conditionalize based on behaviour.
+    var needSiblingIframeHack = true;
+
+    if (needSiblingIframeHack) {
+      gChromeHelper.sendAsyncMessage('run', gApp);
+
+      gChromeHelper.addMessageListener('appMessage', handleTestMessage);
+      gChromeHelper.addMessageListener('appError', function(data) {
+        ok(false, "Error in app frame: " + data.message);
+      });
+
+      cleanupFrame = function() {
+        gChromeHelper.sendAsyncMessage('close', {});
+      };
+    } else {
+      var ifr = document.createElement('iframe');
+      ifr.setAttribute('mozbrowser', 'true');
+      ifr.setAttribute('mozapp', gApp.manifestURL);
+
+      cleanupFrame = function() {
+        ifr.removeEventListener('mozbrowsershowmodalprompt', listener);
+        domParent.removeChild(ifr);
+      };
+
+      // Set us up to listen for messages from the app.
+      var listener = function(e) {
+        var message = e.detail.message; // e.detail.message;
+        handleTestMessage(message);
+      };
+
+      // This event is triggered when the app calls "alert".
+      ifr.addEventListener('mozbrowsershowmodalprompt', listener, false);
+      ifr.addEventListener('mozbrowsererror', function(evt) {
+        ok(false, "Error in app frame: " + evt.detail);
+      });
+
+      ifr.setAttribute('src', gApp.manifest.launch_path);
+      var domParent = document.getElementById('content');
+      if (!domParent) {
+        document.createElement('div');
+        document.body.insertBefore(domParent, document.body.firstChild);
+      }
+      domParent.appendChild(ifr);
+    }
+  }
+
+  var tests = [
+    // Permissions
+    function() {
+      info("pushing permissions");
+      SpecialPowers.pushPermissions(
+        [{ "type": "browser", "allow": 1, "context": document },
+         { "type": "embed-apps", "allow": 1, "context": document },
+         { "type": "webapps-manage", "allow": 1, "context": document }
+        ],
+        runTests);
+    },
+
+    // Preferences
+    function() {
+      info("pushing preferences: " + gOptions.extraPrefs.set);
+      SpecialPowers.pushPrefEnv({
+        "set": gOptions.extraPrefs.set
+      }, runTests);
+    },
+
+    function() {
+      info("enabling use of mozbrowser");
+      //SpecialPowers.setAllAppsLaunchable(true);
+      SpecialPowers.setBoolPref("dom.mozBrowserFramesEnabled", true);
+      runTests();
+    },
+
+    // No confirmation needed when an app is installed
+    function() {
+      SpecialPowers.autoConfirmAppInstall(function() {
+        SpecialPowers.autoConfirmAppUninstall(runTests);
+      });
+    },
+
+    // Installing the app
+    installApp,
+
+    // Run tests in app
+    testApp,
+
+    // Uninstall the app
+    uninstallApp,
+  ];
+
+  function runTests() {
+    if (!tests.length) {
+      ok(true, 'DONE!');
+      SimpleTest.finish();
+      return;
+    }
+
+    var test = tests.shift();
+    test();
+  }
+
+  SimpleTest.waitForExplicitFinish();
+
+  function runAppTest(options) {
+    gOptions = options;
+    var href = document.location.href;
+    gManifestURL = href.substring(0, href.lastIndexOf('/') + 1) +
+      options.appManifest;
+    runTests();
+  }
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/shim_app_as_test_chrome.js
@@ -0,0 +1,178 @@
+/**
+ * This is the chrome helper for shim_app_as_test.js.  Its load is triggered by
+ * shim_app_as_test.js by a call to SpecialPowers.loadChromeScript and runs
+ * in the parent process in a sandbox created with the system principal.  (Which
+ * seems like it can never get collected because it's reachable via the
+ * apparently singleton SpecialPowersObserverAPI instance and there's no logic
+ * to support reaping.  Wuh-oh.)
+ *
+ * It exists to help install fake privileged/certified applications.  It needs
+ * to exist because:
+ * - We need to poke at DOMApplicationRegistry directly.
+ * - By using SpecialPowers.loadChromeScript we are able to ensure this file
+ *   is run in the parent process.  This is important because
+ *   DOMApplicationRegistry only lives in the parent process!
+ * - By running entirely in a chrome privileged compartment, we avoid crazy
+ *   wrapper problems that we would otherwise face with our shenanigans of
+ *   directly meddling with DOMApplicationRegistry.  (And hopefully save
+ *   anyone changing DOMApplicationRegistry from frustration/hating us if
+ *   things were just barely working.)
+ * - Bug 1097479 means that embed-webapps doesn't work when the content process
+ *   that is telling us to do things is itself OOP.  So it falls upon us to
+ *   handle the running of the app by creating a sibling mozbrowser/mozapp
+ *   iframe to the one running the mochitests.
+ *
+ * Note that in this file we try to do *only* those things that can't otherwise
+ * be cleanly done using SpecialPowers.
+ *
+ * Want to better understand our execution context?  Check out
+ * SpecialPowersObserverAPI.js and search on SPLoadChromeScript.
+ */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const CC = Components.Constructor;
+
+Cu.import('resource://gre/modules/Webapps.jsm'); // for DOMApplicationRegistry
+Cu.import('resource://gre/modules/AppsUtils.jsm'); // for AppUtils
+Cu.import('resource://gre/modules/Services.jsm'); // for AppUtils
+
+// Yes, you would think there was something like this already exposed easily
+// in a JSM somewhere.  No.
+function fetchManifest(manifestURL) {
+  return new Promise(function(resolve, reject) {
+    let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+                .createInstance(Ci.nsIXMLHttpRequest);
+    xhr.open("GET", manifestURL, true);
+    xhr.responseType = "json";
+
+    xhr.addEventListener("load", function() {
+      if (xhr.status == 200) {
+        resolve(xhr.response);
+      } else {
+        reject();
+      }
+    });
+
+    xhr.addEventListener("error", function() {
+      reject();
+    });
+
+    xhr.send(null);
+  });
+}
+
+/**
+ * Install an app using confirmInstall using pre-chewed data.  This avoids the
+ * check in the normal installApp flow that gets all judgemental about the
+ * installation of privileged and certified apps.
+ */
+function installApp(req) {
+  fetchManifest(req.manifestURL).then(function(manifestObj) {
+    var data = {
+      // cloneAppObj normalizes the representation for us
+      app: AppsUtils.cloneAppObject({
+        installOrigin: req.origin,
+        origin: req.origin,
+        manifestURL: req.manifestURL,
+        appStatus: AppsUtils.getAppManifestStatus(manifestObj),
+        receipts: [],
+        categories: []
+      }),
+
+      from: req.origin, // unused?
+      oid: 0, // unused?
+      requestID: 0, // unused-ish
+      appId: 0, // unused
+      isBrowser: false,
+      isPackage: false, // used
+      // magic to auto-ack... don't think we care about this...
+      forceSuccessAck: false
+      // stuff that probably doesn't matter: 'mm', 'apkInstall',
+    };
+    // cloneAppObject does not propagate the manifest
+    data.app.manifest = manifestObj;
+
+    return DOMApplicationRegistry.confirmInstall(data).then(
+      function() {
+        var appId =
+          DOMApplicationRegistry.getAppLocalIdByManifestURL(req.manifestURL);
+        // act like this is a privileged app having all of its permissions
+        // authorized at first run.
+        DOMApplicationRegistry.updatePermissionsForApp(
+          appId,
+          /* preinstalled */ true,
+          /* system update? */ true);
+
+        sendAsyncMessage(
+          'installed',
+          {
+            appId: appId,
+            manifestURL: req.manifestURL,
+            manifest: manifestObj
+          });
+      },
+      function(err) {
+        sendAsyncMessage('installed', false);
+      });
+  });
+}
+
+function uninstallApp(appInfo) {
+  DOMApplicationRegistry.uninstall(appInfo.manifestURL).then(
+    function() {
+      sendAsyncMessage('uninstalled', true);
+    },
+    function() {
+      sendAsyncMessage('uninstalled', false);
+    });
+}
+
+var activeIframe = null;
+
+/**
+ * Run our app in a sibling mozbrowser/mozapp iframe to the mochitest iframe.
+ * This is needed because we can't nest mozbrowser/mozapp iframes inside our
+ * already-OOP iframe until bug 1097479 is resolved.
+ */
+function runApp(appInfo) {
+  let shellDomWindow = Services.wm.getMostRecentWindow('navigator:browser');
+  let sysAppFrame = shellDomWindow.document.body.querySelector('#systemapp');
+  let sysAppDoc = sysAppFrame.contentDocument;
+
+  let siblingFrame = sysAppDoc.body.querySelector('#test-container');
+
+  let ifr = activeIframe = sysAppDoc.createElement('iframe');
+  ifr.setAttribute('mozbrowser', 'true');
+  ifr.setAttribute('remote', 'true');
+  ifr.setAttribute('mozapp', appInfo.manifestURL);
+
+  ifr.addEventListener('mozbrowsershowmodalprompt', function(evt) {
+    var message = evt.detail.message;
+    // only send the message as long as we haven't been told to clean up.
+    if (activeIframe) {
+      sendAsyncMessage('appMessage', message);
+    }
+  }, false);
+  ifr.addEventListener('mozbrowsererror', function(evt) {
+    if (activeIframe) {
+      sendAsyncMessage('appError', { message: '' + evt.detail });
+    }
+  });
+
+  ifr.setAttribute('src', appInfo.manifest.launch_path);
+  siblingFrame.parentElement.appendChild(ifr);
+}
+
+function closeApp() {
+  if (activeIframe) {
+    activeIframe.parentElement.removeChild(activeIframe);
+    activeIframe = null;
+  }
+}
+
+addMessageListener('install', installApp);
+addMessageListener('uninstall', uninstallApp);
+addMessageListener('run', runApp);
+addMessageListener('close', closeApp);
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/test_downloads_adopt_download.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=825318
+-->
+<head>
+  <title>Test for Bug 825318 mozDownloadManager.adoptDownload</title>
+  <script type="application/javascript;version=1.7" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript;version=1.7" src="shim_app_as_test.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=825318">Mozilla Bug 825318</a>
+<p id="display"></p>
+<div id="content">
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript;version=1.7">
+
+runAppTest({
+  appFile: 'testapp_downloads_adopt_download.html',
+  appManifest: 'testapp_downloads_adopt_download.manifest',
+  appType: 'certified',
+  extraPrefs: {
+    set: [["dom.mozDownloads.enabled", true]]
+  }
+});
+
+</script>
+</pre>
+</body>
+</html>
+
--- a/dom/downloads/tests/test_downloads_basic.html
+++ b/dom/downloads/tests/test_downloads_basic.html
@@ -67,29 +67,34 @@ function downloadChange(evt) {
   var download = evt.download;
   checkConsistentDownloadAttributes(download);
   is(download.totalBytes, 1024, "Download total size is 1024 bytes");
 
   if (download.state === "succeeded") {
     is(download.currentBytes, 1024, "Download current size is 1024 bytes");
     SimpleTest.finish();
   } else if (download.state === "downloading") {
+    // Note that this case may or may not trigger, depending on whether the
+    // download is initially reported with 0 bytes (we should happen) or with
+    // 1024 bytes (we should not happen).  If we do happen, an additional 8
+    // TEST-PASS events should be logged.
     ok(download.currentBytes > lastKnownCurrentBytes,
        "Download current size is larger than last download change event");
     lastKnownCurrentBytes = download.currentBytes;
   } else {
     ok(false, "Unexpected download state = " + download.state);
   }
 }
 
 function downloadStart(evt) {
   var download = evt.download;
   checkConsistentDownloadAttributes(download);
 
-  is(download.currentBytes, 0, "Download current size is zero");
+  // We used to check that the currentBytes was 0.  This was incorrect.  It
+  // is very common to first hear about the download already at 1024 bytes.
   is(download.state, "downloading", "Download state is downloading");
 
   download.onstatechange = downloadChange;
 }
 
 var steps = [
   // Start by setting the pref to true.
   function() {
--- a/dom/downloads/tests/test_downloads_large.html
+++ b/dom/downloads/tests/test_downloads_large.html
@@ -2,16 +2,17 @@
 <html>
 <!--
 https://bugzilla.mozilla.org/show_bug.cgi?id=938023
 -->
 <head>
   <title>Test for Bug 938023 Downloads API</title>
   <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="clear_all_done_helper.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 
 <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a>
 <p id="display"></p>
 <div id="content" style="display: none">
 </div>
@@ -41,17 +42,17 @@ function next(args) {
 // Catch all error function.
 function error() {
   ok(false, "API failure");
   SimpleTest.finish();
 }
 
 function getDownloads(downloads) {
   ok(downloads.length == 1, "One downloads after getDownloads");
-  navigator.mozDownloadManager.clearAllDone().then(clearAllDone, error);
+  clearAllDoneHelper(true).then(clearAllDone, error);
 }
 
 function clearAllDone(downloads) {
   ok(downloads.length == 0, "No downloads after clearAllDone");
   SimpleTest.finish();
 }
 
 function downloadChange(evt) {
@@ -71,17 +72,17 @@ var steps = [
     }, next);
   },
 
   // Setup permission and clear current list.
   function() {
     SpecialPowers.pushPermissions([
       {type: "downloads", allow: true, context: document}
     ], function() {
-      navigator.mozDownloadManager.clearAllDone().then(next, error);
+      clearAllDoneHelper(true).then(next, error);
     });
   },
 
   function(downloads) {
     ok(downloads.length == 0, "Start with an empty download list.");
     next();
   },
 
--- a/dom/downloads/tests/test_downloads_pause_remove.html
+++ b/dom/downloads/tests/test_downloads_pause_remove.html
@@ -2,16 +2,17 @@
 <html>
 <!--
 https://bugzilla.mozilla.org/show_bug.cgi?id=938023
 -->
 <head>
   <title>Test for Bug 938023 Downloads API</title>
   <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="clear_all_done_helper.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 
 <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a>
 <p id="display"></p>
 <div id="content" style="display: none">
 </div>
@@ -78,17 +79,17 @@ var steps = [
     }, next);
   },
 
   // Setup permission and clear current list.
   function() {
     SpecialPowers.pushPermissions([
       {type: "downloads", allow: true, context: document}
     ], function() {
-      navigator.mozDownloadManager.clearAllDone().then(next, error);
+      clearAllDoneHelper(true).then(next, error);
     });
   },
 
   function(downloads) {
     ok(downloads.length == 0, "Start with an empty download list.");
     next();
   },
 
--- a/dom/downloads/tests/test_downloads_pause_resume.html
+++ b/dom/downloads/tests/test_downloads_pause_resume.html
@@ -2,16 +2,17 @@
 <html>
 <!--
 https://bugzilla.mozilla.org/show_bug.cgi?id=938023
 -->
 <head>
   <title>Test for Bug 938023 Downloads API</title>
   <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
   <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="clear_all_done_helper.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
 </head>
 <body>
 
 <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=938023">Mozilla Bug 938023</a>
 <p id="display"></p>
 <div id="content" style="display: none">
 </div>
@@ -49,18 +50,17 @@ function error() {
 
 function checkDownloadList(downloads) {
   ok(downloads.length == 0, "No downloads left");
   SimpleTest.finish();
 }
 
 function checkResumeSucceeded(download) {
   ok(download.state == "succeeded", "Download resumed successfully.");
-  navigator.mozDownloadManager.clearAllDone()
-             .then(checkDownloadList, error);
+  clearAllDoneHelper(true).then(checkDownloadList, error);
 }
 
 function downloadChange(evt) {
   var download = evt.download;
 
   if (download.state == "downloading" && !pausing) {
     pausing = true;
     download.pause();
@@ -80,17 +80,17 @@ var steps = [
     }, next);
   },
 
   // Setup permission and clear current list.
   function() {
     SpecialPowers.pushPermissions([
       {type: "downloads", allow: true, context: document}
     ], function() {
-      navigator.mozDownloadManager.clearAllDone().then(next, error);
+      clearAllDoneHelper(true).then(next, error);
     });
   },
 
   function(downloads) {
     ok(downloads.length == 0, "Start with an empty download list.");
     next();
   },
 
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/testapp_downloads_adopt_download.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <script type="application/javascript" src="common_app.js"></script>
+  <meta charset="utf-8">
+</head>
+<body>
+<div id="blah">initial text</div>
+<pre id="test">
+<!-- because of certified CSP, this code must NOT be inline -->
+<script class="testbody" type="text/javascript;version=1.7" src="testapp_downloads_adopt_download.js"></script>
+</pre>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/testapp_downloads_adopt_download.js
@@ -0,0 +1,218 @@
+/**
+ * Test the adoptDownload API.  Specifically, we expect that when we call
+ * adoptDownload with a valid payload that:
+ * - The method will be resolved with a valid, fully populated DOMDownload
+ *   instance, including an id.
+ * - An ondownloadstart notification will be generated and the DOMDownload
+ *   instance it receives will be logically equivalent.
+ *
+ * We also explicitly verify that invalid adoptDownload payloads result in a
+ * rejection and that no download is added.
+ *
+ * This test explicitly does not test that the download is correctly persisted
+ * to the database.  This is done because Downloads.jsm does not provide a means
+ * of safely restarting itself, so Firefox would need to be restarted.  Because
+ * the adoptDownload code is using the Downloads API in a straightforward
+ * manner, it's not considered likely this would regress, and certainly not
+ * considered worth the automated testing overhead of a restart.
+ */
+
+function checkInvalidResult(dict, expectedErr, explanation) {
+  navigator.mozDownloadManager.ondownloadstart = function() {
+    ok(false, "No download should have been added!");
+  };
+  navigator.mozDownloadManager.adoptDownload(dict).then(
+    function() {
+      ok(false, "Invalid adoptDownload did not reject!");
+      runTests();
+    },
+    function(rejectedWith) {
+      is(rejectedWith, expectedErr, explanation + " rejection value");
+      runTests();
+    });
+}
+
+// Pick a date that Date.now() could not possibly return by picking a date in
+// the past.  (We want to make sure the date we provide works.)
+var arbitraryDate = new Date(Date.now() - 60000);
+
+var blobContents = new Uint8Array(256);
+var memBlob = new Blob([blobContents], { type: 'application/octet-stream' });
+var blobStorageName;
+var blobStoragePath = 'blobby.blob';
+
+function checkAdoptedDownload(download, validPayload) {
+  is(download.totalBytes, memBlob.size, 'size');
+  is(download.url, validPayload.url, 'url');
+  // The filesystem path is not practical to check since we can't hard-code it
+  // and the only way to check is to effectively duplicate the logic in
+  // DownloadsAPI.js.  The good news, however, is that the value is
+  // round-tripped from storageName/storagePath to path and back again, and we
+  // also verify the file exists on disk, so we can be reasonably confident this
+  // is correct.  We output it to aid in debugging if things should break,
+  // of course.
+  info('path (not checked): ' + download.path);
+  is(download.storageName, validPayload.storageName, 'storageName');
+  is(download.storagePath, validPayload.storagePath, 'storagePath');
+  is(download.state, 'succeeded', 'state');
+  is(download.contentType, validPayload.contentType, 'contentType');
+  is(download.startTime.valueOf(), arbitraryDate.valueOf(), 'startTime');
+  is(download.sourceAppManifestURL,
+     'http://mochi.test:8888/' +
+       'tests/dom/downloads/tests/testapp_downloads_adopt_download.manifest',
+    'app manifest');
+};
+
+var tests = [
+  function saveBlobToDeviceStorage() {
+    // Only sdcard can handle arbitrary MIME types and is guaranteed to be a
+    // thing.
+    var storage = navigator.getDeviceStorage('sdcard');
+    // We used the non-array helper, so the name we get may be different than
+    // what we asked for.
+    blobStorageName = storage.storageName;
+    ok(!!storage, 'have storage');
+    var req = storage.addNamed(memBlob, blobStoragePath);
+    req.onerror = function() {
+      ok(false, 'problem saving blob to storage: ' + req.error.name);
+    };
+    req.onsuccess = function(evt) {
+      ok(true, 'saved blob: ' + evt.target.result);
+      runTests();
+    };
+  },
+  function addValid() {
+      var validPayload = {
+        // All currently expected consumers are unable to provide a valid URL, and
+        // as a result need to provide an empty string.
+        url: "",
+        storageName: blobStorageName,
+        storagePath: blobStoragePath,
+        contentType: memBlob.type,
+        startTime: arbitraryDate
+      };
+    // Wrap the notification in a check so we can force our logic to be
+    // consistently ordered in the test even if it's not in reality.
+    var notifiedPromise = new Promise(function(resolve, reject) {
+      navigator.mozDownloadManager.ondownloadstart = function(evt) {
+        resolve(evt.download);
+      };
+    });
+
+    // Start the download
+    navigator.mozDownloadManager.adoptDownload(validPayload).then(
+      function(apiDownload) {
+        checkAdoptedDownload(apiDownload, validPayload);
+        ok(!!apiDownload.id, "Need a download id!");
+        notifiedPromise.then(function(notifiedDownload) {
+          checkAdoptedDownload(notifiedDownload, validPayload);
+          is(apiDownload.id, notifiedDownload.id,
+             "Notification should be for the download we adopted");
+          runTests();
+        });
+      },
+      function() {
+        ok(false, "adoptDownload should not have rejected");
+        runTests();
+      });
+  },
+
+  function dictionaryNotProvided() {
+    checkInvalidResult(undefined, "InvalidDownload");
+  },
+  // Missing fields immediately result in rejection with InvalidDownload
+  function missingStorageName() {
+    checkInvalidResult({
+      url: "",
+      // no storageName
+      storagePath: "relpath/filename.txt",
+      contentType: "text/plain",
+      startTime: arbitraryDate
+    }, "InvalidDownload", "missing storage name");
+  },
+  function nullStorageName() {
+    checkInvalidResult({
+      url: "",
+      storageName: null,
+      storagePath: "relpath/filename.txt",
+      contentType: "text/plain",
+      startTime: arbitraryDate
+    }, "InvalidDownload", "null storage name");
+  },
+  function missingStoragePath() {
+    checkInvalidResult({
+      url: "",
+      storageName: blobStorageName,
+      // no storagePath
+      contentType: "text/plain",
+      startTime: arbitraryDate
+    }, "InvalidDownload", "missing storage path");
+  },
+  function nullStoragePath() {
+    checkInvalidResult({
+      url: "",
+      storageName: blobStorageName,
+      storagePath: null,
+      contentType: "text/plain",
+      startTime: arbitraryDate
+    }, "InvalidDownload", "null storage path");
+  },
+  function missingContentType() {
+    checkInvalidResult({
+      url: "",
+      storageName: "sdcard",
+      storagePath: "relpath/filename.txt",
+      // no contentType
+      startTime: arbitraryDate
+    }, "InvalidDownload", "missing content type");
+  },
+  function nullContentType() {
+    checkInvalidResult({
+      url: "",
+      storageName: "sdcard",
+      storagePath: "relpath/filename.txt",
+      contentType: null,
+      startTime: arbitraryDate
+    }, "InvalidDownload", "null content type");
+  },
+  // Incorrect storage names are likewise immediately invalidated
+  function invalidStorageName() {
+    checkInvalidResult({
+      url: "",
+      storageName: "ALMOST CERTAINLY DOES NOT EXIST",
+      storagePath: "relpath/filename.txt",
+      contentType: "text/plain",
+      startTime: arbitraryDate
+    }, "InvalidDownload", "invalid storage name");
+  },
+  // The existence of the file is validated in the parent process
+  function legitStorageInvalidPath() {
+    checkInvalidResult({
+      url: "",
+      storageName: blobStorageName,
+      storagePath: "ALMOST CERTAINLY DOES NOT EXIST",
+      contentType: "text/plain",
+      startTime: arbitraryDate
+    }, "AdoptNoSuchFile", "invalid path");
+  },
+  function allDone() {
+    // Just in case, make sure no other mochitest could mess with us after we've
+    // finished.
+    navigator.mozDownloadManager.ondownloadstart = null;
+    runTests();
+  }
+];
+
+function runTests() {
+  if (!tests.length) {
+    finish();
+    return;
+  }
+
+  var test = tests.shift();
+  if (test.name) {
+    info('starting test: ' + test.name);
+  }
+  test();
+}
+runTests();
new file mode 100644
--- /dev/null
+++ b/dom/downloads/tests/testapp_downloads_adopt_download.manifest
@@ -0,0 +1,10 @@
+{
+  "name": "Downloads certified test fake app",
+  "description": "Test",
+  "launch_path": "http://mochi.test:8888/tests/dom/downloads/tests/testapp_downloads_adopt_download.html",
+  "type": "certified",
+  "permissions": {
+    "device-storage:sdcard":{ "access": "readcreate" },
+    "downloads": {}
+  }
+}
--- a/dom/webidl/Downloads.webidl
+++ b/dom/webidl/Downloads.webidl
@@ -31,20 +31,42 @@ interface DOMDownloadManager : EventTarg
   // download objects.
   Promise<sequence<DOMDownload>> getDownloads();
 
   // Removes one download from the downloads set. Returns a promise resolved
   // with the finalized download.
   [UnsafeInPrerendering]
   Promise<DOMDownload> remove(DOMDownload download);
 
-  // Removes all the completed downloads from the set.  Returns an
-  // array of the completed downloads that were removed.
+  // Removes all completed downloads.  This kicks off an asynchronous process
+  // that will eventually complete, but will not have completed by the time this
+  // method returns.  If you care about the side-effects of this method, know
+  // that each existing download will have its onstatechange method invoked and
+  // will have a new state of "finalized".  (After the download is finalized, no
+  // further events will be generated on it.)
   [UnsafeInPrerendering]
-  Promise<sequence<DOMDownload>> clearAllDone();
+  void clearAllDone();
+
+  // Add completed downloads from applications that must perform the download
+  // process themselves. For example, email.  The method is resolved with a
+  // fully populated DOMDownload instance on success, or rejected in the
+  // event all required options were not provided.
+  //
+  // The adopted download will also be reported via the ondownloadstart event
+  // handler.
+  //
+  // Applications must currently be certified to use this, but it could be
+  // widened at a later time.
+  //
+  // Note that "download" is not actually optional, but WebIDL requires that it
+  // be marked as such because it is not followed by a required argument.  The
+  // promise will be rejected if the dictionary is omitted or the specified
+  // file does not exist on disk.
+  [AvailableIn=CertifiedApps]
+  Promise<DOMDownload> adoptDownload(optional AdoptDownloadDict download);
 
   // Fires when a new download starts.
   attribute EventHandler ondownloadstart;
 };
 
 [JSImplementation="@mozilla.org/downloads/download;1",
  Pref="dom.mozDownloads.enabled",
  CheckPermissions="downloads"]
@@ -54,39 +76,48 @@ interface DOMDownload : EventTarget {
 
   // The number of bytes that we have currently downloaded.
   readonly attribute long long currentBytes;
 
   // The url of the resource.
   readonly attribute DOMString url;
 
   // The full path in local storage where the file will end up once the download
-  // is complete.
+  // is complete. This is equivalent to the concatenation of the 'storagePath'
+  // to the 'mountPoint' of the nsIVolume associated with the 'storageName'
+  // (with delimiter).
   readonly attribute DOMString path;
 
   // The DeviceStorage volume name on which the file is being downloaded.
   readonly attribute DOMString storageName;
 
   // The DeviceStorage path on the volume with 'storageName' of the file being
   // downloaded.
   readonly attribute DOMString storagePath;
 
-  // The state of the download.
+  // The state of the download.  One of: downloading, stopped, succeeded, or
+  // finalized.  A finalized download is a download that has been removed /
+  // cleared and is no longer tracked by the download manager and will not
+  // receive any further onstatechange updates.
   readonly attribute DownloadState state;
 
   // The mime type for this resource.
   readonly attribute DOMString contentType;
 
   // The timestamp this download started.
   readonly attribute Date startTime;
 
   // An opaque identifier for this download. All instances of the same
   // download (eg. in different windows) will have the same id.
   readonly attribute DOMString id;
 
+  // The manifestURL of the application that added this download. Only used for
+  // downloads added via the adoptDownload API call.
+  readonly attribute DOMString? sourceAppManifestURL;
+
   // A DOM error object, that will be not null when a download is stopped
   // because something failed.
   readonly attribute DOMError? error;
 
   // Pauses the download.
   [UnsafeInPrerendering]
   Promise<DOMDownload> pause();
 
@@ -95,8 +126,46 @@ interface DOMDownload : EventTarget {
   [UnsafeInPrerendering]
   Promise<DOMDownload> resume();
 
   // This event is triggered anytime a property of the object changes:
   // - when the transfer progresses, updating currentBytes.
   // - when the state and/or error attributes change.
   attribute EventHandler onstatechange;
 };
+
+// Used to initialize the DOMDownload object for adopted downloads.
+// fields directly maps to the DOMDownload fields.
+dictionary AdoptDownloadDict {
+  // The URL of this resource if there is one available. An empty string if
+  // the download is not accessible via URL. An empty string is chosen over
+  // null so that existinc code does not need to null-check but the value is
+  // still falsey.  (Note: If you do have a usable URL, you should probably not
+  // be using the adoptDownload API and instead be initiating downloads the
+  // normal way.)
+  DOMString url;
+
+  // The storageName of the DeviceStorage instance the file was saved to.
+  // Required but marked as optional so the bindings don't auto-coerce the value
+  // null to "null".
+  DOMString? storageName;
+  // The path of the file within the DeviceStorage instance named by
+  // 'storageName'.  This is used to automatically compute the 'path' of the
+  // download.  Note that when DeviceStorage gives you a path to a file, the
+  // first path segment is the name of the specific device storage and you do
+  // *not* want to include this.  For example, if DeviceStorage tells you the
+  // file has a path of '/sdcard1/actual/path/file.ext', then the storageName
+  // should be 'sdcard1' and the storagePath should be 'actual/path/file.ext'.
+  //
+  // The existence of the file will be validated will be validated with stat()
+  // and the size the file-system tells us will be what we use.
+  //
+  // Required but marked as optional so the bindings don't auto-coerce the value
+  // null to "null".
+  DOMString? storagePath;
+
+  // The mime type for this resource.  Required, but marked as optional because
+  // WebIDL otherwise auto-coerces the value null to "null".
+  DOMString? contentType;
+
+  // The time the download was started. If omitted, the current time is used.
+  Date? startTime;
+};