/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
/* 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/. */

/**
 * This file includes the following constructors and global objects:
 *
 * Download
 * Represents a single download, with associated state and actions.  This object
 * is transient, though it can be included in a DownloadList so that it can be
 * managed by the user interface and persisted across sessions.
 *
 * DownloadSource
 * Represents the source of a download, for example a document or an URI.
 *
 * DownloadTarget
 * Represents the target of a download, for example a file in the global
 * downloads directory, or a file in the system temporary directory.
 *
 * DownloadError
 * Provides detailed information about a download failure.
 *
 * DownloadSaver
 * Template for an object that actually transfers the data for the download.
 *
 * DownloadCopySaver
 * Saver object that simply copies the entire source file to the target.
 *
 * DownloadLegacySaver
 * Saver object that integrates with the legacy nsITransfer interface.
 *
 * DownloadPDFSaver
 * This DownloadSaver type creates a PDF file from the current document in a
 * given window, specified using the windowRef property of the DownloadSource
 * object associated with the download.
 */

"use strict";

this.EXPORTED_SYMBOLS = [
  "Download",
  "DownloadSource",
  "DownloadTarget",
  "DownloadError",
  "DownloadSaver",
  "DownloadCopySaver",
  "DownloadLegacySaver",
  "DownloadPDFSaver",
];

// Globals

const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;

Cu.import("resource://gre/modules/Integration.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                  "resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                  "resource://gre/modules/NetUtil.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                  "resource://gre/modules/osfile.jsm")
XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                  "resource://gre/modules/Promise.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                  "resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                  "resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");

XPCOMUtils.defineLazyServiceGetter(this, "gDownloadHistory",
           "@mozilla.org/browser/download-history;1",
           Ci.nsIDownloadHistory);
XPCOMUtils.defineLazyServiceGetter(this, "gExternalAppLauncher",
           "@mozilla.org/uriloader/external-helper-app-service;1",
           Ci.nsPIExternalAppLauncher);
XPCOMUtils.defineLazyServiceGetter(this, "gExternalHelperAppService",
           "@mozilla.org/uriloader/external-helper-app-service;1",
           Ci.nsIExternalHelperAppService);
XPCOMUtils.defineLazyServiceGetter(this, "gPrintSettingsService",
           "@mozilla.org/gfx/printsettings-service;1",
           Ci.nsIPrintSettingsService);

Integration.downloads.defineModuleGetter(this, "DownloadIntegration",
            "resource://gre/modules/DownloadIntegration.jsm");

const BackgroundFileSaverStreamListener = Components.Constructor(
      "@mozilla.org/network/background-file-saver;1?mode=streamlistener",
      "nsIBackgroundFileSaver");

/**
 * Returns true if the given value is a primitive string or a String object.
 */
function isString(aValue) {
  // We cannot use the "instanceof" operator reliably across module boundaries.
  return (typeof aValue == "string") ||
         (typeof aValue == "object" && "charAt" in aValue);
}

/**
 * Serialize the unknown properties of aObject into aSerializable.
 */
function serializeUnknownProperties(aObject, aSerializable)
{
  if (aObject._unknownProperties) {
    for (let property in aObject._unknownProperties) {
      aSerializable[property] = aObject._unknownProperties[property];
    }
  }
}

/**
 * Check for any unknown properties in aSerializable and preserve those in the
 * _unknownProperties field of aObject. aFilterFn is called for each property
 * name of aObject and should return true only for unknown properties.
 */
function deserializeUnknownProperties(aObject, aSerializable, aFilterFn)
{
  for (let property in aSerializable) {
    if (aFilterFn(property)) {
      if (!aObject._unknownProperties) {
        aObject._unknownProperties = { };
      }

      aObject._unknownProperties[property] = aSerializable[property];
    }
  }
}

/**
 * This determines the minimum time interval between updates to the number of
 * bytes transferred, and is a limiting factor to the sequence of readings used
 * in calculating the speed of the download.
 */
const kProgressUpdateIntervalMs = 400;

// Download

/**
 * Represents a single download, with associated state and actions.  This object
 * is transient, though it can be included in a DownloadList so that it can be
 * managed by the user interface and persisted across sessions.
 */
this.Download = function ()
{
  this._deferSucceeded = Promise.defer();
}

this.Download.prototype = {
  /**
   * DownloadSource object associated with this download.
   */
  source: null,

  /**
   * DownloadTarget object associated with this download.
   */
  target: null,

  /**
   * DownloadSaver object associated with this download.
   */
  saver: null,

  /**
   * Indicates that the download never started, has been completed successfully,
   * failed, or has been canceled.  This property becomes false when a download
   * is started for the first time, or when a failed or canceled download is
   * restarted.
   */
  stopped: true,

  /**
   * Indicates that the download has been completed successfully.
   */
  succeeded: false,

  /**
   * Indicates that the download has been canceled.  This property can become
   * true, then it can be reset to false when a canceled download is restarted.
   *
   * This property becomes true as soon as the "cancel" method is called, though
   * the "stopped" property might remain false until the cancellation request
   * has been processed.  Temporary files or part files may still exist even if
   * they are expected to be deleted, until the "stopped" property becomes true.
   */
  canceled: false,

  /**
   * When the download fails, this is set to a DownloadError instance indicating
   * the cause of the failure.  If the download has been completed successfully
   * or has been canceled, this property is null.  This property is reset to
   * null when a failed download is restarted.
   */
  error: null,

  /**
   * Indicates the start time of the download.  When the download starts,
   * this property is set to a valid Date object.  The default value is null
   * before the download starts.
   */
  startTime: null,

  /**
   * Indicates whether this download's "progress" property is able to report
   * partial progress while the download proceeds, and whether the value in
   * totalBytes is relevant.  This depends on the saver and the download source.
   */
  hasProgress: false,

  /**
   * Progress percent, from 0 to 100.  Intermediate values are reported only if
   * hasProgress is true.
   *
   * @note You shouldn't rely on this property being equal to 100 to determine
   *       whether the download is completed.  You should use the individual
   *       state properties instead.
   */
  progress: 0,

  /**
   * When hasProgress is true, indicates the total number of bytes to be
   * transferred before the download finishes, that can be zero for empty files.
   *
   * When hasProgress is false, this property is always zero.
   *
   * @note This property may be different than the final file size on disk for
   *       downloads that are encoded during the network transfer.  You can use
   *       the "size" property of the DownloadTarget object to get the actual
   *       size on disk once the download succeeds.
   */
  totalBytes: 0,

  /**
   * Number of bytes currently transferred.  This value starts at zero, and may
   * be updated regardless of the value of hasProgress.
   *
   * @note You shouldn't rely on this property being equal to totalBytes to
   *       determine whether the download is completed.  You should use the
   *       individual state properties instead.  This property may not be
   *       updated during the last part of the download.
   */
  currentBytes: 0,

  /**
   * Fractional number representing the speed of the download, in bytes per
   * second.  This value is zero when the download is stopped, and may be
   * updated regardless of the value of hasProgress.
   */
  speed: 0,

  /**
   * Indicates whether, at this time, there is any partially downloaded data
   * that can be used when restarting a failed or canceled download.
   *
   * Even if the download has partial data on disk, hasPartialData will be false
   * if that data cannot be used to restart the download. In order to determine
   * if a part file is being used which contains partial data the
   * Download.target.partFilePath should be checked.
   *
   * This property is relevant while the download is in progress, and also if it
   * failed or has been canceled.  If the download has been completed
   * successfully, this property is always false.
   *
   * Whether partial data can actually be retained depends on the saver and the
   * download source, and may not be known before the download is started.
   */
  hasPartialData: false,

  /**
   * Indicates whether, at this time, there is any data that has been blocked.
   * Since reputation blocking takes place after the download has fully
   * completed a value of true also indicates 100% of the data is present.
   */
  hasBlockedData: false,

  /**
   * This can be set to a function that is called after other properties change.
   */
  onchange: null,

  /**
   * This tells if the user has chosen to open/run the downloaded file after
   * download has completed.
   */
  launchWhenSucceeded: false,

  /**
   * This represents the MIME type of the download.
   */
  contentType: null,

  /**
   * This indicates the path of the application to be used to launch the file,
   * or null if the file should be launched with the default application.
   */
  launcherPath: null,

  /**
   * Raises the onchange notification.
   */
  _notifyChange: function D_notifyChange() {
    try {
      if (this.onchange) {
        this.onchange();
      }
    } catch (ex) {
      Cu.reportError(ex);
    }
  },

  /**
   * The download may be stopped and restarted multiple times before it
   * completes successfully. This may happen if any of the download attempts is
   * canceled or fails.
   *
   * This property contains a promise that is linked to the current attempt, or
   * null if the download is either stopped or in the process of being canceled.
   * If the download restarts, this property is replaced with a new promise.
   *
   * The promise is resolved if the attempt it represents finishes successfully,
   * and rejected if the attempt fails.
   */
  _currentAttempt: null,

  /**
   * Starts the download for the first time, or restarts a download that failed
   * or has been canceled.
   *
   * Calling this method when the download has been completed successfully has
   * no effect, and the method returns a resolved promise.  If the download is
   * in progress, the method returns the same promise as the previous call.
   *
   * If the "cancel" method was called but the cancellation process has not
   * finished yet, this method waits for the cancellation to finish, then
   * restarts the download immediately.
   *
   * @note If you need to start a new download from the same source, rather than
   *       restarting a failed or canceled one, you should create a separate
   *       Download object with the same source as the current one.
   *
   * @return {Promise}
   * @resolves When the download has finished successfully.
   * @rejects JavaScript exception if the download failed.
   */
  start: function D_start()
  {
    // If the download succeeded, it's the final state, we have nothing to do.
    if (this.succeeded) {
      return Promise.resolve();
    }

    // If the download already started and hasn't failed or hasn't been
    // canceled, return the same promise as the previous call, allowing the
    // caller to wait for the current attempt to finish.
    if (this._currentAttempt) {
      return this._currentAttempt;
    }

    // While shutting down or disposing of this object, we prevent the download
    // from returning to be in progress.
    if (this._finalized) {
      return Promise.reject(new DownloadError({
                                message: "Cannot start after finalization."}));
    }

    if (this.error && this.error.becauseBlockedByReputationCheck) {
      return Promise.reject(new DownloadError({
                                message: "Cannot start after being blocked " +
                                         "by a reputation check."}));
    }

    // Initialize all the status properties for a new or restarted download.
    this.stopped = false;
    this.canceled = false;
    this.error = null;
    this.hasProgress = false;
    this.hasBlockedData = false;
    this.progress = 0;
    this.totalBytes = 0;
    this.currentBytes = 0;
    this.startTime = new Date();

    // Create a new deferred object and an associated promise before starting
    // the actual download.  We store it on the download as the current attempt.
    let deferAttempt = Promise.defer();
    let currentAttempt = deferAttempt.promise;
    this._currentAttempt = currentAttempt;

    // Restart the progress and speed calculations from scratch.
    this._lastProgressTimeMs = 0;

    // This function propagates progress from the DownloadSaver object, unless
    // it comes in late from a download attempt that was replaced by a new one.
    // If the cancellation process for the download has started, then the update
    // is ignored.
    function DS_setProgressBytes(aCurrentBytes, aTotalBytes, aHasPartialData)
    {
      if (this._currentAttempt == currentAttempt) {
        this._setBytes(aCurrentBytes, aTotalBytes, aHasPartialData);
      }
    }

    // This function propagates download properties from the DownloadSaver
    // object, unless it comes in late from a download attempt that was
    // replaced by a new one.  If the cancellation process for the download has
    // started, then the update is ignored.
    function DS_setProperties(aOptions)
    {
      if (this._currentAttempt != currentAttempt) {
        return;
      }

      let changeMade = false;

      for (let property of ["contentType", "progress", "hasPartialData",
                            "hasBlockedData"]) {
        if (property in aOptions && this[property] != aOptions[property]) {
          this[property] = aOptions[property];
          changeMade = true;
        }
      }

      if (changeMade) {
        this._notifyChange();
      }
    }

    // Now that we stored the promise in the download object, we can start the
    // task that will actually execute the download.
    deferAttempt.resolve(Task.spawn(function* task_D_start() {
      // Wait upon any pending operation before restarting.
      if (this._promiseCanceled) {
        yield this._promiseCanceled;
      }
      if (this._promiseRemovePartialData) {
        try {
          yield this._promiseRemovePartialData;
        } catch (ex) {
          // Ignore any errors, which are already reported by the original
          // caller of the removePartialData method.
        }
      }

      // In case the download was restarted while cancellation was in progress,
      // but the previous attempt actually succeeded before cancellation could
      // be processed, it is possible that the download has already finished.
      if (this.succeeded) {
        return;
      }

      try {
        // Disallow download if parental controls service restricts it.
        if (yield DownloadIntegration.shouldBlockForParentalControls(this)) {
          throw new DownloadError({ becauseBlockedByParentalControls: true });
        }

        // Disallow download if needed runtime permissions have not been granted
        // by user.
        if (yield DownloadIntegration.shouldBlockForRuntimePermissions()) {
          throw new DownloadError({ becauseBlockedByRuntimePermissions: true });
        }

        // We should check if we have been canceled in the meantime, after all
        // the previous asynchronous operations have been executed and just
        // before we call the "execute" method of the saver.
        if (this._promiseCanceled) {
          // The exception will become a cancellation in the "catch" block.
          throw undefined;
        }

        // Execute the actual download through the saver object.
        this._saverExecuting = true;
        yield this.saver.execute(DS_setProgressBytes.bind(this),
                                 DS_setProperties.bind(this));

        // Now that the actual saving finished, read the actual file size on
        // disk, that may be different from the amount of data transferred.
        yield this.target.refresh();

        // Check for the last time if the download has been canceled. This must
        // be done right before setting the "stopped" property of the download,
        // without any asynchronous operations in the middle, so that another
        // cancellation request cannot start in the meantime and stay unhandled.
        if (this._promiseCanceled) {
          try {
            yield OS.File.remove(this.target.path);
          } catch (ex) {
            Cu.reportError(ex);
          }

          this.target.exists = false;
          this.target.size = 0;

          // Cancellation exceptions will be changed in the catch block below.
          throw new DownloadError();
        }

        // Update the status properties for a successful download.
        this.progress = 100;
        this.succeeded = true;
        this.hasPartialData = false;
      } catch (originalEx) {
        // We may choose a different exception to propagate in the code below,
        // or wrap the original one. We do this mutation in a different variable
        // because of the "no-ex-assign" ESLint rule.
        let ex = originalEx;

        // Fail with a generic status code on cancellation, so that the caller
        // is forced to actually check the status properties to see if the
        // download was canceled or failed because of other reasons.
        if (this._promiseCanceled) {
          throw new DownloadError({ message: "Download canceled." });
        }

        // An HTTP 450 error code is used by Windows to indicate that a uri is
        // blocked by parental controls. This will prevent the download from
        // occuring, so an error needs to be raised. This is not performed
        // during the parental controls check above as it requires the request
        // to start.
        if (this._blockedByParentalControls) {
          ex = new DownloadError({ becauseBlockedByParentalControls: true });
        }

        // Update the download error, unless a new attempt already started. The
        // change in the status property is notified in the finally block.
        if (this._currentAttempt == currentAttempt || !this._currentAttempt) {
          if (!(ex instanceof DownloadError)) {
            let properties = {innerException: ex};

            if (ex.message) {
              properties.message = ex.message;
            }

            ex = new DownloadError(properties);
          }

          this.error = ex;
        }
        throw ex;
      } finally {
        // Any cancellation request has now been processed.
        this._saverExecuting = false;
        this._promiseCanceled = null;

        // Update the status properties, unless a new attempt already started.
        if (this._currentAttempt == currentAttempt || !this._currentAttempt) {
          this._currentAttempt = null;
          this.stopped = true;
          this.speed = 0;
          this._notifyChange();
          if (this.succeeded) {
            yield this._succeed();
          }
        }
      }
    }.bind(this)));

    // Notify the new download state before returning.
    this._notifyChange();
    return currentAttempt;
  },

  /**
   * Perform the actions necessary when a Download succeeds.
   *
   * @return {Promise}
   * @resolves When the steps to take after success have completed.
   * @rejects  JavaScript exception if any of the operations failed.
   */
  _succeed: Task.async(function* () {
    yield DownloadIntegration.downloadDone(this);

    this._deferSucceeded.resolve();

    if (this.launchWhenSucceeded) {
      this.launch().then(null, Cu.reportError);

      // Always schedule files to be deleted at the end of the private browsing
      // mode, regardless of the value of the pref.
      if (this.source.isPrivate) {
        gExternalAppLauncher.deleteTemporaryPrivateFileWhenPossible(
                             new FileUtils.File(this.target.path));
      } else if (Services.prefs.getBoolPref(
                  "browser.helperApps.deleteTempFileOnExit")) {
        gExternalAppLauncher.deleteTemporaryFileOnExit(
                             new FileUtils.File(this.target.path));
      }
    }
  }),

  /**
   * When a request to unblock the download is received, contains a promise
   * that will be resolved when the unblock request is completed. This property
   * will then continue to hold the promise indefinitely.
   */
  _promiseUnblock: null,

  /**
   * When a request to confirm the block of the download is received, contains
   * a promise that will be resolved when cleaning up the download has
   * completed. This property will then continue to hold the promise
   * indefinitely.
   */
  _promiseConfirmBlock: null,

  /**
   * Unblocks a download which had been blocked by reputation.
   *
   * The file will be moved out of quarantine and the download will be
   * marked as succeeded.
   *
   * @return {Promise}
   * @resolves When the Download has been unblocked and succeeded.
   * @rejects  JavaScript exception if any of the operations failed.
   */
  unblock: function() {
    if (this._promiseUnblock) {
      return this._promiseUnblock;
    }

    if (this._promiseConfirmBlock) {
      return Promise.reject(new Error(
        "Download block has been confirmed, cannot unblock."));
    }

    if (!this.hasBlockedData) {
      return Promise.reject(new Error(
        "unblock may only be called on Downloads with blocked data."));
    }

    this._promiseUnblock = Task.spawn(function* () {
      try {
        yield OS.File.move(this.target.partFilePath, this.target.path);
        yield this.target.refresh();
      } catch (ex) {
        yield this.refresh();
        this._promiseUnblock = null;
        throw ex;
      }

      this.succeeded = true;
      this.hasBlockedData = false;
      this._notifyChange();
      yield this._succeed();
    }.bind(this));

    return this._promiseUnblock;
  },

  /**
   * Confirms that a blocked download should be cleaned up.
   *
   * If a download was blocked but retained on disk this method can be used
   * to remove the file.
   *
   * @return {Promise}
   * @resolves When the Download's data has been removed.
   * @rejects  JavaScript exception if any of the operations failed.
   */
  confirmBlock: function() {
    if (this._promiseConfirmBlock) {
      return this._promiseConfirmBlock;
    }

    if (this._promiseUnblock) {
      return Promise.reject(new Error(
        "Download is being unblocked, cannot confirmBlock."));
    }

    if (!this.hasBlockedData) {
      return Promise.reject(new Error(
        "confirmBlock may only be called on Downloads with blocked data."));
    }

    this._promiseConfirmBlock = Task.spawn(function* () {
      try {
        yield OS.File.remove(this.target.partFilePath);
      } catch (ex) {
        yield this.refresh();
        this._promiseConfirmBlock = null;
        throw ex;
      }

      this.hasBlockedData = false;
      this._notifyChange();
    }.bind(this));

    return this._promiseConfirmBlock;
  },

  /*
   * Launches the file after download has completed. This can open
   * the file with the default application for the target MIME type
   * or file extension, or with a custom application if launcherPath
   * is set.
   *
   * @return {Promise}
   * @resolves When the instruction to launch the file has been
   *           successfully given to the operating system. Note that
   *           the OS might still take a while until the file is actually
   *           launched.
   * @rejects  JavaScript exception if there was an error trying to launch
   *           the file.
   */
  launch: function () {
    if (!this.succeeded) {
      return Promise.reject(
        new Error("launch can only be called if the download succeeded")
      );
    }

    return DownloadIntegration.launchDownload(this);
  },

  /*
   * Shows the folder containing the target file, or where the target file
   * will be saved. This may be called at any time, even if the download
   * failed or is currently in progress.
   *
   * @return {Promise}
   * @resolves When the instruction to open the containing folder has been
   *           successfully given to the operating system. Note that
   *           the OS might still take a while until the folder is actually
   *           opened.
   * @rejects  JavaScript exception if there was an error trying to open
   *           the containing folder.
   */
  showContainingDirectory: function D_showContainingDirectory() {
    return DownloadIntegration.showContainingDirectory(this.target.path);
  },

  /**
   * When a request to cancel the download is received, contains a promise that
   * will be resolved when the cancellation request is processed.  When the
   * request is processed, this property becomes null again.
   */
  _promiseCanceled: null,

  /**
   * True between the call to the "execute" method of the saver and the
   * completion of the current download attempt.
   */
  _saverExecuting: false,

  /**
   * Cancels the download.
   *
   * The cancellation request is asynchronous.  Until the cancellation process
   * finishes, temporary files or part files may still exist even if they are
   * expected to be deleted.
   *
   * In case the download completes successfully before the cancellation request
   * could be processed, this method has no effect, and it returns a resolved
   * promise.  You should check the properties of the download at the time the
   * returned promise is resolved to determine if the download was cancelled.
   *
   * Calling this method when the download has been completed successfully,
   * failed, or has been canceled has no effect, and the method returns a
   * resolved promise.  This behavior is designed for the case where the call
   * to "cancel" happens asynchronously, and is consistent with the case where
   * the cancellation request could not be processed in time.
   *
   * @return {Promise}
   * @resolves When the cancellation process has finished.
   * @rejects Never.
   */
  cancel: function D_cancel()
  {
    // If the download is currently stopped, we have nothing to do.
    if (this.stopped) {
      return Promise.resolve();
    }

    if (!this._promiseCanceled) {
      // Start a new cancellation request.
      let deferCanceled = Promise.defer();
      this._currentAttempt.then(() => deferCanceled.resolve(),
                                () => deferCanceled.resolve());
      this._promiseCanceled = deferCanceled.promise;

      // The download can already be restarted.
      this._currentAttempt = null;

      // Notify that the cancellation request was received.
      this.canceled = true;
      this._notifyChange();

      // Execute the actual cancellation through the saver object, in case it
      // has already started.  Otherwise, the cancellation will be handled just
      // before the saver is started.
      if (this._saverExecuting) {
        this.saver.cancel();
      }
    }

    return this._promiseCanceled;
  },

  /**
   * Indicates whether any partially downloaded data should be retained, to use
   * when restarting a failed or canceled download.  The default is false.
   *
   * Whether partial data can actually be retained depends on the saver and the
   * download source, and may not be known before the download is started.
   *
   * To have any effect, this property must be set before starting the download.
   * Resetting this property to false after the download has already started
   * will not remove any partial data.
   *
   * If this property is set to true, care should be taken that partial data is
   * removed before the reference to the download is discarded.  This can be
   * done using the removePartialData or the "finalize" methods.
   */
  tryToKeepPartialData: false,

  /**
   * When a request to remove partially downloaded data is received, contains a
   * promise that will be resolved when the removal request is processed.  When
   * the request is processed, this property becomes null again.
   */
  _promiseRemovePartialData: null,

  /**
   * Removes any partial data kept as part of a canceled or failed download.
   *
   * If the download is not canceled or failed, this method has no effect, and
   * it returns a resolved promise.  If the "cancel" method was called but the
   * cancellation process has not finished yet, this method waits for the
   * cancellation to finish, then removes the partial data.
   *
   * After this method has been called, if the tryToKeepPartialData property is
   * still true when the download is restarted, partial data will be retained
   * during the new download attempt.
   *
   * @return {Promise}
   * @resolves When the partial data has been successfully removed.
   * @rejects JavaScript exception if the operation could not be completed.
   */
  removePartialData: function ()
  {
    if (!this.canceled && !this.error) {
      return Promise.resolve();
    }

    let promiseRemovePartialData = this._promiseRemovePartialData;

    if (!promiseRemovePartialData) {
      let deferRemovePartialData = Promise.defer();
      promiseRemovePartialData = deferRemovePartialData.promise;
      this._promiseRemovePartialData = promiseRemovePartialData;

      deferRemovePartialData.resolve(
        Task.spawn(function* task_D_removePartialData() {
          try {
            // Wait upon any pending cancellation request.
            if (this._promiseCanceled) {
              yield this._promiseCanceled;
            }
            // Ask the saver object to remove any partial data.
            yield this.saver.removePartialData();
            // For completeness, clear the number of bytes transferred.
            if (this.currentBytes != 0 || this.hasPartialData) {
              this.currentBytes = 0;
              this.hasPartialData = false;
              this._notifyChange();
            }
          } finally {
            this._promiseRemovePartialData = null;
          }
        }.bind(this)));
    }

    return promiseRemovePartialData;
  },

  /**
   * This deferred object contains a promise that is resolved as soon as this
   * download finishes successfully, and is never rejected.  This property is
   * initialized when the download is created, and never changes.
   */
  _deferSucceeded: null,

  /**
   * Returns a promise that is resolved as soon as this download finishes
   * successfully, even if the download was stopped and restarted meanwhile.
   *
   * You can use this property for scheduling download completion actions in the
   * current session, for downloads that are controlled interactively.  If the
   * download is not controlled interactively, you should use the promise
   * returned by the "start" method instead, to check for success or failure.
   *
   * @return {Promise}
   * @resolves When the download has finished successfully.
   * @rejects Never.
   */
  whenSucceeded: function D_whenSucceeded()
  {
    return this._deferSucceeded.promise;
  },

  /**
   * Updates the state of a finished, failed, or canceled download based on the
   * current state in the file system.  If the download is in progress or it has
   * been finalized, this method has no effect, and it returns a resolved
   * promise.
   *
   * This allows the properties of the download to be updated in case the user
   * moved or deleted the target file or its associated ".part" file.
   *
   * @return {Promise}
   * @resolves When the operation has completed.
   * @rejects Never.
   */
  refresh: function ()
  {
    return Task.spawn(function* () {
      if (!this.stopped || this._finalized) {
        return;
      }

      if (this.succeeded) {
        let oldExists = this.target.exists;
        let oldSize = this.target.size;
        yield this.target.refresh();
        if (oldExists != this.target.exists || oldSize != this.target.size) {
          this._notifyChange();
        }
        return;
      }

      // Update the current progress from disk if we retained partial data.
      if ((this.hasPartialData || this.hasBlockedData) &&
          this.target.partFilePath) {

        try {
          let stat = yield OS.File.stat(this.target.partFilePath);

          // Ignore the result if the state has changed meanwhile.
          if (!this.stopped || this._finalized) {
            return;
          }

          // Update the bytes transferred and the related progress properties.
          this.currentBytes = stat.size;
          if (this.totalBytes > 0) {
            this.hasProgress = true;
            this.progress = Math.floor(this.currentBytes /
                                           this.totalBytes * 100);
          }
        } catch (ex) {
          if (!(ex instanceof OS.File.Error) || !ex.becauseNoSuchFile) {
            throw ex;
          }
          // Ignore the result if the state has changed meanwhile.
          if (!this.stopped || this._finalized) {
            return;
          }

          this.hasBlockedData = false;
          this.hasPartialData = false;
        }

        this._notifyChange();
      }
    }.bind(this)).then(null, Cu.reportError);
  },

  /**
   * True if the "finalize" method has been called.  This prevents the download
   * from starting again after having been stopped.
   */
  _finalized: false,

  /**
   * Ensures that the download is stopped, and optionally removes any partial
   * data kept as part of a canceled or failed download.  After this method has
   * been called, the download cannot be started again.
   *
   * This method should be used in place of "cancel" and removePartialData while
   * shutting down or disposing of the download object, to prevent other callers
   * from interfering with the operation.  This is required because cancellation
   * and other operations are asynchronous.
   *
   * @param aRemovePartialData
   *        Whether any partially downloaded data should be removed after the
   *        download has been stopped.
   *
   * @return {Promise}
   * @resolves When the operation has finished successfully.
   * @rejects JavaScript exception if an error occurred while removing the
   *          partially downloaded data.
   */
  finalize: function (aRemovePartialData)
  {
    // Prevents the download from starting again after having been stopped.
    this._finalized = true;

    if (aRemovePartialData) {
      // Cancel the download, in case it is currently in progress, then remove
      // any partially downloaded data.  The removal operation waits for
      // cancellation to be completed before resolving the promise it returns.
      this.cancel();
      return this.removePartialData();
    }
    // Just cancel the download, in case it is currently in progress.
    return this.cancel();
  },

  /**
   * Indicates the time of the last progress notification, expressed as the
   * number of milliseconds since January 1, 1970, 00:00:00 UTC.  This is zero
   * until some bytes have actually been transferred.
   */
  _lastProgressTimeMs: 0,

  /**
   * Updates progress notifications based on the number of bytes transferred.
   *
   * The number of bytes transferred is not updated unless enough time passed
   * since this function was last called.  This limits the computation load, in
   * particular when the listeners update the user interface in response.
   *
   * @param aCurrentBytes
   *        Number of bytes transferred until now.
   * @param aTotalBytes
   *        Total number of bytes to be transferred, or -1 if unknown.
   * @param aHasPartialData
   *        Indicates whether the partially downloaded data can be used when
   *        restarting the download if it fails or is canceled.
   */
  _setBytes: function D_setBytes(aCurrentBytes, aTotalBytes, aHasPartialData) {
    let changeMade = (this.hasPartialData != aHasPartialData);
    this.hasPartialData = aHasPartialData;

    // Unless aTotalBytes is -1, we can report partial download progress.  In
    // this case, notify when the related properties changed since last time.
    if (aTotalBytes != -1 && (!this.hasProgress ||
                              this.totalBytes != aTotalBytes)) {
      this.hasProgress = true;
      this.totalBytes = aTotalBytes;
      changeMade = true;
    }

    // Updating the progress and computing the speed require that enough time
    // passed since the last update, or that we haven't started throttling yet.
    let currentTimeMs = Date.now();
    let intervalMs = currentTimeMs - this._lastProgressTimeMs;
    if (intervalMs >= kProgressUpdateIntervalMs) {
      // Don't compute the speed unless we started throttling notifications.
      if (this._lastProgressTimeMs != 0) {
        // Calculate the speed in bytes per second.
        let rawSpeed = (aCurrentBytes - this.currentBytes) / intervalMs * 1000;
        if (this.speed == 0) {
          // When the previous speed is exactly zero instead of a fractional
          // number, this can be considered the first element of the series.
          this.speed = rawSpeed;
        } else {
          // Apply exponential smoothing, with a smoothing factor of 0.1.
          this.speed = rawSpeed * 0.1 + this.speed * 0.9;
        }
      }

      // Start throttling notifications only when we have actually received some
      // bytes for the first time.  The timing of the first part of the download
      // is not reliable, due to possible latency in the initial notifications.
      // This also allows automated tests to receive and verify the number of
      // bytes initially transferred.
      if (aCurrentBytes > 0) {
        this._lastProgressTimeMs = currentTimeMs;

        // Update the progress now that we don't need its previous value.
        this.currentBytes = aCurrentBytes;
        if (this.totalBytes > 0) {
          this.progress = Math.floor(this.currentBytes / this.totalBytes * 100);
        }
        changeMade = true;
      }
    }

    if (changeMade) {
      this._notifyChange();
    }
  },

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable: function ()
  {
    let serializable = {
      source: this.source.toSerializable(),
      target: this.target.toSerializable(),
    };

    let saver = this.saver.toSerializable();
    if (!serializable.source || !saver) {
      // If we are unable to serialize either the source or the saver,
      // we won't persist the download.
      return null;
    }

    // Simplify the representation for the most common saver type.  If the saver
    // is an object instead of a simple string, we can't simplify it because we
    // need to persist all its properties, not only "type".  This may happen for
    // savers of type "copy" as well as other types.
    if (saver !== "copy") {
      serializable.saver = saver;
    }

    if (this.error) {
      serializable.errorObj = this.error.toSerializable();
    }

    if (this.startTime) {
      serializable.startTime = this.startTime.toJSON();
    }

    // These are serialized unless they are false, null, or empty strings.
    for (let property of kPlainSerializableDownloadProperties) {
      if (this[property]) {
        serializable[property] = this[property];
      }
    }

    serializeUnknownProperties(this, serializable);

    return serializable;
  },

  /**
   * Returns a value that changes only when one of the properties of a Download
   * object that should be saved into a file also change.  This excludes
   * properties whose value doesn't usually change during the download lifetime.
   *
   * This function is used to determine whether the download should be
   * serialized after a property change notification has been received.
   *
   * @return String representing the relevant download state.
   */
  getSerializationHash: function ()
  {
    // The "succeeded", "canceled", "error", and startTime properties are not
    // taken into account because they all change before the "stopped" property
    // changes, and are not altered in other cases.
    return this.stopped + "," + this.totalBytes + "," + this.hasPartialData +
           "," + this.contentType;
  },
};

/**
 * Defines which properties of the Download object are serializable.
 */
const kPlainSerializableDownloadProperties = [
  "succeeded",
  "canceled",
  "totalBytes",
  "hasPartialData",
  "hasBlockedData",
  "tryToKeepPartialData",
  "launcherPath",
  "launchWhenSucceeded",
  "contentType",
];

/**
 * Creates a new Download object from a serializable representation.  This
 * function is used by the createDownload method of Downloads.jsm when a new
 * Download object is requested, thus some properties may refer to live objects
 * in place of their serializable representations.
 *
 * @param aSerializable
 *        An object with the following fields:
 *        {
 *          source: DownloadSource object, or its serializable representation.
 *                  See DownloadSource.fromSerializable for details.
 *          target: DownloadTarget object, or its serializable representation.
 *                  See DownloadTarget.fromSerializable for details.
 *          saver: Serializable representation of a DownloadSaver object.  See
 *                 DownloadSaver.fromSerializable for details.  If omitted,
 *                 defaults to "copy".
 *        }
 *
 * @return The newly created Download object.
 */
Download.fromSerializable = function (aSerializable) {
  let download = new Download();
  if (aSerializable.source instanceof DownloadSource) {
    download.source = aSerializable.source;
  } else {
    download.source = DownloadSource.fromSerializable(aSerializable.source);
  }
  if (aSerializable.target instanceof DownloadTarget) {
    download.target = aSerializable.target;
  } else {
    download.target = DownloadTarget.fromSerializable(aSerializable.target);
  }
  if ("saver" in aSerializable) {
    download.saver = DownloadSaver.fromSerializable(aSerializable.saver);
  } else {
    download.saver = DownloadSaver.fromSerializable("copy");
  }
  download.saver.download = download;

  if ("startTime" in aSerializable) {
    let time = aSerializable.startTime.getTime
             ? aSerializable.startTime.getTime()
             : aSerializable.startTime;
    download.startTime = new Date(time);
  }

  // If 'errorObj' is present it will take precedence over the 'error' property.
  // 'error' is a legacy property only containing message, which is insufficient
  // to represent all of the error information.
  //
  // Instead of just replacing 'error' we use a new 'errorObj' so that previous
  // versions will keep it as an unknown property.
  if ("errorObj" in aSerializable) {
    download.error = DownloadError.fromSerializable(aSerializable.errorObj);
  } else if ("error" in aSerializable) {
    download.error = aSerializable.error;
  }

  for (let property of kPlainSerializableDownloadProperties) {
    if (property in aSerializable) {
      download[property] = aSerializable[property];
    }
  }

  deserializeUnknownProperties(download, aSerializable, property =>
    kPlainSerializableDownloadProperties.indexOf(property) == -1 &&
    property != "startTime" &&
    property != "source" &&
    property != "target" &&
    property != "error" &&
    property != "saver");

  return download;
};

// DownloadSource

/**
 * Represents the source of a download, for example a document or an URI.
 */
this.DownloadSource = function () {}

this.DownloadSource.prototype = {
  /**
   * String containing the URI for the download source.
   */
  url: null,

  /**
   * Indicates whether the download originated from a private window.  This
   * determines the context of the network request that is made to retrieve the
   * resource.
   */
  isPrivate: false,

  /**
   * String containing the referrer URI of the download source, or null if no
   * referrer should be sent or the download source is not HTTP.
   */
  referrer: null,

  /**
   * For downloads handled by the (default) DownloadCopySaver, this function
   * can adjust the network channel before it is opened, for example to change
   * the HTTP headers or to upload a stream as POST data.
   *
   * @note If this is defined this object will not be serializable, thus the
   *       Download object will not be persisted across sessions.
   *
   * @param aChannel
   *        The nsIChannel to be adjusted.
   *
   * @return {Promise}
   * @resolves When the channel has been adjusted and can be opened.
   * @rejects JavaScript exception that will cause the download to fail.
   */
   adjustChannel: null,

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable: function ()
  {
    if (this.adjustChannel) {
      // If the callback was used, we can't reproduce this across sessions.
      return null;
    }

    // Simplify the representation if we don't have other details.
    if (!this.isPrivate && !this.referrer && !this._unknownProperties) {
      return this.url;
    }

    let serializable = { url: this.url };
    if (this.isPrivate) {
      serializable.isPrivate = true;
    }
    if (this.referrer) {
      serializable.referrer = this.referrer;
    }

    serializeUnknownProperties(this, serializable);
    return serializable;
  },
};

/**
 * Creates a new DownloadSource object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadSource object.  This may be a
 *        string containing the URI for the download source, an nsIURI, or an
 *        object with the following properties:
 *        {
 *          url: String containing the URI for the download source.
 *          isPrivate: Indicates whether the download originated from a private
 *                     window.  If omitted, the download is public.
 *          referrer: String containing the referrer URI of the download source.
 *                    Can be omitted or null if no referrer should be sent or
 *                    the download source is not HTTP.
 *          adjustChannel: For downloads handled by (default) DownloadCopySaver,
 *                         this function can adjust the network channel before
 *                         it is opened, for example to change the HTTP headers
 *                         or to upload a stream as POST data.  Optional.
 *        }
 *
 * @return The newly created DownloadSource object.
 */
this.DownloadSource.fromSerializable = function (aSerializable) {
  let source = new DownloadSource();
  if (isString(aSerializable)) {
    // Convert String objects to primitive strings at this point.
    source.url = aSerializable.toString();
  } else if (aSerializable instanceof Ci.nsIURI) {
    source.url = aSerializable.spec;
  } else if (aSerializable instanceof Ci.nsIDOMWindow) {
    source.url = aSerializable.location.href;
    source.isPrivate = PrivateBrowsingUtils.isContentWindowPrivate(aSerializable);
    source.windowRef = Cu.getWeakReference(aSerializable);
  } else {
    // Convert String objects to primitive strings at this point.
    source.url = aSerializable.url.toString();
    if ("isPrivate" in aSerializable) {
      source.isPrivate = aSerializable.isPrivate;
    }
    if ("referrer" in aSerializable) {
      source.referrer = aSerializable.referrer;
    }
    if ("adjustChannel" in aSerializable) {
      source.adjustChannel = aSerializable.adjustChannel;
    }

    deserializeUnknownProperties(source, aSerializable, property =>
      property != "url" && property != "isPrivate" && property != "referrer");
  }

  return source;
};

// DownloadTarget

/**
 * Represents the target of a download, for example a file in the global
 * downloads directory, or a file in the system temporary directory.
 */
this.DownloadTarget = function () {}

this.DownloadTarget.prototype = {
  /**
   * String containing the path of the target file.
   */
  path: null,

  /**
   * String containing the path of the ".part" file containing the data
   * downloaded so far, or null to disable the use of a ".part" file to keep
   * partially downloaded data.
   */
  partFilePath: null,

  /**
   * Indicates whether the target file exists.
   *
   * This is a dynamic property updated when the download finishes or when the
   * "refresh" method of the Download object is called. It can be used by the
   * front-end to reduce I/O compared to checking the target file directly.
   */
  exists: false,

  /**
   * Size in bytes of the target file, or zero if the download has not finished.
   *
   * Even if the target file does not exist anymore, this property may still
   * have a value taken from the download metadata. If the metadata has never
   * been available in this session and the size cannot be obtained from the
   * file because it has already been deleted, this property will be zero.
   *
   * For single-file downloads, this property will always match the actual file
   * size on disk, while the totalBytes property of the Download object, when
   * available, may represent the size of the encoded data instead.
   *
   * For downloads involving multiple files, like complete web pages saved to
   * disk, the meaning of this value is undefined. It currently matches the size
   * of the main file only rather than the sum of all the written data.
   *
   * This is a dynamic property updated when the download finishes or when the
   * "refresh" method of the Download object is called. It can be used by the
   * front-end to reduce I/O compared to checking the target file directly.
   */
  size: 0,

  /**
   * Sets the "exists" and "size" properties based on the actual file on disk.
   *
   * @return {Promise}
   * @resolves When the operation has finished successfully.
   * @rejects JavaScript exception.
   */
  refresh: Task.async(function* () {
    try {
      this.size = (yield OS.File.stat(this.path)).size;
      this.exists = true;
    } catch (ex) {
      // Report any error not caused by the file not being there. In any case,
      // the size of the download is not updated and the known value is kept.
      if (!(ex instanceof OS.File.Error && ex.becauseNoSuchFile)) {
        Cu.reportError(ex);
      }
      this.exists = false;
    }
  }),

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable: function ()
  {
    // Simplify the representation if we don't have other details.
    if (!this.partFilePath && !this._unknownProperties) {
      return this.path;
    }

    let serializable = { path: this.path,
                         partFilePath: this.partFilePath };
    serializeUnknownProperties(this, serializable);
    return serializable;
  },
};

/**
 * Creates a new DownloadTarget object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadTarget object.  This may be a
 *        string containing the path of the target file, an nsIFile, or an
 *        object with the following properties:
 *        {
 *          path: String containing the path of the target file.
 *          partFilePath: optional string containing the part file path.
 *        }
 *
 * @return The newly created DownloadTarget object.
 */
this.DownloadTarget.fromSerializable = function (aSerializable) {
  let target = new DownloadTarget();
  if (isString(aSerializable)) {
    // Convert String objects to primitive strings at this point.
    target.path = aSerializable.toString();
  } else if (aSerializable instanceof Ci.nsIFile) {
    // Read the "path" property of nsIFile after checking the object type.
    target.path = aSerializable.path;
  } else {
    // Read the "path" property of the serializable DownloadTarget
    // representation, converting String objects to primitive strings.
    target.path = aSerializable.path.toString();
    if ("partFilePath" in aSerializable) {
      target.partFilePath = aSerializable.partFilePath;
    }

    deserializeUnknownProperties(target, aSerializable, property =>
      property != "path" && property != "partFilePath");
  }
  return target;
};

// DownloadError

/**
 * Provides detailed information about a download failure.
 *
 * @param aProperties
 *        Object which may contain any of the following properties:
 *          {
 *            result: Result error code, defaulting to Cr.NS_ERROR_FAILURE
 *            message: String error message to be displayed, or null to use the
 *                     message associated with the result code.
 *            inferCause: If true, attempts to determine if the cause of the
 *                        download is a network failure or a local file failure,
 *                        based on a set of known values of the result code.
 *                        This is useful when the error is received by a
 *                        component that handles both aspects of the download.
 *          }
 *        The properties object may also contain any of the DownloadError's
 *        because properties, which will be set accordingly in the error object.
 */
this.DownloadError = function (aProperties)
{
  const NS_ERROR_MODULE_BASE_OFFSET = 0x45;
  const NS_ERROR_MODULE_NETWORK = 6;
  const NS_ERROR_MODULE_FILES = 13;

  // Set the error name used by the Error object prototype first.
  this.name = "DownloadError";
  this.result = aProperties.result || Cr.NS_ERROR_FAILURE;
  if (aProperties.message) {
    this.message = aProperties.message;
  } else if (aProperties.becauseBlocked ||
             aProperties.becauseBlockedByParentalControls ||
             aProperties.becauseBlockedByReputationCheck ||
             aProperties.becauseBlockedByRuntimePermissions) {
    this.message = "Download blocked.";
  } else {
    let exception = new Components.Exception("", this.result);
    this.message = exception.toString();
  }
  if (aProperties.inferCause) {
    let module = ((this.result & 0x7FFF0000) >> 16) -
                 NS_ERROR_MODULE_BASE_OFFSET;
    this.becauseSourceFailed = (module == NS_ERROR_MODULE_NETWORK);
    this.becauseTargetFailed = (module == NS_ERROR_MODULE_FILES);
  }
  else {
    if (aProperties.becauseSourceFailed) {
      this.becauseSourceFailed = true;
    }
    if (aProperties.becauseTargetFailed) {
      this.becauseTargetFailed = true;
    }
  }

  if (aProperties.becauseBlockedByParentalControls) {
    this.becauseBlocked = true;
    this.becauseBlockedByParentalControls = true;
  } else if (aProperties.becauseBlockedByReputationCheck) {
    this.becauseBlocked = true;
    this.becauseBlockedByReputationCheck = true;
    this.reputationCheckVerdict = aProperties.reputationCheckVerdict || "";
  } else if (aProperties.becauseBlockedByRuntimePermissions) {
    this.becauseBlocked = true;
    this.becauseBlockedByRuntimePermissions = true;
  } else if (aProperties.becauseBlocked) {
    this.becauseBlocked = true;
  }

  if (aProperties.innerException) {
    this.innerException = aProperties.innerException;
  }

  this.stack = new Error().stack;
}

/**
 * These constants are used by the reputationCheckVerdict property and indicate
 * the detailed reason why a download is blocked.
 *
 * @note These values should not be changed because they can be serialized.
 */
this.DownloadError.BLOCK_VERDICT_MALWARE = "Malware";
this.DownloadError.BLOCK_VERDICT_POTENTIALLY_UNWANTED = "PotentiallyUnwanted";
this.DownloadError.BLOCK_VERDICT_UNCOMMON = "Uncommon";

this.DownloadError.prototype = {
  __proto__: Error.prototype,

  /**
   * The result code associated with this error.
   */
  result: false,

  /**
   * Indicates an error occurred while reading from the remote location.
   */
  becauseSourceFailed: false,

  /**
   * Indicates an error occurred while writing to the local target.
   */
  becauseTargetFailed: false,

  /**
   * Indicates the download failed because it was blocked.  If the reason for
   * blocking is known, the corresponding property will be also set.
   */
  becauseBlocked: false,

  /**
   * Indicates the download was blocked because downloads are globally
   * disallowed by the Parental Controls or Family Safety features on Windows.
   */
  becauseBlockedByParentalControls: false,

  /**
   * Indicates the download was blocked because it failed the reputation check
   * and may be malware.
   */
  becauseBlockedByReputationCheck: false,

  /**
   * Indicates the download was blocked because a runtime permission required to
   * download files was not granted.
   *
   * This does not apply to all systems. On Android this flag is set to true if
   * a needed runtime permission (storage) has not been granted by the user.
   */
  becauseBlockedByRuntimePermissions: false,

  /**
   * If becauseBlockedByReputationCheck is true, indicates the detailed reason
   * why the download was blocked, according to the "BLOCK_VERDICT_" constants.
   *
   * If the download was not blocked or the reason for the block is unknown,
   * this will be an empty string.
   */
  reputationCheckVerdict: "",

  /**
   * If this DownloadError was caused by an exception this property will
   * contain the original exception. This will not be serialized when saving
   * to the store.
   */
  innerException: null,

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable: function ()
  {
    let serializable = {
      result: this.result,
      message: this.message,
      becauseSourceFailed: this.becauseSourceFailed,
      becauseTargetFailed: this.becauseTargetFailed,
      becauseBlocked: this.becauseBlocked,
      becauseBlockedByParentalControls: this.becauseBlockedByParentalControls,
      becauseBlockedByReputationCheck: this.becauseBlockedByReputationCheck,
      becauseBlockedByRuntimePermissions: this.becauseBlockedByRuntimePermissions,
      reputationCheckVerdict: this.reputationCheckVerdict,
    };

    serializeUnknownProperties(this, serializable);
    return serializable;
  },
};

/**
 * Creates a new DownloadError object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadError object.
 *
 * @return The newly created DownloadError object.
 */
this.DownloadError.fromSerializable = function (aSerializable) {
  let e = new DownloadError(aSerializable);
  deserializeUnknownProperties(e, aSerializable, property =>
    property != "result" &&
    property != "message" &&
    property != "becauseSourceFailed" &&
    property != "becauseTargetFailed" &&
    property != "becauseBlocked" &&
    property != "becauseBlockedByParentalControls" &&
    property != "becauseBlockedByReputationCheck" &&
    property != "becauseBlockedByRuntimePermissions" &&
    property != "reputationCheckVerdict");

  return e;
};

// DownloadSaver

/**
 * Template for an object that actually transfers the data for the download.
 */
this.DownloadSaver = function () {}

this.DownloadSaver.prototype = {
  /**
   * Download object for raising notifications and reading properties.
   *
   * If the tryToKeepPartialData property of the download object is false, the
   * saver should never try to keep partially downloaded data if the download
   * fails.
   */
  download: null,

  /**
   * Executes the download.
   *
   * @param aSetProgressBytesFn
   *        This function may be called by the saver to report progress. It
   *        takes three arguments: the first is the number of bytes transferred
   *        until now, the second is the total number of bytes to be
   *        transferred (or -1 if unknown), the third indicates whether the
   *        partially downloaded data can be used when restarting the download
   *        if it fails or is canceled.
   * @param aSetPropertiesFn
   *        This function may be called by the saver to report information
   *        about new download properties discovered by the saver during the
   *        download process. It takes an object where the keys represents
   *        the names of the properties to set, and the value represents the
   *        value to set.
   *
   * @return {Promise}
   * @resolves When the download has finished successfully.
   * @rejects JavaScript exception if the download failed.
   */
  execute: function DS_execute(aSetProgressBytesFn, aSetPropertiesFn)
  {
    throw new Error("Not implemented.");
  },

  /**
   * Cancels the download.
   */
  cancel: function DS_cancel()
  {
    throw new Error("Not implemented.");
  },

  /**
   * Removes any partial data kept as part of a canceled or failed download.
   *
   * This method is never called until the promise returned by "execute" is
   * either resolved or rejected, and the "execute" method is not called again
   * until the promise returned by this method is resolved or rejected.
   *
   * @return {Promise}
   * @resolves When the operation has finished successfully.
   * @rejects JavaScript exception.
   */
  removePartialData: function DS_removePartialData()
  {
    return Promise.resolve();
  },

  /**
   * This can be called by the saver implementation when the download is already
   * started, to add it to the browsing history.  This method has no effect if
   * the download is private.
   */
  addToHistory: function ()
  {
    if (this.download.source.isPrivate) {
      return;
    }

    let sourceUri = NetUtil.newURI(this.download.source.url);
    let referrer = this.download.source.referrer;
    let referrerUri = referrer ? NetUtil.newURI(referrer) : null;
    let targetUri = NetUtil.newURI(new FileUtils.File(
                                       this.download.target.path));

    // The start time is always available when we reach this point.
    let startPRTime = this.download.startTime.getTime() * 1000;

    try {
      gDownloadHistory.addDownload(sourceUri, referrerUri, startPRTime,
                                   targetUri);
    }
    catch (ex) {
      if (!(ex instanceof Components.Exception) ||
          ex.result != Cr.NS_ERROR_NOT_AVAILABLE) {
        throw ex;
      }
      //
      // Under normal operation the download history service may not
      // be available. We don't want all downloads that are public to fail
      // when this happens so we'll ignore this error and this error only!
      //
    }
  },

  /**
   * Returns a static representation of the current object state.
   *
   * @return A JavaScript object that can be serialized to JSON.
   */
  toSerializable: function ()
  {
    throw new Error("Not implemented.");
  },

  /**
   * Returns the SHA-256 hash of the downloaded file, if it exists.
   */
  getSha256Hash: function ()
  {
    throw new Error("Not implemented.");
  },

  getSignatureInfo: function ()
  {
    throw new Error("Not implemented.");
  },
}; // DownloadSaver

/**
 * Creates a new DownloadSaver object from its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadSaver object.  If no initial
 *        state information for the saver object is needed, can be a string
 *        representing the class of the download operation, for example "copy".
 *
 * @return The newly created DownloadSaver object.
 */
this.DownloadSaver.fromSerializable = function (aSerializable) {
  let serializable = isString(aSerializable) ? { type: aSerializable }
                                             : aSerializable;
  let saver;
  switch (serializable.type) {
    case "copy":
      saver = DownloadCopySaver.fromSerializable(serializable);
      break;
    case "legacy":
      saver = DownloadLegacySaver.fromSerializable(serializable);
      break;
    case "pdf":
      saver = DownloadPDFSaver.fromSerializable(serializable);
      break;
    default:
      throw new Error("Unrecoginzed download saver type.");
  }
  return saver;
};

// DownloadCopySaver

/**
 * Saver object that simply copies the entire source file to the target.
 */
this.DownloadCopySaver = function () {}

this.DownloadCopySaver.prototype = {
  __proto__: DownloadSaver.prototype,

  /**
   * BackgroundFileSaver object currently handling the download.
   */
  _backgroundFileSaver: null,

  /**
   * Indicates whether the "cancel" method has been called.  This is used to
   * prevent the request from starting in case the operation is canceled before
   * the BackgroundFileSaver instance has been created.
   */
  _canceled: false,

  /**
   * Save the SHA-256 hash in raw bytes of the downloaded file. This is null
   * unless BackgroundFileSaver has successfully completed saving the file.
   */
  _sha256Hash: null,

  /**
   * Save the signature info as an nsIArray of nsIX509CertList of nsIX509Cert
   * if the file is signed. This is empty if the file is unsigned, and null
   * unless BackgroundFileSaver has successfully completed saving the file.
   */
  _signatureInfo: null,

  /**
   * Save the redirects chain as an nsIArray of nsIPrincipal.
   */
  _redirects: null,

  /**
   * True if the associated download has already been added to browsing history.
   */
  alreadyAddedToHistory: false,

  /**
   * String corresponding to the entityID property of the nsIResumableChannel
   * used to execute the download, or null if the channel was not resumable or
   * the saver was instructed not to keep partially downloaded data.
   */
  entityID: null,

  /**
   * Implements "DownloadSaver.execute".
   */
  execute: function DCS_execute(aSetProgressBytesFn, aSetPropertiesFn)
  {
    let copySaver = this;

    this._canceled = false;

    let download = this.download;
    let targetPath = download.target.path;
    let partFilePath = download.target.partFilePath;
    let keepPartialData = download.tryToKeepPartialData;

    return Task.spawn(function* task_DCS_execute() {
      // Add the download to history the first time it is started in this
      // session.  If the download is restarted in a different session, a new
      // history visit will be added.  We do this just to avoid the complexity
      // of serializing this state between sessions, since adding a new visit
      // does not have any noticeable side effect.
      if (!this.alreadyAddedToHistory) {
        this.addToHistory();
        this.alreadyAddedToHistory = true;
      }

      // To reduce the chance that other downloads reuse the same final target
      // file name, we should create a placeholder as soon as possible, before
      // starting the network request.  The placeholder is also required in case
      // we are using a ".part" file instead of the final target while the
      // download is in progress.
      try {
        // If the file already exists, don't delete its contents yet.
        let file = yield OS.File.open(targetPath, { write: true });
        yield file.close();
      } catch (ex) {
        if (!(ex instanceof OS.File.Error)) {
          throw ex;
        }
        // Throw a DownloadError indicating that the operation failed because of
        // the target file.  We cannot translate this into a specific result
        // code, but we preserve the original message using the toString method.
        let error = new DownloadError({ message: ex.toString() });
        error.becauseTargetFailed = true;
        throw error;
      }

      try {
        let deferSaveComplete = Promise.defer();

        if (this._canceled) {
          // Don't create the BackgroundFileSaver object if we have been
          // canceled meanwhile.
          throw new DownloadError({ message: "Saver canceled." });
        }

        // Create the object that will save the file in a background thread.
        let backgroundFileSaver = new BackgroundFileSaverStreamListener();
        try {
          // When the operation completes, reflect the status in the promise
          // returned by this download execution function.
          backgroundFileSaver.observer = {
            onTargetChange: function () { },
            onSaveComplete: (aSaver, aStatus) => {
              // Send notifications now that we can restart if needed.
              if (Components.isSuccessCode(aStatus)) {
                // Save the hash before freeing backgroundFileSaver.
                this._sha256Hash = aSaver.sha256Hash;
                this._signatureInfo = aSaver.signatureInfo;
                this._redirects = aSaver.redirects;
                deferSaveComplete.resolve();
              } else {
                // Infer the origin of the error from the failure code, because
                // BackgroundFileSaver does not provide more specific data.
                let properties = { result: aStatus, inferCause: true };
                deferSaveComplete.reject(new DownloadError(properties));
              }
              // Free the reference cycle, to release resources earlier.
              backgroundFileSaver.observer = null;
              this._backgroundFileSaver = null;
            },
          };

          // Create a channel from the source, and listen to progress
          // notifications.
          let channel = NetUtil.newChannel({
            uri: download.source.url,
            loadUsingSystemPrincipal: true,
          });
          if (channel instanceof Ci.nsIPrivateBrowsingChannel) {
            channel.setPrivate(download.source.isPrivate);
          }
          if (channel instanceof Ci.nsIHttpChannel &&
              download.source.referrer) {
            channel.referrer = NetUtil.newURI(download.source.referrer);
          }

          // If we have data that we can use to resume the download from where
          // it stopped, try to use it.
          let resumeAttempted = false;
          let resumeFromBytes = 0;
          if (channel instanceof Ci.nsIResumableChannel && this.entityID &&
              partFilePath && keepPartialData) {
            try {
              let stat = yield OS.File.stat(partFilePath);
              channel.resumeAt(stat.size, this.entityID);
              resumeAttempted = true;
              resumeFromBytes = stat.size;
            } catch (ex) {
              if (!(ex instanceof OS.File.Error) || !ex.becauseNoSuchFile) {
                throw ex;
              }
            }
          }

          channel.notificationCallbacks = {
            QueryInterface: XPCOMUtils.generateQI([Ci.nsIInterfaceRequestor]),
            getInterface: XPCOMUtils.generateQI([Ci.nsIProgressEventSink]),
            onProgress: function DCSE_onProgress(aRequest, aContext, aProgress,
                                                 aProgressMax)
            {
              let currentBytes = resumeFromBytes + aProgress;
              let totalBytes = aProgressMax == -1 ? -1 : (resumeFromBytes +
                                                          aProgressMax);
              aSetProgressBytesFn(currentBytes, totalBytes, aProgress > 0 &&
                                  partFilePath && keepPartialData);
            },
            onStatus: function () { },
          };

          // If the callback was set, handle it now before opening the channel.
          if (download.source.adjustChannel) {
            yield download.source.adjustChannel(channel);
          }

          // Open the channel, directing output to the background file saver.
          backgroundFileSaver.QueryInterface(Ci.nsIStreamListener);
          channel.asyncOpen2({
            onStartRequest: function (aRequest, aContext) {
              backgroundFileSaver.onStartRequest(aRequest, aContext);

              // Check if the request's response has been blocked by Windows
              // Parental Controls with an HTTP 450 error code.
              if (aRequest instanceof Ci.nsIHttpChannel &&
                  aRequest.responseStatus == 450) {
                // Set a flag that can be retrieved later when handling the
                // cancellation so that the proper error can be thrown.
                this.download._blockedByParentalControls = true;
                aRequest.cancel(Cr.NS_BINDING_ABORTED);
                return;
              }

              aSetPropertiesFn({ contentType: channel.contentType });

              // Ensure we report the value of "Content-Length", if available,
              // even if the download doesn't generate any progress events
              // later.
              if (channel.contentLength >= 0) {
                aSetProgressBytesFn(0, channel.contentLength);
              }

              // If the URL we are downloading from includes a file extension
              // that matches the "Content-Encoding" header, for example ".gz"
              // with a "gzip" encoding, we should save the file in its encoded
              // form.  In all other cases, we decode the body while saving.
              if (channel instanceof Ci.nsIEncodedChannel &&
                  channel.contentEncodings) {
                let uri = channel.URI;
                if (uri instanceof Ci.nsIURL && uri.fileExtension) {
                  // Only the first, outermost encoding is considered.
                  let encoding = channel.contentEncodings.getNext();
                  if (encoding) {
                    channel.applyConversion =
                      gExternalHelperAppService.applyDecodingForExtension(
                                                uri.fileExtension, encoding);
                  }
                }
              }

              if (keepPartialData) {
                // If the source is not resumable, don't keep partial data even
                // if we were asked to try and do it.
                if (aRequest instanceof Ci.nsIResumableChannel) {
                  try {
                    // If reading the ID succeeds, the source is resumable.
                    this.entityID = aRequest.entityID;
                  } catch (ex) {
                    if (!(ex instanceof Components.Exception) ||
                        ex.result != Cr.NS_ERROR_NOT_RESUMABLE) {
                      throw ex;
                    }
                    keepPartialData = false;
                  }
                } else {
                  keepPartialData = false;
                }
              }

              // Enable hashing and signature verification before setting the
              // target.
              backgroundFileSaver.enableSha256();
              backgroundFileSaver.enableSignatureInfo();
              if (partFilePath) {
                // If we actually resumed a request, append to the partial data.
                if (resumeAttempted) {
                  // TODO: Handle Cr.NS_ERROR_ENTITY_CHANGED
                  backgroundFileSaver.enableAppend();
                }

                // Use a part file, determining if we should keep it on failure.
                backgroundFileSaver.setTarget(new FileUtils.File(partFilePath),
                                              keepPartialData);
              } else {
                // Set the final target file, and delete it on failure.
                backgroundFileSaver.setTarget(new FileUtils.File(targetPath),
                                              false);
              }
            }.bind(copySaver),

            onStopRequest: function (aRequest, aContext, aStatusCode) {
              try {
                backgroundFileSaver.onStopRequest(aRequest, aContext,
                                                  aStatusCode);
              } finally {
                // If the data transfer completed successfully, indicate to the
                // background file saver that the operation can finish.  If the
                // data transfer failed, the saver has been already stopped.
                if (Components.isSuccessCode(aStatusCode)) {
                  backgroundFileSaver.finish(Cr.NS_OK);
                }
              }
            }.bind(copySaver),

            onDataAvailable: function (aRequest, aContext, aInputStream,
                                       aOffset, aCount) {
              backgroundFileSaver.onDataAvailable(aRequest, aContext,
                                                  aInputStream, aOffset,
                                                  aCount);
            }.bind(copySaver),
          });

          // We should check if we have been canceled in the meantime, after
          // all the previous asynchronous operations have been executed and
          // just before we set the _backgroundFileSaver property.
          if (this._canceled) {
            throw new DownloadError({ message: "Saver canceled." });
          }

          // If the operation succeeded, store the object to allow cancellation.
          this._backgroundFileSaver = backgroundFileSaver;
        } catch (ex) {
          // In case an error occurs while setting up the chain of objects for
          // the download, ensure that we release the resources of the saver.
          backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE);
          // Since we're not going to handle deferSaveComplete.promise below,
          // we need to make sure that the rejection is handled.
          deferSaveComplete.promise.catch(() => {});
          throw ex;
        }

        // We will wait on this promise in case no error occurred while setting
        // up the chain of objects for the download.
        yield deferSaveComplete.promise;

        yield this._checkReputationAndMove(aSetPropertiesFn);
      } catch (ex) {
        // Ensure we always remove the placeholder for the final target file on
        // failure, independently of which code path failed.  In some cases, the
        // background file saver may have already removed the file.
        try {
          yield OS.File.remove(targetPath);
        } catch (e2) {
          // If we failed during the operation, we report the error but use the
          // original one as the failure reason of the download.  Note that on
          // Windows we may get an access denied error instead of a no such file
          // error if the file existed before, and was recently deleted.
          if (!(e2 instanceof OS.File.Error &&
                (e2.becauseNoSuchFile || e2.becauseAccessDenied))) {
            Cu.reportError(e2);
          }
        }
        throw ex;
      }
    }.bind(this));
  },

  /**
   * Perform the reputation check and cleanup the downloaded data if required.
   * If the download passes the reputation check and is using a part file we
   * will move it to the target path since reputation checking is the final
   * step in the saver.
   *
   * @param aSetPropertiesFn
   *        Function provided to the "execute" method.
   *
   * @return {Promise}
   * @resolves When the reputation check and cleanup is complete.
   * @rejects DownloadError if the download should be blocked.
   */
  _checkReputationAndMove: Task.async(function* (aSetPropertiesFn) {
    let download = this.download;
    let targetPath = this.download.target.path;
    let partFilePath = this.download.target.partFilePath;

    let { shouldBlock, verdict } =
        yield DownloadIntegration.shouldBlockForReputationCheck(download);
    if (shouldBlock) {
      let newProperties = { progress: 100, hasPartialData: false };

      // We will remove the potentially dangerous file if instructed by
      // DownloadIntegration. We will always remove the file when the
      // download did not use a partial file path, meaning it
      // currently has its final filename.
      if (!DownloadIntegration.shouldKeepBlockedData() || !partFilePath) {
        try {
          yield OS.File.remove(partFilePath || targetPath);
        } catch (ex) {
          Cu.reportError(ex);
        }
      } else {
        newProperties.hasBlockedData = true;
      }

      aSetPropertiesFn(newProperties);

      throw new DownloadError({
        becauseBlockedByReputationCheck: true,
        reputationCheckVerdict: verdict,
      });
    }

    if (partFilePath) {
      yield OS.File.move(partFilePath, targetPath);
    }
  }),

  /**
   * Implements "DownloadSaver.cancel".
   */
  cancel: function DCS_cancel()
  {
    this._canceled = true;
    if (this._backgroundFileSaver) {
      this._backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE);
      this._backgroundFileSaver = null;
    }
  },

  /**
   * Implements "DownloadSaver.removePartialData".
   */
  removePartialData: function ()
  {
    return Task.spawn(function* task_DCS_removePartialData() {
      if (this.download.target.partFilePath) {
        try {
          yield OS.File.remove(this.download.target.partFilePath);
        } catch (ex) {
          if (!(ex instanceof OS.File.Error) || !ex.becauseNoSuchFile) {
            throw ex;
          }
        }
      }
    }.bind(this));
  },

  /**
   * Implements "DownloadSaver.toSerializable".
   */
  toSerializable: function ()
  {
    // Simplify the representation if we don't have other details.
    if (!this.entityID && !this._unknownProperties) {
      return "copy";
    }

    let serializable = { type: "copy",
                         entityID: this.entityID };
    serializeUnknownProperties(this, serializable);
    return serializable;
  },

  /**
   * Implements "DownloadSaver.getSha256Hash"
   */
  getSha256Hash: function ()
  {
    return this._sha256Hash;
  },

  /*
   * Implements DownloadSaver.getSignatureInfo.
   */
  getSignatureInfo: function ()
  {
    return this._signatureInfo;
  },

  /*
   * Implements DownloadSaver.getRedirects.
   */
  getRedirects: function ()
  {
    return this._redirects;
  }
};

/**
 * Creates a new DownloadCopySaver object, with its initial state derived from
 * its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadCopySaver object.
 *
 * @return The newly created DownloadCopySaver object.
 */
this.DownloadCopySaver.fromSerializable = function (aSerializable) {
  let saver = new DownloadCopySaver();
  if ("entityID" in aSerializable) {
    saver.entityID = aSerializable.entityID;
  }

  deserializeUnknownProperties(saver, aSerializable, property =>
    property != "entityID" && property != "type");

  return saver;
};

// DownloadLegacySaver

/**
 * Saver object that integrates with the legacy nsITransfer interface.
 *
 * For more background on the process, see the DownloadLegacyTransfer object.
 */
this.DownloadLegacySaver = function ()
{
  this.deferExecuted = Promise.defer();
  this.deferCanceled = Promise.defer();
}

this.DownloadLegacySaver.prototype = {
  __proto__: DownloadSaver.prototype,

  /**
   * Save the SHA-256 hash in raw bytes of the downloaded file. This may be
   * null when nsExternalHelperAppService (and thus BackgroundFileSaver) is not
   * invoked.
   */
  _sha256Hash: null,

  /**
   * Save the signature info as an nsIArray of nsIX509CertList of nsIX509Cert
   * if the file is signed. This is empty if the file is unsigned, and null
   * unless BackgroundFileSaver has successfully completed saving the file.
   */
  _signatureInfo: null,

  /**
   * Save the redirect chain as an nsIArray of nsIPrincipal.
   */
  _redirects: null,

  /**
   * nsIRequest object associated to the status and progress updates we
   * received.  This object is null before we receive the first status and
   * progress update, and is also reset to null when the download is stopped.
   */
  request: null,

  /**
   * This deferred object contains a promise that is resolved as soon as this
   * download finishes successfully, and is rejected in case the download is
   * canceled or receives a failure notification through nsITransfer.
   */
  deferExecuted: null,

  /**
   * This deferred object contains a promise that is resolved if the download
   * receives a cancellation request through the "cancel" method, and is never
   * rejected.  The nsITransfer implementation will register a handler that
   * actually causes the download cancellation.
   */
  deferCanceled: null,

  /**
   * This is populated with the value of the aSetProgressBytesFn argument of the
   * "execute" method, and is null before the method is called.
   */
  setProgressBytesFn: null,

  /**
   * Called by the nsITransfer implementation while the download progresses.
   *
   * @param aCurrentBytes
   *        Number of bytes transferred until now.
   * @param aTotalBytes
   *        Total number of bytes to be transferred, or -1 if unknown.
   */
  onProgressBytes: function DLS_onProgressBytes(aCurrentBytes, aTotalBytes)
  {
    this.progressWasNotified = true;

    // Ignore progress notifications until we are ready to process them.
    if (!this.setProgressBytesFn) {
      // Keep the data from the last progress notification that was received.
      this.currentBytes = aCurrentBytes;
      this.totalBytes = aTotalBytes;
      return;
    }

    let hasPartFile = !!this.download.target.partFilePath;

    this.setProgressBytesFn(aCurrentBytes, aTotalBytes,
                            aCurrentBytes > 0 && hasPartFile);
  },

  /**
   * Whether the onProgressBytes function has been called at least once.
   */
  progressWasNotified: false,

  /**
   * Called by the nsITransfer implementation when the request has started.
   *
   * @param aRequest
   *        nsIRequest associated to the status update.
   * @param aAlreadyAddedToHistory
   *        Indicates that the nsIExternalHelperAppService component already
   *        added the download to the browsing history, unless it was started
   *        from a private browsing window.  When this parameter is false, the
   *        download is added to the browsing history here.  Private downloads
   *        are never added to history even if this parameter is false.
   */
  onTransferStarted: function (aRequest, aAlreadyAddedToHistory)
  {
    // Store the entity ID to use for resuming if required.
    if (this.download.tryToKeepPartialData &&
        aRequest instanceof Ci.nsIResumableChannel) {
      try {
        // If reading the ID succeeds, the source is resumable.
        this.entityID = aRequest.entityID;
      } catch (ex) {
        if (!(ex instanceof Components.Exception) ||
            ex.result != Cr.NS_ERROR_NOT_RESUMABLE) {
          throw ex;
        }
      }
    }

    // For legacy downloads, we must update the referrer at this time.
    if (aRequest instanceof Ci.nsIHttpChannel && aRequest.referrer) {
      this.download.source.referrer = aRequest.referrer.spec;
    }

    if (!aAlreadyAddedToHistory) {
      this.addToHistory();
    }
  },

  /**
   * Called by the nsITransfer implementation when the request has finished.
   *
   * @param aRequest
   *        nsIRequest associated to the status update.
   * @param aStatus
   *        Status code received by the nsITransfer implementation.
   */
  onTransferFinished: function DLS_onTransferFinished(aRequest, aStatus)
  {
    // Store a reference to the request, used when handling completion.
    this.request = aRequest;

    if (Components.isSuccessCode(aStatus)) {
      this.deferExecuted.resolve();
    } else {
      // Infer the origin of the error from the failure code, because more
      // specific data is not available through the nsITransfer implementation.
      let properties = { result: aStatus, inferCause: true };
      this.deferExecuted.reject(new DownloadError(properties));
    }
  },

  /**
   * When the first execution of the download finished, it can be restarted by
   * using a DownloadCopySaver object instead of the original legacy component
   * that executed the download.
   */
  firstExecutionFinished: false,

  /**
   * In case the download is restarted after the first execution finished, this
   * property contains a reference to the DownloadCopySaver that is executing
   * the new download attempt.
   */
  copySaver: null,

  /**
   * String corresponding to the entityID property of the nsIResumableChannel
   * used to execute the download, or null if the channel was not resumable or
   * the saver was instructed not to keep partially downloaded data.
   */
  entityID: null,

  /**
   * Implements "DownloadSaver.execute".
   */
  execute: function DLS_execute(aSetProgressBytesFn, aSetPropertiesFn)
  {
    // Check if this is not the first execution of the download.  The Download
    // object guarantees that this function is not re-entered during execution.
    if (this.firstExecutionFinished) {
      if (!this.copySaver) {
        this.copySaver = new DownloadCopySaver();
        this.copySaver.download = this.download;
        this.copySaver.entityID = this.entityID;
        this.copySaver.alreadyAddedToHistory = true;
      }
      return this.copySaver.execute.apply(this.copySaver, arguments);
    }

    this.setProgressBytesFn = aSetProgressBytesFn;
    if (this.progressWasNotified) {
      this.onProgressBytes(this.currentBytes, this.totalBytes);
    }

    return Task.spawn(function* task_DLS_execute() {
      try {
        // Wait for the component that executes the download to finish.
        yield this.deferExecuted.promise;

        // At this point, the "request" property has been populated.  Ensure we
        // report the value of "Content-Length", if available, even if the
        // download didn't generate any progress events.
        if (!this.progressWasNotified &&
            this.request instanceof Ci.nsIChannel &&
            this.request.contentLength >= 0) {
          aSetProgressBytesFn(0, this.request.contentLength);
        }

        // If the component executing the download provides the path of a
        // ".part" file, it means that it expects the listener to move the file
        // to its final target path when the download succeeds.  In this case,
        // an empty ".part" file is created even if no data was received from
        // the source.
        //
        // When no ".part" file path is provided the download implementation may
        // not have created the target file (if no data was received from the
        // source).  In this case, ensure that an empty file is created as
        // expected.
        if (!this.download.target.partFilePath) {
          try {
            // This atomic operation is more efficient than an existence check.
            let file = yield OS.File.open(this.download.target.path,
                                          { create: true });
            yield file.close();
          } catch (ex) {
            if (!(ex instanceof OS.File.Error) || !ex.becauseExists) {
              throw ex;
            }
          }
        }

        yield this._checkReputationAndMove(aSetPropertiesFn);

      } catch (ex) {
        // Ensure we always remove the final target file on failure,
        // independently of which code path failed.  In some cases, the
        // component executing the download may have already removed the file.
        try {
          yield OS.File.remove(this.download.target.path);
        } catch (e2) {
          // If we failed during the operation, we report the error but use the
          // original one as the failure reason of the download.  Note that on
          // Windows we may get an access denied error instead of a no such file
          // error if the file existed before, and was recently deleted.
          if (!(e2 instanceof OS.File.Error &&
                (e2.becauseNoSuchFile || e2.becauseAccessDenied))) {
            Cu.reportError(e2);
          }
        }
        // In case the operation failed, ensure we stop downloading data.  Since
        // we never re-enter this function, deferCanceled is always available.
        this.deferCanceled.resolve();
        throw ex;
      } finally {
        // We don't need the reference to the request anymore.  We must also set
        // deferCanceled to null in order to free any indirect references it
        // may hold to the request.
        this.request = null;
        this.deferCanceled = null;
        // Allow the download to restart through a DownloadCopySaver.
        this.firstExecutionFinished = true;
      }
    }.bind(this));
  },

  _checkReputationAndMove: function () {
    return DownloadCopySaver.prototype._checkReputationAndMove
                                      .apply(this, arguments);
  },

  /**
   * Implements "DownloadSaver.cancel".
   */
  cancel: function DLS_cancel()
  {
    // We may be using a DownloadCopySaver to handle resuming.
    if (this.copySaver) {
      return this.copySaver.cancel.apply(this.copySaver, arguments);
    }

    // If the download hasn't stopped already, resolve deferCanceled so that the
    // operation is canceled as soon as a cancellation handler is registered.
    // Note that the handler might not have been registered yet.
    if (this.deferCanceled) {
      this.deferCanceled.resolve();
    }
  },

  /**
   * Implements "DownloadSaver.removePartialData".
   */
  removePartialData: function ()
  {
    // DownloadCopySaver and DownloadLeagcySaver use the same logic for removing
    // partially downloaded data, though this implementation isn't shared by
    // other saver types, thus it isn't found on their shared prototype.
    return DownloadCopySaver.prototype.removePartialData.call(this);
  },

  /**
   * Implements "DownloadSaver.toSerializable".
   */
  toSerializable: function ()
  {
    // This object depends on legacy components that are created externally,
    // thus it cannot be rebuilt during deserialization.  To support resuming
    // across different browser sessions, this object is transformed into a
    // DownloadCopySaver for the purpose of serialization.
    return DownloadCopySaver.prototype.toSerializable.call(this);
  },

  /**
   * Implements "DownloadSaver.getSha256Hash".
   */
  getSha256Hash: function ()
  {
    if (this.copySaver) {
      return this.copySaver.getSha256Hash();
    }
    return this._sha256Hash;
  },

  /**
   * Called by the nsITransfer implementation when the hash is available.
   */
  setSha256Hash: function (hash)
  {
    this._sha256Hash = hash;
  },

  /**
   * Implements "DownloadSaver.getSignatureInfo".
   */
  getSignatureInfo: function ()
  {
    if (this.copySaver) {
      return this.copySaver.getSignatureInfo();
    }
    return this._signatureInfo;
  },

  /**
   * Called by the nsITransfer implementation when the hash is available.
   */
  setSignatureInfo: function (signatureInfo)
  {
    this._signatureInfo = signatureInfo;
  },

  /**
   * Implements "DownloadSaver.getRedirects".
   */
  getRedirects: function ()
  {
    if (this.copySaver) {
      return this.copySaver.getRedirects();
    }
    return this._redirects;
  },

  /**
   * Called by the nsITransfer implementation when the redirect chain is
   * available.
   */
  setRedirects: function (redirects)
  {
    this._redirects = redirects;
  },
};

/**
 * Returns a new DownloadLegacySaver object.  This saver type has a
 * deserializable form only when creating a new object in memory, because it
 * cannot be serialized to disk.
 */
this.DownloadLegacySaver.fromSerializable = function () {
  return new DownloadLegacySaver();
};

// DownloadPDFSaver

/**
 * This DownloadSaver type creates a PDF file from the current document in a
 * given window, specified using the windowRef property of the DownloadSource
 * object associated with the download.
 *
 * In order to prevent the download from saving a different document than the one
 * originally loaded in the window, any attempt to restart the download will fail.
 *
 * Since this DownloadSaver type requires a live document as a source, it cannot
 * be persisted across sessions, unless the download already succeeded.
 */
this.DownloadPDFSaver = function () {
}

this.DownloadPDFSaver.prototype = {
  __proto__: DownloadSaver.prototype,

  /**
   * An nsIWebBrowserPrint instance for printing this page.
   * This is null when saving has not started or has completed,
   * or while the operation is being canceled.
   */
  _webBrowserPrint: null,

  /**
   * Implements "DownloadSaver.execute".
   */
  execute: function (aSetProgressBytesFn, aSetPropertiesFn)
  {
    return Task.spawn(function* task_DCS_execute() {
      if (!this.download.source.windowRef) {
        throw new DownloadError({
          message: "PDF saver must be passed an open window, and cannot be restarted.",
          becauseSourceFailed: true,
        });
      }

      let win = this.download.source.windowRef.get();

      // Set windowRef to null to avoid re-trying.
      this.download.source.windowRef = null;

      if (!win) {
        throw new DownloadError({
          message: "PDF saver can't save a window that has been closed.",
          becauseSourceFailed: true,
        });
      }

      this.addToHistory();

      let targetPath = this.download.target.path;

      // An empty target file must exist for the PDF printer to work correctly.
      let file = yield OS.File.open(targetPath, { truncate: true });
      yield file.close();

      let printSettings = gPrintSettingsService.newPrintSettings;

      printSettings.printToFile = true;
      printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF;
      printSettings.toFileName = targetPath;

      printSettings.printSilent = true;
      printSettings.showPrintProgress = false;

      printSettings.printBGImages = true;
      printSettings.printBGColors = true;
      printSettings.printFrameType = Ci.nsIPrintSettings.kFramesAsIs;
      printSettings.headerStrCenter = "";
      printSettings.headerStrLeft = "";
      printSettings.headerStrRight = "";
      printSettings.footerStrCenter = "";
      printSettings.footerStrLeft = "";
      printSettings.footerStrRight = "";

      this._webBrowserPrint = win.QueryInterface(Ci.nsIInterfaceRequestor)
                                 .getInterface(Ci.nsIWebBrowserPrint);

      try {
        yield new Promise((resolve, reject) => {
          this._webBrowserPrint.print(printSettings, {
            onStateChange: function (webProgress, request, stateFlags, status) {
              if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
                if (!Components.isSuccessCode(status)) {
                  reject(new DownloadError({ result: status,
                                             inferCause: true }));
                } else {
                  resolve();
                }
              }
            },
            onProgressChange: function (webProgress, request, curSelfProgress,
                                        maxSelfProgress, curTotalProgress,
                                        maxTotalProgress) {
              aSetProgressBytesFn(curTotalProgress, maxTotalProgress, false);
            },
            onLocationChange: function () {},
            onStatusChange: function () {},
            onSecurityChange: function () {},
          });
        });
      } finally {
        // Remove the print object to avoid leaks
        this._webBrowserPrint = null;
      }

      let fileInfo = yield OS.File.stat(targetPath);
      aSetProgressBytesFn(fileInfo.size, fileInfo.size, false);
    }.bind(this));
  },

  /**
   * Implements "DownloadSaver.cancel".
   */
  cancel: function DCS_cancel()
  {
    if (this._webBrowserPrint) {
      this._webBrowserPrint.cancel();
      this._webBrowserPrint = null;
    }
  },

  /**
   * Implements "DownloadSaver.toSerializable".
   */
  toSerializable: function ()
  {
    if (this.download.succeeded) {
      return DownloadCopySaver.prototype.toSerializable.call(this);
    }

    // This object needs a window to recreate itself. If it didn't succeded
    // it will not be possible to restart. Returning null here will
    // prevent us from serializing it at all.
    return null;
  },
};

/**
 * Creates a new DownloadPDFSaver object, with its initial state derived from
 * its serializable representation.
 *
 * @param aSerializable
 *        Serializable representation of a DownloadPDFSaver object.
 *
 * @return The newly created DownloadPDFSaver object.
 */
this.DownloadPDFSaver.fromSerializable = function (aSerializable) {
  return new DownloadPDFSaver();
};
