Merge fx-team to m-c. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Thu, 02 Oct 2014 13:00:31 -0400
changeset 233007 b85c260821abced52c1ffb6fc45aeef42f0db9bc
parent 232965 7bbb6d89b24f6d79b2fedcca8124000057c701f3 (current diff)
parent 233006 1ee2c06d271fd585845af22bcb41f4f97a09da34 (diff)
child 233022 98fd84e46f107fe12f813f40917383424ea81bd8
child 233111 a6fe13b6d0e980dfe9262e23cca73463812dd004
child 233171 948719006fe8983a4a5cd7b2c96a98026dd5973f
push id611
push userraliiev@mozilla.com
push dateMon, 05 Jan 2015 23:23:16 +0000
treeherdermozilla-release@345cd3b9c445 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone35.0a1
first release with
nightly linux32
b85c260821ab / 35.0a1 / 20141003030204 / files
nightly linux64
b85c260821ab / 35.0a1 / 20141003030204 / files
nightly mac
b85c260821ab / 35.0a1 / 20141003030204 / files
nightly win32
b85c260821ab / 35.0a1 / 20141003030204 / files
nightly win64
b85c260821ab / 35.0a1 / 20141003030204 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge fx-team to m-c. a=merge
testing/profiles/prefs_general.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1611,16 +1611,18 @@ pref("loop.ringtone", "chrome://browser/
 pref("loop.retry_delay.start", 60000);
 pref("loop.retry_delay.limit", 300000);
 pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
 pref("loop.feedback.product", "Loop");
 pref("loop.debug.loglevel", "Error");
 pref("loop.debug.dispatcher", false);
 pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
+pref("loop.oauth.google.redirect_uri", "urn:ietf:wg:oauth:2.0:oob:auto");
+pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
 
 // serverURL to be assigned by services team
 pref("services.push.serverURL", "wss://push.services.mozilla.com/");
 
 pref("social.sidebar.unload_timeout_ms", 10000);
 
 pref("dom.identity.enabled", false);
 
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -689,17 +689,17 @@ var gPopupBlockerObserver = {
 
 function gKeywordURIFixup({ target: browser, data: fixupInfo }) {
   let deserializeURI = (spec) => spec ? makeURI(spec) : null;
 
   // We get called irrespective of whether we did a keyword search, or
   // whether the original input would be vaguely interpretable as a URL,
   // so figure that out first.
   let alternativeURI = deserializeURI(fixupInfo.fixedURI);
-  if (!fixupInfo.fixupUsedKeyword || !alternativeURI || !alternativeURI.host) {
+  if (!fixupInfo.keywordProviderName  || !alternativeURI || !alternativeURI.host) {
     return;
   }
 
   // At this point we're still only just about to load this URI.
   // When the async DNS lookup comes back, we may be in any of these states:
   // 1) still on the previous URI, waiting for the preferredURI (keyword
   //    search) to respond;
   // 2) at the keyword search URI (preferredURI)
@@ -2395,41 +2395,44 @@ let BrowserOnClick = {
     else if (ownerDoc.documentURI.startsWith("about:tabcrashed")) {
       this.onAboutTabCrashed(event, ownerDoc);
     }
   },
 
   receiveMessage: function (msg) {
     switch (msg.name) {
       case "Browser:CertExceptionError":
-        this.onAboutCertError(msg.target, msg.json.elementId,
-                              msg.json.isTopFrame, msg.json.location,
-                              msg.objects.failedChannel);
+        this.onAboutCertError(msg.target, msg.data.elementId,
+                              msg.data.isTopFrame, msg.data.location,
+                              msg.data.sslStatusAsString);
       break;
       case "Browser:SiteBlockedError":
-        this.onAboutBlocked(msg.json.elementId, msg.json.isMalware,
-                            msg.json.isTopFrame, msg.json.location);
+        this.onAboutBlocked(msg.data.elementId, msg.data.isMalware,
+                            msg.data.isTopFrame, msg.data.location);
       break;
       case "Browser:NetworkError":
         // Reset network state, the error page will refresh on its own.
         Services.io.offline = false;
       break;
     }
   },
 
-  onAboutCertError: function (browser, elementId, isTopFrame, location, failedChannel) {
+  onAboutCertError: function (browser, elementId, isTopFrame, location, sslStatusAsString) {
     let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
 
     switch (elementId) {
       case "exceptionDialogButton":
         if (isTopFrame) {
           secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_BAD_CERT_TOP_CLICK_ADD_EXCEPTION);
         }
-        let sslStatus = failedChannel.securityInfo.QueryInterface(Ci.nsISSLStatusProvider)
-                                                  .SSLStatus;
+
+        let serhelper = Cc["@mozilla.org/network/serialization-helper;1"]
+                           .getService(Ci.nsISerializationHelper);
+        let sslStatus = serhelper.deserializeObject(sslStatusAsString);
+        sslStatus.QueryInterface(Components.interfaces.nsISSLStatus);
         let params = { exceptionAdded : false,
                        sslStatus : sslStatus };
 
         try {
           switch (Services.prefs.getIntPref("browser.ssl_override_behavior")) {
             case 2 : // Pre-fetch & pre-populate
               params.prefetchCert = true;
             case 1 : // Pre-populate
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -419,22 +419,33 @@ let ClickEventHandler = {
       sendAsyncMessage("Content:Click", json);
     }
   },
 
   onAboutCertError: function (targetElement, ownerDoc) {
     let docshell = ownerDoc.defaultView.QueryInterface(Ci.nsIInterfaceRequestor)
                                        .getInterface(Ci.nsIWebNavigation)
                                        .QueryInterface(Ci.nsIDocShell);
+    let serhelper = Cc["@mozilla.org/network/serialization-helper;1"]
+                     .getService(Ci.nsISerializationHelper);
+    let serializedSSLStatus = "";
+
+    try {
+      let serializable =  docShell.failedChannel.securityInfo
+                                  .QueryInterface(Ci.nsISSLStatusProvider)
+                                  .SSLStatus
+                                  .QueryInterface(Ci.nsISerializable);
+      serializedSSLStatus = serhelper.serializeToString(serializable);
+    } catch (e) { }
+
     sendAsyncMessage("Browser:CertExceptionError", {
       location: ownerDoc.location.href,
       elementId: targetElement.getAttribute("id"),
       isTopFrame: (ownerDoc.defaultView.parent === ownerDoc.defaultView),
-    }, {
-      failedChannel: docshell.failedChannel
+      sslStatusAsString: serializedSSLStatus
     });
   },
 
   onAboutBlocked: function (targetElement, ownerDoc) {
     sendAsyncMessage("Browser:SiteBlockedError", {
       location: ownerDoc.location.href,
       isMalware: /e=malwareBlocked/.test(ownerDoc.documentURI),
       elementId: targetElement.getAttribute("id"),
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -99,25 +99,25 @@ support-files =
   content_aboutAccounts.js
 [browser_aboutSupport_newtab_security_state.js]
 [browser_aboutHealthReport.js]
 skip-if = os == "linux" # Bug 924307
 [browser_aboutHome.js]
 skip-if = e10s # Bug ?????? - no about:home support yet
 [browser_aboutSyncProgress.js]
 [browser_action_keyword.js]
-skip-if = os == "linux" # Bug 1073339 - Investigate autocomplete test unreliability on Linux 
+skip-if = os == "linux" || e10s # Bug 1073339 - Investigate autocomplete test unreliability on Linux/e10s
 [browser_action_searchengine.js]
-skip-if = os == "linux" # Bug 1073339 - Investigate autocomplete test unreliability on Linux 
+skip-if = os == "linux" || e10s # Bug 1073339 - Investigate autocomplete test unreliability on Linux/e10s
 [browser_action_searchengine_alias.js]
-skip-if = os == "linux" # Bug 1073339 - Investigate autocomplete test unreliability on Linux 
+skip-if = os == "linux" || e10s # Bug 1073339 - Investigate autocomplete test unreliability on Linux/e10s
 [browser_addKeywordSearch.js]
 skip-if = e10s
 [browser_search_favicon.js]
-skip-if = os == "linux" # Bug 1073339 - Investigate autocomplete test unreliability on Linux
+skip-if = os == "linux" || e10s # Bug 1073339 - Investigate autocomplete test unreliability on Linux/e10s
 [browser_alltabslistener.js]
 skip-if = os == "linux" || e10s # Linux: Intermittent failures, bug 951680; e10s: Bug ?????? - notifications don't work correctly.
 [browser_autocomplete_a11y_label.js]
 skip-if = e10s # Bug ????? - no e10s switch-to-tab support yet
 [browser_backButtonFitts.js]
 skip-if = os != "win" || e10s # The Fitts Law back button is only supported on Windows (bug 571454) / e10s - Bug ?????? test touches content (attempts to add an event listener directly to the contentWindow)
 [browser_blob-channelname.js]
 [browser_bookmark_titles.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/GoogleImporter.jsm
@@ -0,0 +1,541 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Log",
+                                  "resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["GoogleImporter"];
+
+let log = Log.repository.getLogger("Loop.Importer.Google");
+log.level = Log.Level.Debug;
+log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+
+/**
+ * Helper function that reads and maps the respective node value from specific
+ * XML DOMNodes to fields on a `target` object.
+ * Example: the value for field 'fullName' can be read from the XML DOMNode
+ *          'name', so that's the mapping we need to make; get the nodeValue of
+ *          the node called 'name' and tack it to the target objects' 'fullName'
+ *          property.
+ *
+ * @param {Map}        fieldMap    Map object containing the field name -> node
+ *                                 name mapping
+ * @param {XMLDOMNode} node        DOM node to fetch the values from for each field
+ * @param {String}     ns XML      namespace for the DOM nodes to retrieve. Optional.
+ * @param {Object}     target      Object to store the values found. Optional.
+ *                                 Defaults to a new object.
+ * @param {Boolean}    wrapInArray Indicates whether to map the field values in
+ *                                 an Array. Optional. Defaults to `false`.
+ * @returns The `target` object with the node values mapped to the appropriate fields.
+ */
+const extractFieldsFromNode = function(fieldMap, node, ns = null, target = {}, wrapInArray = false) {
+  for (let [field, nodeName] of fieldMap) {
+    let nodeList = ns ? node.getElementsByTagNameNS(ns, nodeName) :
+                        node.getElementsByTagName(nodeName);
+    if (nodeList.length) {
+      if (!nodeList[0].firstChild) {
+        continue;
+      }
+      let value = nodeList[0].firstChild.nodeValue;
+      target[field] = wrapInArray ? [value] : value;
+    }
+  }
+  return target;
+};
+
+/**
+ * Helper function that reads the type of (email-)address or phone number from an
+ * XMLDOMNode.
+ *
+ * @param {XMLDOMNode} node
+ * @returns String that depicts the type of field value.
+ */
+const getFieldType = function(node) {
+  if (node.hasAttribute("rel")) {
+    let rel = node.getAttribute("rel");
+    // The 'rel' attribute is formatted like: http://schemas.google.com/g/2005#work.
+    return rel.substr(rel.lastIndexOf("#") + 1);
+  }
+  if (node.hasAttribute("label")) {
+    return node.getAttribute("label");
+  }
+  return "other";
+};
+
+/**
+ * Fetch the preferred entry of a contact. Returns the first entry when no
+ * preferred flag is set.
+ *
+ * @param {Object} contact The contact object to check for preferred entries
+ * @param {String} which   Type of entry to check. Optional, defaults to 'email'
+ * @throws An Error when no (preferred) entries are listed for this contact.
+ */
+const getPreferred = function(contact, which = "email") {
+  if (!(which in contact) || !contact[which].length) {
+    throw new Error("No " + which + " entry available.");
+  }
+  let preferred = contact[which][0];
+  contact[which].some(function(entry) {
+    if (entry.pref) {
+      preferred = entry;
+      return true;
+    }
+    return false;
+  });
+  return preferred;
+};
+
+/**
+ * Fetch an auth token (clientID or client secret), which may be overridden by
+ * a pref if it's set.
+ *
+ * @param {String}  paramValue Initial, default, value of the parameter
+ * @param {String}  prefName   Fully qualified name of the pref to check for
+ * @param {Boolean} encode     Whether to URLEncode the param string
+ */
+const getUrlParam = function(paramValue, prefName, encode = true) {
+  if (Services.prefs.getPrefType(prefName))
+    paramValue = Services.prefs.getCharPref(prefName);
+  paramValue = Services.urlFormatter.formatURL(paramValue);
+
+  return encode ? encodeURIComponent(paramValue) : paramValue;
+};
+
+let gAuthWindow, gProfileId;
+const kAuthWindowSize = {
+  width: 420,
+  height: 460
+};
+const kContactsMaxResults = 10000000;
+const kContactsChunkSize = 100;
+const kTitlebarPollTimeout = 200;
+const kNS_GD = "http://schemas.google.com/g/2005";
+
+/**
+ * GoogleImporter class.
+ *
+ * Main entrypoint is the `startImport` method which calls several tasks necessary
+ * to import contacts from Google.
+ * Authentication is performed using an OAuth strategy which is loaded in a popup
+ * window.
+ */
+this.GoogleImporter = function() {};
+
+this.GoogleImporter.prototype = {
+  /**
+   * Start the import process of contacts from the Google service, using its Contacts
+   * API - https://developers.google.com/google-apps/contacts/v3/.
+   * The import consists of four tasks:
+   * 1. Get the authentication code which can be used to retrieve an OAuth token
+   *    pair. This is the bulk of the authentication flow that will be handled in
+   *    a popup window by Google. The user will need to login to the Google service
+   *    with his or her account and grant permission to our app to manage their
+   *    contacts.
+   * 2. Get the tokenset from the Google service, using the authentication code
+   *    that was retrieved in task 1.
+   * 3. Fetch all the contacts from the Google service, using the OAuth tokenset
+   *    that was retrieved in task 2.
+   * 4. Process the contacts, map them to the MozContact format and store each
+   *    contact in the database, if it doesn't exist yet.
+   *
+   * @param {Object}       options   Options to control the behavior of the import.
+   *                                 Not used by this importer class.
+   * @param {Function}     callback  Function to invoke when the import process
+   *                                 is done or when an error occurs that halts
+   *                                 the import process. The first argument passed
+   *                                 in an Error object or `null` and the second
+   *                                 argument is an object with import statistics.
+   * @param {LoopContacts} db        Instance of the LoopContacts database object,
+   *                                 which will store the newly found contacts
+   * @param {nsIDomWindow} windowRef Reference to the ChromeWindow the import is
+   *                                 invoked from. It will be used to be able to
+   *                                 open a window for the OAuth process with chrome
+   *                                 privileges.
+   */
+  startImport: function(options, callback, db, windowRef) {
+    Task.spawn(function* () {
+      let code = yield this._promiseAuthCode(windowRef);
+      let tokenSet = yield this._promiseTokenSet(code);
+      let contactEntries = yield this._promiseContactEntries(tokenSet);
+      let {total, success, ids} = yield this._processContacts(contactEntries, db);
+      yield this._purgeContacts(ids, db);
+
+      return {
+        total: total,
+        success: success
+      };
+    }.bind(this)).then(stats => callback(null, stats),
+                       error => callback(error))
+                 .then(null, ex => log.error(ex.fileName + ":" + ex.lineNumber + ": " + ex.message));
+  },
+
+  /**
+   * Task that yields an authentication code that is returned after the user signs
+   * in to the Google service. This code can be used by this class to retrieve an
+   * OAuth tokenset.
+   *
+   * @param {nsIDOMWindow} windowRef Reference to the ChromeWindow the import is
+   *                                 invoked from. It will be used to be able to
+   *                                 open a window for the OAuth process with chrome
+   *                                 privileges.
+   * @throws An `Error` object when authentication fails, or the authentication
+   *         code as a String.
+   */
+  _promiseAuthCode: Task.async(function* (windowRef) {
+    // Close a window that got lost in a previous login attempt.
+    if (gAuthWindow && !gAuthWindow.closed) {
+      gAuthWindow.close();
+      gAuthWindow = null;
+    }
+
+    let url = getUrlParam("https://accounts.google.com/o/oauth2/",
+                          "loop.oauth.google.URL", false) +
+              "auth?response_type=code&client_id=" +
+              getUrlParam("%GOOGLE_OAUTH_API_CLIENTID%", "loop.oauth.google.clientIdOverride");
+    for (let param of ["redirect_uri", "scope"]) {
+      url += "&" + param + "=" + encodeURIComponent(
+             Services.prefs.getCharPref("loop.oauth.google." + param));
+    }
+    const features = "centerscreen,resizable=yes,toolbar=no,menubar=no,status=no,directories=no," +
+                     "width=" + kAuthWindowSize.width + ",height=" + kAuthWindowSize.height;
+    gAuthWindow = windowRef.openDialog(windowRef.getBrowserURL(), "_blank", features, url);
+    gAuthWindow.focus();
+
+    let code;
+    // The following loops runs as long as the OAuth windows' titlebar doesn't
+    // yield a response from the Google service. If an error occurs, the loop
+    // will terminate early.
+    while (!code) {
+      if (!gAuthWindow || gAuthWindow.closed) {
+        throw new Error("Popup window was closed before authentication succeeded");
+      }
+
+      let matches = gAuthWindow.document.title.match(/(error|code)=(.*)$/);
+      if (matches && matches.length) {
+        let [, type, message] = matches;
+        gAuthWindow.close();
+        gAuthWindow = null;
+        if (type == "error") {
+          throw new Error("Google authentication failed with error: " + message.trim());
+        } else if (type == "code") {
+          code = message.trim();
+        } else {
+          throw new Error("Unknown response from Google");
+        }
+      } else {
+        yield new Promise(resolve => setTimeout(resolve, kTitlebarPollTimeout));
+      }
+    }
+
+    return code;
+  }),
+
+  /**
+   * Fetch an OAuth tokenset, that will be used to authenticate Google API calls,
+   * using the authentication token retrieved in `_promiseAuthCode`.
+   *
+   * @param {String} code The authentication code.
+   * @returns an `Error` object upon failure or an object containing OAuth tokens.
+   */
+  _promiseTokenSet: function(code) {
+    return new Promise(function(resolve, reject) {
+      let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+                      .createInstance(Ci.nsIXMLHttpRequest);
+
+      request.open("POST", getUrlParam("https://accounts.google.com/o/oauth2/",
+                                       "loop.oauth.google.URL",
+                                       false) + "token");
+
+      request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+
+      request.onload = function() {
+        if (request.status < 400) {
+          let tokenSet = JSON.parse(request.responseText);
+          tokenSet.date = Date.now();
+          resolve(tokenSet);
+        } else {
+          reject(new Error(request.status + " " + request.statusText));
+        }
+      };
+
+      request.onerror = function(error) {
+        reject(error);
+      };
+
+      let body = "grant_type=authorization_code&code=" + encodeURIComponent(code) +
+                 "&client_id=" + getUrlParam("%GOOGLE_OAUTH_API_CLIENTID%",
+                                             "loop.oauth.google.clientIdOverride") +
+                 "&client_secret=" + getUrlParam("%GOOGLE_OAUTH_API_KEY%",
+                                                 "loop.oauth.google.clientSecretOverride") +
+                 "&redirect_uri=" + encodeURIComponent(Services.prefs.getCharPref(
+                                                       "loop.oauth.google.redirect_uri"));
+
+      request.send(body);
+    });
+  },
+
+  /**
+   * Fetches all the contacts in a users' address book.
+   *
+   * @see https://developers.google.com/google-apps/contacts/v3/#retrieving_all_contacts
+   *
+   * @param {Object} tokenSet OAuth tokenset used to authenticate the request
+   * @returns An `Error` object upon failure or an Array of contact XML nodes.
+   */
+  _promiseContactEntries: function(tokenSet) {
+    return new Promise(function(resolve, reject) {
+      let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+                      .createInstance(Ci.nsIXMLHttpRequest);
+
+      request.open("GET", getUrlParam("https://www.google.com/m8/feeds/contacts/default/full",
+                                      "loop.oauth.google.getContactsURL",
+                                      false) + "?max-results=" + kContactsMaxResults);
+
+      request.setRequestHeader("Content-Type", "application/xml; charset=utf-8");
+      request.setRequestHeader("GData-Version", "3.0");
+      request.setRequestHeader("Authorization", "Bearer " + tokenSet.access_token);
+
+      request.onload = function() {
+        if (request.status < 400) {
+          let doc = request.responseXML;
+          // First get the profile id.
+          let currNode = doc.documentElement.firstChild;
+          while (currNode) {
+            if (currNode.nodeType == 1 && currNode.localName == "id") {
+              gProfileId = currNode.firstChild.nodeValue;
+              break;
+            }
+            currNode = currNode.nextSibling;
+          }
+
+          // Then kick of the importing of contact entries.
+          let entries = Array.prototype.slice.call(doc.querySelectorAll("entry"));
+          resolve(entries);
+        } else {
+          reject(new Error(request.status + " " + request.statusText));
+        }
+      };
+
+      request.onerror = function(error) {
+        reject(error);
+      }
+
+      request.send();
+    });
+  },
+
+  /**
+   * Process the contact XML nodes that Google provides, convert them to the MozContact
+   * format, check if the contact already exists in the database and when it doesn't,
+   * store it permanently.
+   * During this process statistics are collected about the amount of successful
+   * imports. The consumer of this class may use these statistics to inform the
+   * user.
+   *
+   * @param {Array}        contactEntries List of XML DOMNodes contact entries.
+   * @param {LoopContacts} db             Instance of the LoopContacts database
+   *                                      object, which will store the newly found
+   *                                      contacts.
+   * @returns An `Error` object upon failure or an Object with statistics in the
+   *          following format: `{ total: 25, success: 13, ids: {} }`.
+   */
+  _processContacts: Task.async(function* (contactEntries, db) {
+    let stats = {
+      total: contactEntries.length,
+      success: 0,
+      ids: {}
+    };
+
+    for (let entry of contactEntries) {
+      let contact = this._processContactFields(entry);
+
+      stats.ids[contact.id] = 1;
+      let existing = yield db.promise("getByServiceId", contact.id);
+      if (existing) {
+        yield db.promise("remove", existing._guid);
+      }
+
+      // If the contact contains neither email nor phone number, then it is not
+      // useful in the Loop address book: do not add.
+      if (!("email" in contact) && !("tel" in contact)) {
+        continue;
+      }
+
+      yield db.promise("add", contact);
+      stats.success++;
+    }
+
+    return stats;
+  }),
+
+  /**
+   * Parse an XML node to map the appropriate data to MozContact field equivalents.
+   *
+   * @param {XMLDOMNode} entry The contact XML node in Google format to process.
+   * @returns `null` if the contact entry appears to be invalid or an Object containing
+   *          all the contact data found in the XML.
+   */
+  _processContactFields: function(entry) {
+    // Basic fields in the main 'atom' namespace.
+    let contact = extractFieldsFromNode(new Map([
+      ["id", "id"],
+      // published: n/a
+      ["updated", "updated"]
+      // bday: n/a
+    ]), entry);
+
+    // Fields that need to wrapped in an Array.
+    extractFieldsFromNode(new Map([
+      ["name", "fullName"],
+      ["givenName", "givenName"],
+      ["familyName", "familyName"],
+      ["additionalName", "additionalName"]
+    ]), entry, kNS_GD, contact, true);
+
+    // The 'note' field needs to wrapped in an array, but its source node is not
+    // namespaced.
+    extractFieldsFromNode(new Map([
+      ["note", "content"]
+    ]), entry, null, contact, true);
+
+    // Process physical, earthly addresses.
+    let addressNodes = entry.getElementsByTagNameNS(kNS_GD, "structuredPostalAddress");
+    if (addressNodes.length) {
+      contact.adr = [];
+      for (let [,addressNode] of Iterator(addressNodes)) {
+        let adr = extractFieldsFromNode(new Map([
+          ["countryName", "country"],
+          ["locality", "city"],
+          ["postalCode", "postcode"],
+          ["region", "region"],
+          ["streetAddress", "street"]
+        ]), addressNode, kNS_GD);
+        if (Object.keys(adr).length) {
+          adr.pref = (addressNode.getAttribute("primary") == "true");
+          adr.type = [getFieldType(addressNode)];
+          contacts.adr.push(adr);
+        }
+      }
+    }
+
+    // Process email addresses.
+    let emailNodes = entry.getElementsByTagNameNS(kNS_GD, "email");
+    if (emailNodes.length) {
+      contact.email = [];
+      for (let [,emailNode] of Iterator(emailNodes)) {
+        contact.email.push({
+          pref: (emailNode.getAttribute("primary") == "true"),
+          type: [getFieldType(emailNode)],
+          value: emailNode.getAttribute("address")
+        });
+      }
+    }
+
+    // Process telephone numbers.
+    let phoneNodes = entry.getElementsByTagNameNS(kNS_GD, "phoneNumber");
+    if (phoneNodes.length) {
+      contact.tel = [];
+      for (let [,phoneNode] of Iterator(phoneNodes)) {
+        contact.tel.push({
+          pref: (phoneNode.getAttribute("primary") == "true"),
+          type: [getFieldType(phoneNode)],
+          value: phoneNode.firstChild.nodeValue
+        });
+      }
+    }
+
+    let orgNodes = entry.getElementsByTagNameNS(kNS_GD, "organization");
+    if (orgNodes.length) {
+      contact.org = [];
+      contact.jobTitle = [];
+      for (let [,orgNode] of Iterator(orgNodes)) {
+        contact.org.push(orgNode.getElementsByTagNameNS(kNS_GD, "orgName")[0].firstChild.nodeValue);
+        contact.jobTitle.push(orgNode.getElementsByTagNameNS(kNS_GD, "orgTitle")[0].firstChild.nodeValue);
+      }
+    }
+
+    contact.category = ["google"];
+
+    // Basic sanity checking: make sure the name field isn't empty
+    if (!("name" in contact) || contact.name[0].length == 0) {
+      if (("familyName" in contact) && ("givenName" in contact)) {
+        // First, try to synthesize a full name from the name fields.
+        // Ordering is culturally sensitive, but we don't have
+        // cultural origin information available here. The best we
+        // can really do is "family, given additional"
+        contact.name = [contact.familyName[0] + ", " + contact.givenName[0]];
+        if (("additionalName" in contact)) {
+          contact.name[0] += " " + contact.additionalName[0];
+        }
+      } else {
+        let profileTitle = extractFieldsFromNode(new Map([["title", "title"]]), entry);
+        if (("title" in profileTitle)) {
+          contact.name = [profileTitle.title];
+        } else if ("familyName" in contact) {
+          contact.name = [contact.familyName[0]];
+        } else if ("givenName" in contact) {
+          contact.name = [contact.givenName[0]];
+        } else if ("org" in contact) {
+          contact.name = [contact.org[0]];
+        } else {
+          let email;
+          try {
+            email = getPreferred(contact);
+          } catch (ex) {}
+          if (email) {
+            contact.name = [email.value];
+          } else {
+            let tel;
+            try {
+              tel = getPreferred(contact, "phone");
+            } catch (ex) {}
+            if (tel) {
+              contact.name = [tel.value];
+            }
+          }
+        }
+      }
+    }
+
+    return contact;
+  },
+
+  /**
+   * Remove all contacts from the database that are not present anymore in the
+   * remote data-source.
+   *
+   * @param {Object}       ids Map of IDs collected earlier of all the contacts
+   *                           that are available on the remote data-source
+   * @param {LoopContacts} db  Instance of the LoopContacts database object, which
+   *                           will store the newly found contacts
+   */
+  _purgeContacts: Task.async(function* (ids, db) {
+    let contacts = yield db.promise("getAll");
+    let profileId = "https://www.google.com/m8/feeds/contacts/" + encodeURIComponent(gProfileId);
+    let processed = 0;
+
+    for (let [guid, contact] of Iterator(contacts)) {
+      if (++processed % kContactsChunkSize === 0) {
+        // Skip a beat every time we processed a chunk.
+        yield new Promise(resolve => Services.tm.currentThread.dispatch(resolve,
+                                       Ci.nsIThread.DISPATCH_NORMAL));
+      }
+
+      if (contact.id.indexOf(profileId) >= 0 && !ids[contact.id]) {
+        yield db.promise("remove", guid);
+      }
+    }
+  })
+};
--- a/browser/components/loop/LoopContacts.jsm
+++ b/browser/components/loop/LoopContacts.jsm
@@ -5,18 +5,22 @@
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "console",
                                   "resource://gre/modules/devtools/Console.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                   "resource:///modules/loop/LoopStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/Promise.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CardDavImporter",
                                   "resource:///modules/loop/CardDavImporter.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "GoogleImporter",
+                                  "resource:///modules/loop/GoogleImporter.jsm");
 XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
   const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
   return new EventEmitter();
 });
 
 this.EXPORTED_SYMBOLS = ["LoopContacts"];
 
 const kObjectStoreName = "contacts";
@@ -319,17 +323,18 @@ LoopStorage.on("upgrade", function(e, db
  * callback Function. MozLoopAPI will cause things to break if this invariant is
  * violated. You'll notice this as well in the documentation for each method.
  */
 let LoopContactsInternal = Object.freeze({
   /**
    * Map of contact importer names to instances
    */
   _importServices: {
-    "carddav": new CardDavImporter()
+    "carddav": new CardDavImporter(),
+    "google": new GoogleImporter()
   },
 
   /**
    * Add a contact to the data store.
    *
    * @param {Object}   details  An object that will be added to the data store
    *                            as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
    *                            for more information of this objects' structure
@@ -765,26 +770,27 @@ let LoopContactsInternal = Object.freeze
    * Import a list of (new) contacts from an external data source.
    *
    * @param {Object}   options  Property bag of options for the importer
    * @param {Function} callback Function that will be invoked once the operation
    *                            finished. The first argument passed will be an
    *                            `Error` object or `null`. The second argument will
    *                            be the result of the operation, if successfull.
    */
-  startImport: function(options, callback) {
+  startImport: function(options, windowRef, callback) {
     if (!("service" in options)) {
       callback(new Error("No import service specified in options"));
       return;
     }
     if (!(options.service in this._importServices)) {
       callback(new Error("Unknown import service specified: " + options.service));
       return;
     }
-    this._importServices[options.service].startImport(options, callback, this);
+    this._importServices[options.service].startImport(options, callback,
+                                                      LoopContacts, windowRef);
   },
 
   /**
    * Search through the data store for contacts that match a certain (sub-)string.
    *
    * @param {String}   query    Needle to search for in our haystack of contacts
    * @param {Function} callback Function that will be invoked once the operation
    *                            finished. The first argument passed will be an
@@ -853,22 +859,34 @@ this.LoopContacts = Object.freeze({
   block: function(guid, callback) {
     return LoopContactsInternal.block(guid, callback);
   },
 
   unblock: function(guid, callback) {
     return LoopContactsInternal.unblock(guid, callback);
   },
 
-  startImport: function(options, callback) {
-    return LoopContactsInternal.startImport(options, callback);
+  startImport: function(options, windowRef, callback) {
+    return LoopContactsInternal.startImport(options, windowRef, callback);
   },
 
   search: function(query, callback) {
     return LoopContactsInternal.search(query, callback);
   },
 
+  promise: function(method, ...params) {
+    return new Promise((resolve, reject) => {
+      this[method](...params, (error, result) => {
+        if (error) {
+          reject(error);
+        } else {
+          resolve(result);
+        }
+      });
+    });
+  },
+
   on: (...params) => eventEmitter.on(...params),
 
   once: (...params) => eventEmitter.once(...params),
 
   off: (...params) => eventEmitter.off(...params)
 });
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -93,25 +93,16 @@ const injectObjectAPI = function(api, ta
   // the `contentObj` without Xrays.
   try {
     Object.seal(Cu.waiveXrays(contentObj));
   } catch (ex) {}
   return contentObj;
 };
 
 /**
- * Get the two-digit hexadecimal code for a byte
- *
- * @param {byte} charCode
- */
-const toHexString = function(charCode) {
-  return ("0" + charCode.toString(16)).slice(-2);
-};
-
-/**
  * Inject the loop API into the given window.  The caller must be sure the
  * window is a loop content window (eg, a panel, chatwindow, or similar).
  *
  * See the documentation on the individual functions for details of the API.
  *
  * @param {nsIDOMWindow} targetWindow The content window to attach the API.
  */
 function injectLoopAPI(targetWindow) {
@@ -208,16 +199,35 @@ function injectLoopAPI(targetWindow) {
         if (contactsAPI) {
           return contactsAPI;
         }
         return contactsAPI = injectObjectAPI(LoopContacts, targetWindow);
       }
     },
 
     /**
+     * Import a list of (new) contacts from an external data source.
+     *
+     * @param {Object}   options  Property bag of options for the importer
+     * @param {Function} callback Function that will be invoked once the operation
+     *                            finished. The first argument passed will be an
+     *                            `Error` object or `null`. The second argument will
+     *                            be the result of the operation, if successfull.
+     */
+    startImport: {
+      enumerable: true,
+      writable: true,
+      value: function(options, callback) {
+        LoopContacts.startImport(options, getChromeWindow(targetWindow), function(...results) {
+          callback(...[cloneValueInto(r, targetWindow) for (r of results)]);
+        });
+      }
+    },
+
+    /**
      * Returns translated strings associated with an element. Designed
      * for use with l10n.js
      *
      * @param {String} key The element id
      * @returns {Object} A JSON string containing the localized
      *                   attribute/value pairs for the element.
      */
     getStrings: {
@@ -548,52 +558,16 @@ function injectLoopAPI(targetWindow) {
      */
     generateUUID: {
       enumerable: true,
       writable: true,
       value: function() {
         return MozLoopService.generateUUID();
       }
     },
-
-    /**
-     * Compose a URL pointing to the location of an avatar by email address.
-     * At the moment we use the Gravatar service to match email addresses with
-     * avatars. This might change in the future as avatars might come from another
-     * source.
-     *
-     * @param {String} emailAddress Users' email address
-     * @param {Number} size         Size of the avatar image to return in pixels.
-     *                              Optional. Default value: 40.
-     * @return the URL pointing to an avatar matching the provided email address.
-     */
-    getUserAvatar: {
-      enumerable: true,
-      writable: true,
-      value: function(emailAddress, size = 40) {
-        if (!emailAddress) {
-          return "";
-        }
-
-        // Do the MD5 dance.
-        let hasher = Cc["@mozilla.org/security/hash;1"]
-                       .createInstance(Ci.nsICryptoHash);
-        hasher.init(Ci.nsICryptoHash.MD5);
-        let stringStream = Cc["@mozilla.org/io/string-input-stream;1"]
-                             .createInstance(Ci.nsIStringInputStream);
-        stringStream.data = emailAddress.trim().toLowerCase();
-        hasher.updateFromStream(stringStream, -1);
-        let hash = hasher.finish(false);
-        // Convert the binary hash data to a hex string.
-        let md5Email = [toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
-
-        // Compose the Gravatar URL.
-        return "http://www.gravatar.com/avatar/" + md5Email + ".jpg?default=blank&s=" + size;
-      }
-    },
   };
 
   function onStatusChanged(aSubject, aTopic, aData) {
     let event = new targetWindow.CustomEvent("LoopStatusChanged");
     targetWindow.dispatchEvent(event)
   };
 
   function onDOMWindowDestroyed(aSubject, aTopic, aData) {
--- a/browser/components/loop/MozLoopPushHandler.jsm
+++ b/browser/components/loop/MozLoopPushHandler.jsm
@@ -162,35 +162,35 @@ let MozLoopPushHandler = {
    * Handles the PushServer registration response.
    *
    * @param {} msg PushServer to UserAgent registration response (parsed from JSON).
    */
   _onRegister: function(msg) {
     switch (msg.status) {
       case 200:
         this._retryEnd(); // reset retry mechanism
-	this.registered = true;
+        this.registered = true;
         if (this.pushUrl !== msg.pushEndpoint) {
           this.pushUrl = msg.pushEndpoint;
           this._registerCallback(null, this.pushUrl);
         }
         break;
 
       case 500:
         // retry the registration request after a suitable delay
         this._retryOperation(() => this._registerChannel());
         break;
 
       case 409:
         this._registerCallback("error: PushServer ChannelID already in use");
-	break;
+        break;
 
       default:
         this._registerCallback("error: PushServer registration failure, status = " + msg.status);
-	break;
+        break;
     }
   },
 
   /**
    * Attempts to open a websocket.
    *
    * A new websocket interface is used each time. If an onStop callback
    * was received, calling asyncOpen() on the same interface will
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -126,16 +126,26 @@ loop.contacts = (function(_, mozL10n) {
         this.setState({showMenu: false});
       }
     },
 
     componentWillUnmount: function() {
       document.body.removeEventListener("click", this._onBodyClick);
     },
 
+    componentShouldUpdate: function(nextProps, nextState) {
+      let currContact = this.props.contact;
+      let nextContact = nextProps.contact;
+      return (
+        currContact.name[0] !== nextContact.name[0] ||
+        currContact.blocked !== nextContact.blocked ||
+        this.getPreferredEmail(currContact).value !== this.getPreferredEmail(nextContact).value
+      );
+    },
+
     handleAction: function(actionName) {
       if (this.props.handleContactAction) {
         this.props.handleContactAction(this.props.contact, actionName);
       }
     },
 
     getContactNames: function() {
       // The model currently does not enforce a name to be present, but we're
@@ -144,29 +154,30 @@ loop.contacts = (function(_, mozL10n) {
       // NOTE: this method of finding a firstname and lastname is not i18n-proof.
       let names = this.props.contact.name[0].split(" ");
       return {
         firstName: names.shift(),
         lastName: names.join(" ")
       };
     },
 
-    getPreferredEmail: function() {
-      // The model currently does not enforce a name to be present, but we're
-      // going to assume it is awaiting more advanced validation of required fields
-      // by the model. (See bug 1069918)
-      let email = this.props.contact.email[0];
-      this.props.contact.email.some(function(address) {
-        if (address.pref) {
-          email = address;
-          return true;
-        }
-        return false;
-      });
-      return email;
+    getPreferredEmail: function(contact = this.props.contact) {
+      let email;
+      // A contact may not contain email addresses, but only a phone number instead.
+      if (contact.email) {
+        email = contact.email[0];
+        contact.email.some(function(address) {
+          if (address.pref) {
+            email = address;
+            return true;
+          }
+          return false;
+        });
+      }
+      return email || { value: "" };
     },
 
     canEdit: function() {
       // We cannot modify imported contacts.  For the moment, the check for
       // determining whether the contact is imported is based on its category.
       return this.props.contact.category[0] != "google";
     },
 
@@ -176,19 +187,17 @@ loop.contacts = (function(_, mozL10n) {
       let cx = React.addons.classSet;
       let contactCSSClass = cx({
         contact: true,
         blocked: this.props.contact.blocked
       });
 
       return (
         React.DOM.li({className: contactCSSClass, onMouseLeave: this.hideDropdownMenu}, 
-          React.DOM.div({className: "avatar"}, 
-            React.DOM.img({src: navigator.mozLoop.getUserAvatar(email.value)})
-          ), 
+          React.DOM.div({className: "avatar"}), 
           React.DOM.div({className: "details"}, 
             React.DOM.div({className: "username"}, React.DOM.strong(null, names.firstName), " ", names.lastName, 
               React.DOM.i({className: cx({"icon icon-google": this.props.contact.category[0] == "google"})}), 
               React.DOM.i({className: cx({"icon icon-blocked": this.props.contact.blocked})})
             ), 
             React.DOM.div({className: "email"}, email.value)
           ), 
           React.DOM.div({className: "icons"}, 
@@ -206,37 +215,39 @@ loop.contacts = (function(_, mozL10n) {
         )
       );
     }
   });
 
   const ContactsList = React.createClass({displayName: 'ContactsList',
     getInitialState: function() {
       return {
-        contacts: {}
+        contacts: {},
+        importBusy: false
       };
     },
 
     componentDidMount: function() {
       let contactsAPI = navigator.mozLoop.contacts;
 
       contactsAPI.getAll((err, contacts) => {
         if (err) {
           throw err;
         }
 
         // Add contacts already present in the DB. We do this in timed chunks to
         // circumvent blocking the main event loop.
         let addContactsInChunks = () => {
           contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
-            this.handleContactAddOrUpdate(contact);
+            this.handleContactAddOrUpdate(contact, false);
           });
           if (contacts.length) {
             setTimeout(addContactsInChunks, 0);
           }
+          this.forceUpdate();
         };
 
         addContactsInChunks(contacts);
 
         // Listen for contact changes/ updates.
         contactsAPI.on("add", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
@@ -247,38 +258,50 @@ loop.contacts = (function(_, mozL10n) {
           this.handleContactRemoveAll();
         });
         contactsAPI.on("update", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
       });
     },
 
-    handleContactAddOrUpdate: function(contact) {
+    handleContactAddOrUpdate: function(contact, render = true) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       contacts[guid] = contact;
-      this.setState({});
+      if (render) {
+        this.forceUpdate();
+      }
     },
 
     handleContactRemove: function(contact) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       if (!contacts[guid]) {
         return;
       }
       delete contacts[guid];
-      this.setState({});
+      this.forceUpdate();
     },
 
     handleContactRemoveAll: function() {
       this.setState({contacts: {}});
     },
 
     handleImportButtonClick: function() {
+      this.setState({ importBusy: true });
+      navigator.mozLoop.startImport({
+        service: "google"
+      }, (err, stats) => {
+        this.setState({ importBusy: false });
+        // TODO: bug 1076764 - proper error and success reporting.
+        if (err) {
+          throw err;
+        }
+      });
     },
 
     handleAddContactButtonClick: function() {
       this.props.startForm("contacts_add");
     },
 
     handleContactAction: function(contact, actionName) {
       switch (actionName) {
@@ -316,22 +339,25 @@ loop.contacts = (function(_, mozL10n) {
         return ContactDetail({key: item._guid, contact: item, 
                               handleContactAction: this.handleContactAction})
       };
 
       let shownContacts = _.groupBy(this.state.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
 
+      // TODO: bug 1076767 - add a spinner whilst importing contacts.
       return (
         React.DOM.div(null, 
           React.DOM.div({className: "content-area"}, 
             ButtonGroup(null, 
-              Button({caption: mozL10n.get("import_contacts_button"), 
-                      disabled: true, 
+              Button({caption: this.state.importBusy
+                               ? mozL10n.get("importing_contacts_progress_button")
+                               : mozL10n.get("import_contacts_button"), 
+                      disabled: this.state.importBusy, 
                       onClick: this.handleImportButtonClick}), 
               Button({caption: mozL10n.get("new_contact_button"), 
                       onClick: this.handleAddContactButtonClick})
             )
           ), 
           React.DOM.ul({className: "contact-list"}, 
             shownContacts.available ?
               shownContacts.available.sort(this.sortContacts).map(viewForItem) :
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -126,16 +126,26 @@ loop.contacts = (function(_, mozL10n) {
         this.setState({showMenu: false});
       }
     },
 
     componentWillUnmount: function() {
       document.body.removeEventListener("click", this._onBodyClick);
     },
 
+    componentShouldUpdate: function(nextProps, nextState) {
+      let currContact = this.props.contact;
+      let nextContact = nextProps.contact;
+      return (
+        currContact.name[0] !== nextContact.name[0] ||
+        currContact.blocked !== nextContact.blocked ||
+        this.getPreferredEmail(currContact).value !== this.getPreferredEmail(nextContact).value
+      );
+    },
+
     handleAction: function(actionName) {
       if (this.props.handleContactAction) {
         this.props.handleContactAction(this.props.contact, actionName);
       }
     },
 
     getContactNames: function() {
       // The model currently does not enforce a name to be present, but we're
@@ -144,29 +154,30 @@ loop.contacts = (function(_, mozL10n) {
       // NOTE: this method of finding a firstname and lastname is not i18n-proof.
       let names = this.props.contact.name[0].split(" ");
       return {
         firstName: names.shift(),
         lastName: names.join(" ")
       };
     },
 
-    getPreferredEmail: function() {
-      // The model currently does not enforce a name to be present, but we're
-      // going to assume it is awaiting more advanced validation of required fields
-      // by the model. (See bug 1069918)
-      let email = this.props.contact.email[0];
-      this.props.contact.email.some(function(address) {
-        if (address.pref) {
-          email = address;
-          return true;
-        }
-        return false;
-      });
-      return email;
+    getPreferredEmail: function(contact = this.props.contact) {
+      let email;
+      // A contact may not contain email addresses, but only a phone number instead.
+      if (contact.email) {
+        email = contact.email[0];
+        contact.email.some(function(address) {
+          if (address.pref) {
+            email = address;
+            return true;
+          }
+          return false;
+        });
+      }
+      return email || { value: "" };
     },
 
     canEdit: function() {
       // We cannot modify imported contacts.  For the moment, the check for
       // determining whether the contact is imported is based on its category.
       return this.props.contact.category[0] != "google";
     },
 
@@ -176,19 +187,17 @@ loop.contacts = (function(_, mozL10n) {
       let cx = React.addons.classSet;
       let contactCSSClass = cx({
         contact: true,
         blocked: this.props.contact.blocked
       });
 
       return (
         <li className={contactCSSClass} onMouseLeave={this.hideDropdownMenu}>
-          <div className="avatar">
-            <img src={navigator.mozLoop.getUserAvatar(email.value)} />
-          </div>
+          <div className="avatar" />
           <div className="details">
             <div className="username"><strong>{names.firstName}</strong> {names.lastName}
               <i className={cx({"icon icon-google": this.props.contact.category[0] == "google"})} />
               <i className={cx({"icon icon-blocked": this.props.contact.blocked})} />
             </div>
             <div className="email">{email.value}</div>
           </div>
           <div className="icons">
@@ -206,37 +215,39 @@ loop.contacts = (function(_, mozL10n) {
         </li>
       );
     }
   });
 
   const ContactsList = React.createClass({
     getInitialState: function() {
       return {
-        contacts: {}
+        contacts: {},
+        importBusy: false
       };
     },
 
     componentDidMount: function() {
       let contactsAPI = navigator.mozLoop.contacts;
 
       contactsAPI.getAll((err, contacts) => {
         if (err) {
           throw err;
         }
 
         // Add contacts already present in the DB. We do this in timed chunks to
         // circumvent blocking the main event loop.
         let addContactsInChunks = () => {
           contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
-            this.handleContactAddOrUpdate(contact);
+            this.handleContactAddOrUpdate(contact, false);
           });
           if (contacts.length) {
             setTimeout(addContactsInChunks, 0);
           }
+          this.forceUpdate();
         };
 
         addContactsInChunks(contacts);
 
         // Listen for contact changes/ updates.
         contactsAPI.on("add", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
@@ -247,38 +258,50 @@ loop.contacts = (function(_, mozL10n) {
           this.handleContactRemoveAll();
         });
         contactsAPI.on("update", (eventName, contact) => {
           this.handleContactAddOrUpdate(contact);
         });
       });
     },
 
-    handleContactAddOrUpdate: function(contact) {
+    handleContactAddOrUpdate: function(contact, render = true) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       contacts[guid] = contact;
-      this.setState({});
+      if (render) {
+        this.forceUpdate();
+      }
     },
 
     handleContactRemove: function(contact) {
       let contacts = this.state.contacts;
       let guid = String(contact._guid);
       if (!contacts[guid]) {
         return;
       }
       delete contacts[guid];
-      this.setState({});
+      this.forceUpdate();
     },
 
     handleContactRemoveAll: function() {
       this.setState({contacts: {}});
     },
 
     handleImportButtonClick: function() {
+      this.setState({ importBusy: true });
+      navigator.mozLoop.startImport({
+        service: "google"
+      }, (err, stats) => {
+        this.setState({ importBusy: false });
+        // TODO: bug 1076764 - proper error and success reporting.
+        if (err) {
+          throw err;
+        }
+      });
     },
 
     handleAddContactButtonClick: function() {
       this.props.startForm("contacts_add");
     },
 
     handleContactAction: function(contact, actionName) {
       switch (actionName) {
@@ -316,22 +339,25 @@ loop.contacts = (function(_, mozL10n) {
         return <ContactDetail key={item._guid} contact={item}
                               handleContactAction={this.handleContactAction} />
       };
 
       let shownContacts = _.groupBy(this.state.contacts, function(contact) {
         return contact.blocked ? "blocked" : "available";
       });
 
+      // TODO: bug 1076767 - add a spinner whilst importing contacts.
       return (
         <div>
           <div className="content-area">
             <ButtonGroup>
-              <Button caption={mozL10n.get("import_contacts_button")}
-                      disabled
+              <Button caption={this.state.importBusy
+                               ? mozL10n.get("importing_contacts_progress_button")
+                               : mozL10n.get("import_contacts_button")}
+                      disabled={this.state.importBusy}
                       onClick={this.handleImportButtonClick} />
               <Button caption={mozL10n.get("new_contact_button")}
                       onClick={this.handleAddContactButtonClick} />
             </ButtonGroup>
           </div>
           <ul className="contact-list">
             {shownContacts.available ?
               shownContacts.available.sort(this.sortContacts).map(viewForItem) :
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -7,47 +7,35 @@
 /* jshint newcap:false, esnext:true */
 /* global loop:true, React */
 
 var loop = loop || {};
 loop.conversation = (function(mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
+  var sharedMixins = loop.shared.mixins;
   var sharedModels = loop.shared.models;
   var OutgoingConversationView = loop.conversationViews.OutgoingConversationView;
 
   var IncomingCallView = React.createClass({displayName: 'IncomingCallView',
+    mixins: [sharedMixins.DropdownMenuMixin],
 
     propTypes: {
       model: React.PropTypes.object.isRequired,
       video: React.PropTypes.bool.isRequired
     },
 
     getDefaultProps: function() {
       return {
-        showDeclineMenu: false,
+        showMenu: false,
         video: true
       };
     },
 
-    getInitialState: function() {
-      return {showDeclineMenu: this.props.showDeclineMenu};
-    },
-
-    componentDidMount: function() {
-      window.addEventListener("click", this.clickHandler);
-      window.addEventListener("blur", this._hideDeclineMenu);
-    },
-
-    componentWillUnmount: function() {
-      window.removeEventListener("click", this.clickHandler);
-      window.removeEventListener("blur", this._hideDeclineMenu);
-    },
-
     clickHandler: function(e) {
       var target = e.target;
       if (!target.classList.contains('btn-chevron')) {
         this._hideDeclineMenu();
       }
     },
 
     _handleAccept: function(callType) {
@@ -99,36 +87,34 @@ loop.conversation = (function(mozL10n) {
       return props;
     },
 
     render: function() {
       /* jshint ignore:start */
       var dropdownMenuClassesDecline = React.addons.classSet({
         "native-dropdown-menu": true,
         "conversation-window-dropdown": true,
-        "visually-hidden": !this.state.showDeclineMenu
+        "visually-hidden": !this.state.showMenu
       });
       return (
         React.DOM.div({className: "call-window"}, 
           React.DOM.h2(null, mozL10n.get("incoming_call_title2")), 
           React.DOM.div({className: "btn-group call-action-group"}, 
 
             React.DOM.div({className: "fx-embedded-call-button-spacer"}), 
 
             React.DOM.div({className: "btn-chevron-menu-group"}, 
               React.DOM.div({className: "btn-group-chevron"}, 
                 React.DOM.div({className: "btn-group"}, 
 
-                  React.DOM.button({className: "btn btn-error btn-decline", 
+                  React.DOM.button({className: "btn btn-decline", 
                           onClick: this._handleDecline}, 
                     mozL10n.get("incoming_call_cancel_button")
                   ), 
-                  React.DOM.div({className: "btn-chevron", 
-                       onClick: this._toggleDeclineMenu}
-                  )
+                  React.DOM.div({className: "btn-chevron", onClick: this.toggleDropdownMenu})
                 ), 
 
                 React.DOM.ul({className: dropdownMenuClassesDecline}, 
                   React.DOM.li({className: "btn-block", onClick: this._handleDeclineBlock}, 
                     mozL10n.get("incoming_call_cancel_and_block_button")
                   )
                 )
 
--- a/browser/components/loop/content/shared/css/contacts.css
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -44,16 +44,30 @@
   background: #eee;
 }
 
 .contact:hover > .icons {
   display: block;
   z-index: 1;
 }
 
+.contact > .details {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.contact:hover > .details {
+  /* Hovering the contact shows the icons/ buttons, which takes up horizontal
+   * space. This causes the fixed-size avatar to resize horizontally, so we assign
+   * a flex value equivalent to the maximum pixel value to avoid the resizing
+   * to happen. Consider this a hack. */
+  flex: 190;
+}
+
 .contact > .avatar {
   width: 40px;
   height: 40px;
   background: #ccc;
   border-radius: 50%;
   margin-right: 10px;
   overflow: hidden;
   box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
--- a/browser/components/loop/moz.build
+++ b/browser/components/loop/moz.build
@@ -9,15 +9,16 @@ JAR_MANIFESTS += ['jar.mn']
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
 
 BROWSER_CHROME_MANIFESTS += [
     'test/mochitest/browser.ini',
 ]
 
 EXTRA_JS_MODULES.loop += [
     'CardDavImporter.jsm',
+    'GoogleImporter.jsm',
     'LoopContacts.jsm',
     'LoopStorage.jsm',
     'MozLoopAPI.jsm',
     'MozLoopPushHandler.jsm',
     'MozLoopService.jsm',
     'MozLoopWorker.js',
 ]
--- a/browser/components/loop/test/mochitest/browser.ini
+++ b/browser/components/loop/test/mochitest/browser.ini
@@ -1,16 +1,21 @@
 [DEFAULT]
 support-files =
+    fixtures/google_auth.txt
+    fixtures/google_contacts.txt
+    fixtures/google_token.txt
+    google_service.sjs
     head.js
     loop_fxa.sjs
     ../../../../base/content/test/general/browser_fxa_oauth.html
 
 [browser_CardDavImporter.js]
 [browser_fxa_login.js]
+[browser_GoogleImporter.js]
 skip-if = e10s
 [browser_loop_fxa_server.js]
 [browser_LoopContacts.js]
 [browser_mozLoop_appVersionInfo.js]
 [browser_mozLoop_prefs.js]
 [browser_mozLoop_doNotDisturb.js]
 [browser_mozLoop_softStart.js]
 skip-if = buildapp == 'mulet'
--- a/browser/components/loop/test/mochitest/browser_CardDavImporter.js
+++ b/browser/components/loop/test/mochitest/browser_CardDavImporter.js
@@ -1,53 +1,13 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const {CardDavImporter} = Cu.import("resource:///modules/loop/CardDavImporter.jsm", {});
 
-const mockDb = {
-  _store: { },
-  _next_guid: 1,
-
-  add: function(details, callback) {
-    if (!("id" in details)) {
-      callback(new Error("No 'id' field present"));
-      return;
-    }
-    details._guid = this._next_guid++;
-    this._store[details._guid] = details;
-    callback(null, details);
-  },
-  remove: function(guid, callback) {
-    if (!guid in this._store) {
-      callback(new Error("Could not find _guid '" + guid + "' in database"));
-      return;
-    }
-    delete this._store[guid];
-    callback(null);
-  },
-  get: function(guid, callback) {
-    callback(null, this._store[guid]);
-  },
-  getByServiceId: function(serviceId, callback) {
-    for (let guid in this._store) {
-      if (serviceId === this._store[guid].id) {
-        callback(null, this._store[guid]);
-        return;
-      }
-    }
-    callback(null, null);
-  },
-  removeAll: function(callback) {
-    this._store = {};
-    this._next_guid = 1;
-    callback(null);
-  }
-};
-
 const kAuth = {
   "method": "basic",
   "user": "username",
   "password": "p455w0rd"
 }
 
 
 // "pid" for "provider ID"
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/browser_GoogleImporter.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {GoogleImporter} = Cu.import("resource:///modules/loop/GoogleImporter.jsm", {});
+
+let importer = new GoogleImporter();
+
+function promiseImport() {
+  return new Promise(function(resolve, reject) {
+    importer.startImport({}, function(err, stats) {
+      if (err) {
+        reject(err);
+      } else {
+        resolve(stats);
+      }
+    }, mockDb, window);
+  });
+}
+
+add_task(function* test_GoogleImport() {
+  let stats;
+  // An error may throw and the test will fail when that happens.
+  stats = yield promiseImport();
+
+  // Assert the world.
+  Assert.equal(stats.total, 5, "Five contacts should get processed");
+  Assert.equal(stats.success, 5, "Five contacts should be imported");
+
+  yield promiseImport();
+  Assert.equal(Object.keys(mockDb._store).length, 5, "Database should contain only five contact after reimport");
+
+  let c = mockDb._store[mockDb._next_guid - 5];
+  Assert.equal(c.name[0], "John Smith", "Full name should match");
+  Assert.equal(c.givenName[0], "John", "Given name should match");
+  Assert.equal(c.familyName[0], "Smith", "Family name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "john.smith@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/0", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 4];
+  Assert.equal(c.name[0], "Jane Smith", "Full name should match");
+  Assert.equal(c.givenName[0], "Jane", "Given name should match");
+  Assert.equal(c.familyName[0], "Smith", "Family name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "jane.smith@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/1", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 3];
+  Assert.equal(c.name[0], "Davy Randall Jones", "Full name should match");
+  Assert.equal(c.givenName[0], "Davy Randall", "Given name should match");
+  Assert.equal(c.familyName[0], "Jones", "Family name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "davy.jones@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/2", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 2];
+  Assert.equal(c.name[0], "noname@example.com", "Full name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "noname@example.com", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/3", "UID should match and be scoped to provider");
+
+  c = mockDb._store[mockDb._next_guid - 1];
+  Assert.equal(c.name[0], "lycnix", "Full name should match");
+  Assert.equal(c.email[0].type, "other", "Email type should match");
+  Assert.equal(c.email[0].value, "lycnix", "Email should match");
+  Assert.equal(c.email[0].pref, true, "Pref should match");
+  Assert.equal(c.category[0], "google", "Category should match");
+  Assert.equal(c.id, "http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/7", "UID should match and be scoped to provider");
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/fixtures/google_auth.txt
@@ -0,0 +1,5 @@
+<!DOCTYPE html>
+<html>
+<head><title>Success code=test-code</title></head>
+<body>Le Code.</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/fixtures/google_contacts.txt
@@ -0,0 +1,94 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<feed gd:etag="W/&quot;DUQNRHc8cCt7I2A9XRdSF04.&quot;" xmlns="http://www.w3.org/2005/Atom" xmlns:batch="http://schemas.google.com/gdata/batch" xmlns:gContact="http://schemas.google.com/contact/2008" xmlns:gd="http://schemas.google.com/g/2005" xmlns:openSearch="http://a9.com/-/spec/opensearch/1.1/">
+  <id>tester@mochi.com</id>
+  <updated>2014-09-26T13:16:35.978Z</updated>
+  <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+  <title>Mochi Tester's Contacts</title>
+  <link href="http://www.google.com/" rel="alternate" type="text/html"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full" rel="http://schemas.google.com/g/2005#feed" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full" rel="http://schemas.google.com/g/2005#post" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/batch" rel="http://schemas.google.com/g/2005#batch" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full?max-results=25" rel="self" type="application/atom+xml"/>
+  <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full?start-index=26&amp;max-results=25" rel="next" type="application/atom+xml"/>
+  <author>
+    <name>Mochi Tester</name>
+    <email>tester@mochi.com</email>
+  </author>
+  <generator uri="http://www.google.com/m8/feeds" version="1.0">Contacts</generator>
+  <openSearch:totalResults>25</openSearch:totalResults>
+  <openSearch:startIndex>1</openSearch:startIndex>
+  <openSearch:itemsPerPage>10000000</openSearch:itemsPerPage>
+  <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/0</id>
+    <updated>2012-08-17T23:50:36.892Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title>John Smith</title>
+    <link gd:etag="&quot;Ug92D34SfCt7I2BmLHJTRgVzTlgrJXEAU08.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/0" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/0" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/0" rel="edit" type="application/atom+xml"/>
+    <gd:name>
+      <gd:fullName>John Smith</gd:fullName>
+      <gd:givenName>John</gd:givenName>
+      <gd:familyName>Smith</gd:familyName>
+    </gd:name>
+    <gd:email address="john.smith@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:website href="http://www.google.com/profiles/109576547678240773721" rel="profile"/>
+  </entry>
+  <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/1</id>
+    <updated>2012-08-17T23:50:36.892Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title>Jane Smith</title>
+    <link gd:etag="&quot;WA9BY1xFWit7I2BhLEkieCxLHEYTGCYuNxo.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/1" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/1" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/1" rel="edit" type="application/atom+xml"/>
+    <gd:name>
+      <gd:fullName>Jane Smith</gd:fullName>
+      <gd:givenName>Jane</gd:givenName>
+      <gd:familyName>Smith</gd:familyName>
+    </gd:name>
+    <gd:email address="jane.smith@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:website href="http://www.google.com/profiles/112886528199784431028" rel="profile"/>
+  </entry>
+  <entry gd:etag="&quot;R3YyejRVLit7I2A9WhJWEkkNQwc.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/2</id>
+    <updated>2012-08-17T23:50:36.892Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2012-08-17T23:50:36.892Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title>Davy Randall Jones</title>
+    <link gd:etag="&quot;KiV2PkYRfCt7I2BuD1AzEBFxD1VcGjwBUyA.&quot;" href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/2" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/2" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/2" rel="edit" type="application/atom+xml"/>
+    <gd:name>
+      <gd:fullName>Davy Randall Jones</gd:fullName>
+      <gd:givenName>Davy Randall</gd:givenName>
+      <gd:familyName>Jones</gd:familyName>
+    </gd:name>
+    <gd:email address="davy.jones@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+    <gContact:website href="http://www.google.com/profiles/109710625881478599011" rel="profile"/>
+  </entry>
+  <entry gd:etag="&quot;Q3w7ezVSLit7I2A9WB5WGUkNRgE.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/3</id>
+    <updated>2007-08-01T05:45:52.203Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2007-08-01T05:45:52.203Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title/>
+    <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/3" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/3" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/3" rel="edit" type="application/atom+xml"/>
+    <gd:email address="noname@example.com" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+  </entry>
+  <entry gd:etag="&quot;Q3w7ezVSLit7I2A9WB5WGUkNRgE.&quot;">
+    <id>http://www.google.com/m8/feeds/contacts/tester%40mochi.com/base/7</id>
+    <updated>2007-08-01T05:45:52.203Z</updated>
+    <app:edited xmlns:app="http://www.w3.org/2007/app">2007-08-01T05:45:52.203Z</app:edited>
+    <category scheme="http://schemas.google.com/g/2005#kind" term="http://schemas.google.com/contact/2008#contact"/>
+    <title/>
+    <link href="https://www.google.com/m8/feeds/photos/media/tester%40mochi.com/7" rel="http://schemas.google.com/contacts/2008/rel#photo" type="image/*"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/7" rel="self" type="application/atom+xml"/>
+    <link href="https://www.google.com/m8/feeds/contacts/tester%40mochi.com/full/7" rel="edit" type="application/atom+xml"/>
+    <gd:email address="lycnix" primary="true" rel="http://schemas.google.com/g/2005#other"/>
+  </entry>
+</feed>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/fixtures/google_token.txt
@@ -0,0 +1,3 @@
+{
+    "access_token": "test-token"
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/test/mochitest/google_service.sjs
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, Constructor: CC} = Components;
+const BinaryInputStream = CC("@mozilla.org/binaryinputstream;1",
+                             "nsIBinaryInputStream",
+                             "setInputStream");
+
+function handleRequest(req, res) {
+  try {
+    reallyHandleRequest(req, res);
+  } catch (ex) {
+    res.setStatusLine("1.0", 200, "AlmostOK");
+    let msg = "Error handling request: " + ex + "\n" + ex.stack;
+    log(msg);
+    res.write(msg);
+  }
+}
+
+function log(msg) {
+  // dump("GOOGLE-SERVER-MOCK: " + msg + "\n");
+}
+
+const kBasePath = "browser/browser/components/loop/test/mochitest/fixtures/";
+
+const kStatusCodes = {
+  400: "Bad Request",
+  401: "Unauthorized",
+  403: "Forbidden",
+  404: "Not Found",
+  405: "Method Not Allowed",
+  500: "Internal Server Error",
+  501: "Not Implemented",
+  503: "Service Unavailable"
+};
+
+function HTTPError(code = 500, message) {
+  this.code = code;
+  this.name = kStatusCodes[code] || "HTTPError";
+  this.message = message || this.name;
+}
+HTTPError.prototype = new Error();
+HTTPError.prototype.constructor = HTTPError;
+
+function sendError(res, err) {
+  if (!(err instanceof HTTPError)) {
+    err = new HTTPError(typeof err == "number" ? err : 500,
+                        err.message || typeof err == "string" ? err : "");
+  }
+  res.setStatusLine("1.1", err.code, err.name);
+  res.write(err.message);
+}
+
+function parseQuery(query, params = {}) {
+  for (let param of query.replace(/^[?&]/, "").split(/(?:&|\?)/)) {
+    param = param.split("=");
+    if (!param[0])
+      continue;
+    params[unescape(param[0])] = unescape(param[1]);
+  }
+  return params;
+}
+
+function getRequestBody(req) {
+  let avail;
+  let bytes = [];
+  let body = new BinaryInputStream(req.bodyInputStream);
+
+  while ((avail = body.available()) > 0)
+    Array.prototype.push.apply(bytes, body.readByteArray(avail));
+
+  return String.fromCharCode.apply(null, bytes);
+}
+
+function getInputStream(path) {
+  let file = Cc["@mozilla.org/file/directory_service;1"]
+               .getService(Ci.nsIProperties)
+               .get("CurWorkD", Ci.nsILocalFile);
+  for (let part of path.split("/"))
+    file.append(part);
+  let fileStream  = Cc["@mozilla.org/network/file-input-stream;1"]
+                      .createInstance(Ci.nsIFileInputStream);
+  fileStream.init(file, 1, 0, false);
+  return fileStream;
+}
+
+function checkAuth(req) {
+  if (!req.hasHeader("Authorization"))
+    throw new HTTPError(401, "No Authorization header provided.");
+
+  let auth = req.getHeader("Authorization");
+  if (auth != "Bearer test-token")
+    throw new HTTPError(401, "Invalid Authorization header content: '" + auth + "'");
+}
+
+function reallyHandleRequest(req, res) {
+  log("method: " + req.method);
+
+  let body = getRequestBody(req);
+  log("body: " + body);
+
+  let contentType = req.hasHeader("Content-Type") ? req.getHeader("Content-Type") : null;
+  log("contentType: " + contentType);
+
+  let params = parseQuery(req.queryString);
+  parseQuery(body, params);
+  log("params: " + JSON.stringify(params));
+
+  // Delegate an authentication request to the correct handler.
+  if ("action" in params) {
+    methodHandlers[params.action](req, res, params);
+  } else {
+    sendError(res, 501);
+  }
+}
+
+function respondWithFile(res, fileName, mimeType) {
+  res.setStatusLine("1.1", 200, "OK");
+  res.setHeader("Content-Type", mimeType);
+
+  let inputStream = getInputStream(kBasePath + fileName);
+  res.bodyOutputStream.writeFrom(inputStream, inputStream.available());
+  inputStream.close();
+}
+
+const methodHandlers = {
+  auth: function(req, res, params) {
+    respondWithFile(res, "google_auth.txt", "text/html");
+  },
+
+  token: function(req, res, params) {
+    respondWithFile(res, "google_token.txt", "application/json");
+  },
+
+  contacts: function(req, res, params) {
+    try {
+      checkAuth(req);
+    } catch (ex) {
+      sendError(res, ex, ex.code);
+      return;
+    }
+
+    respondWithFile(res, "google_contacts.txt", "text/xml");
+  }
+};
--- a/browser/components/loop/test/mochitest/head.js
+++ b/browser/components/loop/test/mochitest/head.js
@@ -193,8 +193,56 @@ let mockPushHandler = {
 
   /**
    * Test-only API to simplify notifying a push notification result.
    */
   notify: function(version) {
     this._notificationCallback(version);
   }
 };
+
+const mockDb = {
+  _store: { },
+  _next_guid: 1,
+
+  add: function(details, callback) {
+    if (!("id" in details)) {
+      callback(new Error("No 'id' field present"));
+      return;
+    }
+    details._guid = this._next_guid++;
+    this._store[details._guid] = details;
+    callback(null, details);
+  },
+  remove: function(guid, callback) {
+    if (!guid in this._store) {
+      callback(new Error("Could not find _guid '" + guid + "' in database"));
+      return;
+    }
+    delete this._store[guid];
+    callback(null);
+  },
+  getAll: function(callback) {
+    callback(null, this._store);
+  },
+  get: function(guid, callback) {
+    callback(null, this._store[guid]);
+  },
+  getByServiceId: function(serviceId, callback) {
+    for (let guid in this._store) {
+      if (serviceId === this._store[guid].id) {
+        callback(null, this._store[guid]);
+        return;
+      }
+    }
+    callback(null, null);
+  },
+  removeAll: function(callback) {
+    this._store = {};
+    this._next_guid = 1;
+    callback(null);
+  },
+  promise: function(method, ...params) {
+    return new Promise(resolve => {
+      this[method](...params, (err, res) => err ? reject(err) : resolve(res));
+    });
+  }
+};
--- a/browser/devtools/app-manager/test/browser.ini
+++ b/browser/devtools/app-manager/test/browser.ini
@@ -2,9 +2,9 @@
 skip-if = e10s # Bug ?????? - devtools tests disabled with e10s
 subsuite = devtools
 support-files =
   head.js
   hosted_app.manifest
   manifest.webapp
 
 [browser_manifest_editor.js]
-skip-if = os == "linux"
+skip-if = true # Bug 989169 - Very intermittent, but App Manager about to be removed
--- a/browser/devtools/app-manager/test/test_connection_store.html
+++ b/browser/devtools/app-manager/test/test_connection_store.html
@@ -21,18 +21,21 @@ Bug 901519 - [app manager] data store fo
       <span id="host" template='{"type":"textContent","path":"host"}'></span>
       <span id="port" template='{"type":"textContent","path":"port"}'></span>
     </div>
 
     <script type="application/javascript;version=1.8" src="chrome://browser/content/devtools/app-manager/template.js"></script>
     <script type="application/javascript;version=1.8">
       const Cu = Components.utils;
       Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
-      DebuggerServer.init(function () { return true; });
-      DebuggerServer.addBrowserActors();
+
+      if (!DebuggerServer.initialized) {
+        DebuggerServer.init(function () { return true; });
+        DebuggerServer.addBrowserActors();
+      }
 
       window.onload = function() {
         SimpleTest.waitForExplicitFinish();
 
         Cu.import("resource://gre/modules/Services.jsm");
         Cu.import("resource:///modules/devtools/gDevTools.jsm");
 
         const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
--- a/browser/devtools/app-manager/test/test_device_store.html
+++ b/browser/devtools/app-manager/test/test_device_store.html
@@ -15,18 +15,21 @@ Bug 901520 - [app manager] data store fo
   </head>
 
   <body>
 
     <script type="application/javascript;version=1.8" src="chrome://browser/content/devtools/app-manager/template.js"></script>
     <script type="application/javascript;version=1.8">
       const Cu = Components.utils;
       Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
-      DebuggerServer.init(function () { return true; });
-      DebuggerServer.addBrowserActors();
+
+      if (!DebuggerServer.initialized) {
+        DebuggerServer.init(function () { return true; });
+        DebuggerServer.addBrowserActors();
+      }
 
       function compare(o1, o2, msg) {
         is(JSON.stringify(o1), JSON.stringify(o2), msg);
       }
 
       window.onload = function() {
         SimpleTest.waitForExplicitFinish();
 
--- a/browser/devtools/app-manager/test/test_remain_connected.html
+++ b/browser/devtools/app-manager/test/test_remain_connected.html
@@ -15,18 +15,21 @@ Bug 912646 - Closing app toolbox causes 
   </head>
 
   <body>
 
     <script type="application/javascript;version=1.8">
       const Cu = Components.utils;
 
       Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
-      DebuggerServer.init(function () { return true; });
-      DebuggerServer.addBrowserActors();
+
+      if (!DebuggerServer.initialized) {
+        DebuggerServer.init(function () { return true; });
+        DebuggerServer.addBrowserActors();
+      }
 
       window.onload = function() {
         SimpleTest.waitForExplicitFinish();
 
         Cu.import("resource:///modules/devtools/gDevTools.jsm");
 
         const {devtools} =
           Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
--- a/browser/devtools/debugger/test/browser.ini
+++ b/browser/devtools/debugger/test/browser.ini
@@ -10,16 +10,17 @@ support-files =
   code_binary_search.coffee
   code_binary_search.js
   code_binary_search.map
   code_blackboxing_blackboxme.js
   code_blackboxing_one.js
   code_blackboxing_three.js
   code_blackboxing_two.js
   code_breakpoints-break-on-last-line-of-script-on-reload.js
+  code_breakpoints-other-tabs.js
   code_function-search-01.js
   code_function-search-02.js
   code_function-search-03.js
   code_location-changes.js
   code_math.js
   code_math.map
   code_math.min.js
   code_math_bogus_map.js
@@ -37,16 +38,18 @@ support-files =
   code_ugly-7.js
   code_ugly-8
   code_ugly-8^headers^
   doc_auto-pretty-print-01.html
   doc_auto-pretty-print-02.html
   doc_binary_search.html
   doc_blackboxing.html
   doc_breakpoints-break-on-last-line-of-script-on-reload.html
+  doc_breakpoints-other-tabs.html
+  doc_breakpoints-reload.html
   doc_closures.html
   doc_closure-optimized-out.html
   doc_cmd-break.html
   doc_cmd-dbg.html
   doc_breakpoint-move.html
   doc_conditional-breakpoints.html
   doc_domnode-variables.html
   doc_editor-mode.html
@@ -128,17 +131,19 @@ skip-if = os == "mac" || e10s # Bug 8954
 [browser_dbg_breakpoints-button-01.js]
 [browser_dbg_breakpoints-button-02.js]
 [browser_dbg_breakpoints-contextmenu-add.js]
 [browser_dbg_breakpoints-contextmenu.js]
 [browser_dbg_breakpoints-disabled-reload.js]
 [browser_dbg_breakpoints-editor.js]
 [browser_dbg_breakpoints-highlight.js]
 [browser_dbg_breakpoints-new-script.js]
+[browser_dbg_breakpoints-other-tabs.js]
 [browser_dbg_breakpoints-pane.js]
+[browser_dbg_breakpoints-reload.js]
 [browser_dbg_chrome-create.js]
 [browser_dbg_chrome-debugging.js]
 [browser_dbg_clean-exit-window.js]
 skip-if = true # Bug 933950 (leaky test)
 [browser_dbg_clean-exit.js]
 [browser_dbg_closure-inspection.js]
 [browser_dbg_cmd-blackbox.js]
 [browser_dbg_cmd-break.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/debugger/test/browser_dbg_breakpoints-other-tabs.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Make sure that setting a breakpoint in one tab, doesn't cause another tab at
+ * the same source to pause at that location.
+ */
+
+const TAB_URL = EXAMPLE_URL + "doc_breakpoints-other-tabs.html";
+
+let test = Task.async(function* () {
+  const [tab1, debuggee1, panel1] = yield initDebugger(TAB_URL);
+  const [tab2, debuggee2, panel2] = yield initDebugger(TAB_URL);
+
+  yield ensureSourceIs(panel1, "code_breakpoints-other-tabs.js", true);
+
+  const sources = panel1.panelWin.DebuggerView.Sources;
+
+  yield panel1.addBreakpoint({
+    url: sources.selectedValue,
+    line: 2
+  });
+
+  const paused = waitForThreadEvents(panel2, "paused");
+  executeSoon(() => debuggee2.testCase());
+  const packet = yield paused;
+
+  is(packet.why.type, "debuggerStatement",
+     "Should have stopped at the debugger statement, not the other tab's breakpoint");
+  is(packet.frame.where.line, 3,
+     "Should have stopped at line 3 (debugger statement), not line 2 (other tab's breakpoint)");
+
+  yield teardown(panel1);
+  yield resumeDebuggerThenCloseAndFinish(panel2);
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/debugger/test/browser_dbg_breakpoints-reload.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Make sure that setting a breakpoint on code that gets run on load, will get
+ * hit when we reload.
+ */
+
+const TAB_URL = EXAMPLE_URL + "doc_breakpoints-reload.html";
+
+let test = Task.async(function* () {
+  requestLongerTimeout(4);
+
+  const [tab, debuggee, panel] = yield initDebugger(TAB_URL);
+
+  yield ensureSourceIs(panel, "doc_breakpoints-reload.html", true);
+
+  const sources = panel.panelWin.DebuggerView.Sources;
+
+  yield panel.addBreakpoint({
+    url: sources.selectedValue,
+    line: 10 // "break on me" string
+  });
+
+  const paused = waitForThreadEvents(panel, "paused");
+  reloadActiveTab(panel);
+  const packet = yield paused;
+
+  is(packet.why.type, "breakpoint",
+     "Should have hit the breakpoint after the reload");
+  is(packet.frame.where.line, 10,
+     "Should have stopped at line 10, where we set the breakpoint");
+
+  yield resumeDebuggerThenCloseAndFinish(panel);
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/debugger/test/code_breakpoints-other-tabs.js
@@ -0,0 +1,4 @@
+function testCase() {
+  var foo = "break on me";
+  debugger;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/debugger/test/doc_breakpoints-other-tabs.html
@@ -0,0 +1,8 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<head>
+  <meta charset="utf-8"/>
+  <title>Debugger Breakpoints Other Tabs Test Page</title>
+</head>
+<script src="code_breakpoints-other-tabs.js"></script>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/debugger/test/doc_breakpoints-reload.html
@@ -0,0 +1,12 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<head>
+  <meta charset="utf-8"/>
+  <title>Debugger Breakpoints Other Tabs Test Page</title>
+</head>
+<script>
+  (function () {
+    window.foo = "break on me";
+  }());
+</script>
--- a/browser/devtools/profiler/utils/shared.js
+++ b/browser/devtools/profiler/utils/shared.js
@@ -116,17 +116,17 @@ ProfilerConnection.prototype = {
    * Initializes a connection to miscellaneous actors which are going to be
    * used in tandem with the profiler actor.
    */
   _connectMiscActors: function() {
     // Only initialize the framerate front if the respective actor is available.
     // Older Gecko versions don't have an existing implementation, in which case
     // all the methods we need can be easily mocked.
     if (this._target.form && this._target.form.framerateActor) {
-    this._framerate = new FramerateFront(this._target.client, this._target.form);
+      this._framerate = new FramerateFront(this._target.client, this._target.form);
     } else {
       this._framerate = {
         startRecording: () => {},
         stopRecording: () => {},
         cancelRecording: () => {},
         isRecording: () => false,
         getPendingTicks: () => null
       };
--- a/browser/devtools/shared/telemetry.js
+++ b/browser/devtools/shared/telemetry.js
@@ -31,17 +31,17 @@
  * 6. When your tool is closed call:
  *      this._telemetry.toolClosed("mytoolname");
  *
  * Note:
  * You can view telemetry stats for your local Firefox instance via
  * about:telemetry.
  *
  * You can view telemetry stats for large groups of Firefox users at
- * metrics.mozilla.com.
+ * telemetry.mozilla.org.
  */
 
 const TOOLS_OPENED_PREF = "devtools.telemetry.tools.opened.version";
 
 this.Telemetry = function() {
   // Bind pretty much all functions so that callers do not need to.
   this.toolOpened = this.toolOpened.bind(this);
   this.toolClosed = this.toolClosed.bind(this);
@@ -165,16 +165,21 @@ Telemetry.prototype = {
       userHistogram: "DEVTOOLS_RESPONSIVE_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_RESPONSIVE_TIME_ACTIVE_SECONDS"
     },
     developertoolbar: {
       histogram: "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_DEVELOPERTOOLBAR_TIME_ACTIVE_SECONDS"
     },
+    webide: {
+      histogram: "DEVTOOLS_WEBIDE_OPENED_BOOLEAN",
+      userHistogram: "DEVTOOLS_WEBIDE_OPENED_PER_USER_FLAG",
+      timerHistogram: "DEVTOOLS_WEBIDE_TIME_ACTIVE_SECONDS"
+    },
     custom: {
       histogram: "DEVTOOLS_CUSTOM_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_CUSTOM_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_CUSTOM_TIME_ACTIVE_SECONDS"
     }
   },
 
   /**
@@ -189,33 +194,52 @@ Telemetry.prototype = {
 
     if (charts.histogram) {
       this.log(charts.histogram, true);
     }
     if (charts.userHistogram) {
       this.logOncePerBrowserVersion(charts.userHistogram, true);
     }
     if (charts.timerHistogram) {
-      this._timers.set(charts.timerHistogram, new Date());
+      this.startTimer(charts.timerHistogram);
     }
   },
 
   toolClosed: function(id) {
     let charts = this._histograms[id];
 
     if (!charts || !charts.timerHistogram) {
       return;
     }
 
-    let startTime = this._timers.get(charts.timerHistogram);
+    this.stopTimer(charts.timerHistogram);
+  },
 
+  /**
+   * Record the start time for a timing-based histogram entry.
+   *
+   * @param String histogramId
+   *        Histogram in which the data is to be stored.
+   */
+  startTimer: function(histogramId) {
+    this._timers.set(histogramId, new Date());
+  },
+
+  /**
+   * Stop the timer and log elasped time for a timing-based histogram entry.
+   *
+   * @param String histogramId
+   *        Histogram in which the data is to be stored.
+   */
+  stopTimer: function(histogramId) {
+    let startTime = this._timers.get(histogramId);
     if (startTime) {
       let time = (new Date() - startTime) / 1000;
-      this.log(charts.timerHistogram, time);
-      this._timers.delete(charts.timerHistogram);
+      this.log(histogramId, time);
+      this._timers.delete(histogramId);
     }
   },
 
   /**
    * Log a value to a histogram.
    *
    * @param  {String} histogramId
    *         Histogram in which the data is to be stored.
@@ -253,20 +277,17 @@ Telemetry.prototype = {
       latestObj[perUserHistogram] = currentVersion;
       latest = JSON.stringify(latestObj);
       Services.prefs.setCharPref(TOOLS_OPENED_PREF, latest);
       this.log(perUserHistogram, value);
     }
   },
 
   destroy: function() {
-    for (let [histogram, time] of this._timers) {
-      time = (new Date() - time) / 1000;
-
-      this.log(histogram, time);
-      this._timers.delete(histogram);
+    for (let histogramId of this._timers.keys()) {
+      this.stopTimer(histogramId);
     }
   }
 };
 
 XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
   return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
 });
--- a/browser/devtools/webide/content/runtimedetails.js
+++ b/browser/devtools/webide/content/runtimedetails.js
@@ -83,17 +83,17 @@ function CheckLockState() {
   certCheckResult.textContent = sUnknown;
 
   if (AppManager.connection &&
       AppManager.connection.status == Connection.Status.CONNECTED) {
 
     // ADB check
     if (AppManager.selectedRuntime instanceof USBRuntime) {
       let device = Devices.getByName(AppManager.selectedRuntime.id);
-      if (device.summonRoot) {
+      if (device && device.summonRoot) {
         device.isRoot().then(isRoot => {
           if (isRoot) {
             adbCheckResult.textContent = sYes;
             flipCertPerfButton.removeAttribute("disabled");
           } else {
             adbCheckResult.textContent = sNo;
             adbRootAction.removeAttribute("hidden");
           }
--- a/browser/devtools/webide/content/webide.js
+++ b/browser/devtools/webide/content/webide.js
@@ -16,16 +16,17 @@ const {AppProjects} = require("devtools/
 const {Connection} = require("devtools/client/connection-manager");
 const {AppManager} = require("devtools/webide/app-manager");
 const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
 const ProjectEditor = require("projecteditor/projecteditor");
 const {Devices} = Cu.import("resource://gre/modules/devtools/Devices.jsm");
 const {GetAvailableAddons} = require("devtools/webide/addons");
 const {GetTemplatesJSON, GetAddonsJSON} = require("devtools/webide/remote-resources");
 const utils = require("devtools/webide/utils");
+const Telemetry = require("devtools/shared/telemetry");
 
 const Strings = Services.strings.createBundle("chrome://browser/locale/devtools/webide.properties");
 
 const HTML = "http://www.w3.org/1999/xhtml";
 const HELP_URL = "https://developer.mozilla.org/docs/Tools/WebIDE/Troubleshooting";
 
 // download template index early
 GetTemplatesJSON(true);
@@ -42,16 +43,19 @@ window.addEventListener("load", function
 
 window.addEventListener("unload", function onUnload() {
   window.removeEventListener("unload", onUnload);
   UI.uninit();
 });
 
 let UI = {
   init: function() {
+    this._telemetry = new Telemetry();
+    this._telemetry.toolOpened("webide");
+
     AppManager.init();
 
     this.onMessage = this.onMessage.bind(this);
     window.addEventListener("message", this.onMessage);
 
     this.appManagerUpdate = this.appManagerUpdate.bind(this);
     AppManager.on("app-manager-update", this.appManagerUpdate);
 
@@ -80,16 +84,18 @@ let UI = {
     this.setupDeck();
   },
 
   uninit: function() {
     window.removeEventListener("focus", this.onfocus, true);
     AppManager.off("app-manager-update", this.appManagerUpdate);
     AppManager.uninit();
     window.removeEventListener("message", this.onMessage);
+    this.updateConnectionTelemetry();
+    this._telemetry.toolClosed("webide");
   },
 
   canWindowClose: function() {
     if (this.projecteditor) {
       return this.projecteditor.confirmUnsaved();
     }
     return true;
   },
@@ -112,16 +118,17 @@ let UI = {
     switch (what) {
       case "runtimelist":
         this.updateRuntimeList();
         this.autoConnectRuntime();
         break;
       case "connection":
         this.updateRuntimeButton();
         this.updateCommands();
+        this.updateConnectionTelemetry();
         break;
       case "project":
         this._updatePromise = Task.spawn(function() {
           UI.updateTitle();
           yield UI.destroyToolbox();
           UI.updateCommands();
           UI.updateProjectButton();
           UI.openProject();
@@ -220,22 +227,23 @@ let UI = {
     }, 6000);
   },
 
   cancelBusyTimeout: function() {
     clearTimeout(this._busyTimeout);
   },
 
   busyWithProgressUntil: function(promise, operationDescription) {
-    this.busyUntil(promise, operationDescription);
+    let busy = this.busyUntil(promise, operationDescription);
     let win = document.querySelector("window");
     let progress = document.querySelector("#action-busy-determined");
     progress.mode = "undetermined";
     win.classList.add("busy-determined");
     win.classList.remove("busy-undetermined");
+    return busy;
   },
 
   busyUntil: function(promise, operationDescription) {
     // Freeze the UI until the promise is resolved. A 30s timeout
     // will unfreeze the UI, just in case the promise never gets
     // resolved.
     this._busyPromise = promise;
     this._busyOperationDescription = operationDescription;
@@ -367,16 +375,17 @@ let UI = {
         }
       }
     }
   },
 
   connectToRuntime: function(runtime) {
     let name = runtime.getName();
     let promise = AppManager.connectToRuntime(runtime);
+    promise.then(() => this.initConnectionTelemetry());
     return this.busyUntil(promise, "connecting to runtime");
   },
 
   updateRuntimeButton: function() {
     let labelNode = document.querySelector("#runtime-panel-button > .panel-button-label");
     if (!AppManager.selectedRuntime) {
       labelNode.setAttribute("value", Strings.GetStringFromName("runtimeButton_label"));
     } else {
@@ -391,16 +400,57 @@ let UI = {
       this.lastConnectedRuntime = AppManager.selectedRuntime.type + ":" + AppManager.selectedRuntime.getID();
     } else {
       this.lastConnectedRuntime = "";
     }
     Services.prefs.setCharPref("devtools.webide.lastConnectedRuntime",
                                this.lastConnectedRuntime);
   },
 
+  _actionsToLog: new Set(),
+
+  /**
+   * For each new connection, track whether play and debug were ever used.  Only
+   * one value is collected for each button, even if they are used multiple
+   * times during a connection.
+   */
+  initConnectionTelemetry: function() {
+    this._actionsToLog.add("play");
+    this._actionsToLog.add("debug");
+  },
+
+  /**
+   * Action occurred.  Log that it happened, and remove it from the loggable
+   * set.
+   */
+  onAction: function(action) {
+    if (!this._actionsToLog.has(action)) {
+      return;
+    }
+    this.logActionState(action, true);
+    this._actionsToLog.delete(action);
+  },
+
+  /**
+   * Connection status changed or we are shutting down.  Record any loggable
+   * actions as having not occurred.
+   */
+  updateConnectionTelemetry: function() {
+    for (let action of this._actionsToLog.values()) {
+      this.logActionState(action, false);
+    }
+    this._actionsToLog.clear();
+  },
+
+  logActionState: function(action, state) {
+    let histogramId = "DEVTOOLS_WEBIDE_CONNECTION_" +
+                      action.toUpperCase() + "_USED";
+    this._telemetry.log(histogramId, state);
+  },
+
   /********** PROJECTS **********/
 
   // Panel & button
 
   updateProjectButton: function() {
     let buttonNode = document.querySelector("#project-panel-button");
     let labelNode = buttonNode.querySelector(".panel-button-label");
     let imageNode = buttonNode.querySelector(".panel-button-image");
@@ -832,18 +882,17 @@ let UI = {
     // properly anymore.
     this.toolboxIframe.remove();
     this.toolboxIframe = null;
 
     let splitter = document.querySelector(".devtools-horizontal-splitter");
     splitter.setAttribute("hidden", "true");
     document.querySelector("#action-button-debug").removeAttribute("active");
   },
-}
-
+};
 
 let Cmds = {
   quit: function() {
     if (UI.canWindowClose()) {
       window.close();
     }
   },
 
@@ -1103,34 +1152,46 @@ let Cmds = {
     UI.selectDeckPanel("runtimedetails");
   },
 
   showMonitor: function() {
     UI.selectDeckPanel("monitor");
   },
 
   play: function() {
+    let busy;
     switch(AppManager.selectedProject.type) {
       case "packaged":
-        return UI.busyWithProgressUntil(AppManager.installAndRunProject(), "installing and running app");
+        busy = UI.busyWithProgressUntil(AppManager.installAndRunProject(),
+                                        "installing and running app");
+        break;
       case "hosted":
-        return UI.busyUntil(AppManager.installAndRunProject(), "installing and running app");
+        busy = UI.busyUntil(AppManager.installAndRunProject(),
+                            "installing and running app");
+        break;
       case "runtimeApp":
-        return UI.busyUntil(AppManager.launchOrReloadRuntimeApp(), "launching / reloading app");
+        busy = UI.busyUntil(AppManager.launchOrReloadRuntimeApp(), "launching / reloading app");
+        break;
       case "tab":
-        return UI.busyUntil(AppManager.reloadTab(), "reloading tab");
+        busy = UI.busyUntil(AppManager.reloadTab(), "reloading tab");
+        break;
     }
-    return promise.reject();
+    if (!busy) {
+      return promise.reject();
+    }
+    UI.onAction("play");
+    return busy;
   },
 
   stop: function() {
     return UI.busyUntil(AppManager.stopRunningApp(), "stopping app");
   },
 
   toggleToolbox: function() {
+    UI.onAction("debug");
     if (UI.toolboxIframe) {
       UI.destroyToolbox();
       return promise.resolve();
     } else {
       return UI.createToolbox();
     }
   },
 
--- a/browser/devtools/webide/modules/app-manager.js
+++ b/browser/devtools/webide/modules/app-manager.js
@@ -21,16 +21,17 @@ const {AppActorFront} = require("devtool
 const {getDeviceFront} = require("devtools/server/actors/device");
 const {getPreferenceFront} = require("devtools/server/actors/preference");
 const {setTimeout} = require("sdk/timers");
 const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
 const {USBRuntime, WiFiRuntime, SimulatorRuntime,
        gLocalRuntime, gRemoteRuntime} = require("devtools/webide/runtimes");
 const discovery = require("devtools/toolkit/discovery/discovery");
 const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+const Telemetry = require("devtools/shared/telemetry");
 
 const Strings = Services.strings.createBundle("chrome://browser/locale/devtools/webide.properties");
 
 const WIFI_SCANNING_PREF = "devtools.remote.wifi.scan";
 
 exports.AppManager = AppManager = {
 
   // FIXME: will break when devtools/app-manager will be removed:
@@ -63,16 +64,18 @@ exports.AppManager = AppManager = {
     this.trackUSBRuntimes();
     this.trackWiFiRuntimes();
     this.trackSimulatorRuntimes();
 
     this.onInstallProgress = this.onInstallProgress.bind(this);
 
     this.observe = this.observe.bind(this);
     Services.prefs.addObserver(WIFI_SCANNING_PREF, this, false);
+
+    this._telemetry = new Telemetry();
   },
 
   uninit: function() {
     this.selectedProject = null;
     this.selectedRuntime = null;
     this.untrackUSBRuntimes();
     this.untrackWiFiRuntimes();
     this.untrackSimulatorRuntimes();
@@ -367,16 +370,35 @@ exports.AppManager = AppManager = {
           () => {},
           deferred.reject.bind(deferred));
       } catch(e) {
         console.error(e);
         deferred.reject();
       }
     }, deferred.reject);
 
+    // Record connection result in telemetry
+    let logResult = result => {
+      this._telemetry.log("DEVTOOLS_WEBIDE_CONNECTION_RESULT", result);
+      if (runtime.type) {
+        this._telemetry.log("DEVTOOLS_WEBIDE_" + runtime.type +
+                            "_CONNECTION_RESULT", result);
+      }
+    };
+    deferred.promise.then(() => logResult(true), () => logResult(false));
+
+    // If successful, record connection time in telemetry
+    deferred.promise.then(() => {
+      const timerId = "DEVTOOLS_WEBIDE_CONNECTION_TIME_SECONDS";
+      this._telemetry.startTimer(timerId);
+      this.connection.once(Connection.Events.STATUS_CHANGED, () => {
+        this._telemetry.stopTimer(timerId);
+      });
+    });
+
     return deferred.promise;
   },
 
   isMainProcessDebuggable: function() {
     return this._listTabsResponse &&
            this._listTabsResponse.consoleActor;
   },
 
--- a/browser/devtools/webide/test/chrome.ini
+++ b/browser/devtools/webide/test/chrome.ini
@@ -28,8 +28,9 @@ support-files =
 [test_newapp.html]
 [test_import.html]
 [test_duplicate_import.html]
 [test_runtime.html]
 [test_manifestUpdate.html]
 [test_addons.html]
 [test_deviceinfo.html]
 [test_autoconnect_runtime.html]
+[test_telemetry.html]
--- a/browser/devtools/webide/test/head.js
+++ b/browser/devtools/webide/test/head.js
@@ -110,16 +110,24 @@ function waitForUpdate(win, update) {
       return;
     }
     win.AppManager.off("app-manager-update", onUpdate);
     deferred.resolve(win.UI._updatePromise);
   });
   return deferred.promise;
 }
 
+function waitForTime(time) {
+  let deferred = promise.defer();
+  setTimeout(() => {
+    deferred.resolve();
+  }, time);
+  return deferred.promise;
+}
+
 function documentIsLoaded(doc) {
   let deferred = promise.defer();
   if (doc.readyState == "complete") {
     deferred.resolve();
   } else {
     doc.addEventListener("readystatechange", function onChange() {
       if (doc.readyState == "complete") {
         doc.removeEventListener("readystatechange", onChange);
--- a/browser/devtools/webide/test/test_deviceinfo.html
+++ b/browser/devtools/webide/test/test_deviceinfo.html
@@ -15,18 +15,21 @@
   <body>
 
     <script type="application/javascript;version=1.8">
       window.onload = function() {
         SimpleTest.waitForExplicitFinish();
 
         Task.spawn(function* () {
           Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
-          DebuggerServer.init(function () { return true; });
-          DebuggerServer.addBrowserActors();
+
+          if (!DebuggerServer.initialized) {
+            DebuggerServer.init(function () { return true; });
+            DebuggerServer.addBrowserActors();
+          }
 
           let win = yield openWebIDE();
 
           let permIframe = win.document.querySelector("#deck-panel-permissionstable");
           let infoIframe = win.document.querySelector("#deck-panel-runtimedetails");
 
           yield documentIsLoaded(permIframe.contentWindow.document);
           yield documentIsLoaded(infoIframe.contentWindow.document);
--- a/browser/devtools/webide/test/test_runtime.html
+++ b/browser/devtools/webide/test/test_runtime.html
@@ -13,31 +13,46 @@
   </head>
 
   <body>
 
     <script type="application/javascript;version=1.8">
       window.onload = function() {
         SimpleTest.waitForExplicitFinish();
 
+        let win;
+
+        SimpleTest.registerCleanupFunction(() => {
+          Task.spawn(function*() {
+            if (win) {
+              yield closeWebIDE(win);
+            }
+            DebuggerServer.destroy();
+            yield removeAllProjects();
+          });
+        });
+
         Task.spawn(function* () {
 
           function isPlayActive() {
             return !win.document.querySelector("#cmd_play").hasAttribute("disabled");
           }
 
           function isStopActive() {
             return !win.document.querySelector("#cmd_stop").hasAttribute("disabled");
           }
 
           Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
-          DebuggerServer.init(function () { return true; });
-          DebuggerServer.addBrowserActors();
 
-          let win = yield openWebIDE();
+          if (!DebuggerServer.initialized) {
+            DebuggerServer.init(function () { return true; });
+            DebuggerServer.addBrowserActors();
+          }
+
+          win = yield openWebIDE();
 
           win.AppManager.runtimeList.usb.push({
             connect: function(connection) {
               ok(connection, win.AppManager.connection, "connection is valid");
               connection.host = null; // force connectPipe
               connection.connect();
               return promise.resolve();
             },
@@ -114,22 +129,16 @@
           // Toolbox opens automatically for main process / runtime apps
           ok(win.UI.toolboxPromise, "Toolbox promise exists");
           yield win.UI.toolboxPromise;
 
           ok(win.UI.toolboxIframe, "Toolbox iframe exists");
 
           yield win.Cmds.disconnectRuntime();
 
-          yield closeWebIDE(win);
-
-          DebuggerServer.destroy();
-
-          yield removeAllProjects();
-
           SimpleTest.finish();
 
         });
       }
 
 
     </script>
   </body>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webide/test/test_telemetry.html
@@ -0,0 +1,255 @@
+<!DOCTYPE html>
+
+<html>
+
+  <head>
+    <meta charset="utf8">
+    <title></title>
+
+    <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+    <script type="application/javascript" src="chrome://mochikit/content/chrome-harness.js"></script>
+    <script type="application/javascript;version=1.8" src="head.js"></script>
+    <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  </head>
+
+  <body>
+
+    <script type="application/javascript;version=1.8">
+      const Telemetry = require("devtools/shared/telemetry");
+      const { USBRuntime, WiFiRuntime, SimulatorRuntime, gRemoteRuntime,
+              gLocalRuntime } = require("devtools/webide/runtimes");
+
+      // Because we need to gather stats for the period of time that a tool has
+      // been opened we make use of setTimeout() to create tool active times.
+      const TOOL_DELAY = 200;
+
+      function patchTelemetry() {
+        Telemetry.prototype.telemetryInfo = {};
+        Telemetry.prototype._oldlog = Telemetry.prototype.log;
+        Telemetry.prototype.log = function(histogramId, value) {
+          if (histogramId) {
+            if (!this.telemetryInfo[histogramId]) {
+              this.telemetryInfo[histogramId] = [];
+            }
+            this.telemetryInfo[histogramId].push(value);
+          }
+        }
+      }
+
+      function resetTelemetry() {
+        Telemetry.prototype.log = Telemetry.prototype._oldlog;
+        delete Telemetry.prototype._oldlog;
+        delete Telemetry.prototype.telemetryInfo;
+      }
+
+      function cycleWebIDE() {
+        return Task.spawn(function*() {
+          let win = yield openWebIDE();
+          // Wait a bit, so we're open for a non-zero time
+          yield waitForTime(TOOL_DELAY);
+          yield closeWebIDE(win);
+        });
+      }
+
+      function addFakeRuntimes(win) {
+        // We use the real runtimes here (and switch out some functionality)
+        // so we can ensure that logging happens as it would in real use.
+
+        let usb = new USBRuntime("fakeUSB");
+        // Use local pipe instead
+        usb.connect = function(connection) {
+          ok(connection, win.AppManager.connection, "connection is valid");
+          connection.host = null; // force connectPipe
+          connection.connect();
+          return promise.resolve();
+        };
+        win.AppManager.runtimeList.usb.push(usb);
+
+        let wifi = new WiFiRuntime("fakeWiFi");
+        // Use local pipe instead
+        wifi.connect = function(connection) {
+          ok(connection, win.AppManager.connection, "connection is valid");
+          connection.host = null; // force connectPipe
+          connection.connect();
+          return promise.resolve();
+        };
+        win.AppManager.runtimeList.wifi.push(wifi);
+
+        let sim = new SimulatorRuntime("fakeSimulator");
+        // Use local pipe instead
+        sim.connect = function(connection) {
+          ok(connection, win.AppManager.connection, "connection is valid");
+          connection.host = null; // force connectPipe
+          connection.connect();
+          return promise.resolve();
+        };
+        sim.getName = function() {
+          return this.version;
+        };
+        win.AppManager.runtimeList.simulator.push(sim);
+
+        let remote = gRemoteRuntime;
+        // Use local pipe instead
+        remote.connect = function(connection) {
+          ok(connection, win.AppManager.connection, "connection is valid");
+          connection.host = null; // force connectPipe
+          connection.connect();
+          return promise.resolve();
+        };
+        let local = gLocalRuntime;
+        win.AppManager.runtimeList.custom = [gRemoteRuntime, gLocalRuntime];
+
+        win.AppManager.update("runtimelist");
+      }
+
+      function addTestApp(win) {
+        return Task.spawn(function*() {
+          let packagedAppLocation = getTestFilePath("app");
+          yield win.Cmds.importPackagedApp(packagedAppLocation);
+        });
+      }
+
+      function startConnection(win, type, index) {
+        let panelNode = win.document.querySelector("#runtime-panel");
+        let items = panelNode.querySelectorAll(".runtime-panel-item-" + type);
+        if (index === undefined) {
+          is(items.length, 1, "Found one runtime button");
+        }
+
+        let deferred = promise.defer();
+        win.AppManager.connection.once(
+            win.Connection.Events.CONNECTED,
+            () => deferred.resolve());
+
+        items[index || 0].click();
+
+        return deferred.promise;
+      }
+
+      function waitUntilConnected(win) {
+        return Task.spawn(function*() {
+          ok(win.document.querySelector("window").className, "busy", "UI is busy");
+          yield win.UI._busyPromise;
+          is(Object.keys(DebuggerServer._connections).length, 1, "Connected");
+        });
+      }
+
+      function connectToRuntime(win, type, index) {
+        return Task.spawn(function*() {
+          yield startConnection(win, type, index);
+          yield waitUntilConnected(win);
+        });
+      }
+
+      function checkResults() {
+        let result = Telemetry.prototype.telemetryInfo;
+        for (let [histId, value] of Iterator(result)) {
+          if (histId.endsWith("OPENED_PER_USER_FLAG")) {
+            ok(value.length === 1 && !!value[0],
+               "Per user value " + histId + " has a single value of true");
+          } else if (histId.endsWith("OPENED_BOOLEAN")) {
+            ok(value.length > 1, histId + " has more than one entry");
+
+            let okay = value.every(function(element) {
+              return !!element;
+            });
+
+            ok(okay, "All " + histId + " entries are true");
+          } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) {
+            ok(value.length > 1, histId + " has more than one entry");
+
+            let okay = value.every(function(element) {
+              return element > 0;
+            });
+
+            ok(okay, "All " + histId + " entries have time > 0");
+          } else if (histId === "DEVTOOLS_WEBIDE_CONNECTION_RESULT") {
+            ok(value.length === 5, histId + " has 5 connection results");
+
+            let okay = value.every(function(element) {
+              return !!element;
+            });
+
+            ok(okay, "All " + histId + " connections succeeded");
+          } else if (histId.endsWith("CONNECTION_RESULT")) {
+            ok(value.length === 1 && !!value[0],
+               histId + " has 1 successful connection");
+          } else if (histId === "DEVTOOLS_WEBIDE_CONNECTION_TIME_SECONDS") {
+            ok(value.length === 5, histId + " has 5 connection results");
+
+            let okay = value.every(function(element) {
+              return element > 0;
+            });
+
+            ok(okay, "All " + histId + " connections have time > 0");
+          } else if (histId.endsWith("USED")) {
+            ok(value.length === 5, histId + " has 5 connection actions");
+
+            let okay = value.every(function(element) {
+              return !element;
+            });
+
+            ok(okay, "All " + histId + " actions were skipped");
+          } else {
+            ok(false, "Unexpected " + histId + " was logged");
+          }
+        }
+      }
+
+      window.onload = function() {
+        SimpleTest.waitForExplicitFinish();
+
+        let win;
+
+        SimpleTest.registerCleanupFunction(() => {
+          Task.spawn(function*() {
+            if (win) {
+              yield closeWebIDE(win);
+            }
+            DebuggerServer.destroy();
+            yield removeAllProjects();
+            resetTelemetry();
+          });
+        });
+
+        Task.spawn(function*() {
+          Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
+
+          if (!DebuggerServer.initialized) {
+            DebuggerServer.init(function () { return true; });
+            DebuggerServer.addBrowserActors();
+          }
+
+          patchTelemetry();
+
+          // Cycle once, so we can test for multiple opens
+          yield cycleWebIDE();
+
+          win = yield openWebIDE();
+          // Wait a bit, so we're open for a non-zero time
+          yield waitForTime(TOOL_DELAY);
+          addFakeRuntimes(win);
+          yield addTestApp(win);
+
+          // Each one should log a connection result and non-zero connection
+          // time
+          yield connectToRuntime(win, "usb");
+          yield waitForTime(TOOL_DELAY);
+          yield connectToRuntime(win, "wifi");
+          yield waitForTime(TOOL_DELAY);
+          yield connectToRuntime(win, "simulator");
+          yield waitForTime(TOOL_DELAY);
+          yield connectToRuntime(win, "custom", 0 /* remote */);
+          yield waitForTime(TOOL_DELAY);
+          yield connectToRuntime(win, "custom", 1 /* local */);
+          yield waitForTime(TOOL_DELAY);
+          yield closeWebIDE(win);
+
+          checkResults();
+
+          SimpleTest.finish();
+        });
+      }
+    </script>
+  </body>
+</html>
--- a/configure.in
+++ b/configure.in
@@ -3963,16 +3963,29 @@ AC_SUBST(MOZ_MOZILLA_API_KEY)
 MOZ_ARG_WITH_STRING(google-api-keyfile,
 [  --with-google-api-keyfile=file   Use the secret key contained in the given keyfile for Google API requests],
   MOZ_GOOGLE_API_KEY=`cat $withval`)
 if test -z "$MOZ_GOOGLE_API_KEY"; then
     MOZ_GOOGLE_API_KEY=no-google-api-key
 fi
 AC_SUBST(MOZ_GOOGLE_API_KEY)
 
+# Allow to specify a Google OAuth API key file that contains the client ID and
+# the secret key to be used for various Google OAuth API requests.
+MOZ_ARG_WITH_STRING(google-oauth-api-keyfile,
+[ --with-google-oauth-api-keyfile=file  Use the client id and secret key contained in the given keyfile for Google OAuth API requests],
+ [MOZ_GOOGLE_OAUTH_API_CLIENTID=`cat $withval | cut -f 1 -d " "`
+  MOZ_GOOGLE_OAUTH_API_KEY=`cat $withval | cut -f 2 -d " "`])
+if test -z "$MOZ_GOOGLE_OAUTH_API_CLIENTID"; then
+    MOZ_GOOGLE_OAUTH_API_CLIENTID=no-google-oauth-api-clientid
+    MOZ_GOOGLE_OAUTH_API_KEY=no-google-oauth-api-key
+fi
+AC_SUBST(MOZ_GOOGLE_OAUTH_API_CLIENTID)
+AC_SUBST(MOZ_GOOGLE_OAUTH_API_KEY)
+
 # Allow specifying a Bing API key file that contains the client ID and the
 # secret key to be used for the Bing Translation API requests.
 MOZ_ARG_WITH_STRING(bing-api-keyfile,
 [  --with-bing-api-keyfile=file   Use the client id and secret key contained in the given keyfile for Bing API requests],
  [MOZ_BING_API_CLIENTID=`cat $withval | cut -f 1 -d " "`
   MOZ_BING_API_KEY=`cat $withval | cut -f 2 -d " "`])
 if test -z "$MOZ_BING_API_CLIENTID"; then
     MOZ_BING_API_CLIENTID=no-bing-api-clientid
--- a/docshell/base/nsDefaultURIFixup.cpp
+++ b/docshell/base/nsDefaultURIFixup.cpp
@@ -295,56 +295,51 @@ nsDefaultURIFixup::GetFixupURIInfo(const
 
     // Now we need to check whether "scheme" is something we don't
     // really know about.
     nsCOMPtr<nsIProtocolHandler> ourHandler, extHandler;
     
     ioService->GetProtocolHandler(scheme.get(), getter_AddRefs(ourHandler));
     extHandler = do_GetService(NS_NETWORK_PROTOCOL_CONTRACTID_PREFIX"default");
 
-    nsCOMPtr<nsIURI> uri;
     if (ourHandler != extHandler || !PossiblyHostPortUrl(uriString)) {
         // Just try to create an URL out of it
-        rv = NS_NewURI(getter_AddRefs(uri), uriString, nullptr);
-        if (NS_SUCCEEDED(rv)) {
-            info->mFixedURI = uri;
-        }
+        rv = NS_NewURI(getter_AddRefs(info->mFixedURI), uriString, nullptr);
 
-        if (!uri && rv != NS_ERROR_MALFORMED_URI) {
+        if (!info->mFixedURI && rv != NS_ERROR_MALFORMED_URI) {
             return rv;
         }
     }
 
-    if (uri && ourHandler == extHandler && sFixupKeywords &&
+    if (info->mFixedURI && ourHandler == extHandler && sFixupKeywords &&
         (aFixupFlags & FIXUP_FLAG_FIX_SCHEME_TYPOS)) {
         nsCOMPtr<nsIExternalProtocolService> extProtService =
             do_GetService(NS_EXTERNALPROTOCOLSERVICE_CONTRACTID);
         if (extProtService) {
             bool handlerExists = false;
             rv = extProtService->ExternalProtocolHandlerExists(scheme.get(), &handlerExists);
             if (NS_FAILED(rv)) {
                 return rv;
             }
             // This basically means we're dealing with a theoretically valid
             // URI... but we have no idea how to load it. (e.g. "christmas:humbug")
             // It's more likely the user wants to search, and so we
             // chuck this over to their preferred search provider instead:
             if (!handlerExists) {
-                nsresult rv = KeywordToURI(uriString, aPostData, getter_AddRefs(uri));
-                if (NS_SUCCEEDED(rv) && uri) {
-                  info->mFixupUsedKeyword = true;
-                }
+                TryKeywordFixupForURIInfo(uriString, info, aPostData);
             }
         }
     }
     
-    if (uri) {
-        if (aFixupFlags & FIXUP_FLAGS_MAKE_ALTERNATE_URI)
-            info->mFixupCreatedAlternateURI = MakeAlternateURI(uri);
-        info->mPreferredURI = uri;
+    if (info->mFixedURI) {
+        if (!info->mPreferredURI) {
+            if (aFixupFlags & FIXUP_FLAGS_MAKE_ALTERNATE_URI)
+                info->mFixupCreatedAlternateURI = MakeAlternateURI(info->mFixedURI);
+            info->mPreferredURI = info->mFixedURI;
+        }
         return NS_OK;
     }
 
     // Fix up protocol string before calling KeywordURIFixup, because
     // it cares about the hostname of such URIs:
     nsCOMPtr<nsIURI> uriWithProtocol;
     bool inputHadDuffProtocol = false;
 
@@ -369,19 +364,20 @@ nsDefaultURIFixup::GetFixupURIInfo(const
     if (uriWithProtocol) {
         info->mFixedURI = uriWithProtocol;
     }
 
     // See if it is a keyword
     // Test whether keywords need to be fixed up
     if (sFixupKeywords && (aFixupFlags & FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP) &&
         !inputHadDuffProtocol) {
-        KeywordURIFixup(uriString, info, aPostData);
-        if (info->mPreferredURI)
+        if (NS_SUCCEEDED(KeywordURIFixup(uriString, info, aPostData)) &&
+            info->mPreferredURI) {
             return NS_OK;
+        }
     }
 
     // Did the caller want us to try an alternative URI?
     // If so, attempt to fixup http://foo into http://www.foo.com
 
     if (info->mFixedURI && aFixupFlags & FIXUP_FLAGS_MAKE_ALTERNATE_URI) {
         info->mFixupCreatedAlternateURI = MakeAlternateURI(info->mFixedURI);
     }
@@ -410,32 +406,29 @@ nsDefaultURIFixup::GetFixupURIInfo(const
         }
 
         return NS_OK;
     }
 
     // If we still haven't been able to construct a valid URI, try to force a
     // keyword match.  This catches search strings with '.' or ':' in them.
     if (sFixupKeywords && (aFixupFlags & FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP)) {
-        rv = KeywordToURI(aStringURI, aPostData, getter_AddRefs(info->mPreferredURI));
-        if (NS_SUCCEEDED(rv) && info->mPreferredURI)
-        {
-            info->mFixupUsedKeyword = true;
-            return NS_OK;
-        }
+        rv = TryKeywordFixupForURIInfo(aStringURI, info, aPostData);
     }
 
     return rv;
 }
 
 NS_IMETHODIMP nsDefaultURIFixup::KeywordToURI(const nsACString& aKeyword,
                                               nsIInputStream **aPostData,
-                                              nsIURI **aURI)
+                                              nsIURIFixupInfo **aInfo)
 {
-    *aURI = nullptr;
+    nsRefPtr<nsDefaultURIFixupInfo> info = new nsDefaultURIFixupInfo(aKeyword);
+    NS_ADDREF(*aInfo = info);
+
     if (aPostData) {
         *aPostData = nullptr;
     }
     NS_ENSURE_STATE(Preferences::GetRootBranch());
 
     // Strip leading "?" and leading/trailing spaces from aKeyword
     nsAutoCString keyword(aKeyword);
     if (StringBeginsWith(keyword, NS_LITERAL_CSTRING("?"))) {
@@ -446,30 +439,34 @@ NS_IMETHODIMP nsDefaultURIFixup::Keyword
     if (XRE_GetProcessType() == GeckoProcessType_Content) {
         dom::ContentChild* contentChild = dom::ContentChild::GetSingleton();
         if (!contentChild) {
             return NS_ERROR_NOT_AVAILABLE;
         }
 
         ipc::OptionalInputStreamParams postData;
         ipc::OptionalURIParams uri;
-        if (!contentChild->SendKeywordToURI(keyword, &postData, &uri)) {
+        nsAutoString providerName;
+        if (!contentChild->SendKeywordToURI(keyword, &providerName, &postData, &uri)) {
             return NS_ERROR_FAILURE;
         }
 
+        CopyUTF8toUTF16(keyword, info->mKeywordAsSent);
+        info->mKeywordProviderName = providerName;
+
         if (aPostData) {
             nsTArray<ipc::FileDescriptor> fds;
             nsCOMPtr<nsIInputStream> temp = DeserializeInputStream(postData, fds);
             temp.forget(aPostData);
 
             MOZ_ASSERT(fds.IsEmpty());
         }
 
         nsCOMPtr<nsIURI> temp = DeserializeURI(uri);
-        temp.forget(aURI);
+        info->mPreferredURI = temp.forget();
         return NS_OK;
     }
 
 #ifdef MOZ_TOOLKIT_SEARCH
     // Try falling back to the search service's default search engine
     nsCOMPtr<nsIBrowserSearchService> searchSvc = do_GetService("@mozilla.org/browser/search-service;1");
     if (searchSvc) {
         nsCOMPtr<nsISearchEngine> defaultEngine;
@@ -481,17 +478,18 @@ NS_IMETHODIMP nsDefaultURIFixup::Keyword
             // parameters that are specific to keyword searches.
             NS_NAMED_LITERAL_STRING(mozKeywordSearch, "application/x-moz-keywordsearch");
             bool supportsResponseType = false;
             defaultEngine->SupportsResponseType(mozKeywordSearch, &supportsResponseType);
             if (supportsResponseType) {
                 responseType.Assign(mozKeywordSearch);
             }
 
-            defaultEngine->GetSubmission(NS_ConvertUTF8toUTF16(keyword),
+            NS_ConvertUTF8toUTF16 keywordW(keyword);
+            defaultEngine->GetSubmission(keywordW,
                                          responseType,
                                          NS_LITERAL_STRING("keyword"),
                                          getter_AddRefs(submission));
 
             if (submission) {
                 nsCOMPtr<nsIInputStream> postData;
                 submission->GetPostData(getter_AddRefs(postData));
                 if (aPostData) {
@@ -499,40 +497,44 @@ NS_IMETHODIMP nsDefaultURIFixup::Keyword
                 } else if (postData) {
                   // The submission specifies POST data (i.e. the search
                   // engine's "method" is POST), but our caller didn't allow
                   // passing post data back. No point passing back a URL that
                   // won't load properly.
                   return NS_ERROR_FAILURE;
                 }
 
-                // This notification is meant for Firefox Health Report so it
-                // can increment counts from the search engine. The assumption
-                // here is that this keyword/submission will eventually result
-                // in a search. Since we only generate a URI here, there is the
-                // possibility we'll increment the counter without actually
-                // incurring a search. A robust solution would involve currying
-                // the search engine's name through various function calls.
-                nsCOMPtr<nsIObserverService> obsSvc = mozilla::services::GetObserverService();
-                if (obsSvc) {
-                  // Note that "keyword-search" refers to a search via the url
-                  // bar, not a bookmarks keyword search.
-                  obsSvc->NotifyObservers(defaultEngine, "keyword-search", NS_ConvertUTF8toUTF16(keyword).get());
-                }
-
-                return submission->GetUri(aURI);
+                defaultEngine->GetName(info->mKeywordProviderName);
+                info->mKeywordAsSent = keywordW;
+                return submission->GetUri(getter_AddRefs(info->mPreferredURI));
             }
         }
     }
 #endif
 
     // out of options
     return NS_ERROR_NOT_AVAILABLE;
 }
 
+// Helper to deal with passing around uri fixup stuff
+nsresult
+nsDefaultURIFixup::TryKeywordFixupForURIInfo(const nsACString & aURIString,
+                                             nsDefaultURIFixupInfo* aFixupInfo,
+                                             nsIInputStream **aPostData)
+{
+    nsCOMPtr<nsIURIFixupInfo> keywordInfo;
+    nsresult rv = KeywordToURI(aURIString, aPostData, getter_AddRefs(keywordInfo));
+    if (NS_SUCCEEDED(rv)) {
+        keywordInfo->GetKeywordProviderName(aFixupInfo->mKeywordProviderName);
+        keywordInfo->GetKeywordAsSent(aFixupInfo->mKeywordAsSent);
+        keywordInfo->GetPreferredURI(getter_AddRefs(aFixupInfo->mPreferredURI));
+    }
+    return rv;
+}
+
 bool nsDefaultURIFixup::MakeAlternateURI(nsIURI *aURI)
 {
     if (!Preferences::GetRootBranch())
     {
         return false;
     }
     if (!Preferences::GetBool("browser.fixup.alternate.enabled", true))
     {
@@ -918,19 +920,20 @@ bool nsDefaultURIFixup::PossiblyByteExpa
     {
         if (*iter >= 0x0080 && *iter <= 0x00FF)
             return true;
         ++iter;
     }
     return false;
 }
 
-void nsDefaultURIFixup::KeywordURIFixup(const nsACString & aURIString,
-                                        nsDefaultURIFixupInfo* aFixupInfo,
-                                        nsIInputStream **aPostData)
+nsresult
+nsDefaultURIFixup::KeywordURIFixup(const nsACString & aURIString,
+                                   nsDefaultURIFixupInfo* aFixupInfo,
+                                   nsIInputStream **aPostData)
 {
     // These are keyword formatted strings
     // "what is mozilla"
     // "what is mozilla?"
     // "docshell site:mozilla.org" - has no dot/colon in the first space-separated substring
     // "?mozilla" - anything that begins with a question mark
     // "?site:mozilla.org docshell"
     // Things that have a quote before the first dot/colon
@@ -1018,17 +1021,16 @@ void nsDefaultURIFixup::KeywordURIFixup(
         pos++;
         iter++;
     }
 
     if (lastLSBracketLoc > 0 || foundRSBrackets != 1) {
         looksLikeIpv6 = false;
     }
 
-    nsresult rv;
     nsAutoCString asciiHost;
     nsAutoCString host;
 
     bool isValidAsciiHost = aFixupInfo->mFixedURI &&
         NS_SUCCEEDED(aFixupInfo->mFixedURI->GetAsciiHost(asciiHost)) &&
         !asciiHost.IsEmpty();
 
     bool isValidHost = aFixupInfo->mFixedURI &&
@@ -1036,72 +1038,66 @@ void nsDefaultURIFixup::KeywordURIFixup(
         !host.IsEmpty();
 
     // If there are 2 dots and only numbers between them, an optional port number
     // and a trailing slash, then don't do a keyword lookup
     if (foundDots == 2 && lastSlashLoc == pos - 1 &&
         ((foundDots + foundDigits == pos - 1) ||
          (foundColons == 1 && firstColonLoc > lastDotLoc &&
           foundDots + foundDigits + foundColons == pos - 1))) {
-        return;
+        return NS_OK;
     }
 
     uint32_t posWithNoTrailingSlash = pos;
     if (lastSlashLoc == pos - 1) {
         posWithNoTrailingSlash -= 1;
     }
     // If there are 3 dots and only numbers between them, an optional port number
     // and an optional trailling slash, then don't do a keyword lookup (ipv4)
     if (foundDots == 3 &&
         ((foundDots + foundDigits == posWithNoTrailingSlash) ||
          (foundColons == 1 && firstColonLoc > lastDotLoc &&
           foundDots + foundDigits + foundColons == posWithNoTrailingSlash))) {
-        return;
+        return NS_OK;
     }
 
     // If there are only colons and only hexadecimal characters ([a-z][0-9])
     // enclosed in [], then don't do a keyword lookup
     if (looksLikeIpv6) {
-        return;
+        return NS_OK;
     }
 
+    nsresult rv = NS_OK;
     // We do keyword lookups if a space or quote preceded the dot, colon
     // or question mark (or if the latter were not found)
     // or when the host is the same as asciiHost and there are no
     // characters from [a-z][A-Z]
     if (((firstSpaceLoc < firstDotLoc || firstQuoteLoc < firstDotLoc) &&
          (firstSpaceLoc < firstColonLoc || firstQuoteLoc < firstColonLoc) &&
          (firstSpaceLoc < firstQMarkLoc || firstQuoteLoc < firstQMarkLoc)) || firstQMarkLoc == 0 ||
         (isValidAsciiHost && isValidHost && !hasAsciiAlpha &&
          host.EqualsIgnoreCase(asciiHost.get()))) {
 
-        rv = KeywordToURI(aFixupInfo->mOriginalInput, aPostData,
-                          getter_AddRefs(aFixupInfo->mPreferredURI));
-        if (NS_SUCCEEDED(rv) && aFixupInfo->mPreferredURI) {
-            aFixupInfo->mFixupUsedKeyword = true;
-        }
+        rv = TryKeywordFixupForURIInfo(aFixupInfo->mOriginalInput, aFixupInfo, aPostData);
     }
     // ... or if there is no question mark or colon, and there is either no
     // dot, or exactly 1 and it is the first or last character of the input:
     else if ((firstDotLoc == uint32_t(kNotFound) ||
               (foundDots == 1 && (firstDotLoc == 0 || firstDotLoc == aURIString.Length() - 1))) &&
               firstColonLoc == uint32_t(kNotFound) && firstQMarkLoc == uint32_t(kNotFound)) {
 
         if (isValidAsciiHost && IsDomainWhitelisted(asciiHost, firstDotLoc)) {
-            return;
+            return NS_OK;
         }
 
         // If we get here, we don't have a valid URI, or we did but the
         // host is not whitelisted, so we do a keyword search *anyway*:
-        rv = KeywordToURI(aFixupInfo->mOriginalInput, aPostData,
-                          getter_AddRefs(aFixupInfo->mPreferredURI));
-        if (NS_SUCCEEDED(rv) && aFixupInfo->mPreferredURI) {
-            aFixupInfo->mFixupUsedKeyword = true;
-        }
+        rv = TryKeywordFixupForURIInfo(aFixupInfo->mOriginalInput, aFixupInfo, aPostData);
     }
+    return rv;
 }
 
 bool nsDefaultURIFixup::IsDomainWhitelisted(const nsAutoCString aAsciiHost,
                                             const uint32_t aDotLoc)
 {
     // Check if this domain is whitelisted as an actual
     // domain (which will prevent a keyword query)
     // NB: any processing of the host here should stay in sync with
@@ -1129,17 +1125,16 @@ nsresult NS_NewURIFixup(nsIURIFixup **aU
     return fixup->QueryInterface(NS_GET_IID(nsIURIFixup), (void **) aURIFixup);
 }
 
 
 /* Implementation of nsIURIFixupInfo */
 NS_IMPL_ISUPPORTS(nsDefaultURIFixupInfo, nsIURIFixupInfo)
 
 nsDefaultURIFixupInfo::nsDefaultURIFixupInfo(const nsACString& aOriginalInput):
-    mFixupUsedKeyword(false),
     mFixupChangedProtocol(false),
     mFixupCreatedAlternateURI(false)
 {
   mOriginalInput = aOriginalInput;
 }
 
 
 nsDefaultURIFixupInfo::~nsDefaultURIFixupInfo()
@@ -1173,19 +1168,26 @@ NS_IMETHODIMP
 nsDefaultURIFixupInfo::GetFixedURI(nsIURI** aFixedURI)
 {
     *aFixedURI = mFixedURI;
     NS_IF_ADDREF(*aFixedURI);
     return NS_OK;
 }
 
 NS_IMETHODIMP
-nsDefaultURIFixupInfo::GetFixupUsedKeyword(bool* aOut)
+nsDefaultURIFixupInfo::GetKeywordProviderName(nsAString& aOut)
 {
-    *aOut = mFixupUsedKeyword;
+    aOut = mKeywordProviderName;
+    return NS_OK;
+}
+
+NS_IMETHODIMP
+nsDefaultURIFixupInfo::GetKeywordAsSent(nsAString& aOut)
+{
+    aOut = mKeywordAsSent;
     return NS_OK;
 }
 
 NS_IMETHODIMP
 nsDefaultURIFixupInfo::GetFixupChangedProtocol(bool* aOut)
 {
     *aOut = mFixupChangedProtocol;
     return NS_OK;
--- a/docshell/base/nsDefaultURIFixup.h
+++ b/docshell/base/nsDefaultURIFixup.h
@@ -25,19 +25,22 @@ protected:
 
 private:
     /* additional members */
     nsresult FileURIFixup(const nsACString &aStringURI, nsIURI** aURI);
     nsresult ConvertFileToStringURI(const nsACString& aIn, nsCString& aOut);
     nsresult FixupURIProtocol(const nsACString& aIn,
                               nsDefaultURIFixupInfo* aFixupInfo,
                               nsIURI** aURI);
-    void KeywordURIFixup(const nsACString &aStringURI,
-                         nsDefaultURIFixupInfo* aFixupInfo,
-                         nsIInputStream** aPostData);
+    nsresult KeywordURIFixup(const nsACString &aStringURI,
+                             nsDefaultURIFixupInfo* aFixupInfo,
+                             nsIInputStream** aPostData);
+    nsresult TryKeywordFixupForURIInfo(const nsACString &aStringURI,
+                                       nsDefaultURIFixupInfo* aFixupInfo,
+                                       nsIInputStream** aPostData);
     bool PossiblyByteExpandedFileName(const nsAString& aIn);
     bool PossiblyHostPortUrl(const nsACString& aUrl);
     bool MakeAlternateURI(nsIURI *aURI);
     bool IsLikelyFTP(const nsCString& aHostSpec);
     bool IsDomainWhitelisted(const nsAutoCString aAsciiHost,
                              const uint32_t aDotLoc);
 };
 
@@ -53,14 +56,15 @@ public:
 
 protected:
     virtual ~nsDefaultURIFixupInfo();
 
 private:
     nsCOMPtr<nsISupports> mConsumer;
     nsCOMPtr<nsIURI> mPreferredURI;
     nsCOMPtr<nsIURI> mFixedURI;
-    bool mFixupUsedKeyword;
     bool mFixupChangedProtocol;
     bool mFixupCreatedAlternateURI;
+    nsString mKeywordProviderName;
+    nsString mKeywordAsSent;
     nsAutoCString mOriginalInput;
 };
 #endif
--- a/docshell/base/nsDocShell.cpp
+++ b/docshell/base/nsDocShell.cpp
@@ -196,16 +196,20 @@
 #include "nsIURIFixup.h"
 #include "nsIURILoader.h"
 #include "nsIWebBrowserFind.h"
 #include "nsIWidget.h"
 #include "mozilla/dom/EncodingUtils.h"
 #include "mozilla/dom/ScriptSettings.h"
 #include "mozilla/dom/URLSearchParams.h"
 
+#ifdef MOZ_TOOLKIT_SEARCH
+#include "nsIBrowserSearchService.h"
+#endif
+
 static NS_DEFINE_CID(kAppShellCID, NS_APPSHELL_CID);
 
 #if defined(DEBUG_bryner) || defined(DEBUG_chb)
 //#define DEBUG_DOCSHELL_FOCUS
 #define DEBUG_PAGE_CACHE
 #endif
 
 #ifdef XP_WIN
@@ -4578,41 +4582,41 @@ nsDocShell::LoadURIWithBase(const char16
     uriString.StripChars("\r\n");
     NS_ENSURE_TRUE(!uriString.IsEmpty(), NS_ERROR_FAILURE);
 
     rv = NS_NewURI(getter_AddRefs(uri), uriString);
     if (uri) {
         aLoadFlags &= ~LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP;
     }
     
+    nsCOMPtr<nsIURIFixupInfo> fixupInfo;
     if (sURIFixup) {
         // Call the fixup object.  This will clobber the rv from NS_NewURI
         // above, but that's fine with us.  Note that we need to do this even
         // if NS_NewURI returned a URI, because fixup handles nested URIs, etc
         // (things like view-source:mozilla.org for example).
         uint32_t fixupFlags = 0;
         if (aLoadFlags & LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP) {
           fixupFlags |= nsIURIFixup::FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
         }
         if (aLoadFlags & LOAD_FLAGS_FIXUP_SCHEME_TYPOS) {
           fixupFlags |= nsIURIFixup::FIXUP_FLAG_FIX_SCHEME_TYPOS;
         }
         nsCOMPtr<nsIInputStream> fixupStream;
-        nsCOMPtr<nsIURIFixupInfo> fixupInfo;
         rv = sURIFixup->GetFixupURIInfo(uriString, fixupFlags,
                                         getter_AddRefs(fixupStream),
                                         getter_AddRefs(fixupInfo));
 
         if (NS_SUCCEEDED(rv)) {
             fixupInfo->GetPreferredURI(getter_AddRefs(uri));
             fixupInfo->SetConsumer(GetAsSupports(this));
         }
 
         if (fixupStream) {
-            // CreateFixupURI only returns a post data stream if it succeeded
+            // GetFixupURIInfo only returns a post data stream if it succeeded
             // and changed the URI, in which case we should override the
             // passed-in post data.
             postStream = fixupStream;
         }
 
         if (aLoadFlags & LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP) {
             nsCOMPtr<nsIObserverService> serv = services::GetObserverService();
             if (serv) {
@@ -4661,16 +4665,23 @@ nsDocShell::LoadURIWithBase(const char16
     }
 
     loadInfo->SetLoadType(ConvertLoadTypeToDocShellLoadInfo(loadType));
     loadInfo->SetPostDataStream(postStream);
     loadInfo->SetReferrer(aReferringURI);
     loadInfo->SetHeadersStream(aHeaderStream);
     loadInfo->SetBaseURI(aBaseURI);
 
+    if (fixupInfo) {
+        nsAutoString searchProvider, keyword;
+        fixupInfo->GetKeywordProviderName(searchProvider);
+        fixupInfo->GetKeywordAsSent(keyword);
+        MaybeNotifyKeywordSearchLoading(searchProvider, keyword);
+    }
+
     rv = LoadURI(uri, loadInfo, extraFlags, true);
 
     // Save URI string in case it's needed later when
     // sending to search engine service in EndPageLoad()
     mOriginalUriString = uriString; 
 
     return rv;
 }
@@ -7377,16 +7388,17 @@ nsDocShell::EndPageLoad(nsIWebProgress *
             nsCOMPtr<nsIInputStream> newPostData;
 
             nsAutoCString oldSpec;
             url->GetSpec(oldSpec);
       
             //
             // First try keyword fixup
             //
+            nsAutoString keywordProviderName, keywordAsSent;
             if (aStatus == NS_ERROR_UNKNOWN_HOST && mAllowKeywordFixup) {
                 bool keywordsEnabled =
                     Preferences::GetBool("keyword.enabled", false);
 
                 nsAutoCString host;
                 url->GetHost(host);
 
                 nsAutoCString scheme;
@@ -7407,21 +7419,22 @@ nsDocShell::EndPageLoad(nsIWebProgress *
                 // determine on a per url basis if we want keywords
                 // enabled...this is just a bandaid...
                 if (keywordsEnabled && !scheme.IsEmpty() &&
                     (scheme.Find("http") != 0)) {
                     keywordsEnabled = false;
                 }
 
                 if (keywordsEnabled && (kNotFound == dotLoc)) {
+                    nsCOMPtr<nsIURIFixupInfo> info;
                     // only send non-qualified hosts to the keyword server
                     if (!mOriginalUriString.IsEmpty()) {
                         sURIFixup->KeywordToURI(mOriginalUriString,
                                                 getter_AddRefs(newPostData),
-                                                getter_AddRefs(newURI));
+                                                getter_AddRefs(info));
                     }
                     else {
                         //
                         // If this string was passed through nsStandardURL by
                         // chance, then it may have been converted from UTF-8 to
                         // ACE, which would result in a completely bogus keyword
                         // query.  Here we try to recover the original Unicode
                         // value, but this is not 100% correct since the value may
@@ -7433,23 +7446,29 @@ nsDocShell::EndPageLoad(nsIWebProgress *
                         nsAutoCString utf8Host;
                         nsCOMPtr<nsIIDNService> idnSrv =
                             do_GetService(NS_IDNSERVICE_CONTRACTID);
                         if (idnSrv &&
                             NS_SUCCEEDED(idnSrv->IsACE(host, &isACE)) && isACE &&
                             NS_SUCCEEDED(idnSrv->ConvertACEtoUTF8(host, utf8Host))) {
                             sURIFixup->KeywordToURI(utf8Host,
                                                     getter_AddRefs(newPostData),
-                                                    getter_AddRefs(newURI));
+                                                    getter_AddRefs(info));
                         } else {
                             sURIFixup->KeywordToURI(host,
                                                     getter_AddRefs(newPostData),
-                                                    getter_AddRefs(newURI));
+                                                    getter_AddRefs(info));
                         }
                     }
+
+                    info->GetPreferredURI(getter_AddRefs(newURI));
+                    if (newURI) {
+                        info->GetKeywordAsSent(keywordAsSent);
+                        info->GetKeywordProviderName(keywordProviderName);
+                    }
                 } // end keywordsEnabled
             }
 
             //
             // Now try change the address, e.g. turn http://foo into
             // http://www.foo.com
             //
             if (aStatus == NS_ERROR_UNKNOWN_HOST ||
@@ -7472,16 +7491,18 @@ nsDocShell::EndPageLoad(nsIWebProgress *
                             // an alternate one.
                             doCreateAlternate = false;
                         }
                     }
                 }
                 if (doCreateAlternate) {
                     newURI = nullptr;
                     newPostData = nullptr;
+                    keywordProviderName.Truncate();
+                    keywordAsSent.Truncate();
                     sURIFixup->CreateFixupURI(oldSpec,
                       nsIURIFixup::FIXUP_FLAGS_MAKE_ALTERNATE_URI,
                                               getter_AddRefs(newPostData),
                                               getter_AddRefs(newURI));
                 }
             }
 
             // Did we make a new URI that is different to the old one? If so
@@ -7492,16 +7513,20 @@ nsDocShell::EndPageLoad(nsIWebProgress *
                 // otherwise there's little point trying to load it again.
                 bool sameURI = false;
                 url->Equals(newURI, &sameURI);
                 if (!sameURI) {
                     nsAutoCString newSpec;
                     newURI->GetSpec(newSpec);
                     NS_ConvertUTF8toUTF16 newSpecW(newSpec);
 
+                    // This notification is meant for Firefox Health Report so it
+                    // can increment counts from the search engine
+                    MaybeNotifyKeywordSearchLoading(keywordProviderName, keywordAsSent);
+
                     return LoadURI(newSpecW.get(),  // URI string
                                    LOAD_FLAGS_NONE, // Load flags
                                    nullptr,          // Referring URI
                                    newPostData,      // Post data stream
                                    nullptr);         // Headers stream
                 }
             }
         }
@@ -13503,8 +13528,41 @@ nsDocShell::GetOpenedRemote()
   return openedRemote;
 }
 
 URLSearchParams*
 nsDocShell::GetURLSearchParams()
 {
   return mURLSearchParams;
 }
+
+void
+nsDocShell::MaybeNotifyKeywordSearchLoading(const nsString &aProvider,
+                                            const nsString &aKeyword) {
+
+  if (aProvider.IsEmpty()) {
+    return;
+  }
+
+  if (XRE_GetProcessType() == GeckoProcessType_Content) {
+    dom::ContentChild* contentChild = dom::ContentChild::GetSingleton();
+    if (contentChild) {
+      contentChild->SendNotifyKeywordSearchLoading(aProvider, aKeyword);
+    }
+    return;
+  }
+
+#ifdef MOZ_TOOLKIT_SEARCH
+  nsCOMPtr<nsIBrowserSearchService> searchSvc = do_GetService("@mozilla.org/browser/search-service;1");
+  if (searchSvc) {
+    nsCOMPtr<nsISearchEngine> searchEngine;
+    searchSvc->GetEngineByName(aProvider, getter_AddRefs(searchEngine));
+    if (searchEngine) {
+      nsCOMPtr<nsIObserverService> obsSvc = mozilla::services::GetObserverService();
+      if (obsSvc) {
+        // Note that "keyword-search" refers to a search via the url
+        // bar, not a bookmarks keyword search.
+        obsSvc->NotifyObservers(searchEngine, "keyword-search", aKeyword.get());
+      }
+    }
+  }
+#endif
+}
--- a/docshell/base/nsDocShell.h
+++ b/docshell/base/nsDocShell.h
@@ -973,16 +973,19 @@ private:
 
     // Separate function to do the actual name (i.e. not _top, _self etc.)
     // searching for FindItemWithName.
     nsresult DoFindItemWithName(const char16_t* aName,
                                 nsISupports* aRequestor,
                                 nsIDocShellTreeItem* aOriginalRequestor,
                                 nsIDocShellTreeItem** _retval);
 
+    // Notify consumers of a search being loaded through the observer service:
+    void MaybeNotifyKeywordSearchLoading(const nsString &aProvider, const nsString &aKeyword);
+
 #ifdef DEBUG
     // We're counting the number of |nsDocShells| to help find leaks
     static unsigned long gNumberOfDocShells;
 #endif /* DEBUG */
 
 public:
     class InterfaceRequestorProxy : public nsIInterfaceRequestor {
     public:
--- a/docshell/base/nsIURIFixup.idl
+++ b/docshell/base/nsIURIFixup.idl
@@ -7,17 +7,17 @@
 #include "nsISupports.idl"
 
 interface nsIURI;
 interface nsIInputStream;
 
 /**
  * Interface indicating what we found/corrected when fixing up a URI
  */
-[scriptable, uuid(62aac1e0-3da8-4920-bd1b-a54fc2e2eb24)]
+[scriptable, uuid(4819f183-b532-4932-ac09-b309cd853be7)]
 interface nsIURIFixupInfo : nsISupports
 {
   /**
    * Consumer that asked for fixed up URI.
    */
   attribute nsISupports consumer;
 
   /**
@@ -31,19 +31,26 @@ interface nsIURIFixupInfo : nsISupports
   /**
    * The fixed-up original input, *never* using a keyword search.
    * (might be null if the original input was not recoverable as
    * a URL, e.g. "foo bar"!)
    */
   readonly attribute nsIURI fixedURI;
 
   /**
-   * Whether the preferred option ended up using a keyword search.
+   * The name of the keyword search provider used to provide a keyword search;
+   * empty string if no keyword search was done.
    */
-  readonly attribute boolean fixupUsedKeyword;
+  readonly attribute AString keywordProviderName;
+
+  /**
+   * The keyword as used for the search (post trimming etc.)
+   * empty string if no keyword search was done.
+   */
+  readonly attribute AString keywordAsSent;
 
   /**
    * Whether we changed the protocol instead of using one from the input as-is.
    */
   readonly attribute boolean fixupChangedProtocol;
 
   /**
    * Whether we created an alternative URI. We might have added a prefix and/or
@@ -58,17 +65,17 @@ interface nsIURIFixupInfo : nsISupports
    */
   readonly attribute AUTF8String originalInput;
 };
 
 
 /**
  * Interface implemented by objects capable of fixing up strings into URIs
  */
-[scriptable, uuid(49298f2b-3630-4874-aecc-522300a7fead)]
+[scriptable, uuid(d2a78abe-e678-4103-9bcc-dd1377460c44)]
 interface nsIURIFixup : nsISupports
 {
     /** No fixup flags. */
     const unsigned long FIXUP_FLAG_NONE = 0;
 
     /**
      * Allow the fixup to use a keyword lookup service to complete the URI.
      * The fixup object implementer should honour this flag and only perform
@@ -141,12 +148,12 @@ interface nsIURIFixup : nsISupports
      *
      * @param aKeyword  The keyword string to convert into a URI
      * @param aPostData The POST data to submit to the returned URI
      *                  (see nsISearchSubmission).
      *
      * @throws NS_ERROR_FAILURE if the resulting URI requires submission of POST
      *         data and aPostData is null.
      */
-    nsIURI keywordToURI(in AUTF8String aKeyword,
-                        [optional] out nsIInputStream aPostData);
+    nsIURIFixupInfo keywordToURI(in AUTF8String aKeyword,
+                                 [optional] out nsIInputStream aPostData);
 };
 
--- a/docshell/test/browser/browser.ini
+++ b/docshell/test/browser/browser.ini
@@ -90,12 +90,11 @@ skip-if = e10s # Bug ?????? - event hand
 skip-if = e10s
 [browser_loadDisallowInherit.js]
 skip-if = e10s
 [browser_loadURI.js]
 skip-if = e10s # Bug ?????? - event handler checks event.target is the content document and test e10s-utils doesn't do that.
 [browser_onbeforeunload_navigation.js]
 skip-if = e10s
 [browser_search_notification.js]
-skip-if = e10s
 [browser_timelineMarkers-01.js]
 [browser_timelineMarkers-02.js]
 skip-if = e10s
--- a/docshell/test/browser/browser_search_notification.js
+++ b/docshell/test/browser/browser_search_notification.js
@@ -1,14 +1,35 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 function test() {
   waitForExplicitFinish();
 
+  const kSearchEngineID = "test_urifixup_search_engine";
+  const kSearchEngineURL = "http://localhost/?search={searchTerms}";
+  Services.search.addEngineWithDetails(kSearchEngineID, "", "", "", "get",
+                                       kSearchEngineURL);
+
+  let oldDefaultEngine = Services.search.defaultEngine;
+  Services.search.defaultEngine = Services.search.getEngineByName(kSearchEngineID);
+
+  let selectedName = Services.search.defaultEngine.name;
+  is(selectedName, kSearchEngineID, "Check fake search engine is selected");
+
+  registerCleanupFunction(function() {
+    if (oldDefaultEngine) {
+      Services.search.defaultEngine = oldDefaultEngine;
+    }
+    let engine = Services.search.getEngineByName(kSearchEngineID);
+    if (engine) {
+      Services.search.removeEngine(engine);
+    }
+  });
+
   let tab = gBrowser.addTab();
   gBrowser.selectedTab = tab;
 
   function observer(subject, topic, data) {
     Services.obs.removeObserver(observer, "keyword-search");
     is(topic, "keyword-search", "Got keyword-search notification");
 
     let engine = Services.search.defaultEngine;
--- a/docshell/test/unit/test_nsDefaultURIFixup_info.js
+++ b/docshell/test/unit/test_nsDefaultURIFixup_info.js
@@ -525,17 +525,17 @@ function run_test() {
       if (makeAlternativeURI && alternativeURI != null) {
         do_check_eq(info.fixedURI.spec, alternativeURI);
       } else {
         do_check_eq(info.fixedURI && info.fixedURI.spec, expectedFixedURI);
       }
 
       // Check booleans on input:
       let couldDoKeywordLookup = flags & urifixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
-      do_check_eq(info.fixupUsedKeyword, couldDoKeywordLookup && expectKeywordLookup);
+      do_check_eq(!!info.keywordProviderName, couldDoKeywordLookup && expectKeywordLookup);
       do_check_eq(info.fixupChangedProtocol, expectProtocolChange);
       do_check_eq(info.fixupCreatedAlternateURI, makeAlternativeURI && alternativeURI != null);
 
       // Check the preferred URI
       let requiresWhitelistedDomain = flags & urifixup.FIXUP_FLAG_REQUIRE_WHITELISTED_HOST;
       if (couldDoKeywordLookup) {
         if (expectKeywordLookup) {
           if (!affectedByWhitelist || (affectedByWhitelist && !inWhitelist)) {
--- a/dom/ipc/ContentParent.cpp
+++ b/dom/ipc/ContentParent.cpp
@@ -180,16 +180,20 @@ using namespace mozilla::system;
 #include "nsIIPCBackgroundChildCreateCallback.h"
 #endif
 
 
 #if defined(MOZ_CONTENT_SANDBOX) && defined(XP_LINUX)
 #include "mozilla/Sandbox.h"
 #endif
 
+#ifdef MOZ_TOOLKIT_SEARCH
+#include "nsIBrowserSearchService.h"
+#endif
+
 static NS_DEFINE_CID(kCClipboardCID, NS_CLIPBOARD_CID);
 static const char* sClipboardTextFlavors[] = { kUnicodeMime };
 
 using base::ChildPrivileges;
 using base::KillProcess;
 using namespace mozilla::dom::bluetooth;
 using namespace mozilla::dom::cellbroadcast;
 using namespace mozilla::dom::devicestorage;
@@ -3800,40 +3804,67 @@ ContentParent::RecvSetFakeVolumeState(co
     return true;
 #else
     NS_WARNING("ContentParent::RecvSetFakeVolumeState shouldn't be called when MOZ_WIDGET_GONK is not defined");
     return false;
 #endif
 }
 
 bool
-ContentParent::RecvKeywordToURI(const nsCString& aKeyword, OptionalInputStreamParams* aPostData,
+ContentParent::RecvKeywordToURI(const nsCString& aKeyword,
+                                nsString* aProviderName,
+                                OptionalInputStreamParams* aPostData,
                                 OptionalURIParams* aURI)
 {
     nsCOMPtr<nsIURIFixup> fixup = do_GetService(NS_URIFIXUP_CONTRACTID);
     if (!fixup) {
         return true;
     }
 
     nsCOMPtr<nsIInputStream> postData;
-    nsCOMPtr<nsIURI> uri;
+    nsCOMPtr<nsIURIFixupInfo> info;
+
     if (NS_FAILED(fixup->KeywordToURI(aKeyword, getter_AddRefs(postData),
-                                      getter_AddRefs(uri)))) {
+                                      getter_AddRefs(info)))) {
         return true;
     }
+    info->GetKeywordProviderName(*aProviderName);
 
     nsTArray<mozilla::ipc::FileDescriptor> fds;
     SerializeInputStream(postData, *aPostData, fds);
     MOZ_ASSERT(fds.IsEmpty());
 
+    nsCOMPtr<nsIURI> uri;
+    info->GetPreferredURI(getter_AddRefs(uri));
     SerializeURI(uri, *aURI);
     return true;
 }
 
 bool
+ContentParent::RecvNotifyKeywordSearchLoading(const nsString &aProvider,
+                                              const nsString &aKeyword) {
+#ifdef MOZ_TOOLKIT_SEARCH
+    nsCOMPtr<nsIBrowserSearchService> searchSvc = do_GetService("@mozilla.org/browser/search-service;1");
+    if (searchSvc) {
+        nsCOMPtr<nsISearchEngine> searchEngine;
+        searchSvc->GetEngineByName(aProvider, getter_AddRefs(searchEngine));
+        if (searchEngine) {
+            nsCOMPtr<nsIObserverService> obsSvc = mozilla::services::GetObserverService();
+            if (obsSvc) {
+                // Note that "keyword-search" refers to a search via the url
+                // bar, not a bookmarks keyword search.
+                obsSvc->NotifyObservers(searchEngine, "keyword-search", aKeyword.get());
+            }
+        }
+    }
+#endif
+    return true;
+}
+
+bool
 ContentParent::ShouldContinueFromReplyTimeout()
 {
     return false;
 }
 
 bool
 ContentParent::RecvRecordingDeviceEvents(const nsString& aRecordingStatus,
                                          const nsString& aPageURL,
--- a/dom/ipc/ContentParent.h
+++ b/dom/ipc/ContentParent.h
@@ -626,19 +626,24 @@ private:
 
     virtual bool RecvAddNewProcess(const uint32_t& aPid,
                                    const InfallibleTArray<ProtocolFdMapping>& aFds) MOZ_OVERRIDE;
 
     virtual bool RecvCreateFakeVolume(const nsString& fsName, const nsString& mountPoint) MOZ_OVERRIDE;
 
     virtual bool RecvSetFakeVolumeState(const nsString& fsName, const int32_t& fsState) MOZ_OVERRIDE;
 
-    virtual bool RecvKeywordToURI(const nsCString& aKeyword, OptionalInputStreamParams* aPostData,
+    virtual bool RecvKeywordToURI(const nsCString& aKeyword,
+                                  nsString* aProviderName,
+                                  OptionalInputStreamParams* aPostData,
                                   OptionalURIParams* aURI) MOZ_OVERRIDE;
 
+    virtual bool RecvNotifyKeywordSearchLoading(const nsString &aProvider,
+                                                const nsString &aKeyword) MOZ_OVERRIDE; 
+
     virtual void ProcessingError(Result what) MOZ_OVERRIDE;
 
     virtual bool RecvAllocateLayerTreeId(uint64_t* aId) MOZ_OVERRIDE;
     virtual bool RecvDeallocateLayerTreeId(const uint64_t& aId) MOZ_OVERRIDE;
 
     virtual bool RecvGetGraphicsFeatureStatus(const int32_t& aFeature,
                                               int32_t* aStatus,
                                               bool* aSuccess) MOZ_OVERRIDE;
--- a/dom/ipc/PContent.ipdl
+++ b/dom/ipc/PContent.ipdl
@@ -665,17 +665,19 @@ parent:
 
     sync AddNewProcess(uint32_t pid, ProtocolFdMapping[] aFds);
 
     // called by the child (test code only) to propagate volume changes to the parent
     async CreateFakeVolume(nsString fsName, nsString mountPoint);
     async SetFakeVolumeState(nsString fsName, int32_t fsState);
 
     sync KeywordToURI(nsCString keyword)
-        returns (OptionalInputStreamParams postData, OptionalURIParams uri);
+        returns (nsString providerName, OptionalInputStreamParams postData, OptionalURIParams uri);
+
+    sync NotifyKeywordSearchLoading(nsString providerName, nsString keyword);
 
     // Tell the compositor to allocate a layer tree id for nested remote mozbrowsers.
     sync AllocateLayerTreeId()
         returns (uint64_t id);
     async DeallocateLayerTreeId(uint64_t id);
 
     sync SpeakerManagerForceSpeaker(bool aEnable);
 
--- a/dom/ipc/moz.build
+++ b/dom/ipc/moz.build
@@ -121,16 +121,19 @@ LOCAL_INCLUDES += [
     '/xpcom/threads',
 ]
 
 DEFINES['BIN_SUFFIX'] = '"%s"' % CONFIG['BIN_SUFFIX']
 
 if CONFIG['MOZ_WIDGET_TOOLKIT'] in ('android', 'gtk2', 'gonk', 'qt'):
     DEFINES['MOZ_ENABLE_FREETYPE'] = True
 
+if CONFIG['MOZ_TOOLKIT_SEARCH']:
+    DEFINES['MOZ_TOOLKIT_SEARCH'] = True
+
 for var in ('MOZ_PERMISSIONS', 'MOZ_CHILD_PERMISSIONS'):
     if CONFIG[var]:
         DEFINES[var] = True
 
 JAR_MANIFESTS += ['jar.mn']
 
 MOCHITEST_CHROME_MANIFESTS += ['tests/chrome.ini']
 MOCHITEST_MANIFESTS += ['tests/mochitest.ini']
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -22,17 +22,16 @@ import org.mozilla.gecko.DynamicToolbar.
 import org.mozilla.gecko.DynamicToolbar.VisibilityTransition;
 import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.db.BrowserContract.Combined;
 import org.mozilla.gecko.db.BrowserContract.ReadingListItems;
 import org.mozilla.gecko.db.BrowserContract.SearchHistory;
 import org.mozilla.gecko.db.BrowserDB;
-import org.mozilla.gecko.db.DBUtils;
 import org.mozilla.gecko.db.SuggestedSites;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.favicons.LoadFaviconTask;
 import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.favicons.decoders.IconDirectoryEntry;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.activities.FxAccountGetStartedActivity;
@@ -42,16 +41,17 @@ import org.mozilla.gecko.gfx.LayerMargin
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.health.BrowserHealthRecorder;
 import org.mozilla.gecko.health.BrowserHealthReporter;
 import org.mozilla.gecko.health.HealthRecorder;
 import org.mozilla.gecko.health.SessionInformation;
 import org.mozilla.gecko.home.BrowserSearch;
 import org.mozilla.gecko.home.HomeBanner;
 import org.mozilla.gecko.home.HomePager;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener;
 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
 import org.mozilla.gecko.home.HomePanelsManager;
 import org.mozilla.gecko.home.SearchEngine;
 import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.GeckoMenuItem;
 import org.mozilla.gecko.preferences.ClearOnShutdownPref;
 import org.mozilla.gecko.preferences.GeckoPreferences;
 import org.mozilla.gecko.prompts.Prompt;
@@ -69,16 +69,17 @@ import org.mozilla.gecko.util.HardwareUt
 import org.mozilla.gecko.util.MenuUtils;
 import org.mozilla.gecko.util.NativeEventListener;
 import org.mozilla.gecko.util.NativeJSObject;
 import org.mozilla.gecko.util.PrefUtils;
 import org.mozilla.gecko.util.StringUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.UIAsyncTask;
 import org.mozilla.gecko.widget.ButtonToast;
+import org.mozilla.gecko.widget.ButtonToast.ToastListener;
 import org.mozilla.gecko.widget.GeckoActionProvider;
 
 import android.app.Activity;
 import android.app.AlertDialog;
 import android.app.KeyguardManager;
 import android.content.BroadcastReceiver;
 import android.content.ContentValues;
 import android.content.Context;
@@ -134,16 +135,17 @@ public class BrowserApp extends GeckoApp
                         implements TabsPanel.TabsLayoutChangeListener,
                                    PropertyAnimator.PropertyAnimationListener,
                                    View.OnKeyListener,
                                    LayerView.OnMetricsChangedListener,
                                    BrowserSearch.OnSearchListener,
                                    BrowserSearch.OnEditSuggestionListener,
                                    HomePager.OnNewTabsListener,
                                    OnUrlOpenListener,
+                                   OnUrlOpenInBackgroundListener,
                                    ActionModeCompat.Presenter,
                                    LayoutInflater.Factory {
     private static final String LOGTAG = "GeckoBrowserApp";
 
     private static final int TABS_ANIMATION_DURATION = 450;
 
     private static final int READER_ADD_SUCCESS = 0;
     private static final int READER_ADD_FAILED = 1;
@@ -1766,17 +1768,23 @@ public class BrowserApp extends GeckoApp
     public void onSaveInstanceState(Bundle outState) {
         super.onSaveInstanceState(outState);
         mDynamicToolbar.onSaveInstanceState(outState);
         outState.putInt(STATE_ABOUT_HOME_TOP_PADDING, mHomePagerContainer.getPaddingTop());
     }
 
     /**
      * Attempts to switch to an open tab with the given URL.
+     * <p>
+     * If the tab exists, this method cancels any in-progress editing as well as
+     * calling {@link Tabs#selectTab(int)}.
      *
+     * @param url of tab to switch to.
+     * @param flags to obey: if {@link OnUrlOpenListener.Flags#ALLOW_SWITCH_TO_TAB}
+     *        is not present, return false.
      * @return true if we successfully switched to a tab, false otherwise.
      */
     private boolean maybeSwitchToTab(String url, EnumSet<OnUrlOpenListener.Flags> flags) {
         if (!flags.contains(OnUrlOpenListener.Flags.ALLOW_SWITCH_TO_TAB)) {
             return false;
         }
 
         final Tabs tabs = Tabs.getInstance();
@@ -1787,16 +1795,36 @@ public class BrowserApp extends GeckoApp
         } else {
             tab = tabs.getFirstTabForUrl(url, tabs.getSelectedTab().isPrivate());
         }
 
         if (tab == null) {
             return false;
         }
 
+        return maybeSwitchToTab(tab.getId());
+    }
+
+    /**
+     * Attempts to switch to an open tab with the given unique tab ID.
+     * <p>
+     * If the tab exists, this method cancels any in-progress editing as well as
+     * calling {@link Tabs#selectTab(int)}.
+     *
+     * @param id of tab to switch to.
+     * @return true if we successfully switched to the tab, false otherwise.
+     */
+    private boolean maybeSwitchToTab(int id) {
+        final Tabs tabs = Tabs.getInstance();
+        final Tab tab = tabs.getTab(id);
+
+        if (tab == null) {
+            return false;
+        }
+
         // Set the target tab to null so it does not get selected (on editing
         // mode exit) in lieu of the tab we are about to select.
         mTargetTabForEditingMode = null;
         tabs.selectTab(tab.getId());
 
         mBrowserToolbar.cancelEdit();
 
         return true;
@@ -3083,16 +3111,63 @@ public class BrowserApp extends GeckoApp
             Intent intent = new Intent(Intent.ACTION_VIEW);
             intent.setData(Uri.parse(url));
             startActivity(intent);
         } else if (!maybeSwitchToTab(url, flags)) {
             openUrlAndStopEditing(url);
         }
     }
 
+    // HomePager.OnUrlOpenInBackgroundListener
+    @Override
+    public void onUrlOpenInBackground(final String url, EnumSet<OnUrlOpenInBackgroundListener.Flags> flags) {
+        if (url == null) {
+            throw new IllegalArgumentException("url must not be null");
+        }
+        if (flags == null) {
+            throw new IllegalArgumentException("flags must not be null");
+        }
+
+        final boolean isPrivate = flags.contains(OnUrlOpenInBackgroundListener.Flags.PRIVATE);
+
+        int loadFlags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_BACKGROUND;
+        if (isPrivate) {
+            loadFlags |= Tabs.LOADURL_PRIVATE;
+        }
+
+        final Tab newTab = Tabs.getInstance().loadUrl(url, loadFlags);
+
+        // We switch to the desired tab by unique ID, which closes any window
+        // for a race between opening the tab and closing it, and switching to
+        // it. We could also switch to the Tab explicitly, but we don't want to
+        // hold a reference to the Tab itself in the anonymous listener class.
+        final int newTabId = newTab.getId();
+
+        final ToastListener listener = new ButtonToast.ToastListener() {
+            @Override
+            public void onButtonClicked() {
+                maybeSwitchToTab(newTabId);
+            }
+
+            @Override
+            public void onToastHidden(ButtonToast.ReasonHidden reason) { }
+        };
+
+        final String message = isPrivate ?
+                getResources().getString(R.string.new_private_tab_opened) :
+                getResources().getString(R.string.new_tab_opened);
+        final String buttonMessage = getResources().getString(R.string.switch_button_message);
+        getButtonToast().show(false,
+                              message,
+                              ButtonToast.LENGTH_SHORT,
+                              buttonMessage,
+                              R.drawable.switch_button_icon,
+                              listener);
+    }
+
     // BrowserSearch.OnSearchListener
     @Override
     public void onSearch(SearchEngine engine, String text) {
         // Don't store searches that happen in private tabs. This assumes the user can only
         // perform a search inside the currently selected tab, which is true for searches
         // that come from SearchEngineRow.
         if (!Tabs.getInstance().getSelectedTab().isPrivate()) {
             storeSearchQuery(text);
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -43,16 +43,17 @@ DEFINES += \
   $(NULL)
 
 GARBAGE += \
   AndroidManifest.xml  \
   WebappManifestFragment.xml.frag \
   classes.dex  \
   gecko.ap_  \
   res/values/strings.xml \
+  res/raw/browsersearch.json \
   res/raw/suggestedsites.json \
   .aapt.deps \
   fennec_ids.txt \
   javah.out \
   jni-stubs.inc \
   GeneratedJNIWrappers.cpp \
   GeneratedJNIWrappers.h \
   $(NULL)
@@ -254,21 +255,22 @@ android_res_files := $(filter-out $(not_
 # contents have changed; otherwise, this will force rebuild them as
 # part of every build.
 .locales.deps: FORCE
 	$(TOUCH) $@
 	$(MAKE) -C locales
 
 
 # This .deps pattern saves an invocation of the sub-Make: the single
-# invocation generates both strings.xml and suggestedsites.json. The
-# trailing semi-colon defines an empty recipe: defining no recipe at
-# all causes Make to treat the target differently, in a way that
-# defeats our dependencies.
+# invocation generates strings.xml, browsersearch.json, and
+# suggestedsites.json. The trailing semi-colon defines an empty
+# recipe: defining no recipe at all causes Make to treat the target
+# differently, in a way that defeats our dependencies.
 res/values/strings.xml: .locales.deps ;
+res/raw/browsersearch.json: .locales.deps ;
 res/raw/suggestedsites.json: .locales.deps ;
 
 all_resources = \
   $(CURDIR)/AndroidManifest.xml \
   $(CURDIR)/WebappManifestFragment.xml.frag \
   $(android_res_files) \
   $(ANDROID_GENERATED_RESFILES) \
   $(NULL)
--- a/mobile/android/base/RemoteTabsExpandableListAdapter.java
+++ b/mobile/android/base/RemoteTabsExpandableListAdapter.java
@@ -96,44 +96,54 @@ public class RemoteTabsExpandableListAda
             view = convertView;
         } else {
             final LayoutInflater inflater = LayoutInflater.from(context);
             view = inflater.inflate(groupLayoutId, parent, false);
         }
 
         final RemoteClient client = clients.get(groupPosition);
 
+        // UI elements whose state depends on isExpanded, roughly from left to
+        // right: device type icon; client name text color; expanded state
+        // indicator.
+        final int deviceTypeResId;
+        final int textColorResId;
+        final int deviceExpandedResId;
+
+        if (isExpanded && !client.tabs.isEmpty()) {
+            deviceTypeResId = "desktop".equals(client.deviceType) ? R.drawable.sync_desktop : R.drawable.sync_mobile;
+            textColorResId = R.color.home_text_color;
+            deviceExpandedResId = R.drawable.home_group_expanded;
+        } else {
+            deviceTypeResId = "desktop".equals(client.deviceType) ? R.drawable.sync_desktop_inactive : R.drawable.sync_mobile_inactive;
+            textColorResId = R.color.home_text_color_disabled;
+            deviceExpandedResId = R.drawable.home_group_collapsed;
+        }
+
+        // Now update the UI.
         final TextView nameView = (TextView) view.findViewById(R.id.client);
         nameView.setText(client.name);
+        nameView.setTextColor(context.getResources().getColor(textColorResId));
 
         final TextView lastModifiedView = (TextView) view.findViewById(R.id.last_synced);
         final long now = System.currentTimeMillis();
         lastModifiedView.setText(TabsAccessor.getLastSyncedString(context, now, client.lastModified));
 
         // These views exists only in some of our group views: they are present
         // for the home panel groups and not for the tabs tray groups.
         // Therefore, we must handle null.
         final ImageView deviceTypeView = (ImageView) view.findViewById(R.id.device_type);
         if (deviceTypeView != null) {
-            if ("desktop".equals(client.deviceType)) {
-                deviceTypeView.setBackgroundResource(R.drawable.sync_desktop);
-            } else {
-                deviceTypeView.setBackgroundResource(R.drawable.sync_mobile);
-            }
+            deviceTypeView.setImageResource(deviceTypeResId);
         }
 
         final ImageView deviceExpandedView = (ImageView) view.findViewById(R.id.device_expanded);
         if (deviceExpandedView != null) {
             // If there are no tabs to display, don't show an indicator at all.
-            if (client.tabs.isEmpty()) {
-                deviceExpandedView.setBackgroundResource(0);
-            } else {
-                final int resourceId = isExpanded ? R.drawable.home_group_expanded : R.drawable.home_group_collapsed;
-                deviceExpandedView.setBackgroundResource(resourceId);
-            }
+            deviceExpandedView.setImageResource(client.tabs.isEmpty() ? 0 : deviceExpandedResId);
         }
 
         return view;
     }
 
     @Override
     public boolean isChildSelectable(int groupPosition, int childPosition) {
         return true;
--- a/mobile/android/base/home/HomeFragment.java
+++ b/mobile/android/base/home/HomeFragment.java
@@ -1,39 +1,38 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * 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/. */
 
 package org.mozilla.gecko.home;
 
+import java.util.EnumSet;
+
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.EditBookmarkDialog;
-import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.ReaderModeUtils;
-import org.mozilla.gecko.Tab;
-import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.db.BrowserContract.SuggestedSites;
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.favicons.Favicons;
 import org.mozilla.gecko.home.HomeContextMenuInfo.RemoveItemType;
+import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener;
 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
 import org.mozilla.gecko.home.TopSitesGridView.TopSitesGridContextMenuInfo;
 import org.mozilla.gecko.util.Clipboard;
 import org.mozilla.gecko.util.StringUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.UIAsyncTask;
-import org.mozilla.gecko.widget.ButtonToast;
 
 import android.app.Activity;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
 import android.os.Bundle;
 import android.support.v4.app.Fragment;
@@ -67,32 +66,43 @@ public abstract class HomeFragment exten
     private boolean mCanLoadHint;
 
     // Whether the fragment has loaded its content
     private boolean mIsLoaded;
 
     // On URL open listener
     protected OnUrlOpenListener mUrlOpenListener;
 
+    // Helper for opening a tab in the background.
+    private OnUrlOpenInBackgroundListener mUrlOpenInBackgroundListener;
+
     @Override
     public void onAttach(Activity activity) {
         super.onAttach(activity);
 
         try {
             mUrlOpenListener = (OnUrlOpenListener) activity;
         } catch (ClassCastException e) {
             throw new ClassCastException(activity.toString()
                     + " must implement HomePager.OnUrlOpenListener");
         }
+
+        try {
+            mUrlOpenInBackgroundListener = (OnUrlOpenInBackgroundListener) activity;
+        } catch (ClassCastException e) {
+            throw new ClassCastException(activity.toString()
+                    + " must implement HomePager.OnUrlOpenInBackgroundListener");
+        }
     }
 
     @Override
     public void onDetach() {
         super.onDetach();
         mUrlOpenListener = null;
+        mUrlOpenInBackgroundListener = null;
     }
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
         final Bundle args = getArguments();
         if (args != null) {
@@ -200,50 +210,33 @@ public abstract class HomeFragment exten
         }
 
         if (itemId == R.id.home_open_private_tab || itemId == R.id.home_open_new_tab) {
             if (info.url == null) {
                 Log.e(LOGTAG, "Can't open in new tab because URL is null");
                 return false;
             }
 
-            int flags = Tabs.LOADURL_NEW_TAB | Tabs.LOADURL_BACKGROUND;
-            final boolean isPrivate = (item.getItemId() == R.id.home_open_private_tab);
-            if (isPrivate) {
-                flags |= Tabs.LOADURL_PRIVATE;
+            // Some pinned site items have "user-entered" urls. URLs entered in
+            // the PinSiteDialog are wrapped in a special URI until we can get a
+            // valid URL. If the url is a user-entered url, decode the URL
+            // before loading it.
+            final String url = StringUtils.decodeUserEnteredUrl(info.isInReadingList()
+                    ? ReaderModeUtils.getAboutReaderForUrl(info.url)
+                    : info.url);
+
+            final EnumSet<OnUrlOpenInBackgroundListener.Flags> flags = EnumSet.noneOf(OnUrlOpenInBackgroundListener.Flags.class);
+            if (item.getItemId() == R.id.home_open_private_tab) {
+                flags.add(OnUrlOpenInBackgroundListener.Flags.PRIVATE);
             }
 
+            mUrlOpenInBackgroundListener.onUrlOpenInBackground(url, flags);
+
             Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.CONTEXT_MENU);
 
-            final String url = (info.isInReadingList() ? ReaderModeUtils.getAboutReaderForUrl(info.url) : info.url);
-
-            // Some pinned site items have "user-entered" urls. URLs entered in the PinSiteDialog are wrapped in
-            // a special URI until we can get a valid URL. If the url is a user-entered url, decode the URL before loading it.
-            final Tab newTab = Tabs.getInstance().loadUrl(StringUtils.decodeUserEnteredUrl(url), flags);
-            final int newTabId = newTab.getId(); // We don't want to hold a reference to the Tab.
-
-            final String message = isPrivate ?
-                    getResources().getString(R.string.new_private_tab_opened) :
-                    getResources().getString(R.string.new_tab_opened);
-            final String buttonMessage = getResources().getString(R.string.switch_button_message);
-            final GeckoApp geckoApp = (GeckoApp) context;
-            geckoApp.getButtonToast().show(false,
-                    message,
-                    ButtonToast.LENGTH_SHORT,
-                    buttonMessage,
-                    R.drawable.switch_button_icon,
-                    new ButtonToast.ToastListener() {
-                        @Override
-                        public void onButtonClicked() {
-                            Tabs.getInstance().selectTab(newTabId);
-                        }
-
-                        @Override
-                        public void onToastHidden(ButtonToast.ReasonHidden reason) { }
-                    });
             return true;
         }
 
         if (itemId == R.id.home_edit_bookmark) {
             // UI Dialog associates to the activity context, not the applications'.
             new EditBookmarkDialog(context).show(info.url);
             return true;
         }
--- a/mobile/android/base/home/HomePager.java
+++ b/mobile/android/base/home/HomePager.java
@@ -76,16 +76,37 @@ public class HomePager extends ViewPager
         public enum Flags {
             ALLOW_SWITCH_TO_TAB,
             OPEN_WITH_INTENT
         }
 
         public void onUrlOpen(String url, EnumSet<Flags> flags);
     }
 
+    /**
+     * Interface for requesting a new tab be opened in the background.
+     * <p>
+     * This is the <code>HomeFragment</code> equivalent of opening a new tab by
+     * long clicking a link and selecting the "Open new [private] tab" context
+     * menu option.
+     */
+    public interface OnUrlOpenInBackgroundListener {
+        public enum Flags {
+            PRIVATE,
+        }
+
+        /**
+         * Open a new tab with the given URL
+         *
+         * @param url to open.
+         * @param flags to open new tab with.
+         */
+        public void onUrlOpenInBackground(String url, EnumSet<Flags> flags);
+    }
+
     public interface OnNewTabsListener {
         public void onNewTabs(List<String> urls);
     }
 
     /**
      * Interface for listening into ViewPager panel changes
      */
     public interface OnPanelChangeListener {
--- a/mobile/android/base/locales/Makefile.in
+++ b/mobile/android/base/locales/Makefile.in
@@ -23,33 +23,30 @@ endif
 $(warnIfEmpty,AB_CD) # todo: $(errorIfEmpty )
 
 dir-res-values := ../res/values
 strings-xml    := $(dir-res-values)/strings.xml
 strings-xml-in := $(srcdir)/../strings.xml.in
 
 GARBAGE += $(strings-xml)
 
-dir-res-raw         := ../res/raw
-suggestedsites-json := $(dir-res-raw)/suggestedsites.json
-
-GARBAGE += \
-  $(suggestedsites-json) \
-  $(NULL)
+dir-res-raw := ../res/raw
+suggestedsites := $(dir-res-raw)/suggestedsites.json
+browsersearch := $(dir-res-raw)/browsersearch.json
 
 libs realchrome:: \
   $(strings-xml) \
-  $(suggestedsites-json) \
   $(NULL)
 
 chrome-%:: AB_CD=$*
 chrome-%::
 	@$(MAKE) \
 	  $(dir-res-values)-$(AB_rCD)/strings.xml \
 	  $(dir-res-raw)-$(AB_rCD)/suggestedsites.json \
+	  $(dir-res-raw)-$(AB_rCD)/browsersearch.json \
 	  AB_CD=$*
 
 # setup the path to bookmarks.inc. copied and tweaked version of MERGE_FILE from config/config.mk
 MOBILE_LOCALE_SRCDIR = $(if $(filter en-US,$(AB_CD)),$(topsrcdir)/mobile/locales/en-US,$(or $(realpath $(L10NBASEDIR)),$(abspath $(L10NBASEDIR)))/$(AB_CD)/mobile)
 
 ifdef LOCALE_MERGEDIR
 BOOKMARKSPATH = $(firstword \
   $(wildcard $(LOCALE_MERGEDIR)/mobile/profile/bookmarks.inc ) \
@@ -89,26 +86,47 @@ strings-xml-preqs =\
 	  -DMOZ_ANDROID_SHARED_FXACCOUNT_TYPE=$(MOZ_ANDROID_SHARED_FXACCOUNT_TYPE) \
 	  -DMOZ_APP_DISPLAYNAME='@MOZ_APP_DISPLAYNAME@' \
 	  -DSTRINGSPATH='$(STRINGSPATH)' \
 	  -DSYNCSTRINGSPATH='$(SYNCSTRINGSPATH)' \
 	  -DSEARCHSTRINGSPATH='$(SEARCHSTRINGSPATH)' \
       $< \
 	  -o $@)
 
-suggestedsites-srcdir := $(if $(filter en-US,$(AB_CD)),,$(or $(realpath $(L10NBASEDIR)),$(abspath $(L10NBASEDIR)))/$(AB_CD)/mobile/chrome)
+# Arg 1: Valid Make identifier, like suggestedsites.
+# Arg 2: File name, like suggestedsites.json.
+define generated_file_template
 
 # Determine the ../res/raw[-*] path.  This can be ../res/raw when no
 # locale is explicitly specified.
-suggestedsites-json-bypath = $(filter %/suggestedsites.json,$(MAKECMDGOALS))
-ifeq (,$(strip $(suggestedsites-json-bypath)))
-  suggestedsites-json-bypath = $(suggestedsites-json)
+$(1)-bypath = $(filter %/$(2),$(MAKECMDGOALS))
+ifeq (,$$(strip $$($(1)-bypath)))
+  $(1)-bypath = $($(1))
 endif
-suggestedsites-dstdir-raw = $(patsubst %/,%,$(dir $(suggestedsites-json-bypath)))
+$(1)-dstdir-raw = $$(patsubst %/,%,$$(dir $$($(1)-bypath)))
+
+GARBAGE += $($(1))
+
+libs realchrome:: $($(1))
+endef
+
+# L10NBASEDIR is not defined for en-US.
+l10n-srcdir := $(if $(filter en-US,$(AB_CD)),,$(or $(realpath $(L10NBASEDIR)),$(abspath $(L10NBASEDIR)))/$(AB_CD)/mobile/chrome)
+
+$(eval $(call generated_file_template,suggestedsites,suggestedsites.json))
 
 $(suggestedsites-dstdir-raw)/suggestedsites.json: FORCE
 	$(call py_action,generate_suggestedsites, \
 		--verbose \
 		--android-package-name=$(ANDROID_PACKAGE_NAME) \
 		--resources=$(srcdir)/../resources \
-		$(if $(filter en-US,$(AB_CD)),,--srcdir=$(suggestedsites-srcdir)) \
+		$(if $(filter en-US,$(AB_CD)),,--srcdir=$(l10n-srcdir)) \
 		--srcdir=$(topsrcdir)/mobile/locales/en-US/chrome \
 		$@)
+
+$(eval $(call generated_file_template,browsersearch,browsersearch.json))
+
+$(browsersearch-dstdir-raw)/browsersearch.json: FORCE
+	$(call py_action,generate_browsersearch, \
+		--verbose \
+		$(if $(filter en-US,$(AB_CD)),,--srcdir=$(l10n-srcdir)) \
+		--srcdir=$(topsrcdir)/mobile/locales/en-US/chrome \
+		$@)
--- a/mobile/android/base/overlays/ui/SendTabDeviceListArrayAdapter.java
+++ b/mobile/android/base/overlays/ui/SendTabDeviceListArrayAdapter.java
@@ -129,20 +129,20 @@ public class SendTabDeviceListArrayAdapt
             }
         });
 
         return row;
     }
 
     private static int getImage(ParcelableClientRecord record) {
         if ("mobile".equals(record.type)) {
-            return R.drawable.sync_mobile;
+            return R.drawable.sync_mobile_inactive;
         }
 
-        return R.drawable.sync_desktop;
+        return R.drawable.sync_desktop_inactive;
     }
 
     public void switchState(State newState) {
         if (currentState == newState) {
             return;
         }
 
         currentState = newState;
index a0fb321d459e6f6427ad1d17a5ca501da0f75d8c..0306cbe8da6a07e0a71cd0e5ba54cf08e97302e4
GIT binary patch
literal 542
zc%17D@N?(olHy`uVBq!ia0vp^{6H+u!3-oH_de|dQY`6?zK#qG8~eHcB(ehe3dtTp
zz6=aiY77hwEes65fI<x~7#K<o7#Ln9Ffdrn0O~K8vn$XBC{Y*S6XFV__w3oTZ{NQC
z`}gk!10Vy)1q$v50|)~svU~S#h%AHw6xp?F7eoz^0Wo3c&Yf_@5D|zPxFK+3f#CoD
z|CaK`kAW`GC<*clX0W{T*v;(I57+D89AAC0iT!7h`N1Urok8hqo$8mGjn7oul};Zt
z{eC;zt|=3!hcU_9-9_`%_Er@jr^eI8F+}2W?uFYzO$IzH7s5mw1NFGH9<Z{U`Cq@V
z^$geM<a^&rZmAg_TeoNH%<RYAIn9>skFvJ^f0}Wo$U^D(NtQaU6U%g#8!Los=ryc9
zw3a1=>#OXCfQo$#y-nAd?s47yU%boz{pJ5x<iDQ({PEA8`S;%DN4z<I<9yNnc&-g%
zQ+`<TH2~eATH+c}l9E`GYL#4+3Zxi}3=B<l4GnY+3_}bJt&B{pj7@Y6%&iOz&bIGo
fLD7(#pOTqYiCe>-TkJc48W=oX{an^LB{Ts5_^R@8
index 33d8815442adb7774ecb423335b404792d092497..2caf71ffc2d62f6a53e3f1f513e8228f4fbb0a86
GIT binary patch
literal 552
zc%17D@N?(olHy`uVBq!ia0vp^;y}#L!3-oV3YVk+DVB6cUq=Rpjs4tz5?O(Kg=CK)
zUj~LMH3o);76yi2K%s^g3=E|P3=FRl7#OT(0L>_vvn$XBC{Y^V6XFV_6~RDBNeM{G
z%gaL;N?@R%pa9{*6~Ng*m2hQnm9nz3KtVVgu1rQoMovx+qW=H?|2aIXwSX3@mIV0)
zGnid}6>ItFU*?_s$E9vRs$IV|I(})lIc@R5<ekB5otK)=RFw|uMy-7e)WDeJ?e3x$
z#W7<mkTc2C#W6(Ua&G^1p=JXfmj1M^m%3l1{{Giz(_vb2r#r9QMvn7B$>Afm%NAxg
zsCMK$UnV=-BcdR)zdXx-Blk+RpY7iyRhuS6FDW@9z~(Tgz~6jsk?*6wzSE3FHFi9a
z{2$5njGaGlU-^oxT|wd3&-qrr+P3rljj!$RcK6>l?D6Vn-e+`n_B{1JDHB!1|D604
zqhG<;zg3i}V%`=Hu-{xGN>UO_QmvAUQh^kMk%6I!uAzahfnkWDft9JHm4St>fw`4|
jLF@0Sswf(A^HVa@DsgN0vP^d-Py>UftDnm{r-UW|Al9j=
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..f8ea56b3357afcebaae6b42b4e5a6630044566a8
GIT binary patch
literal 325
zc$@)60lNN)P)<h;3K|Lk000e1NJLTq001li000~a1^@s68vb3Z0003DNkl<ZSPAWz
zOA3Q95QduvT}lqnmAB}1dV}7e7bv|!k0vhcUP=*k(dmp-sFE6Hz!yXaN#>FKe$K)~
z?L1GKrYAJtuq7V%9Hu93a?Y~sKHN+X4KxRUD|SnXB?-w_1C4u+SFlpb^`aJ-{L^4V
zTAHRgU@~Z}fqH6HRki)EjmBLo%Mxha&QHBDruAEEA<uL2dmEPHdrVVMa8VSGu?6P#
zc}rq$5uE*^U=A%wY$Pn9#>t6<IkY6Pk+6gsCnpl-(2~SP!V+qnoJg2MOA@;|!oeSS
zJOQ+)2MLWSayCbJbAqS%YLl+1)>fmTx22M(LqRL@*Ty+l<6XlMMeSE4%qQiAw?UT=
X6rMu6+=`}^00000NkvXXu0mjfvO<YK
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..4a9886f620178489e14b58bad49ed7c5bc977194
GIT binary patch
literal 357
zc$@)c0h<1aP)<h;3K|Lk000e1NJLTq001li000~a1^@s68vb3Z0003jNkl<ZSPAWz
zO{#-16h>dl0}(_!bJDR3(VcV$Zvh22P`ZOIMR(y2I&vU96!G_Ku5?sV6GCehB800q
zH_7psfy#aaN0KB*p1v@~TtWB8Qvm+?B7b2RUY{4+JtydzgPy+y`&i#41`(SOm>2Fv
z#S7<z48JmnaU2iBFn~u;v@C0ibWY8-ZS;LVwXW;nI1VV%ycC<Jf#-Q>+ZJ_QqpB+S
zzK^mjQ4|G&AV8ky$g&J^93xHBagIp}97WM>Zi1!zUTuk`Ih5Bkz{;vL3yon-amvGt
zVP#dCg~qU^IOSo+u(B%6LStA{oboVZSXq^3;pPlWU*P!{!1VQ?Ddg?LHfMNqz;AzK
zpPrDdZI8-sc*p;;G_s<#^b;%X;S+D=TA*e)i1Bm>n;v(O`V=7h00000NkvXXu0mjf
DvvQhq
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..7c9d0ad8b48d8f1aed5ef489b7bd1fb0f9498e26
GIT binary patch
literal 309
zc$@(>0m}Y~P)<h;3K|Lk000e1NJLTq000#L001Ef1^@s6HvHf%0002|Nkl<ZNDX6T
z00Kot#S|b_1jOo$jEqbmF;c+)|Ns91)!zi-zWev@&tX(jQc41H=8|Fv0kz4@f`WpJ
zfr`WkC?rK%g$1Zu9Z5$c5}Q<JJye4_$ov0cy6@e)#|UGSN+X*?a-<Mr*{Gq!gc50L
zM#Ge}@FGU_XqXZcN~Eb74O7y>ix}0TVM<IWk){TvHYH6fZq)-9QNXrR_2<u@c|kO8
z_aS6J-v15cf?6s#0m#7DuU~%=6&2;-<>md1Qz1m+56JtV)*4>G!^4vYG7v=LRS1#1
zL7P_J`}gn93kV221zMsC#KOS#Daq|AX#4aAP=6J$eL5Ea$$vY<6@TK-00000NkvXX
Hu0mjfSqFq)
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..76f8039042e5cdcab2015f76bd5736804ad2edbd
GIT binary patch
literal 310
zc$@(?0m=S}P)<h;3K|Lk000e1NJLTq000#L001Ef1^@s6HvHf%0002}Nkl<ZNDX6T
z0D`@H_ogs0F%|v)|6iSvk&y``MhXDx`v=r_^WVRJea_C#a~SvS+m{3sm`jQw_|yU|
zNCvs82%jP{<ce5;u2hF<1UkPF#wL{p246i$56C>m{rmU-hZ*4L=*S3TlS(6-Lvo}L
zW7(*o#Do%QYDUA9wD2NE^=Oz96H27184Xj?!iyNyqhU%+D3PWHr8Xr^D{j>T7g4~b
zO!e#6uX#Z<ZucQ%Kra1*;9_UJdGlscSXkH>5RF|0is&CEpzSwNgs^Z#L`3pdty;wc
zqOnM$3*DeitIx^FX&$g)mI}1)I?&bs(5)qg57l=aWDdwY02@VIw)Won*8l(j07*qo
IM6N<$g6*J&CIA2c
index 3466680d8d9f3a3cca4c1d8196e8ea0b30685839..667c6d59a20328d8c7003d6b69ff69a2768bce43
GIT binary patch
literal 381
zc%17D@N?(olHy`uVBq!ia0vp^JU}eS!VDy(g##u6DVB6cUq=Rpjs4tz5?O(Kg=CK)
zUj~LMH3o);76yi2K%s^g3=E|P3=FRl7#OT(FffQ0%-I!a1C%fb@Ck7R(wjGL-n)12
z)~#FjgTc<7JNNC|2V?*RfsF0jx9`}o10wSO|Nn<#rq_Xb_)3EOf*C|oC2l`beE3-T
zfr84D?@Nj<0c9AIyxm>+C3Z2~1ah1`T^vI=t|uo57$q<;x$QR)IQr#XCDVkYch%u+
zkIpp|SpL4ZmaXvozUr-v9v|;87cUfX5m?2th;fx{mc*5e6|Mpd3}^G?FV+}d2l-aD
z#5JNMC9x#cD!C{XNHG{07@FuB8t57rh8P-J8JSoao9G&tTNxOfZQsv=q9HdwB{QuO
Ww}w5p*mnRmFnGH9xvX<aXaWFumx9m$
index 9b13df8bebbf1d99a681cb7f2f5b0acf5c655b67..3e497fcd68918effaa53cab1b0a466e9e9c3c92d
GIT binary patch
literal 406
zc%17D@N?(olHy`uVBq!ia0vp^f<Vl}!VDzKxV?LT6id3JuOkD)#(wTUiL5}rLb6AY
zF9SoB8UsT^3j@P1pisjL28L1t28LG&3=CE?7#PG0=Ijcz0ZQlw_=LCuX(=fwMMXsg
z1qCHAkd>7Mk}@(f^78UP22dO*3uOHN|DV^wHy-4gk|4ie29Z>W+mDqWswh5Dc<{q*
z&lR8qW0JSK3t#?T1AibV!PCVtgyVX0LP#0|lQJV4+r-tJJUlrY`*?VEl^>Rn2znov
zlwh#GdTXQdw>(2;=JdCBMPtj?`!i_GtNVWK*6yhaxeksU0?dc!?JoCkmgKpU{ywK+
zgTyz7)`lhnMGXc9?kc%?3eQ$C0_{>Qag8WRNi0dVN-jzTQVd20h9<g(2D%1@A%+H4
trj}L)7P<!JRt5&Gzo)9AXvob^$xN%nt>Mcu-I+iQ44$rjF6*2UngEHta0>tc
index 83f98d3c0b114737f70f7f59340dff790fbbe38d..0e85be6ddb7856bcd7d0cfcfa384e051d5af0173
GIT binary patch
literal 218
zc%17D@N?(olHy`uVBq!ia0vp^GC(ZE!3HEf*!MjFQVTs@978Mw--aIKJ*>df!^@q*
za$~jRy4rL#iAK%>#V&2FyV?_M7AY*;+*6x=_wa=)GSBbK?uheUWi2w%pCd6)>Dq!v
z8HPtPqaAyT&lwwgdwFV2b2KwKmXon|s>I?qUqv5&y_ek^<R7qM!qWJo4<4MqbnwoE
zcY-b3zNpB1SKn#g`+d7ep465}3+&k^O^>chIJQsB>av}Q)O7wLSN`-TtFDQMJ~o)@
Sr27TrSO!m5KbLh*2~7a>tXEwC
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..462a88c34d70c148527eb6da256bbb80359d382c
GIT binary patch
literal 256
zc$@(M0ssDqP)<h;3K|Lk000e1NJLTq000~S000sQ1^@s6NC&>;0002TNkl<ZI1%kt
zu?@o@5DZpIODGi^co1*k0g_pmC8@K5y@i*cfWnpq6c=L&iAYe`#wj8rp+EgUcdsiR
zatTh`wogLHJxJ|_0A6)nKRi+4fs47tVw~4d&|{_4tt?AeYnQ9F2Bj3n7);ZIaU8)I
zgX<rL0a8j-RRtjg`o0I}+||%^-O}TEU5pAgO{2o5ocPPqoWv=vQ!wSs=KN{tX6sT9
z|KHN+rirbjltpXvKeu%4fV=z2k@m%=E9MXK9V;A>b6OwG%uSY^LPPlg0000<MNUMn
GLSTX#5^X5}
index c237c29559c2b4f0e74dd89f33bcbe17fb0f866e..c931dc3170ae806f380dfa79a61bf9259e0568db
GIT binary patch
literal 260
zc%17D@N?(olHy`uVBq!ia0vp^d_XM5!3HEhoL#F9q)vOfIEGjVCWnNCm?Wp9%=!NR
zf4#24*%J-y3|IfKYwcuPGD%|Ulqq_QS6@8}?Du_T#2v7m?}U<qY@09ZE{TA*S`*~N
z6qw)a`ThO<dbjYFUj7nAW)FU0GtZ<WZl;eomtH&|8Pa%|shHQmd41Ey&V>)98ZA{t
zBAE@&-(~;U_^9i|BLg!H<>oHt9;L_6&(Hs+;3^{{)1T0lXyU=Ip&ZZ1l^`Pf=FOX}
z2CXlEBAXi;4n!&(=E~^Mn9z2cCq=5+V3XPeUkAg0#Hl<C={mOG_inRg00B=|KbLh*
G2~7aOPhe93
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ea02c5d554dd2272e29587681a8784264a686e86
GIT binary patch
literal 250
zc$@+H00sYvP)<h;3K|Lk000e1NJLTq000gE000&U1^@s6@Xf6!0002NNkl<ZI1yvq
zyLYb{BO~KHAl71FU|<AdeBeJ2Tm#}H7A7X9IY5=#_*5gw0nKOB2I4s^KtUal#J_+4
z!jKf;WCAT(1(ehQX#_jZ+1YtDPSr>f`}gk$@fewq#7JU}YQz&<qk%K10tb{kzyO%)
zLD_|90M`iA5sIekK9sMBCJyF;H2w!-j3VRLuV3~c6>My5k3cjU_z%)}1&DRgBp6s(
zS!>b7koZ?vfJH_MunfT{tbk&vND5(0SYed{0MULf)Iez3-~a#s07*qoM6N<$f(2M%
Ab^rhX
index 054d5324a42b7d6d1c2dd6f06c793367fd96c5e0..3c85ff7354e554798f6072f8b423dcde45cff5eb
GIT binary patch
literal 469
zc%17D@N?(olHy`uVBq!ia0vp^B0wz1!3-p&&zy?{QY`6?zK#qG8~eHcB(ehe3dtTp
zz6=aiY77hwEes65fI<x~7#K<o7#Ln9FfdrnU|<k0n6oR;1}G62;1l8sr1$RKyMO=w
zeJ}vz?%A^kBC;0@;Nn1XKN!Hd$Rtqw|NsB1UVgm+G*q%A$S;^7_R-6typEXmwvwLK
z-j=@R{-z0y4HI(<|2afo0xDum@^*KTDouZ41mxs<x;TbtoS%B>C0B!j0Luek>$m%E
zf3LsjloXrnFl)X%hZUb->ak~EJP-Pxx}o)LrLqanm3=3AosN4%U-Zk=w^At;nLEj<
zThA-};@kFjjQkBRER0H2Q(ib8bW&WQ60(W8&Hfz2;p7FvXO8i|>3Yf*^Fgv_kG;7D
z&}!8Z*NBpo#FA92<f2p{#b9J$XrgOqple_lVrXb(WMXA(qHADoWngf&eLo9|hTQy=
Z%(P0}8ur{`-vQLX;OXk;vd$@?2>>b4x~%{J
index 615568e0b1687bc1d0d39d6bd1ba49fd1776933c..300ad9edcdc6af2db2ec305ecfa57acb155aca12
GIT binary patch
literal 487
zc%17D@N?(olHy`uVBq!ia0vp^azHG?!3-qVmezg)QY`6?zK#qG8~eHcB(ehe3dtTp
zz6=aiY77hwEes65fI<x~7#K<o7#Ln9FfdrnU|<k0n6oR;1}Nbj;1l8sq!kqvmB2s|
z6(}euD4_zN2$~wW)c^ngoi;2#1T;>(B*-tAA?DG`lHA^&*uKL4_Kpc{6I)xFn;IL=
ztrD3HRKl3#?d~EmJF^w!pH5E~#}JM4wHG7#8Wea~E(qQ!cYXUee-(?i$JCz--zqCI
zH2A!fQQ8?cGnRW-(6OkSnT%Il<l}Z_@;>Fta>{+Tuu1LZnk5&DJY}=L_e3q|zxz5N
z`u*bE<ICUP{ia#6UReHF?YyN-+?_fPU9T_Uiht~GwovM)tJ=x~$96q5t`5q-|8chR
z%IWg&|FL9!5nodf8ZQrYlxm4<L`h0wNvc(HQ7VvPFfuSS(KR&CH82b@G_W$Yv@)>J
oH88g_FlhZfRTV`;ZhlH;S|x4`UzX|41ZrULboFyt=akR{00@Gb<NyEw
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..efff1314f591585f7ab33a4991552344eb6573c2
GIT binary patch
literal 383
zc%17D@N?(olHy`uVBq!ia0vp^7C@}b!3HD`PCx0!z`*F{>Eaj?!TC1QyX&w4&)UzH
zZe}mHb!#rQ^)oMC?^Hc6lJCr#TN7dwR!?wU;m);!$CdZuJJS;hN~b4%o;Ppr#=~Bj
zu7Nq5bgVC4(QBEYp=hv9;_=)AHN1yE+%!4+-Pl0XX5wyvjkZ>nYKJ9O%73!4x1aoY
zqt}E#@=*qx`lsB<`VrC;GI0T`^6$9BbI%%<{oT8aRq6Pr@T&XY`J<mrR;m24Yn^oN
zM^C%{rQ36@Z~gy#*+jOsBqS*`^iTZDJuNrBZ_@es$9G#K%j;eH{MIqqtjr5c*prsw
zcOk}T153DokASgEIBD!Tk=wIMY|Y~&XN9wSe9+}mXmXyh%Aw-<XH!?7XVdE0Ufx_O
tw9Wgs&~|C<3YEv@jZf-c9DVhJy?JxQ_f3o+^nd}&;OXk;vd$@?2>`F}qCo%v
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2bb9fa7d363ad254a1801de1dd4848617e88dfc6
GIT binary patch
literal 419
zc$@*F0bKrxP)<h;3K|Lk000e1NJLTq001}u001Kh1^@s6!I#NK0004MNkl<ZSPAV}
zF=~ZC5Y2v~A_$fqAYhduAtWadyn*F!a<4#g3o9>>GLTeBdJ7vx)UT5nAt0ai<8+gs
z5r$o7p7P$j-DcNZSe`u3uU*%D0)RVX%+3N4lR}OYl6f75;W16qw_{>f@+j^Ff%nqI
zkr?8L@!&Yl^RYO3w+h7kjSVb0hazun{MG~-8(4VG%K35&>2VydFo3YrGyyJQppk;|
zp6AWuZ+)4gIfd{0^Y@3t0Vr_KKoA7;Nb|1i=6V>0(DyyGZ3|7)Komt#*ELjCHP_=f
zhO#Un%d)8`3fg}spG^XjB>6n~7s17~B`TOQkD3bP^-CUF9hBz@kC8ga>z6#VIw;Q*
z9wT*-*DrZ!bx@usJVxpuuV3=e>YzMNc#PCRUccm_)j@fl@EC16Rm0=C`N)p<YZ+)=
zqB49?7ONdFe|dkcs#+BO#UA`#2tK#z*A8!f_NeOP+?1i7cf7^g{{U_Skix_TJUajY
N002ovPDHLkV1jubyvG0l
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ab34d7b6b58c515c9920806a022bf4c7deff78b2
GIT binary patch
literal 431
zc$@*R0Z{&lP)<h;3K|Lk000e1NJLTq000~S001lq1^@s6c6Zo00004YNkl<ZSPAV|
zy-veG4DPj6yCxD7Te5aTLaM|=6oxV}bm$v&Ld=Z3KsVNQD7*r&!GciMWQ8pm+k%eo
z1c{6y<XDL^IZ}H*|9$ay&fqhhpJxz;;gz*^VvHF<)&JGkT|SXBLeBd*j<1nyXYe-&
zg5U|t<GR_I_!oel^Z<4VunWey3G<=ndHXKF&YH+pB?quh-C4OaD;{vZE;bDa0GX0B
zBL_GnicXRw=X76JMl)KHW7s8|RzwACoMxp|DHSuIO(Uj^3s6cqhQxNIR7@Ecpp<e9
ziS0_Mm@+OvDdiXv+gqo!H0feqq|=VL0xqBgi1BL01=X>)PN^6W1M3GWT(}?PcWJdH
zN-HFo;&R~8`Qf~uu{>0WDO_BI+hSKi$g=D;ccXHyW7e$$j2NdsX52)~a|M-i9kWJ#
zoWlm4dBiwU^9}iQ#?-*cz`ASyVhC^bm8R*f@B4TG>!I2M=)~RoPj&9<>kT=VXtWu8
ZcHh8)TSd%&?+*X~002ovPDHLkV1hF?z+wOZ
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..74eee72c951fff79ec382661fc08386273cbbf0a
GIT binary patch
literal 445
zc$@*f0Yd(XP)<h;3K|Lk000e1NJLTq000~S001lq1^@s6c6Zo00004mNkl<ZSPAV|
zy-LJD5Wd|6O`qf`t`h}8n?QQCxIBSxAZTGHSSa`+u~7SjfZE+PSKK=&Nglv(B+e8Y
z76O?J38{8r+1dH~cD_xD9kGBB$MJ#ZdG{b*2qBiR+Nhsk?3<?9L7tDIC?2CIssw`h
zzW)u%lL-L&Qf%@(Ka;NO-p&Aq2RQ~7?<7!o)px9kZI^ggcxU=})i)E!D=o*;h6)cg
z#UcoTNZ*f@vn*>=4#bjbGuLVWCuiDFYA9thU_Kf)WmJHnlwyc&H<Yp|qXG=26hmyg
zp_EM-6<{c(7-HLJr?m6ZP196=?XWAL0y=<F%9sioW1pQ;HXt+!eqr9=gi3$7)pjVQ
z64Z6Q3d8UV{gNbETbA`n#prekC;ihElw4KSR{7|%()F!~g_L3+W$<tu=UO@IveFUj
z&IZ&sczYiaj;$GdU*O%Yex1|SFid!tWm!Ih?|PVj>xDpEunlyho+P%TphI*Hl|I14
n``Nc?#|zl{+G1|(LLt^a`o3~TFpn^V00000NkvXXu0mjf)1<{o
index d37ac4d4bb70f96d4bf85f99b3b9ef4bf7e50474..68dff82b41d24cca7f5e4f2306bf00a25b20f8f4
GIT binary patch
literal 607
zc%17D@N?(olHy`uVBq!ia0vp^3P7yS!3-p~ezFh-QY`6?zK#qG8~eHcB(j1OBzpw;
zGB8xBF)%c=FfjZA3N^f7U???UV0e|lz+g3lfkC`r&aOZkphQc6Plzi680_7<cmMwV
zKyuZpReSdA*|%>WkPBz*1A|?=c0m~1wrvBFJ9g~Yy?Zy10apgp4q-s_?%cT($OV!>
zWk8Yb+qXkh!p#J7fsFtE|KABu%mq3^t0c%Tn1Ru)IFL<v?`5?M&z|m44SK?URYmiK
z*2~Ar+OHld={!`_eXXGP;LWun*GupAT>$E0O!9Vj(N@{{gaZ^Ro-U3d7N^%<iR5oG
z5MX)06sIAlp*64Kf$9JM>{4<*5!*_HUhX?LUCnoQvX7~w#MfWTYT29_7Vof?Up&k6
z&AG-SdQF;jlh5QRu-s8%68vm+z^S`L^Qc#6&e<br+Aimg`gQK*FBEuG5%lOyyok8!
zI`jXJ*12dNeas(dceg?IKvCJ*b(Ym@)>S@seyXJ&Kj~?XuF=fZSH0uk?b-RS%J9C@
zc99dyr$uXbOp{pa7`J3kSLMvz&$ySoOm-=oQTY+*0o4-Mh?11Vl2ohYqEsNoU}Ruu
zqHAcNYhV~+XlP|*Vr6WiYhZ3=U~sm5KMRV6-29Zxv`X9>_S|CM0g7u+S3j3^P6<r_
DhU5qw
index b8868f85b12ae7525bb01a65a4528d8fcae67a04..bbba4d66656b3efd9eab77f6ff1abc38eb30ae67
GIT binary patch
literal 667
zc%17D@N?(olHy`uVBq!ia0vp^`arC}!3-p~uD|FFq*&4&eH|GXHuiJ>Nn`~{CVK?=
zGB8xBF)%c=FfjZA3N^f7U???UV0e|lz+g3lfkC`r&aOZkphR7OPlzi67$_<#Dk&++
z%gaO9($dmEE|4K7CkJ5wNo2OHtSm&a5*R=Pfj~w^2Ev9g;ELhefw~kF6d>aN|NpPr
zs$Bv!MWZChFPMSRwJ3<aSa{Dht*aMaYVUP>r4z_@S?#IrYgN4`DsS{3D?d_tsHpHD
z`srIxFfb;0ySr$f+TN-H<oxt>aSX9IJ@xW!sYVBpwukn!m7KP9dMqmaAD+QA^RCp^
zb7^1e*gu~URyZcj<DP7hX7h<Xu9{2D#e}y~?i|YumUz46RZ3<Yi`mXF&+JY}eBS+n
zdDekwA<O1z!n#Lg!(7feJaW-KFuhM?ed)fwr|<1HD26X6y2WsNuJVN+dD>;ao269j
z=dJj&A%9y`K~D3NjK>aH{1$m^%d09cRcPJSwv}!?cHW5H$aF`{-8(#bTIYMp&le>f
zKNRbj_xM0B=O^jU@i&;KbuTceJXri7kL9TB3&B4Rl{BggA1JaueXKvd+3$~d$&&+{
zS5;n}y1)6=&6(F7_4S(S=FAsAnUKIfVOiZIU?8ZLxJHzuB$lLFB^RXvDF!10Lla#?
z16>2d5JLkiQ%frY3ta<qD+7bp-&0jlH00)|WTsW(*6?MS?o3cR@pScbS?83{1OQje
B**5?H
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2f79d5d698b90f4d423f25f6df756e1cb5c155f1
GIT binary patch
literal 624
zc%17D@N?(olHy`uVBq!ia0vp^5kPFk!3HEvR?bUhU|=%$ba4!+V0=5r*6*-^%+ZtW
zD;ks)0<L!UP89x7$QtH%abj};v)YXWM;!%2!;RZSr|e`7$<-3NXP+f7QR^sItx#{g
z%IbeL+v+R3?jGd3CNtk}`R3WFHxmU|towYWLUnyF>+l@D{<5UXT88g_jJo)akeuze
zmorp1Zg?5A|6!?J!j{c(>&ssno%cG>aGULhcnhz%qx>s|59yOss`dQ%WE*6-^JWTF
z<ZO*vySV7xqY%}Ej$Hk`SLJ#!Z;mV#+HhauzuoU0>sanRV7xOo^7qZ0X^}Pa^p38*
z^Qqg)<KGnVw{OMon_SkK`d4rJj}#-#smY<|e{VP9e*Dqm|JFZB=YMT45p-J|D7XF7
zqFNExN}ImNYwo{){&>U2GtOPISEj$bQklhkOzT(3a_9TEUI;tZJ!aB5kiwKwX&KRQ
zlI6swq74j_IVU`Qn!u<gtZ@4DK_(wHhiTKBS!eh(=;*U^8_Z;gjN=naFk;-aM}};T
zK5Q|+IT+9BK6$Qu;&F6krR9u2T&z%I3?vJlDzzv7DYsZLebVubU7x<Mws>)@*YZHw
z>pg{=f4K`A9k_K~D856?QYmJ#Sb|wXjQ0n>cZZ`ZlcgWsYst>%oS@FPE&N;Oi>5Ei
zJN4zC$`|}}-@c<%>rmb!mIdD(Ywx;Cot|YZq4uNrn~eld-h#;A%ci9EF8^qCf05+k
Vvc4xK7l0{=!PC{xWt~$(69C!U6{P?G
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2a53582542f1b5bcb92073b7fb3e045cfc4ba34c
GIT binary patch
literal 738
zc%17D@N?(olHy`uVBq!ia0vp^5kPFk!3HEvR?bUhU|?G5>Eakt!T9#h?ylPoB5V&l
zCgyh@>^<n};gNc2sn5$pPnzTpOfd7`c=uiN6V5qWnLbH9g_9jtg;X}Sgs_>L?aMB5
zd!X9J`u9)d!Kh!X$BZvo8ZY+WJgNIC%l8AhCQ`PYi(aJ49Pd9kC8o7C@kP$>t#Rx3
zf2rDg|HVmGj|UYtb<!*ctAE=#JSvvkyutI^y4!E-9L^bwR#?c`tqogkzjmY4r3AsG
z95wx1HGAi@q8V&SJJz>WuS%&u$$$3g)tC2XXRka^wpH%#oTwjrCwOZWJe2$|tdR8i
zt=s=Y_F=XArq2C)=tJo9({-Oq<G&rRy*u4|>7}n9lmE_m|N3~X-M<2hnCH_^KV9;s
z|M%aiUQeI=*#EocW_{MyS-0PR_x%5_di(92ReR^v{Qi`-xv<ww*>d0Ayt`Rji%vhi
zv^7e1dxXxN8oTnhWw(8oU*2)~rOY3TnZ0b=mcJ-pcDVS=Odqu`8%nzuyxicznCw?F
zr6JQwC-A@wVOiCLS$y6i2G3mFIcF?8EX3Mlc`=Y_*+422`ODS#H@fyy9rN8{M<LT@
z<9!#H#0rivNt&;hoMTw<%K2Q}zu7l-?<_p_*y8xJ<<Bi%-08L4uvKAQQF&PTix#Em
zlaD{~^{SUUUv$=c?5*`+O=IrU27Afxfj6G*&CResXeSz`@LTx5#H}Pz@oVR|zxk~d
z#&1@=|JET-@sq29n-4yy+kCSondSUjhn*23+pD*gx!;KDwolu9^LgEV|MgRfb}v3v
cm|f49^o051sTF#+fN7V()78&qol`;+03{w}A^-pY
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..337b049e5e63dca4156115958530c8ca5c3ca2ab
GIT binary patch
literal 602
zc%17D@N?(olHy`uVBq!ia0vp^T0rc?!3HE1Q(STw7?>12T^vI)Sl>q3dLIrD$(<zT
zA~>s0)T^_*d-v=`3-%xS$G)BCkpjotMgQ2!*=6srm>;})aIO4;bxZlQrmD@8=4nZf
zZn8ZhlBZDn{qFawdnYT+`<?njJz~CA)t=p?BfnC2OX!xRM+(FqhD)@qk9eWS(G<4+
zdUcoh);gxspZ75PSTP=Kt@vOcUGVeBulBiq&v|kma7Ze4J`+*tzI6VuYD~fVUF&{d
zd;k6Y+Nib9Ec-rpJV}z{m?g8|&F>78sB*2TUcxHoXE-ZgHb;B9Z27oxvUt$siiXGA
zcr(M*d{21CS$G}nt37@DZCQ;{$|-UEvJ5>9nOx(U*Y^pfzVFdm)wM}9;c;f~szo`h
zH!7kQuhNKSG@EyAh1RLChTi3-BuN(sFHXAn<Ey%6G%wq|I~uE8ByZ0>lAvYP5`MBh
zaH-bzBM})hVsfU<NRwyUxHxb7ZLbNL2S0>v$lX2j#9zxLnq2pfx1LOjIX7qWHvMPf
zg*i`u3H2qZS0!#>@jHA(a=P*JB`>*;E8V!K6yf{1HM2ON`*_ggk7dlo@kN*SO)k@U
zWMMvS`@w_@?Qh>Q`){BAjdSyy_PHMmXWenhbKk)!e<YNn)7;R(dQ)1ZLczr8ciMG7
z%j8N3PIXw*|Fr0*&_|;@@s{Zqjy>iu-g)H3ub!#v3iQkuJTZOr!u_)O+$D=znYI-R
Q0n-eFr>mdKI;Vst09G9rC;$Ke
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..382c71db3077a01ba48f777ee128e06db0ca0e95
GIT binary patch
literal 546
zc%17D@N?(olHy`uVBq!ia0vp^T0rc?!3HE1Q(STw7#LrAx;Tbpu)dw;>vh;c<oMi!
zTaGGUnVWZ~qBY6HvTdGY;D+w7E(M7XpSkWSUEsg1y`$i{U%$fIOGo}|^Dc4DasHhl
zRKcwH`RB9w_x~)PZMRiWd*A!_k<&umuQvv*>e?<U9q0bloaeB9&KE1We%tG%_ZRzK
z-Lh!c-FK2l?k<S!WHYYg-&nu+{(JjvQEL+>A8~l1>2fb!=gggX4R^0*Z54RWel>4<
z`Ic+>0=rVropF{9`@z?gATRYbHO^H?;}TnMY45K<xf8k<3RWzvs6M#lOuMIY?6M=#
zdAG~HbZ-b-xHqm}uRl_&<V&lD(v;sbT2=@=RXZT-Z=>b1le6Hsa6eaI9@CwVM;1Fx
znaxmc^N}QJ?H>25_VtsBm?JIY5<9vsOg(>#<A9rpr<Q$3tbDIqXw?e4x5nFI)>(S0
zN9DbIqqQVBMlt(P_t&Z&E3WM6{i++As?61;zpQJCU9sOSGtQ+wYxhiF*w$}n88Yqk
zQ(<Gjp!t^SzOzjxxm#D1UDlal&VF#+$;lTBr+Y0;e$`z5T-V$Bnx@~W*^e#cf2?u4
z<$pRMZnBAA-rod+ierXzon7YXiDm9=*rwiOoV>tBeb+z6&aHjN9&sr?1;#ytr>mdK
II;Vst0A8Z@g#Z8m
--- a/mobile/android/base/resources/layout/home_remote_tabs_group.xml
+++ b/mobile/android/base/resources/layout/home_remote_tabs_group.xml
@@ -13,17 +13,18 @@
     android:gravity="center_vertical"
     android:minHeight="@dimen/page_row_height" >
 
     <ImageView
         android:id="@+id/device_type"
         android:layout_width="@dimen/favicon_bg"
         android:layout_height="@dimen/favicon_bg"
         android:layout_marginLeft="10dip"
-        android:layout_marginRight="10dip" />
+        android:layout_marginRight="10dip"
+        android:scaleType="center" />
 
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:layout_marginRight="10dip"
         android:layout_weight="1"
         android:orientation="vertical" >
 
@@ -43,11 +44,12 @@
             android:maxLength="1024" />
     </LinearLayout>
 
     <ImageView
         android:id="@+id/device_expanded"
         android:layout_height="wrap_content"
         android:layout_width="wrap_content"
         android:layout_marginLeft="10dip"
-        android:layout_marginRight="10dip" />
+        android:layout_marginRight="10dip"
+        android:scaleType="center" />
 
 </LinearLayout>
--- a/mobile/android/base/resources/layout/home_remote_tabs_hidden_devices_footer.xml
+++ b/mobile/android/base/resources/layout/home_remote_tabs_hidden_devices_footer.xml
@@ -13,14 +13,16 @@
     style="@style/Widget.RemoteTabsClientView"
     android:layout_width="match_parent"
     android:layout_height="@dimen/home_remote_tabs_hidden_footer_height"
     android:gravity="center_vertical" >
 
     <TextView
         android:id="@+id/hidden_devices"
         style="@style/Widget.Home.ActionItem"
+        android:background="@drawable/action_bar_button"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:gravity="center"
-        android:maxLength="1024" />
+        android:maxLength="1024"
+        android:textColor="@color/home_text_color_disabled" />
 
 </LinearLayout>
--- a/mobile/android/base/resources/layout/sync_list_item.xml
+++ b/mobile/android/base/resources/layout/sync_list_item.xml
@@ -15,17 +15,16 @@
         android:id="@+id/img"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:padding="10dp"
         android:layout_marginRight="10dip" />
 
     <TextView
         android:id="@+id/client_name"
-        style="@style/ShareOverlayButton.Text"
         android:layout_gravity="center"
         android:layout_width="0dp"
         android:layout_weight="0.5"
         android:layout_height="wrap_content" />
 
     <CheckBox
         android:id="@+id/check"
         android:layout_width="40dp"
--- a/mobile/android/base/resources/values/colors.xml
+++ b/mobile/android/base/resources/values/colors.xml
@@ -101,16 +101,20 @@
   <color name="url_bar_urltext">#A6A6A6</color>
   <color name="url_bar_domaintext">#000</color>
   <color name="url_bar_domaintext_private">#FFF</color>
   <color name="url_bar_blockedtext">#b14646</color>
   <color name="url_bar_shadow">#12000000</color>
 
   <color name="home_button_bar_bg">#FFF5F7F9</color>
 
+  <!-- Colour used for share overlay button labels -->
+  <color name="home_text_color">@color/text_color_primary</color>
+  <color name="home_text_color_disabled">#AFB1B3</color>
+
   <color name="panel_image_item_background">#D1D9E1</color>
 
   <!-- Swipe to refresh colors for dynamic panel -->
   <color name="swipe_refresh_orange">#FFFFC26C</color>
   <color name="swipe_refresh_white">#FFFFFFFF</color>
 
   <!-- Swipe to refresh colors for remote tabs -->
   <color name="swipe_refresh_orange1">#EE6700</color>
--- a/mobile/android/base/resources/values/styles.xml
+++ b/mobile/android/base/resources/values/styles.xml
@@ -592,16 +592,17 @@
     <style name="Widget.RemoteTabsItemView" parent="Widget.TwoLinePageRow"/>
 
     <style name="Widget.RemoteTabsClientView" parent="Widget.TwoLinePageRow">
         <item name="android:background">#fff5f7f9</item>
     </style>
 
     <style name="Widget.RemoteTabsListView" parent="Widget.HomeListView">
         <item name="android:childDivider">#E7ECF0</item>
+        <item name="android:drawSelectorOnTop">true</item>
     </style>
 
     <!-- TabsTray Row -->
     <style name="TabRowTextAppearance">
         <item name="android:textColor">#FFFFFFFF</item>
         <item name="android:singleLine">true</item>
         <item name="android:ellipsize">middle</item>
     </style>
--- a/netwerk/test/httpserver/httpd.js
+++ b/netwerk/test/httpserver/httpd.js
@@ -2849,18 +2849,17 @@ ServerHandler.prototype =
         }
       }
       catch (e)
       {
         fis.close();
         throw e;
       }
 
-      function writeMore()
-      {
+      let writeMore = function () {
         gThreadManager.currentThread
                       .dispatch(writeData, Ci.nsIThread.DISPATCH_NORMAL);
       }
 
       var input = new BinaryInputStream(fis);
       var output = new BinaryOutputStream(response.bodyOutputStream);
       var writeData =
         {
--- a/python/moz.build
+++ b/python/moz.build
@@ -38,16 +38,17 @@ PYTHON_UNIT_TESTS += [
     'mozbuild/mozbuild/test/frontend/__init__.py',
     'mozbuild/mozbuild/test/frontend/test_context.py',
     'mozbuild/mozbuild/test/frontend/test_emitter.py',
     'mozbuild/mozbuild/test/frontend/test_namespaces.py',
     'mozbuild/mozbuild/test/frontend/test_reader.py',
     'mozbuild/mozbuild/test/frontend/test_sandbox.py',
     'mozbuild/mozbuild/test/test_base.py',
     'mozbuild/mozbuild/test/test_containers.py',
+    'mozbuild/mozbuild/test/test_dotproperties.py',
     'mozbuild/mozbuild/test/test_expression.py',
     'mozbuild/mozbuild/test/test_jarmaker.py',
     'mozbuild/mozbuild/test/test_line_endings.py',
     'mozbuild/mozbuild/test/test_makeutil.py',
     'mozbuild/mozbuild/test/test_mozconfig.py',
     'mozbuild/mozbuild/test/test_mozinfo.py',
     'mozbuild/mozbuild/test/test_preprocessor.py',
     'mozbuild/mozbuild/test/test_pythonutil.py',
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/action/generate_browsersearch.py
@@ -0,0 +1,98 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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/.
+
+'''
+Script to generate the browsersearch.json file for Fennec.
+
+This script follows these steps:
+
+1. Read the region.properties file in all the given source directories (see
+srcdir option). Merge all properties into a single dict accounting for the
+priority of source directories.
+
+2. Read the default search plugin from the 'browser.search.defaultenginename'.
+
+3. Read the list of search plugins from the 'browser.search.order.INDEX'
+properties with values identifying particular search plugins by name.
+
+4. Generate a JSON representation of 2. and 3., and write the result to
+browsersearch.json in the locale-specific raw resource directory
+e.g. raw/browsersearch.json, raw-pt-rBR/browsersearch.json.
+'''
+
+from __future__ import print_function
+
+import argparse
+import json
+import re
+import sys
+import os
+
+from mozbuild.dotproperties import (
+    DotProperties,
+)
+from mozbuild.util import (
+    FileAvoidWrite,
+)
+import mozpack.path as mozpath
+
+
+def merge_properties(filename, srcdirs):
+    """Merges properties from the given file in the given source directories."""
+    properties = DotProperties()
+    for srcdir in srcdirs:
+        path = mozpath.join(srcdir, filename)
+        try:
+            properties.update(path)
+        except IOError:
+            # Ignore non-existing files
+            continue
+    return properties
+
+
+def main(args):
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--verbose', '-v', default=False, action='store_true',
+                        help='be verbose')
+    parser.add_argument('--silent', '-s', default=False, action='store_true',
+                        help='be silent')
+    parser.add_argument('--srcdir', metavar='SRCDIR',
+                        action='append', required=True,
+                        help='directories to read inputs from, in order of priority')
+    parser.add_argument('output', metavar='OUTPUT',
+                        help='output')
+    opts = parser.parse_args(args)
+
+    # Use reversed order so that the first srcdir has higher priority to override keys.
+    properties = merge_properties('region.properties', reversed(opts.srcdir))
+
+    default = properties.get('browser.search.defaultenginename')
+    engines = properties.get_list('browser.search.order')
+
+    if opts.verbose:
+        print('Read {len} engines: {engines}'.format(len=len(engines), engines=engines))
+        print("Default engine is '{default}'.".format(default=default))
+
+    browsersearch = {}
+    browsersearch['default'] = default
+    browsersearch['engines'] = engines
+
+    # FileAvoidWrite creates its parent directories.
+    output = os.path.abspath(opts.output)
+    fh = FileAvoidWrite(output)
+    json.dump(browsersearch, fh)
+    existed, updated = fh.close()
+
+    if not opts.silent:
+        if updated:
+            print('{output} updated'.format(output=output))
+        else:
+            print('{output} already up-to-date'.format(output=output))
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv[1:]))
--- a/python/mozbuild/mozbuild/action/generate_suggestedsites.py
+++ b/python/mozbuild/mozbuild/action/generate_suggestedsites.py
@@ -24,82 +24,44 @@ 4. Generate a JSON representation of eac
 write the result to suggestedsites.json on the locale-specific raw resource
 directory e.g. raw/suggestedsites.json, raw-pt-rBR/suggestedsites.json.
 '''
 
 from __future__ import print_function
 
 import argparse
 import json
-import re
 import sys
 import os
 
+from mozbuild.dotproperties import (
+    DotProperties,
+)
 from mozbuild.util import (
     FileAvoidWrite,
 )
 from mozpack.files import (
     FileFinder,
 )
 import mozpack.path as mozpath
 
 
-def read_properties_file(filename):
-    """Reads a properties file into a dict.
-
-    Ignores empty, comment lines, and keys not starting with the prefix for
-    suggested sites ('browser.suggestedsites'). Removes the prefix from all
-    matching keys i.e. turns 'browser.suggestedsites.foo' into simply 'foo'
-    """
-    prefix = 'browser.suggestedsites.'
-    properties = {}
-    for l in open(filename, 'rt').readlines():
-        line = l.strip()
-        if not line.startswith(prefix):
-            continue
-        (k, v) = re.split('\s*=\s*', line, 1)
-        properties[k[len(prefix):]] = v
-    return properties
-
-
 def merge_properties(filename, srcdirs):
     """Merges properties from the given file in the given source directories."""
-    properties = {}
+    properties = DotProperties()
     for srcdir in srcdirs:
         path = mozpath.join(srcdir, filename)
         try:
-            properties.update(read_properties_file(path))
-        except IOError, e:
+            properties.update(path)
+        except IOError:
             # Ignore non-existing files
             continue
     return properties
 
 
-def get_site_list_from_properties(properties):
-    """Turns {'list.0':'foo', 'list.1':'bar'} into ['foo', 'bar']."""
-    prefix = 'list.'
-    indexes = []
-    for k, v in properties.iteritems():
-        if not k.startswith(prefix):
-            continue
-        indexes.append(int(k[len(prefix):]))
-    return [properties[prefix + str(index)] for index in sorted(indexes)]
-
-
-def get_site_from_properties(name, properties):
-    """Turns {'foo.title':'title', ...} into {'title':'title', ...}."""
-    prefix = '{name}.'.format(name=name)
-    try:
-        site = dict((k, properties[prefix + k]) for k in ('title', 'url', 'bgcolor'))
-    except IndexError, e:
-        raise Exception("Could not find required property for '{name}: {error}'"
-                        .format(name=name, error=str(e)))
-    return site
-
-
 def main(args):
     parser = argparse.ArgumentParser()
     parser.add_argument('--verbose', '-v', default=False, action='store_true',
                         help='be verbose')
     parser.add_argument('--silent', '-s', default=False, action='store_true',
                         help='be silent')
     parser.add_argument('--android-package-name', metavar='NAME',
                         required=True,
@@ -110,30 +72,30 @@ def main(args):
     parser.add_argument('--srcdir', metavar='SRCDIR',
                         action='append', required=True,
                         help='directories to read inputs from, in order of priority')
     parser.add_argument('output', metavar='OUTPUT',
                         help='output')
     opts = parser.parse_args(args)
 
     # Use reversed order so that the first srcdir has higher priority to override keys.
-    all_properties = merge_properties('region.properties', reversed(opts.srcdir))
-    names = get_site_list_from_properties(all_properties)
+    properties = merge_properties('region.properties', reversed(opts.srcdir))
+    names = properties.get_list('browser.suggestedsites.list')
     if opts.verbose:
         print('Reading {len} suggested sites: {names}'.format(len=len(names), names=names))
 
     # Keep these two in sync.
     image_url_template = 'android.resource://%s/drawable/suggestedsites_{name}' % opts.android_package_name
     drawables_template = 'drawable*/suggestedsites_{name}.*'
 
     # Load properties corresponding to each site name and define their
     # respective image URL.
     sites = []
     for name in names:
-        site = get_site_from_properties(name, all_properties)
+        site = properties.get_dict('browser.suggestedsites.{name}'.format(name=name), required_keys=('title', 'url', 'bgcolor'))
         site['imageurl'] = image_url_template.format(name=name)
         sites.append(site)
 
         # Now check for existence of an appropriately named drawable.  If none
         # exists, throw.  This stops a locale discovering, at runtime, that the
         # corresponding drawable was not added to en-US.
         if not opts.resources:
             continue
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/dotproperties.py
@@ -0,0 +1,80 @@
+# 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 contains utility functions for reading .properties files, like
+# region.properties.
+
+
+from __future__ import unicode_literals
+
+import codecs
+import re
+import os
+import sys
+
+if sys.version_info[0] == 3:
+    str_type = str
+else:
+    str_type = basestring
+
+class DotProperties:
+    r'''A thin representation of a key=value .properties file.'''
+
+    def __init__(self, file=None):
+        self._properties = {}
+        if file:
+            self.update(file)
+
+    def update(self, file):
+        '''Updates properties from a file name or file-like object.
+
+        Ignores empty lines and comment lines.'''
+
+        if isinstance(file, str_type):
+            f = codecs.open(file, 'r', 'utf-8')
+        else:
+            f = file
+
+        for l in f.readlines():
+            line = l.strip()
+            if not line or line.startswith('#'):
+                continue
+            (k, v) = re.split('\s*=\s*', line, 1)
+            self._properties[k] = v
+
+    def get(self, key, default=None):
+        return self._properties.get(key, default)
+
+    def get_list(self, prefix):
+        '''Turns {'list.0':'foo', 'list.1':'bar'} into ['foo', 'bar'].
+
+        Returns [] to indicate an empty or missing list.'''
+
+        if not prefix.endswith('.'):
+            prefix = prefix + '.'
+        indexes = []
+        for k, v in self._properties.iteritems():
+            if not k.startswith(prefix):
+                continue
+            indexes.append(int(k[len(prefix):]))
+        return [self._properties[prefix + str(index)] for index in sorted(indexes)]
+
+    def get_dict(self, prefix, required_keys=[]):
+        '''Turns {'foo.title':'title', ...} into {'title':'title', ...}.
+
+        If |required_keys| is present, it must be an iterable of required key
+        names.  If a required key is not present, ValueError is thrown.
+
+        Returns {} to indicate an empty or missing dict.'''
+
+        if not prefix.endswith('.'):
+            prefix = prefix + '.'
+
+        D = dict((k[len(prefix):], v) for k, v in self._properties.iteritems() if k.startswith(prefix))
+
+        for required_key in required_keys:
+            if not required_key in D:
+                raise ValueError('Required key %s not present' % required_key)
+
+        return D
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/data/bad.properties
@@ -0,0 +1,12 @@
+# A region.properties file with invalid unicode byte sequences.  The
+# sequences were cribbed from Markus Kuhn's "UTF-8 decoder capability
+# and stress test", available at
+# http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt
+
+# 3.5  Impossible bytes                                                         |
+#                                                                               |
+# The following two bytes cannot appear in a correct UTF-8 string               |
+#                                                                               |
+# 3.5.1  fe = ""                                                               |
+# 3.5.2  ff = ""                                                               |
+# 3.5.3  fe fe ff ff = ""                                                   |
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/data/valid.properties
@@ -0,0 +1,11 @@
+# A region.properties file with unicode characters.
+
+# Danish.
+# ####  ~~ Søren Munk Skrøder, sskroeder - 2009-05-30 @ #mozmae
+
+# Korean.
+A.title=한메일
+
+# Russian.
+list.0 = test
+list.1 = Яндекс
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/test_dotproperties.py
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+
+import os
+import unittest
+
+from StringIO import StringIO
+
+import mozpack.path as mozpath
+
+from mozbuild.dotproperties import (
+    DotProperties,
+)
+
+from mozunit import (
+    main,
+)
+
+test_data_path = mozpath.abspath(mozpath.dirname(__file__))
+test_data_path = mozpath.join(test_data_path, 'data')
+
+
+class TestDotProperties(unittest.TestCase):
+    def test_get(self):
+        contents = StringIO('''
+key=value
+''')
+        p = DotProperties(contents)
+        self.assertEqual(p.get('missing'), None)
+        self.assertEqual(p.get('missing', 'default'), 'default')
+        self.assertEqual(p.get('key'), 'value')
+
+
+    def test_update(self):
+        contents = StringIO('''
+old=old value
+key=value
+''')
+        p = DotProperties(contents)
+        self.assertEqual(p.get('old'), 'old value')
+        self.assertEqual(p.get('key'), 'value')
+
+        new_contents = StringIO('''
+key=new value
+''')
+        p.update(new_contents)
+        self.assertEqual(p.get('old'), 'old value')
+        self.assertEqual(p.get('key'), 'new value')
+
+
+    def test_get_list(self):
+        contents = StringIO('''
+list.0=A
+list.1=B
+list.2=C
+
+order.1=B
+order.0=A
+order.2=C
+''')
+        p = DotProperties(contents)
+        self.assertEqual(p.get_list('missing'), [])
+        self.assertEqual(p.get_list('list'), ['A', 'B', 'C'])
+        self.assertEqual(p.get_list('order'), ['A', 'B', 'C'])
+
+
+    def test_get_dict(self):
+        contents = StringIO('''
+A.title=title A
+
+B.title=title B
+B.url=url B
+''')
+        p = DotProperties(contents)
+        self.assertEqual(p.get_dict('missing'), {})
+        self.assertEqual(p.get_dict('A'), {'title': 'title A'})
+        self.assertEqual(p.get_dict('B'), {'title': 'title B', 'url': 'url B'})
+        with self.assertRaises(ValueError):
+            p.get_dict('A', required_keys=['title', 'url'])
+        with self.assertRaises(ValueError):
+            p.get_dict('missing', required_keys=['key'])
+
+    def test_unicode(self):
+        contents = StringIO('''
+# Danish.
+# ####  ~~ Søren Munk Skrøder, sskroeder - 2009-05-30 @ #mozmae
+
+# Korean.
+A.title=한메일
+
+# Russian.
+list.0 = test
+list.1 = Яндекс
+''')
+        p = DotProperties(contents)
+        self.assertEqual(p.get_dict('A'), {'title': '한메일'})
+        self.assertEqual(p.get_list('list'), ['test', 'Яндекс'])
+
+    def test_valid_unicode_from_file(self):
+        # The contents of valid.properties is identical to the contents of the
+        # test above.  This specifically exercises reading from a file.
+        p = DotProperties(os.path.join(test_data_path, 'valid.properties'))
+        self.assertEqual(p.get_dict('A'), {'title': '한메일'})
+        self.assertEqual(p.get_list('list'), ['test', 'Яндекс'])
+
+    def test_bad_unicode_from_file(self):
+        # The contents of bad.properties is not valid Unicode; see the comments
+        # in the file itself for details.
+        with self.assertRaises(UnicodeDecodeError):
+            DotProperties(os.path.join(test_data_path, 'bad.properties'))
+
+
+if __name__ == '__main__':
+    main()
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -247,16 +247,18 @@ user_pref("dom.mozApps.debug", true);
 
 // Don't fetch or send directory tiles data from real servers
 user_pref("browser.newtabpage.directory.source", 'data:application/json,{"testing":1}');
 user_pref("browser.newtabpage.directory.ping", "");
 
 // Enable Loop
 user_pref("loop.enabled", true);
 user_pref("loop.throttled", false);
+user_pref("loop.oauth.google.URL", "http://%(server)s/browser/browser/components/loop/test/mochitest/google_service.sjs?action=");
+user_pref("loop.oauth.google.getContactsURL", "http://%(server)s/browser/browser/components/loop/test/mochitest/google_service.sjs?action=contacts");
 
 // Ensure UITour won't hit the network
 user_pref("browser.uitour.pinnedTabUrl", "http://%(server)s/uitour-dummy/pinnedTab");
 user_pref("browser.uitour.url", "http://%(server)s/uitour-dummy/tour");
 
 user_pref("media.eme.enabled", true);
 
 // Don't prompt about e10s
--- a/toolkit/components/places/BookmarkJSONUtils.jsm
+++ b/toolkit/components/places/BookmarkJSONUtils.jsm
@@ -148,26 +148,17 @@ this.BookmarkJSONUtils = Object.freeze({
       try {
         Services.telemetry
                 .getHistogramById("PLACES_BACKUPS_TOJSON_MS")
                 .add(Date.now() - startTime);
       } catch (ex) {
         Components.utils.reportError("Unable to report telemetry.");
       }
 
-      startTime = Date.now();
       let hash = generateHash(jsonString);
-      // Report the time taken to generate the hash.
-      try {
-        Services.telemetry
-                .getHistogramById("PLACES_BACKUPS_HASHING_MS")
-                .add(Date.now() - startTime);
-      } catch (ex) {
-        Components.utils.reportError("Unable to report telemetry.");
-      }
 
       if (hash === aOptions.failIfHashIs) {
         let e = new Error("Hash conflict");
         e.becauseSameHash = true;
         throw e;
       }
 
       // Do not write to the tmp folder, otherwise if it has a different
--- a/toolkit/components/places/PlacesDBUtils.jsm
+++ b/toolkit/components/places/PlacesDBUtils.jsm
@@ -875,21 +875,16 @@ this.PlacesDBUtils = {
                     JOIN moz_bookmarks t ON t.id = b.parent
                     AND t.parent <> :tags_folder
                     WHERE b.type = :type_bookmark` },
 
       { histogram: "PLACES_TAGS_COUNT",
         query:     `SELECT count(*) FROM moz_bookmarks
                     WHERE parent = :tags_folder` },
 
-      { histogram: "PLACES_FOLDERS_COUNT",
-        query:     `SELECT count(*) FROM moz_bookmarks
-                    WHERE TYPE = :type_folder
-                    AND parent NOT IN (0, :places_root, :tags_folder)` },
-
       { histogram: "PLACES_KEYWORDS_COUNT",
         query:     "SELECT count(*) FROM moz_keywords" },
 
       { histogram: "PLACES_SORTED_BOOKMARKS_PERC",
         query:     `SELECT IFNULL(ROUND((
                       SELECT count(*) FROM moz_bookmarks b
                       JOIN moz_bookmarks t ON t.id = b.parent
                       AND t.parent <> :tags_folder AND t.parent > :places_root
@@ -916,24 +911,16 @@ this.PlacesDBUtils = {
       { histogram: "PLACES_DATABASE_FILESIZE_MB",
         callback: function () {
           let DBFile = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
           DBFile.append("places.sqlite");
           return parseInt(DBFile.fileSize / BYTES_PER_MEBIBYTE);
         }
       },
 
-      { histogram: "PLACES_DATABASE_JOURNALSIZE_MB",
-        callback: function () {
-          let DBFile = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
-          DBFile.append("places.sqlite-wal");
-          return parseInt(DBFile.fileSize / BYTES_PER_MEBIBYTE);
-        }
-      },
-
       { histogram: "PLACES_DATABASE_PAGESIZE_B",
         query:     "PRAGMA page_size /* PlacesDBUtils.jsm PAGESIZE_B */" },
 
       { histogram: "PLACES_DATABASE_SIZE_PER_PAGE_B",
         query:     "PRAGMA page_count",
         callback: function (aDbPageCount) {
           // Note that the database file size would not be meaningful for this
           // calculation, because the file grows in fixed-size chunks.
@@ -941,28 +928,19 @@ this.PlacesDBUtils = {
           let placesPageCount = probeValues.PLACES_PAGES_COUNT;
           return Math.round((dbPageSize * aDbPageCount) / placesPageCount);
         }
       },
 
       { histogram: "PLACES_ANNOS_BOOKMARKS_COUNT",
         query:     "SELECT count(*) FROM moz_items_annos" },
 
-      // LENGTH is not a perfect measure, since it returns the number of bytes
-      // only for BLOBs, the number of chars for anything else.  Though it's
-      // the best approximation we have.
-      { histogram: "PLACES_ANNOS_BOOKMARKS_SIZE_KB",
-        query:     "SELECT SUM(LENGTH(content))/1024 FROM moz_items_annos" },
-
       { histogram: "PLACES_ANNOS_PAGES_COUNT",
         query:     "SELECT count(*) FROM moz_annos" },
 
-      { histogram: "PLACES_ANNOS_PAGES_SIZE_KB",
-        query:     "SELECT SUM(LENGTH(content))/1024 FROM moz_annos" },
-
       { histogram: "PLACES_MAINTENANCE_DAYSFROMLAST",
         callback: function () {
           try {
             let lastMaintenance = Services.prefs.getIntPref("places.database.lastMaintenance");
             let nowSeconds = parseInt(Date.now() / 1000);
             return parseInt((nowSeconds - lastMaintenance) / 86400);
           } catch (ex) {
             return 60;
--- a/toolkit/components/places/SQLFunctions.cpp
+++ b/toolkit/components/places/SQLFunctions.cpp
@@ -10,17 +10,16 @@
 #include "nsEscape.h"
 #include "mozIPlacesAutoComplete.h"
 #include "SQLFunctions.h"
 #include "nsMathUtils.h"
 #include "nsUTF8Utils.h"
 #include "nsINavHistoryService.h"
 #include "nsPrintfCString.h"
 #include "nsNavHistory.h"
-#include "mozilla/Telemetry.h"
 #include "mozilla/Likely.h"
 
 using namespace mozilla::storage;
 
 // Keep the GUID-related parts of this file in sync with toolkit/downloads/SQLFunctions.cpp!
 
 ////////////////////////////////////////////////////////////////////////////////
 //// Anonymous Helpers
@@ -452,18 +451,16 @@ namespace places {
                                             nsIVariant **_result)
   {
     // Fetch arguments.  Use default values if they were omitted.
     uint32_t numEntries;
     nsresult rv = aArguments->GetNumEntries(&numEntries);
     NS_ENSURE_SUCCESS(rv, rv);
     NS_ASSERTION(numEntries > 0, "unexpected number of arguments");
 
-    Telemetry::AutoTimer<Telemetry::PLACES_FRECENCY_CALC_TIME_MS> timer;
-
     int64_t pageId = aArguments->AsInt64(0);
     int32_t typed = numEntries > 1 ? aArguments->AsInt32(1) : 0;
     int32_t fullVisitCount = numEntries > 2 ? aArguments->AsInt32(2) : 0;
     int64_t bookmarkId = numEntries > 3 ? aArguments->AsInt64(3) : 0;
     int32_t visitCount = 0;
     int32_t hidden = 0;
     int32_t isQuery = 0;
     float pointsForSampledVisits = 0.0;
--- a/toolkit/components/places/UnifiedComplete.js
+++ b/toolkit/components/places/UnifiedComplete.js
@@ -707,30 +707,31 @@ Search.prototype = {
       hasFirstResult = true;
     }
 
     if (this._enableActions && !hasFirstResult) {
       // If it's not a bookmarked keyword, then it may be a search engine
       // with an alias - which works like a keyword.
       hasFirstResult = yield this._matchSearchEngineAlias();
     }
+
     let shouldAutofill = this._shouldAutofill;
     if (this.pending && !hasFirstResult && shouldAutofill) {
-      // Or it may look like a URL we know about from search engines.
-      hasFirstResult = yield this._matchSearchEngineUrl();
-    }
-
-    if (this.pending && !hasFirstResult && shouldAutofill) {
       // It may also look like a URL we know from the database.
       // Here we can only try to predict whether the URL autofill query is
       // likely to return a result.  If the prediction ends up being wrong,
       // later we will need to make up for the lack of a special first result.
       hasFirstResult = yield this._matchKnownUrl(conn, queries);
     }
 
+    if (this.pending && !hasFirstResult && shouldAutofill) {
+      // Or it may look like a URL we know about from search engines.
+      hasFirstResult = yield this._matchSearchEngineUrl();
+    }
+
     if (this.pending && this._enableActions && !hasFirstResult) {
       // If we don't have a result that matches what we know about, then
       // we use a fallback for things we don't know about.
       yield this._matchHeuristicFallback();
     }
 
     // IMPORTANT: No other first result heuristics should run after
     // _matchHeuristicFallback().
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/data/engine-rel-searchform.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>engine-rel-searchform.xml</ShortName>
+<Url type="text/html" method="GET" template="http://example.com/?search" rel="searchform"/>
+</SearchPlugin>
--- a/toolkit/components/places/tests/unifiedcomplete/test_searchEngine_host.js
+++ b/toolkit/components/places/tests/unifiedcomplete/test_searchEngine_host.js
@@ -1,28 +1,84 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
+Cu.import("resource://testing-common/httpd.js");
+
+function* addTestEngines(items) {
+  let httpServer = new HttpServer();
+  httpServer.start(-1);
+  httpServer.registerDirectory("/", do_get_cwd());
+  let gDataUrl = "http://localhost:" + httpServer.identity.primaryPort + "/data/";
+  do_register_cleanup(() => httpServer.stop(() => {}));
+
+  let engines = [];
+
+  for (let item of items) {
+    do_print("Adding engine: " + item);
+    yield new Promise(resolve => {
+      Services.obs.addObserver(function obs(subject, topic, data) {
+        let engine = subject.QueryInterface(Ci.nsISearchEngine);
+        do_print("Observed " + data + " for " + engine.name);
+        if (data != "engine-added" || engine.name != item) {
+          return;
+        }
+
+        Services.obs.removeObserver(obs, "browser-search-engine-modified");
+        engines.push(engine);
+        resolve();
+      }, "browser-search-engine-modified", false);
+
+      do_print("`Adding engine from URL: " + gDataUrl + item);
+      Services.search.addEngine(gDataUrl + item,
+                                Ci.nsISearchEngine.DATA_XML, null, false);
+    });
+  }
+
+  return engines;
+}
+
+
 add_task(function* test_searchEngine_autoFill() {
   Services.search.addEngineWithDetails("MySearchEngine", "", "", "",
                                        "GET", "http://my.search.com/");
   let engine = Services.search.getEngineByName("MySearchEngine");
   do_register_cleanup(() => Services.search.removeEngine(engine));
 
   // Add an uri that matches the search string with high frecency.
   let uri = NetUtil.newURI("http://www.example.com/my/");
   let visits = [];
   for (let i = 0; i < 100; ++i) {
     visits.push({ uri , title: "Terms - SearchEngine Search" });
   }
   yield promiseAddVisits(visits);
   addBookmark({ uri: uri, title: "Example bookmark" });
-  Assert.ok(frecencyForUrl(uri) > 10000);
+  ok(frecencyForUrl(uri) > 10000, "Adeded URI should have expected high frecency");
 
   do_log_info("Check search domain is autoFilled even if there's an higher frecency match");
   yield check_autocomplete({
     search: "my",
     autofilled: "my.search.com",
     completed: "http://my.search.com"
   });
 
   yield cleanup();
 });
+
+add_task(function* test_searchEngine_noautoFill() {
+  let engineName = "engine-rel-searchform.xml";
+  let [engine] = yield addTestEngines([engineName]);
+  do_register_cleanup(() => Services.search.removeEngine(engine));
+  equal(engine.searchForm, "http://example.com/?search");
+
+  Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+  yield promiseAddVisits(NetUtil.newURI("http://example.com/my/"));
+
+  do_print("Check search domain is not autoFilled if it matches a visited domain");
+  yield check_autocomplete({
+    search: "example",
+    autofilled: "example.com/",
+    completed: "example.com/"
+  });
+
+  yield cleanup();
+});
+
--- a/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini
+++ b/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini
@@ -1,11 +1,14 @@
 [DEFAULT]
 head = head_autocomplete.js
 tail = 
+support-files =
+  data/engine-rel-searchform.xml
+
 
 [test_416211.js]
 [test_416214.js]
 [test_417798.js]
 [test_418257.js]
 [test_422277.js]
 [test_autocomplete_functional.js]
 [test_autocomplete_on_value_removed_479089.js]
--- a/toolkit/components/places/tests/unit/test_telemetry.js
+++ b/toolkit/components/places/tests/unit/test_telemetry.js
@@ -4,34 +4,28 @@
 // Tests common Places telemetry probes by faking the telemetry service.
 
 Components.utils.import("resource://gre/modules/PlacesDBUtils.jsm");
 
 let histograms = {
   PLACES_PAGES_COUNT: function (val) do_check_eq(val, 1),
   PLACES_BOOKMARKS_COUNT: function (val) do_check_eq(val, 1),
   PLACES_TAGS_COUNT: function (val) do_check_eq(val, 1),
-  PLACES_FOLDERS_COUNT: function (val) do_check_eq(val, 1),
   PLACES_KEYWORDS_COUNT: function (val) do_check_eq(val, 1),
   PLACES_SORTED_BOOKMARKS_PERC: function (val) do_check_eq(val, 100),
   PLACES_TAGGED_BOOKMARKS_PERC: function (val) do_check_eq(val, 100),
   PLACES_DATABASE_FILESIZE_MB: function (val) do_check_true(val > 0),
-  // The journal may have been truncated.
-  PLACES_DATABASE_JOURNALSIZE_MB: function (val) do_check_true(val >= 0),
   PLACES_DATABASE_PAGESIZE_B: function (val) do_check_eq(val, 32768),
   PLACES_DATABASE_SIZE_PER_PAGE_B: function (val) do_check_true(val > 0),
   PLACES_EXPIRATION_STEPS_TO_CLEAN2: function (val) do_check_true(val > 1),
   //PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS:  function (val) do_check_true(val > 1),
   PLACES_IDLE_FRECENCY_DECAY_TIME_MS: function (val) do_check_true(val > 0),
   PLACES_IDLE_MAINTENANCE_TIME_MS: function (val) do_check_true(val > 0),
   PLACES_ANNOS_BOOKMARKS_COUNT: function (val) do_check_eq(val, 1),
-  PLACES_ANNOS_BOOKMARKS_SIZE_KB: function (val) do_check_eq(val, 1),
   PLACES_ANNOS_PAGES_COUNT: function (val) do_check_eq(val, 1),
-  PLACES_ANNOS_PAGES_SIZE_KB: function (val) do_check_eq(val, 1),
-  PLACES_FRECENCY_CALC_TIME_MS: function (val) do_check_true(val >= 0),
   PLACES_MAINTENANCE_DAYSFROMLAST: function (val) do_check_true(val >= 0),
 }
 
 function run_test()
 {
   run_next_test();
 }
 
--- a/toolkit/components/promiseworker/PromiseWorker.jsm
+++ b/toolkit/components/promiseworker/PromiseWorker.jsm
@@ -245,31 +245,37 @@ this.BasePromiseWorker.prototype = {
    * @param {Array} args The arguments to pass to `fun`. If any
    * of the arguments is a Promise, it is resolved before posting the
    * message. By convention, the last argument may be an object `options`
    * with some of the following fields:
    * - {number|null} outExecutionDuration A parameter to be filled with the
    *   duration of the off main thread execution for this call.
    * @param {*=} closure An object holding references that should not be
    * garbage-collected before the message treatment is complete.
+   * @param {Array=} transfers An array of objects that should be transfered
+   * to the worker instead of being copied. If any of the objects is a Promise,
+   * it is resolved before posting the message.
    *
    * @return {promise}
    */
-  post: function(fun, args, closure) {
+  post: function(fun, args, closure, transfers) {
     return Task.spawn(function* postMessage() {
       // Normalize in case any of the arguments is a promise
       if (args) {
         args = yield Promise.resolve(Promise.all(args));
       }
+      if (transfers) {
+        transfers = yield Promise.resolve(Promise.all(transfers));
+      }
 
       let id = ++this._id;
       let message = {fun: fun, args: args, id: id};
       this.log("Posting message", message);
       try {
-        this._worker.postMessage(message);
+        this._worker.postMessage(message, ...[transfers]);
       } catch (ex if typeof ex == "number") {
         this.log("Could not post message", message, "due to xpcom error", ex);
         // handle raw xpcom errors (see eg bug 961317)
         throw new Components.Exception("Error in postMessage", ex);
       } catch (ex) {
         this.log("Could not post message", message, "due to error", ex);
         throw ex;
       }
--- a/toolkit/components/promiseworker/tests/xpcshell/test_Promise.js
+++ b/toolkit/components/promiseworker/tests/xpcshell/test_Promise.js
@@ -55,11 +55,31 @@ add_task(function* test_rejected_promise
   try {
     yield worker.post("bounce", message);
     do_throw("I shound have thrown an error by now");
   } catch (ex if ex == error) {
     do_print("I threw the right error");
   }
 });
 
+add_task(function* test_transfer_args() {
+  let array = new Uint8Array(4);
+  for (let i = 0; i < 4; ++i) {
+    array[i] = i;
+  }
+  Assert.equal(array.buffer.byteLength, 4, "The buffer is not neutered yet");
+
+  let result = (yield worker.post("bounce", [array.buffer], [], [array.buffer]))[0];
+
+  // Check that the buffer has been sent
+  Assert.equal(array.buffer.byteLength, 0, "The buffer has been neutered");
+
+  // Check that the result is correct
+  Assert.equal(result.byteLength, 4, "The result has the right size");
+  let array2 = new Uint8Array(result);
+  for (let i = 0; i < 4; ++i) {
+    Assert.equal(array2[i], i);
+  }
+});
+
 function run_test() {
   run_next_test();
 }
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -3076,25 +3076,16 @@
   "PLACES_TAGS_COUNT": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "200",
     "n_buckets": 10,
     "extended_statistics_ok": true,
     "description": "PLACES: Number of tags"
   },
-  "PLACES_FOLDERS_COUNT": {
-    "alert_emails": ["places-telemetry-alerts@mozilla.com"],
-    "expires_in_version": "40",
-    "kind": "exponential",
-    "high": "200",
-    "n_buckets": 10,
-    "extended_statistics_ok": true,
-    "description": "PLACES: Number of folders"
-  },
   "PLACES_KEYWORDS_COUNT": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "200",
     "n_buckets": 10,
     "extended_statistics_ok": true,
     "description": "PLACES: Number of keywords"
   },
@@ -3126,25 +3117,16 @@
     "expires_in_version": "never",
     "kind": "exponential",
     "low": 50,
     "high": 2000,
     "n_buckets": 10,
     "extended_statistics_ok": true,
     "description": "PLACES: Time to convert and write bookmarks.html"
   },
-  "PLACES_BACKUPS_HASHING_MS": {
-    "expires_in_version": "40",
-    "kind": "exponential",
-    "low": 50,
-    "high": 2000,
-    "n_buckets": 10,
-    "extended_statistics_ok": true,
-    "description": "PLACES: Time to calculate the md5 hash for a backup"
-  },
   "PLACES_FAVICON_ICO_SIZES": {
     "expires_in_version" : "never",
     "kind": "exponential",
     "high": 524288,
     "n_buckets" : 100,
     "description": "PLACES: Size of the ICO favicon files loaded from the web (Bytes)"
   },
   "PLACES_FAVICON_PNG_SIZES": {
@@ -3260,25 +3242,16 @@
     "expires_in_version": "never",
     "kind": "exponential",
     "low": 5,
     "high": "200",
     "n_buckets": 10,
     "extended_statistics_ok": true,
     "description": "PLACES: Database filesize (MB)"
   },
-  "PLACES_DATABASE_JOURNALSIZE_MB": {
-    "alert_emails": ["places-telemetry-alerts@mozilla.com"],
-    "expires_in_version": "40",
-    "kind": "exponential",
-    "high": "50",
-    "n_buckets": 10,
-    "extended_statistics_ok": true,
-    "description": "PLACES: Database journal size (MB)"
-  },
   "PLACES_DATABASE_PAGESIZE_B": {
     "expires_in_version": "never",
     "kind": "exponential",
     "low": 1024,
     "high": "32768",
     "n_buckets": 10,
     "extended_statistics_ok": true,
     "description": "PLACES: Database page size (bytes)"
@@ -3365,54 +3338,25 @@
     "expires_in_version": "never",
     "kind": "exponential",
     "low": 50,
     "high": "5000",
     "n_buckets": 10,
     "extended_statistics_ok": true,
     "description": "PLACES: Number of bookmarks annotations"
   },
-  "PLACES_ANNOS_BOOKMARKS_SIZE_KB": {
-    "alert_emails": ["places-telemetry-alerts@mozilla.com"],
-    "expires_in_version": "40",
-    "kind": "exponential",
-    "low": 10,
-    "high": "10000",
-    "n_buckets": 10,
-    "extended_statistics_ok": true,
-    "description": "PLACES: Size of bookmarks annotations (KB)"
-  },
   "PLACES_ANNOS_PAGES_COUNT": {
     "expires_in_version": "never",
     "kind": "exponential",
     "low": 50,
     "high": "5000",
     "n_buckets": 10,
     "extended_statistics_ok": true,
     "description": "PLACES: Number of pages annotations"
   },
-  "PLACES_ANNOS_PAGES_SIZE_KB": {
-    "alert_emails": ["places-telemetry-alerts@mozilla.com"],
-    "expires_in_version": "40",
-    "kind": "exponential",
-    "low": 10,
-    "high": "10000",
-    "n_buckets": 10,
-    "extended_statistics_ok": true,
-    "description": "PLACES: Size of pages annotations (KB)"
-  },
-  "PLACES_FRECENCY_CALC_TIME_MS": {
-    "alert_emails": ["places-telemetry-alerts@mozilla.com"],
-    "expires_in_version": "40",
-    "kind": "exponential",
-    "high": "100",
-    "n_buckets": 10,
-    "extended_statistics_ok": true,
-    "description": "PLACES: Time to calculate frecency of a page (ms)"
-  },
   "PLACES_MAINTENANCE_DAYSFROMLAST": {
     "expires_in_version" : "never",
     "kind": "exponential",
     "low": 7,
     "high": 60,
     "n_buckets" : 10,
     "description": "PLACES: Days from last maintenance"
   },
@@ -5918,16 +5862,21 @@
     "kind": "boolean",
     "description": "How many times has the devtool's Responsive View been opened via the toolbox button?"
   },
   "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "How many times has the devtool's Developer Toolbar been opened via the toolbox button?"
   },
+  "DEVTOOLS_WEBIDE_OPENED_BOOLEAN": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "How many times has the DevTools WebIDE been opened?"
+  },
   "DEVTOOLS_CUSTOM_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "How many times has a custom developer tool been opened via the toolbox button?"
   },
   "DEVTOOLS_TOOLBOX_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
@@ -6033,16 +5982,21 @@
     "kind": "flag",
     "description": "How many users have opened the devtool's Responsive View been opened via the toolbox button?"
   },
   "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
     "description": "How many users have opened the devtool's Developer Toolbar been opened via the toolbox button?"
   },
+  "DEVTOOLS_WEBIDE_OPENED_PER_USER_FLAG": {
+    "expires_in_version": "never",
+    "kind": "flag",
+    "description": "How many users have opened the DevTools WebIDE?"
+  },
   "DEVTOOLS_CUSTOM_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
     "description": "How many users have opened a custom developer tool via the toolbox button?"
   },
   "DEVTOOLS_TOOLBOX_TIME_ACTIVE_SECONDS": {
     "expires_in_version": "never",
     "kind": "exponential",
@@ -6192,23 +6146,77 @@
   },
   "DEVTOOLS_DEVELOPERTOOLBAR_TIME_ACTIVE_SECONDS": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "10000000",
     "n_buckets": 100,
     "description": "How long has the developer toolbar been active (seconds)"
   },
+  "DEVTOOLS_WEBIDE_TIME_ACTIVE_SECONDS": {
+    "expires_in_version": "never",
+    "kind": "exponential",
+    "high": "10000000",
+    "n_buckets": 100,
+    "description": "How long has WebIDE been active (seconds)"
+  },
   "DEVTOOLS_CUSTOM_TIME_ACTIVE_SECONDS": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "10000000",
     "n_buckets": 100,
     "description": "How long has a custom developer tool been active (seconds)"
   },
+  "DEVTOOLS_WEBIDE_CONNECTION_RESULT": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Did WebIDE runtime connection succeed?"
+  },
+  "DEVTOOLS_WEBIDE_USB_CONNECTION_RESULT": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Did WebIDE USB runtime connection succeed?"
+  },
+  "DEVTOOLS_WEBIDE_WIFI_CONNECTION_RESULT": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Did WebIDE WiFi runtime connection succeed?"
+  },
+  "DEVTOOLS_WEBIDE_SIMULATOR_CONNECTION_RESULT": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Did WebIDE simulator runtime connection succeed?"
+  },
+  "DEVTOOLS_WEBIDE_REMOTE_CONNECTION_RESULT": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Did WebIDE remote runtime connection succeed?"
+  },
+  "DEVTOOLS_WEBIDE_LOCAL_CONNECTION_RESULT": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Did WebIDE local runtime connection succeed?"
+  },
+  "DEVTOOLS_WEBIDE_CONNECTION_TIME_SECONDS": {
+    "expires_in_version": "never",
+    "kind": "exponential",
+    "high": "10000000",
+    "n_buckets": 100,
+    "description": "How long was WebIDE connected to a runtime (seconds)?"
+  },
+  "DEVTOOLS_WEBIDE_CONNECTION_PLAY_USED": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Was WebIDE's play button used during this runtime connection?"
+  },
+  "DEVTOOLS_WEBIDE_CONNECTION_DEBUG_USED": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "Was WebIDE's debug button used during this runtime connection?"
+  },
   "BROWSER_IS_USER_DEFAULT": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "The result of the startup default desktop browser check."
   },
   "MIXED_CONTENT_PAGE_LOAD": {
     "expires_in_version": "never",
     "kind": "enumerated",
--- a/toolkit/components/urlformatter/Makefile.in
+++ b/toolkit/components/urlformatter/Makefile.in
@@ -1,22 +1,26 @@
 #
 # 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/.
 
-export:: mozilla_api_key google_api_key bing_api_key
+export:: mozilla_api_key google_api_key google_oauth_api_key bing_api_key
 
-EXTRA_PP_COMPONENTS_FLAGS = -I mozilla_api_key -I google_api_key -I bing_api_key
+EXTRA_PP_COMPONENTS_FLAGS = -I mozilla_api_key -I google_api_key -I google_oauth_api_key -I bing_api_key
 
 include $(topsrcdir)/config/rules.mk
 
 mozilla_api_key:
 	@echo '#define MOZ_MOZILLA_API_KEY $(MOZ_MOZILLA_API_KEY)' > $@
 
 google_api_key:
 	@echo '#define MOZ_GOOGLE_API_KEY $(MOZ_GOOGLE_API_KEY)' > $@
 
+google_oauth_api_key:
+	@echo '#define MOZ_GOOGLE_OAUTH_API_KEY $(MOZ_GOOGLE_OAUTH_API_KEY)' >> $@
+	@echo '#define MOZ_GOOGLE_OAUTH_API_CLIENTID $(MOZ_GOOGLE_OAUTH_API_CLIENTID)' >> $@
+
 bing_api_key:
 	@echo '#define MOZ_BING_API_KEY $(MOZ_BING_API_KEY)' > $@
 	@echo '#define MOZ_BING_API_CLIENTID $(MOZ_BING_API_CLIENTID)' >> $@
 
-GARBAGE += mozilla_api_key google_api_key bing_api_key
+GARBAGE += mozilla_api_key google_api_key google_oauth_api_key bing_api_key
--- a/toolkit/components/urlformatter/nsURLFormatter.js
+++ b/toolkit/components/urlformatter/nsURLFormatter.js
@@ -100,16 +100,18 @@ nsURLFormatterService.prototype = {
     APP:              function() this.appInfo.name.toLowerCase().replace(/ /, ""),
     OS:               function() this.appInfo.OS,
     XPCOMABI:         function() this.ABI,
     BUILD_TARGET:     function() this.appInfo.OS + "_" + this.ABI,
     OS_VERSION:       function() this.OSVersion,
     CHANNEL:          function() UpdateChannel.get(),
     MOZILLA_API_KEY:   function() "@MOZ_MOZILLA_API_KEY@",
     GOOGLE_API_KEY:   function() "@MOZ_GOOGLE_API_KEY@",
+    GOOGLE_OAUTH_API_CLIENTID:function() "@MOZ_GOOGLE_OAUTH_API_CLIENTID@",
+    GOOGLE_OAUTH_API_KEY:     function() "@MOZ_GOOGLE_OAUTH_API_KEY@",
     BING_API_CLIENTID:function() "@MOZ_BING_API_CLIENTID@",
     BING_API_KEY:     function() "@MOZ_BING_API_KEY@",
     DISTRIBUTION:     function() this.distribution.id,
     DISTRIBUTION_VERSION: function() this.distribution.version
   },
 
   formatURL: function uf_formatURL(aFormat) {
     var _this = this;
--- a/toolkit/devtools/server/actors/script.js
+++ b/toolkit/devtools/server/actors/script.js
@@ -482,36 +482,34 @@ function ThreadActor(aParent, aGlobal)
   this._threadLifetimePool = null;
   this._tabClosed = false;
 
   this._options = {
     useSourceMaps: false,
     autoBlackBox: false
   };
 
+  this.breakpointStore = new BreakpointStore();
+  this.blackBoxedSources = new Set(["self-hosted"]);
+  this.prettyPrintedSources = new Map();
+
   // A map of actorID -> actor for breakpoints created and managed by the
   // server.
   this._hiddenBreakpoints = new Map();
 
   this.global = aGlobal;
 
   this._allEventsListener = this._allEventsListener.bind(this);
   this.onNewGlobal = this.onNewGlobal.bind(this);
   this.onNewSource = this.onNewSource.bind(this);
   this.uncaughtExceptionHook = this.uncaughtExceptionHook.bind(this);
   this.onDebuggerStatement = this.onDebuggerStatement.bind(this);
   this.onNewScript = this.onNewScript.bind(this);
 }
 
-/**
- * The breakpoint store must be shared across instances of ThreadActor so that
- * page reloads don't blow away all of our breakpoints.
- */
-ThreadActor.breakpointStore = new BreakpointStore();
-
 ThreadActor.prototype = {
   // Used by the ObjectActor to keep track of the depth of grip() calls.
   _gripDepth: null,
 
   actorPrefix: "context",
 
   get dbg() {
     if (!this._dbg) {
@@ -530,18 +528,16 @@ ThreadActor.prototype = {
     return this.dbg.makeGlobalObjectReference(this._parent.window);
   },
 
   get state() { return this._state; },
   get attached() this.state == "attached" ||
                  this.state == "running" ||
                  this.state == "paused",
 
-  get breakpointStore() { return ThreadActor.breakpointStore; },
-
   get threadLifetimePool() {
     if (!this._threadLifetimePool) {
       this._threadLifetimePool = new ActorPool(this.conn);
       this.conn.addActorPool(this._threadLifetimePool);
       this._threadLifetimePool.objectActors = new WeakMap();
     }
     return this._threadLifetimePool;
   },
@@ -4902,24 +4898,16 @@ function ThreadSources(aThreadActor, aOp
   this._sourceMapsByOriginalSource = Object.create(null);
   // source url --> SourceActor
   this._sourceActors = Object.create(null);
   // original url --> generated url
   this._generatedUrlsByOriginalUrl = Object.create(null);
 }
 
 /**
- * Must be a class property because it needs to persist across reloads, same as
- * the breakpoint store.
- */
-ThreadSources._blackBoxedSources = new Set(["self-hosted"]);
-ThreadSources._prettyPrintedSources = new Map();
-
-
-/**
  * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular
  * expression matches, we can be fairly sure that the source is minified, and
  * treat it as such.
  */
 const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/;
 
 ThreadSources.prototype = {
   /**
@@ -5205,74 +5193,74 @@ ThreadSources.prototype = {
   /**
    * Returns true if URL for the given source is black boxed.
    *
    * @param aURL String
    *        The URL of the source which we are checking whether it is black
    *        boxed or not.
    */
   isBlackBoxed: function (aURL) {
-    return ThreadSources._blackBoxedSources.has(aURL);
+    return this._thread.blackBoxedSources.has(aURL);
   },
 
   /**
    * Add the given source URL to the set of sources that are black boxed.
    *
    * @param aURL String
    *        The URL of the source which we are black boxing.
    */
   blackBox: function (aURL) {
-    ThreadSources._blackBoxedSources.add(aURL);
+    this._thread.blackBoxedSources.add(aURL);
   },
 
   /**
    * Remove the given source URL to the set of sources that are black boxed.
    *
    * @param aURL String
    *        The URL of the source which we are no longer black boxing.
    */
   unblackBox: function (aURL) {
-    ThreadSources._blackBoxedSources.delete(aURL);
+    this._thread.blackBoxedSources.delete(aURL);
   },
 
   /**
    * Returns true if the given URL is pretty printed.
    *
    * @param aURL String
    *        The URL of the source that might be pretty printed.
    */
   isPrettyPrinted: function (aURL) {
-    return ThreadSources._prettyPrintedSources.has(aURL);
+    return this._thread.prettyPrintedSources.has(aURL);
   },
 
   /**
    * Add the given URL to the set of sources that are pretty printed.
    *
    * @param aURL String
    *        The URL of the source to be pretty printed.
    */
   prettyPrint: function (aURL, aIndent) {
-    ThreadSources._prettyPrintedSources.set(aURL, aIndent);
+    this._thread.prettyPrintedSources.set(aURL, aIndent);
   },
 
   /**
    * Return the indent the given URL was pretty printed by.
    */
   prettyPrintIndent: function (aURL) {
-    return ThreadSources._prettyPrintedSources.get(aURL);
+    return this._thread.prettyPrintedSources.get(aURL);
   },
 
   /**
    * Remove the given URL from the set of sources that are pretty printed.
    *
    * @param aURL String
    *        The URL of the source that is no longer pretty printed.
    */
   disablePrettyPrint: function (aURL) {
-    ThreadSources._prettyPrintedSources.delete(aURL);
+    this._thread.prettyPrintedSources.delete(aURL);
   },
 
   /**
    * Normalize multiple relative paths towards the base paths on the right.
    */
   _normalize: function (...aURLs) {
     dbg_assert(aURLs.length > 1, "Should have more than 1 URL");
     let base = Services.io.newURI(aURLs.pop(), null, null);
@@ -5490,26 +5478,14 @@ function makeDebuggeeValueIfNeeded(obj, 
   return value;
 }
 
 function getInnerId(window) {
   return window.QueryInterface(Ci.nsIInterfaceRequestor).
                 getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
 };
 
-function getInnerId(window) {
-  return window.QueryInterface(Ci.nsIInterfaceRequestor).
-                getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
-};
-
 const symbolProtoToString = typeof Symbol === "function" ? Symbol.prototype.toString : null;
 
 function getSymbolName(symbol) {
   const name = symbolProtoToString.call(symbol).slice("Symbol(".length, -1);
   return name || undefined;
 }
-
-exports.cleanup = function() {
-  // Reset shared globals when reloading the debugger server
-  ThreadActor.breakpointStore = new BreakpointStore();
-  ThreadSources._blackBoxedSources.clear();
-  ThreadSources._prettyPrintedSources.clear();
-}
--- a/toolkit/devtools/server/main.js
+++ b/toolkit/devtools/server/main.js
@@ -270,23 +270,16 @@ var DebuggerServer = {
       this._connections[connID].close();
     }
 
     for (let id of Object.getOwnPropertyNames(gRegisteredModules)) {
       this.unregisterModule(id);
     }
     gRegisteredModules = {};
 
-    // The thread actor is special. It isn't registered as all the other ones
-    // with a global or tab scope. It is loaded instead by its parent tab actor
-    // on an 'attach' request. But tests still expect to observe its state
-    // being reset when DebuggerServer is reset, so let's explicitly reset
-    // it here.
-    require("devtools/server/actors/script").cleanup();
-
     this.closeAllListeners();
     this.globalActorFactories = {};
     this.tabActorFactories = {};
     this._allowConnection = null;
     this._transportInitialized = false;
     this._initialized = false;
 
     dumpn("Debugger server is shut down.");
--- a/toolkit/devtools/server/tests/mochitest/test_connectToChild.html
+++ b/toolkit/devtools/server/tests/mochitest/test_connectToChild.html
@@ -62,18 +62,22 @@ function runTests() {
     };
     TestActor.prototype.requestTypes = {
       "hello": TestActor.prototype.hello
     };
     DebuggerServer.addTabActor(TestActor, "testActor");
   }, false);
 
   // Instantiate a minimal server
-  DebuggerServer.init(function () { return true; });
-  DebuggerServer.addBrowserActors();
+  if (!DebuggerServer.initialized) {
+    DebuggerServer.init(function () { return true; });
+  }
+  if (!DebuggerServer.createRootActor) {
+    DebuggerServer.addBrowserActors();
+  }
 
   function firstClient() {
     // Fake a first connection to an iframe
     let transport = DebuggerServer.connectPipe();
     let conn = transport._serverConnection;
     let client = new DebuggerClient(transport);
     DebuggerServer.connectToChild(conn, iframe).then(actor => {
       ok(actor.testActor, "Got the test actor");
--- a/toolkit/devtools/server/tests/mochitest/test_connection-manager.html
+++ b/toolkit/devtools/server/tests/mochitest/test_connection-manager.html
@@ -17,18 +17,20 @@ window.onload = function() {
   SimpleTest.waitForExplicitFinish();
 
   var Cu = Components.utils;
 
   Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
   Cu.import("resource://gre/modules/devtools/Loader.jsm");
   Cu.import("resource://gre/modules/Services.jsm");
 
-  DebuggerServer.init(function () { return true; });
-  DebuggerServer.addBrowserActors();
+  if (!DebuggerServer.initialized) {
+    DebuggerServer.init(function () { return true; });
+    DebuggerServer.addBrowserActors();
+  }
 
   var {ConnectionManager, Connection} = devtools.require("devtools/client/connection-manager");
 
   var orgCount = ConnectionManager.connections.length;
 
   ConnectionManager.once("new", (event, c) => {
     is(ConnectionManager.connections[orgCount], c, "new event fired, with correct connection");
   });
--- a/toolkit/devtools/server/tests/mochitest/test_device.html
+++ b/toolkit/devtools/server/tests/mochitest/test_device.html
@@ -23,18 +23,20 @@ window.onload = function() {
   Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
   Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
   Cu.import("resource://gre/modules/Services.jsm");
 
   SimpleTest.waitForExplicitFinish();
 
   var {getDeviceFront} = devtools.require("devtools/server/actors/device");
 
-  DebuggerServer.init(function () { return true; });
-  DebuggerServer.addBrowserActors();
+  if (!DebuggerServer.initialized) {
+    DebuggerServer.init(function () { return true; });
+    DebuggerServer.addBrowserActors();
+  }
 
   var client = new DebuggerClient(DebuggerServer.connectPipe());
   client.connect(function onConnect() {
     client.listTabs(function onListTabs(aResponse) {
       var d = getDeviceFront(client, aResponse);
 
       var desc, permissions;
       var appInfo = Services.appinfo;
--- a/toolkit/devtools/server/tests/mochitest/test_framerate_01.html
+++ b/toolkit/devtools/server/tests/mochitest/test_framerate_01.html
@@ -29,18 +29,20 @@ window.onload = function() {
   Cu.import("resource://gre/modules/devtools/Loader.jsm");
   Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
   Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
 
   SimpleTest.waitForExplicitFinish();
 
   var {FramerateFront} = devtools.require("devtools/server/actors/framerate");
 
-  DebuggerServer.init(function () { return true; });
-  DebuggerServer.addBrowserActors();
+  if (!DebuggerServer.initialized) {
+    DebuggerServer.init(function () { return true; });
+    DebuggerServer.addBrowserActors();
+  }
 
   var client = new DebuggerClient(DebuggerServer.connectPipe());
   client.connect(function onConnect() {
     client.listTabs(function onListTabs(aResponse) {
       var form = aResponse.tabs[aResponse.selected];
       var front = FramerateFront(client, form);
 
       window.setTimeout(() => {
--- a/toolkit/devtools/server/tests/mochitest/test_framerate_02.html
+++ b/toolkit/devtools/server/tests/mochitest/test_framerate_02.html
@@ -29,18 +29,20 @@ window.onload = function() {
   Cu.import("resource://gre/modules/devtools/Loader.jsm");
   Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
   Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
 
   SimpleTest.waitForExplicitFinish();
 
   var {FramerateFront} = devtools.require("devtools/server/actors/framerate");
 
-  DebuggerServer.init(function () { return true; });
-  DebuggerServer.addBrowserActors();
+  if (!DebuggerServer.initialized) {
+    DebuggerServer.init(function () { return true; });
+    DebuggerServer.addBrowserActors();
+  }
 
   var client = new DebuggerClient(DebuggerServer.connectPipe());
   client.connect(function onConnect() {
     client.listTabs(function onListTabs(aResponse) {
       var form = aResponse.tabs[aResponse.selected];
       var front = FramerateFront(client, form);
 
       front.stopRecording().then(rawData => {
--- a/toolkit/devtools/server/tests/mochitest/test_framerate_03.html
+++ b/toolkit/devtools/server/tests/mochitest/test_framerate_03.html
@@ -32,18 +32,20 @@ window.onload = function() {
 
   SimpleTest.waitForExplicitFinish();
 
   var {FramerateFront} = devtools.require("devtools/server/actors/framerate");
   var START_TICK = 2000;
   var STOP_TICK = 3000;
   var TOTAL_TIME = 5000;
 
-  DebuggerServer.init(function () { return true; });
-  DebuggerServer.addBrowserActors();
+  if (!DebuggerServer.initialized) {
+    DebuggerServer.init(function () { return true; });
+    DebuggerServer.addBrowserActors();
+  }
 
   var client = new DebuggerClient(DebuggerServer.connectPipe());
   client.connect(function onConnect() {
     client.listTabs(function onListTabs(aResponse) {
       var form = aResponse.tabs[aResponse.selected];
       var front = FramerateFront(client, form);
 
       front.startRecording().then(() => {
--- a/toolkit/devtools/server/tests/unit/test_breakpointstore.js
+++ b/toolkit/devtools/server/tests/unit/test_breakpointstore.js
@@ -7,17 +7,16 @@
 const { BreakpointStore, ThreadActor } = devtools.require("devtools/server/actors/script");
 
 function run_test()
 {
   Cu.import("resource://gre/modules/jsdebugger.jsm");
   addDebuggerToGlobal(this);
 
   test_has_breakpoint();
-  test_bug_754251();
   test_add_breakpoint();
   test_remove_breakpoint();
   test_find_breakpoints();
   test_duplicate_breakpoints();
 }
 
 function test_has_breakpoint() {
   let bpStore = new BreakpointStore();
@@ -51,26 +50,16 @@ function test_has_breakpoint() {
   do_check_true(!!bpStore.hasBreakpoint(columnLocation),
                 "Breakpoint with column added but not found in Breakpoint Store.");
 
   bpStore.removeBreakpoint(columnLocation);
   do_check_eq(null, bpStore.hasBreakpoint(columnLocation),
               "Breakpoint with column removed but still exists in Breakpoint Store.");
 }
 
-// Note: Removing this test will regress bug 754251. See comment above
-// ThreadActor.breakpointStore.
-function test_bug_754251() {
-  let instance1 = new ThreadActor();
-  let instance2 = new ThreadActor();
-  do_check_true(instance1.breakpointStore instanceof BreakpointStore);
-  do_check_eq(instance1.breakpointStore, ThreadActor.breakpointStore);
-  do_check_eq(instance2.breakpointStore, ThreadActor.breakpointStore);
-}
-
 function test_add_breakpoint() {
   // Breakpoint with column
   let bpStore = new BreakpointStore();
   let location = {
     url: "http://example.com/foo.js",
     line: 10,
     column: 9
   };