Bug 1357171 - cloud storage module r=gijs r=mconnor draft
authorPunam <pdahiya@mozilla.com>
Thu, 27 Jul 2017 10:14:17 -0700
changeset 617029 e0c90341b1ee4a67945bbd2035524ce8b4224fa6
parent 617016 bd10e5ba87d50cfc5aa47241358374b695e95779
child 639663 e6c5610d7818a655c96a4f6551ae3b172bde1048
push id70886
push userbmo:pdahiya@mozilla.com
push dateThu, 27 Jul 2017 18:33:01 +0000
reviewersgijs, mconnor
bugs1357171, 1365129
milestone56.0a1
Bug 1357171 - cloud storage module r=gijs r=mconnor * Has storage providers metadata * Scan for storage providers returning preferred provider * Helper methods to access cloud services prefs * Helper method to set cloud storage as default download directory * Notify observers for displaying cloud storage prompt * Support pref value browser.downloads.folderList 3 * Read dropbox custom downloadpath from info.json * Handle rejected providers by saving response in cloud.services.rejected.key * Load providers metadata from providers.json * Tested using add-on implemented in Bug 1365129 * Tests for Linux, Mac and Win Platform MozReview-Commit-ID: LrmoDfsRTBV
toolkit/components/cloudstorage/CloudStorage.jsm
toolkit/components/cloudstorage/content/providers.json
toolkit/components/cloudstorage/jar.mn
toolkit/components/cloudstorage/moz.build
toolkit/components/cloudstorage/tests/unit/.eslintrc.js
toolkit/components/cloudstorage/tests/unit/cloud/info.json
toolkit/components/cloudstorage/tests/unit/test_cloudstorage.js
toolkit/components/cloudstorage/tests/unit/xpcshell.ini
toolkit/components/jsdownloads/src/DownloadIntegration.jsm
toolkit/components/moz.build
toolkit/mozapps/downloads/nsHelperAppDlg.js
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/CloudStorage.jsm
@@ -0,0 +1,591 @@
+/* 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/. */
+
+/**
+ * Java Script module that helps consumers store data directly
+ * to cloud storage provider download folders.
+ *
+ * Takes cloud storage providers metadata as JSON input on Mac, Linux and Windows.
+ *
+ * Handles scan, prompt response save and exposes preferred storage provider.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["CloudStorage"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.importGlobalProperties(["fetch"]);
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+                                  "resource://gre/modules/osfile.jsm");
+
+const CLOUD_SERVICES_PREF = "cloud.services.";
+const CLOUD_PROVIDERS_URI = "resource://cloudstorage/providers.json";
+
+/**
+ * Provider metadata JSON is loaded from resource://cloudstorage/providers.json
+ * Sample providers.json format
+ *
+ * {
+ *   "Dropbox": {
+ *     "displayName": "Dropbox",
+ *     "relativeDownloadPath": ["homeDir", "Dropbox"],
+ *     "relativeDiscoveryPath":  {
+ *       "linux": ["homeDir", ".dropbox", "info.json"],
+ *       "macosx": ["homeDir", ".dropbox", "info.json"],
+ *       "win": ["LocalAppData", "Dropbox", "info.json"]
+ *     },
+ *     "typeSpecificData": {
+ *       "default": "Downloads",
+ *       "screenshot": "Screenshots"
+ *     }
+ *  }
+ *
+ * Providers JSON is flat list of providers metdata with property as key in format @Provider
+ *
+ * @Provider - Unique cloud provider key, possible values: "Dropbox", "GDrive"
+ *
+ * @displayName - cloud storage name displayed in the prompt.
+ *
+ * @relativeDownloadPath - download path on user desktop for a cloud storage provider.
+ * By default downloadPath is a concatenation of home dir and name of dropbox folder.
+ * Example value: ["homeDir", "Dropbox"]
+ *
+ * @relativeDiscoveryPath - Lists discoveryPath by platform. Provider is not supported on a platform
+ * if its value doesn't exist in relativeDiscoveryPath. relativeDiscoveryPath by platform is stored
+ * as an array ofsubdirectories, which when concatenated, forms discovery path.
+ * During scan discoveryPath is checked for the existence of cloud storage provider on user desktop.
+ *
+ * @typeSpecificData - provides folder name for a cloud storage depending
+ * on type of data downloaded. Default folder is 'Downloads'. Other options are
+ * 'screenshot' depending on provider support.
+ */
+
+/**
+ *
+ * Internal cloud services prefs
+ *
+ * cloud.services.storage.key - set to string with preferred provider key
+ *
+ * cloud.services.lastPrompt - set to time when last prompt was shown
+ *
+ * cloud.services.interval.prompt - set to time interval in days after which prompt should be shown
+ *
+ * cloud.services.rejected.key - set to string with comma separated provider keys rejected
+ * by user when prompted to opt-in
+ *
+ * browser.download.folderList - set to int and indicates the location users wish to save downloaded files to.
+ *     0 - The desktop is the default download location.
+ *     1 - The system's downloads folder is the default download location.
+ *     2 - The default download location is elsewhere as specified in
+ *         browser.download.dir.
+ *     3 - The default download location is elsewhere as specified by
+ *         cloud storage API getDownloadFolder
+ *
+ * browser.download.dir - local file handle
+ *   A local folder user may have selected for downloaded files to be
+ *   saved. This folder is enabled when folderList equals 2.
+ */
+
+/**
+ * The external API exported by this module.
+ */
+
+this.CloudStorage = {
+  /**
+    * Init method to initialize providers metadata
+    */
+  async init() {
+    let isInitialized = null;
+    try {
+      // Invoke internal method asynchronously to read and
+      // parse providers metadata from JSON
+      isInitialized = await CloudStorageInternal.initProviders();
+    } catch (err) {
+      Cu.reportError(err);
+    }
+    return isInitialized;
+  },
+
+  /**
+   * Returns information to allow the consumer to decide whether showing
+   * a doorhanger prompt is appropriate. If a preferred provider is set
+   * on desktop, user is not prompted again and method returns null.
+   *
+   * @return {Promise} which resolves to an object with property name
+   * as 'key' and 'value'.
+   * 'key' property is provider key such as 'Dropbox', 'GDrive'.
+   * 'value' property contains metadata for respective provider.
+   * Resolves null if it's not appropriate to prompt.
+   */
+  promisePromptInfo() {
+    return CloudStorageInternal.promisePromptInfo();
+  },
+
+  /**
+   * Save user response from doorhanger prompt.
+   * If user confirms and checks 'always remember', update prefs
+   * cloud.services.storage.key and browser.download.folderList to pick
+   * download location from cloud storage API
+   * If user denies, save provider as rejected in cloud.services.rejected.key
+   *
+   * @param key
+   *        cloud storage provider key from provider metadata
+   * @param remember
+   *        bool value indicating whether user has asked to always remember
+   *        the settings
+   * @param selected
+   *        bool value by default set to false indicating if user has selected
+   *        to save downloaded file with cloud provider
+   */
+  savePromptResponse(key, remember, selected = false) {
+    Services.prefs.setIntPref(CLOUD_SERVICES_PREF + "lastprompt",
+                              Math.floor(Date.now() / 1000));
+    if (remember) {
+      if (selected) {
+        CloudStorageInternal.setCloudStoragePref(key);
+      } else {
+        // Store provider as rejected by setting cloud.services.rejected.key
+        // and not use in re-prompt
+        CloudStorageInternal.handleRejected(key);
+      }
+    }
+  },
+
+  /**
+   * Retrieve download folder of an opted-in storage provider
+   * by type specific data
+   * @param typeSpecificData
+   *        type of data downloaded, options are 'default', 'screenshot'
+   * @return {Promise} which resolves to full path to provider download folder
+   */
+  getDownloadFolder(typeSpecificData) {
+    return CloudStorageInternal.getDownloadFolder(typeSpecificData);
+  },
+
+  /**
+   * Get provider opted-in by user to store downloaded files
+   *
+   * @return {String}
+   * Storage provider key from provider metadata. Return empty string
+   * if user has not selected a preferred provider.
+   */
+  getPreferredProvider() {
+    return CloudStorageInternal.preferredProviderKey;
+  },
+
+  /**
+   * Get providers found on user desktop. Used for unit tests
+   *
+   * @return {Promise}
+   * @resolves
+   * Map object with entries key set to storage provider key and values set to
+   * storage provider metadata
+   */
+  getStorageProviders() {
+    return CloudStorageInternal.getStorageProviders();
+  },
+};
+
+/**
+ * The internal API for the CloudStorage module.
+ */
+
+var CloudStorageInternal = {
+  /**
+   * promiseInit saves returned init method promise and is
+   * used to wait for initialization to complete.
+   */
+  promiseInit: null,
+
+  /**
+   * Internal property having storage providers data
+   */
+  providersMetaData: null,
+
+  async _downloadJSON(uri) {
+    let json = null;
+    try {
+      let response = await fetch(uri);
+      if (response.ok) {
+        json = await response.json();
+      }
+    } catch (e) {
+      Cu.reportError("Fetching " + uri + " results in error: " + e);
+    }
+    return json;
+  },
+
+  /**
+   * Loads storage providers metadata asynchronously from providers.json.
+   *
+   * @returns {Promise} with resolved boolean value true if providers
+   * metadata is successfully initialized
+   */
+  async initProviders() {
+    let response = await this._downloadJSON(CLOUD_PROVIDERS_URI);
+    this.providersMetaData = await this._parseProvidersJSON(response);
+
+    let providersCount = Object.keys(this.providersMetaData).length;
+    if (providersCount > 0) {
+      // Array of boolean results for each provider handled for custom downloadpath
+      let handledProviders = await this.initDownloadPathIfProvidersExist();
+      if (handledProviders.length === providersCount) {
+        return true;
+      }
+    }
+    return false;
+  },
+
+  /**
+   * Load parsed metadata inside providers object
+   */
+  _parseProvidersJSON(providers) {
+    if (!providers) {
+      return {};
+    }
+
+    // Use relativeDiscoveryPath to filter providers object by platform.
+    // DownloadPath and discoveryPath are stored as
+    // array of subdirectories inside providers.json
+    // Update providers object discoveryPath and downloadPath
+    // property values by concatenating subdirectories and forming platform
+    // specific directory path
+
+    Object.getOwnPropertyNames(providers).forEach(key => {
+      if (providers[key].relativeDiscoveryPath.hasOwnProperty(AppConstants.platform)) {
+        providers[key].discoveryPath =
+          this._concatPath(providers[key].relativeDiscoveryPath[AppConstants.platform]);
+        providers[key].downloadPath =
+          this._concatPath(providers[key].relativeDownloadPath);
+      } else {
+        // delete key not supported on AppConstants.platform
+        delete providers[key];
+      }
+    });
+    return providers;
+  },
+
+  /**
+   * Concatenate subdir value inside array to form
+   * platform specific directory path
+   *
+   * @param arrDirs
+   *        String Array containing sub directories name
+   * @returns Path of type String
+   */
+  _concatPath(arrDirs) {
+    let dirPath = "";
+    for (let subDir of arrDirs) {
+      switch (subDir) {
+        case "homeDir":
+          subDir = OS.Constants.Path.homeDir ? OS.Constants.Path.homeDir : "";
+          break;
+        case "LocalAppData":
+          if (OS.Constants.Win) {
+            let nsIFileLocal = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
+            subDir = nsIFileLocal && nsIFileLocal.path ? nsIFileLocal.path : "";
+          } else {
+            subDir = "";
+          }
+          break;
+      }
+      dirPath = OS.Path.join(dirPath, subDir);
+    }
+    return dirPath;
+  },
+
+  /**
+   * Check for custom download paths and override providers metadata
+   * downloadPath property
+   *
+   * For dropbox open config file ~/.dropbox/info.json
+   * and override downloadPath with path found
+   * See https://www.dropbox.com/en/help/desktop-web/find-folder-paths
+   *
+   * For all other providers we are using downloadpath from providers.json
+   *
+   * @returns {Promise} with array boolean values for respective provider. Value is true if a
+   * provider exist on user desktop and its downloadPath is updated. Promise returns with
+   * resolved array value when all providers in metadata are handled.
+   */
+  initDownloadPathIfProvidersExist() {
+    let providerKeys = Object.keys(this.providersMetaData);
+    let promises = providerKeys.map(key => {
+      return key === "Dropbox" ?
+             this._initDropbox(key) :
+             Promise.resolve(false);
+    });
+    return Promise.all(promises);
+  },
+
+  /**
+   * Read Dropbox info.json and override providers metadata
+   * downloadPath property
+   *
+   * @return {Promise}
+   * @resolves
+   * false if dropbox provider is not found. Returns true if dropbox service exist
+   * on user desktop and downloadPath in providermetadata is updated with
+   * value read from config file info.json
+   */
+  async _initDropbox(key) {
+    // Check if Dropbox provider exist on desktop before continuing
+    if (!await this._checkIfAssetExists(this.providersMetaData[key].discoveryPath)) {
+      return false;
+    }
+
+    // Check in cloud.services.rejected.key if Dropbox is previously rejected before continuing
+    let rejectedKeys = this.cloudStorageRejectedKeys.split(",");
+    if (rejectedKeys.includes(key)) {
+      return false;
+    }
+
+    let file = null;
+    try {
+      file = new FileUtils.File(this.providersMetaData[key].discoveryPath);
+    } catch (ex) {
+      return false;
+    }
+
+    let data = await this._downloadJSON(Services.io.newFileURI(file).spec);
+
+    if (!data) {
+      return false;
+    }
+
+    let path = data && data.personal && data.personal.path;
+    if (!path) {
+      return false;
+    }
+    let isUsable = await this._isUsableDirectory(path);
+    if (isUsable) {
+      this.providersMetaData.Dropbox.downloadPath = path;
+    }
+    return isUsable;
+  },
+
+  /**
+   * Determines if a given directory is valid and can be used to download files
+   *
+   * @param full absolute path to the directory
+   *
+   * @return {Promise} which resolves true if we can use the directory, false otherwise.
+   */
+  async _isUsableDirectory(path) {
+    let isUsable = false;
+    try {
+      let info = await OS.File.stat(path);
+      isUsable = info.isDir;
+    } catch (e) {
+      // Directory doesn't exist, so isUsable will still be false
+    }
+    return isUsable;
+  },
+
+  /**
+   * Retrieve download folder of preferred provider by type specific data
+   *
+   * @param dataType
+   *        type of data downloaded, options are 'default', 'screenshot'
+   *        default value is 'default'
+   * @return {Promise} which resolves to full path to download folder
+   */
+  async getDownloadFolder(dataType = "default") {
+    // Wait for cloudstorage to initialize if providers metadata is not available
+    if (!this.providersMetaData) {
+      let isInitialized = await this.promiseInit;
+      if (!isInitialized && !this.providersMetaData) {
+        Cu.reportError("CloudStorage: Failed to initialize and retrieve download folder ");
+        return null;
+      }
+    }
+
+    let key = this.preferredProviderKey;
+    if (!key || !this.providersMetaData.hasOwnProperty(key)) {
+      return null;
+    }
+
+    let provider = this.providersMetaData[key];
+    if (!provider.typeSpecificData[dataType]) {
+      return null;
+    }
+
+    let downloadDirPath = OS.Path.join(provider.downloadPath,
+                                       provider.typeSpecificData[dataType]);
+    if (!(await this._isUsableDirectory(downloadDirPath))) {
+      return null;
+    }
+    return downloadDirPath;
+  },
+
+  /**
+   * Return scanned provider info used by consumer inside doorhanger prompt.
+   * @return {Promise}
+   * which resolves to an object with property 'key' as found provider and
+   * property 'value' as provider metadata.
+   * Resolves null if no provider info is returned.
+   */
+  async promisePromptInfo() {
+    // Check if user has not previously opted-in for preferred provider download folder
+    // and if time elapsed since last prompt shown has exceeded maximum allowed interval
+    // in pref cloud.services.interval.prompt before continuing to scan for providers
+    if (!this.preferredProviderKey && this.shouldPrompt()) {
+      return this.scan();
+    }
+    return Promise.resolve(null);
+  },
+
+  /**
+   * Check if its time to prompt by reading lastprompt service pref.
+   * Return true if pref doesn't exist or last prompt time is
+   * more than prompt interval
+   */
+  shouldPrompt() {
+    let lastPrompt = this.lastPromptTime;
+    let now = Math.floor(Date.now() / 1000);
+    let interval = now - lastPrompt;
+
+    // Convert prompt interval to seconds
+    let maxAllow = this.promptInterval * 24 * 60 * 60;
+    return interval >= maxAllow;
+  },
+
+  /**
+   * Scans for local storage providers available on user desktop
+   *
+   * providers list is read in order as specified in providers.json.
+   * If a user has multiple cloud storage providers on desktop, return the first
+   * provider after filtering the rejected keys
+   *
+   * @return {Promise}
+   * which resolves to an object providerInfo with found provider key and value
+   * as provider metadata. Resolves null if no valid provider found
+   */
+  async scan() {
+    let providers = await this.getStorageProviders();
+    if (!providers.size) {
+      // No storage services installed on user desktop
+      return null;
+    }
+
+    // Filter the rejected providers in cloud.services.rejected.key
+    // from the providers map object
+    let rejectedKeys = this.cloudStorageRejectedKeys.split(",");
+    for (let rejectedKey of rejectedKeys) {
+      providers.delete(rejectedKey);
+    }
+
+    // Pick first storage provider from providers
+    let provider = providers.entries().next().value;
+    if (provider) {
+      return {key: provider[0], value: provider[1]};
+    }
+    return null;
+  },
+
+  /**
+   * Checks if the asset with input path exist on
+   * file system
+   * @return {Promise}
+   * @resolves
+   * boolean value of file existence check
+   */
+  _checkIfAssetExists(path) {
+    return OS.File.exists(path).catch(err => {
+      Cu.reportError(`Couldn't check existance of ${path}`, err);
+      return false;
+    });
+  },
+
+  /**
+   * get access to all local storage providers available on user desktop
+   *
+   * @return {Promise}
+   * @resolves
+   * Map object with entries key set to storage provider key and values set to
+   * storage provider metadata
+   */
+  async getStorageProviders() {
+    let providers = Object.entries(this.providersMetaData || {});
+
+    // Array of promises with boolean value exist for respective storage.
+    let promises = providers.map(([, provider]) => this._checkIfAssetExists(provider.discoveryPath));
+    let results = await Promise.all(promises);
+
+    // Filter providers array to remove provider with discoveryPath asset exist resolved value false
+    providers = providers.filter((_, idx) => results[idx]);
+    return new Map(providers);
+  },
+
+  /**
+   * Save the rejected provider in cloud.services.rejected.key. Pref
+   * stores rejected keys value as comma separated string.
+   *
+   * @param key
+   *        Provider key to be saved in cloud.services.rejected.key pref
+   */
+  handleRejected(key) {
+    let rejected = this.cloudStorageRejectedKeys;
+
+    if (!rejected) {
+      Services.prefs.setCharPref(CLOUD_SERVICES_PREF + "rejected.key", key);
+    } else {
+      // Pref exists with previous rejected keys, append
+      // key at the end and update pref
+      let keys = rejected.split(",");
+      if (key) {
+        keys.push(key);
+      }
+      Services.prefs.setCharPref(CLOUD_SERVICES_PREF + "rejected.key", keys.join(","));
+    }
+  },
+
+  /**
+   *
+   * Sets pref cloud.services.storage.key. It updates download browser.download.folderList
+   * value to 3 indicating download location is stored elsewhere, as specified by
+   * cloud storage API getDownloadFolder
+   *
+   * @param key
+   *        cloud storage provider key from provider metadata
+   */
+  setCloudStoragePref(key) {
+    Services.prefs.setCharPref(CLOUD_SERVICES_PREF + "storage.key", key);
+    Services.prefs.setIntPref("browser.download.folderList", 3);
+  },
+};
+
+/**
+ * Provider key retrieved from service pref cloud.services.storage.key
+ */
+XPCOMUtils.defineLazyPreferenceGetter(CloudStorageInternal, "preferredProviderKey",
+  CLOUD_SERVICES_PREF + "storage.key", "");
+
+/**
+ * Provider keys rejected by user for default download
+ */
+XPCOMUtils.defineLazyPreferenceGetter(CloudStorageInternal, "cloudStorageRejectedKeys",
+  CLOUD_SERVICES_PREF + "rejected.key", "");
+
+/**
+ * Lastprompt time in seconds, by default set to 0
+ */
+XPCOMUtils.defineLazyPreferenceGetter(CloudStorageInternal, "lastPromptTime",
+  CLOUD_SERVICES_PREF + "lastprompt", 0 /* 0 second */);
+
+/**
+ * show prompt interval in days, by default set to 0
+ */
+XPCOMUtils.defineLazyPreferenceGetter(CloudStorageInternal, "promptInterval",
+  CLOUD_SERVICES_PREF + "interval.prompt", 0 /* 0 days */);
+
+CloudStorageInternal.promiseInit = CloudStorage.init();
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/content/providers.json
@@ -0,0 +1,27 @@
+{
+  "Dropbox": {
+    "displayName": "Dropbox",
+    "relativeDownloadPath": ["homeDir", "Dropbox"],
+    "relativeDiscoveryPath": {
+      "linux": ["homeDir", ".dropbox", "info.json"],
+      "macosx": ["homeDir", ".dropbox", "info.json"],
+      "win": ["LocalAppData", "Dropbox", "info.json"]
+    },
+    "typeSpecificData": {
+      "default": "Downloads",
+      "screenshot": "Screenshots"
+    }
+  },
+
+  "GDrive": {
+    "displayName": "Google Drive",
+    "relativeDownloadPath": ["homeDir", "Google Drive"],
+    "relativeDiscoveryPath": {
+      "macosx": ["homeDir", "Library", "Application Support", "Google", "Drive"],
+      "win": ["LocalAppData", "Google", "Drive"]
+    },
+    "typeSpecificData": {
+      "default": "Downloads"
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/jar.mn
@@ -0,0 +1,7 @@
+# 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/.
+
+toolkit.jar:
+% resource cloudstorage %content/
+  content/       (content/*)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/moz.build
@@ -0,0 +1,18 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+XPCSHELL_TESTS_MANIFESTS += [
+    'tests/unit/xpcshell.ini',
+]
+
+JAR_MANIFESTS += ['jar.mn']
+
+EXTRA_JS_MODULES += [
+    'CloudStorage.jsm',
+]
+
+with Files('**'):
+    BUG_COMPONENT = ('Toolkit', 'General')
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/tests/unit/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+  "extends": [
+    "plugin:mozilla/xpcshell-test"
+  ]
+};
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/tests/unit/cloud/info.json
@@ -0,0 +1,1 @@
+{"personal": {"path": "Test"}}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/tests/unit/test_cloudstorage.js
@@ -0,0 +1,278 @@
+"use strict";
+
+// Globals
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cu = Components.utils;
+var Cr = Components.results;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/AppConstants.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "CloudStorage",
+                                  "resource://gre/modules/CloudStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+
+const CLOUD_SERVICES_PREF = "cloud.services.";
+const DROPBOX_DOWNLOAD_FOLDER = "Dropbox";
+const GOOGLE_DRIVE_DOWNLOAD_FOLDER = "Google Drive";
+const DROPBOX_CONFIG_FOLDER = (AppConstants.platform === "win") ? "Dropbox" :
+                                                                  ".dropbox";
+const DROPBOX_KEY = "Dropbox";
+const GDRIVE_KEY = "GDrive";
+
+var nsIDropboxFile, nsIGDriveFile;
+
+function run_test() {
+  registerFakePath("Home", do_get_file("cloud/"));
+  registerFakePath("LocalAppData", do_get_file("cloud/"));
+  do_register_cleanup(() => {
+    cleanupPrefs();
+  });
+  run_next_test();
+}
+
+/**
+ * Replaces a directory service entry with a given nsIFile.
+ */
+function registerFakePath(key, file) {
+  let dirsvc = Services.dirsvc.QueryInterface(Ci.nsIProperties);
+  let originalFile;
+  try {
+    // If a file is already provided save it and undefine, otherwise set will
+    // throw for persistent entries (ones that are cached).
+    originalFile = dirsvc.get(key, Ci.nsIFile);
+    dirsvc.undefine(key);
+  } catch (e) {
+    // dirsvc.get will throw if nothing provides for the key and dirsvc.undefine
+    // will throw if it's not a persistent entry, in either case we don't want
+    // to set the original file in cleanup.
+    originalFile = undefined;
+  }
+
+  dirsvc.set(key, file);
+  do_register_cleanup(() => {
+    dirsvc.undefine(key);
+    if (originalFile) {
+      dirsvc.set(key, originalFile);
+    }
+  });
+}
+
+function mock_dropbox() {
+  let discoveryFolder = null;
+  if (AppConstants.platform === "win") {
+    discoveryFolder = FileUtils.getFile("LocalAppData", [DROPBOX_CONFIG_FOLDER]);
+  } else {
+    discoveryFolder = FileUtils.getFile("Home", [DROPBOX_CONFIG_FOLDER]);
+  }
+  discoveryFolder.append("info.json");
+  let fileDir = discoveryFolder.parent;
+  if (!fileDir.exists()) {
+    fileDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+  }
+  do_get_file("cloud/info.json").copyTo(fileDir, "info.json");
+  let exist = fileDir.exists();
+  Assert.ok(exist, "file exists on desktop");
+
+  // Mock Dropbox Download folder in Home directory
+  let downloadFolder = FileUtils.getFile("Home", [DROPBOX_DOWNLOAD_FOLDER, "Downloads"]);
+  if (!downloadFolder.exists()) {
+    downloadFolder.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+  }
+
+  do_register_cleanup(() => {
+    if (discoveryFolder.exists()) {
+      discoveryFolder.remove(false);
+    }
+    if (downloadFolder.exists()) {
+      downloadFolder.remove(false);
+    }
+  });
+
+  return discoveryFolder;
+}
+
+function mock_gdrive() {
+  let discoveryFolder = null;
+  if (AppConstants.platform === "win") {
+    discoveryFolder = FileUtils.getFile("LocalAppData", ["Google", "Drive"]);
+  } else {
+    discoveryFolder = FileUtils.getFile("Home", ["Library", "Application Support", "Google", "Drive"]);
+  }
+  if (!discoveryFolder.exists()) {
+    discoveryFolder.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+  }
+  let exist = discoveryFolder.exists();
+  Assert.ok(exist, "file exists on desktop");
+
+  // Mock Google Drive Download folder in Home directory
+  let downloadFolder = FileUtils.getFile("Home", [GOOGLE_DRIVE_DOWNLOAD_FOLDER, "Downloads"]);
+  if (!downloadFolder.exists()) {
+    downloadFolder.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+  }
+
+  do_register_cleanup(() => {
+    if (discoveryFolder.exists()) {
+      discoveryFolder.remove(false);
+    }
+    if (downloadFolder.exists()) {
+      downloadFolder.remove(false);
+    }
+  });
+
+  return discoveryFolder;
+}
+
+function cleanupPrefs() {
+  try {
+    Services.prefs.clearUserPref(CLOUD_SERVICES_PREF + "lastprompt");
+    Services.prefs.clearUserPref(CLOUD_SERVICES_PREF + "storage.key");
+    Services.prefs.clearUserPref(CLOUD_SERVICES_PREF + "rejected.key");
+    Services.prefs.clearUserPref(CLOUD_SERVICES_PREF + "interval.prompt");
+    Services.prefs.setIntPref("browser.download.folderList", 2);
+  } catch (e) {
+    do_throw("Failed to cleanup prefs: " + e);
+  }
+}
+
+function promiseGetStorageProviders() {
+  return CloudStorage.getStorageProviders();
+}
+
+function promisePromptInfo() {
+  return CloudStorage.promisePromptInfo();
+}
+
+async function checkScan(expectedKey) {
+  let metadata = await promiseGetStorageProviders();
+  let scanProvider = await promisePromptInfo();
+
+  if (!expectedKey) {
+    Assert.equal(metadata.size, 0, "Number of storage providers");
+    Assert.ok(!scanProvider, "No provider in scan results");
+  } else {
+    Assert.ok(metadata.size, "Number of storage providers");
+    Assert.equal(scanProvider.key, expectedKey, "Scanned provider key returned");
+  }
+  return metadata;
+}
+
+async function checkSavedPromptResponse(aKey, metadata, remember, selected = false) {
+  CloudStorage.savePromptResponse(aKey, remember, selected);
+
+  if (remember && selected) {
+    // Save prompt response with option to always remember the setting
+    // and provider with aKey selected as cloud storage provider
+    // Sets user download settings to always save to cloud
+
+    // Check preferred provider key, should be set to dropbox
+    let prefProvider = CloudStorage.getPreferredProvider();
+    Assert.equal(prefProvider, aKey, "Saved Response preferred provider key returned");
+    // Check browser.download.folderlist pref should be set to 3
+    Assert.equal(Services.prefs.getIntPref("browser.download.folderList"), 3,
+                 "Default download location set to 3");
+
+    // Preferred download folder should be set to provider downloadPath from metadata
+    let path = await CloudStorage.getDownloadFolder();
+    let nsIDownloadFolder = new FileUtils.File(path);
+    Assert.ok(nsIDownloadFolder, "Download folder retrieved");
+    Assert.equal(nsIDownloadFolder.parent.path, metadata.get(aKey).downloadPath,
+                 "Default download Folder Path");
+  } else if (remember && !selected) {
+    // Save prompt response with option to always remember the setting
+    // and provider with aKey rejected as cloud storage provider
+    // Sets cloud.services.rejected.key pref with provider key.
+    // Provider is ignored in next scan and never re-prompted again
+
+    let scanResult = await promisePromptInfo();
+    if (scanResult) {
+      Assert.notEqual(scanResult.key, DROPBOX_KEY, "Scanned provider key returned is not Dropbox");
+    } else {
+      Assert.ok(!scanResult, "No provider in scan results");
+    }
+  }
+}
+
+
+
+add_task(async function test_checkInit() {
+  let {CloudStorageInternal} = Cu.import("resource://gre/modules/CloudStorage.jsm", {});
+  let isInitialized = await CloudStorageInternal.promiseInit;
+  Assert.ok(isInitialized, "Providers Metadata successfully initialized");
+});
+
+add_task(async function test_noStorageProvider() {
+  await checkScan();
+  cleanupPrefs();
+});
+
+/**
+ * Check scan and save prompt response flow if only dropbox exists on desktop.
+ */
+add_task(async function test_dropboxStorageProvider() {
+  nsIDropboxFile = mock_dropbox();
+  let result = await checkScan(DROPBOX_KEY);
+
+  // Always save to cloud
+  await checkSavedPromptResponse(DROPBOX_KEY, result, true, true);
+  cleanupPrefs();
+
+  // Reject dropbox as cloud storage provider and never re-prompt again
+  await checkSavedPromptResponse(DROPBOX_KEY, result, true);
+
+  // Uninstall dropbox by removing discovery folder
+  nsIDropboxFile.remove(false);
+  cleanupPrefs();
+});
+
+
+/**
+ * Check scan and save prompt response flow if only gdrive exists on desktop.
+ */
+add_task(async function test_gDriveStorageProvider() {
+  nsIGDriveFile = mock_gdrive();
+  let result;
+  if (AppConstants.platform === "linux") {
+    result = await checkScan();
+  } else {
+    result = await checkScan(GDRIVE_KEY);
+  }
+
+  if (result.size || AppConstants.platform !== "linux") {
+    // Always save to cloud
+    await checkSavedPromptResponse(GDRIVE_KEY, result, true, true);
+    cleanupPrefs();
+
+    // Reject Google Drive as cloud storage provider and never re-prompt again
+    await checkSavedPromptResponse(GDRIVE_KEY, result, true);
+  }
+  // Uninstall gDrive by removing  discovery folder /Home/Library/Application Support/Google/Drive
+  nsIGDriveFile.remove(false);
+  cleanupPrefs();
+});
+
+/**
+ * Check scan and save prompt response flow if multiple provider exists on desktop.
+ */
+add_task(async function test_multipleStorageProvider() {
+  nsIDropboxFile = mock_dropbox();
+  nsIGDriveFile = mock_gdrive();
+
+  // Dropbox picked by scan if multiple providers found
+  let result = await checkScan(DROPBOX_KEY);
+
+  // Always save to cloud
+  await checkSavedPromptResponse(DROPBOX_KEY, result, true, true);
+  cleanupPrefs();
+
+  // Reject dropbox as cloud storage provider and never re-prompt again
+  await checkSavedPromptResponse(DROPBOX_KEY, result, true);
+
+  // Uninstall dropbox and gdrive by removing discovery folder
+  nsIDropboxFile.remove(false);
+  nsIGDriveFile.remove(false);
+  cleanupPrefs();
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/cloudstorage/tests/unit/xpcshell.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+head =
+firefox-appdir = browser
+skip-if = toolkit == 'android'
+support-files =
+  cloud/**
+
+[test_cloudstorage.js]
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -39,16 +39,18 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CloudStorage",
+                                  "resource://gre/modules/CloudStorage.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "gDownloadPlatform",
                                    "@mozilla.org/toolkit/download-platform;1",
                                    "mozIDownloadPlatform");
 XPCOMUtils.defineLazyServiceGetter(this, "gEnvironment",
                                    "@mozilla.org/process/environment;1",
                                    "nsIEnvironment");
 XPCOMUtils.defineLazyServiceGetter(this, "gMIMEService",
@@ -306,16 +308,24 @@ this.DownloadIntegration = {
                                                          Ci.nsIFile);
           directoryPath = directory.path;
           await OS.File.makeDir(directoryPath, { ignoreExisting: true });
         } catch(ex) {
           // Either the preference isn't set or the directory cannot be created.
           directoryPath = await this.getSystemDownloadsDirectory();
         }
         break;
+      case 3: // Cloud Storage
+        try {
+          directoryPath = await CloudStorage.getDownloadFolder();
+        } catch(ex) {
+          // Either the preference isn't set or the directory cannot be created.
+          directoryPath = await this.getSystemDownloadsDirectory();
+        }
+        break;
       default:
         directoryPath = await this.getSystemDownloadsDirectory();
     }
     return directoryPath;
   },
 
   /**
    * Returns the temporary downloads directory asynchronously.
--- a/toolkit/components/moz.build
+++ b/toolkit/components/moz.build
@@ -16,16 +16,17 @@ DIRS += [
     'aboutcheckerboard',
     'aboutmemory',
     'aboutperformance',
     'addoncompat',
     'alerts',
     'apppicker',
     'asyncshutdown',
     'browser',
+    'cloudstorage',
     'commandlines',
     'contentprefs',
     'contextualidentity',
     'crashmonitor',
     'diskspacewatcher',
     'downloads',
     'extensions',
     'exthelper',
--- a/toolkit/mozapps/downloads/nsHelperAppDlg.js
+++ b/toolkit/mozapps/downloads/nsHelperAppDlg.js
@@ -264,16 +264,19 @@ nsUnknownContentTypeDialog.prototype = {
           }
           catch (ex) {
             // When the default download directory is write-protected,
             // prompt the user for a different target file.
           }
 
           // Check to make sure we have a valid directory, otherwise, prompt
           if (result) {
+            // Notifications for CloudStorage API consumers to show offer
+            // prompts while downloading. See Bug 1365129
+            Services.obs.notifyObservers(null, "cloudstorage-prompt-notification", result.path);
             // This path is taken when we have a writable default download directory.
             aLauncher.saveDestinationAvailable(result);
             return;
           }
         }
       }
 
       // Use file picker to show dialog.