Bug 1069816: implement Google contacts import class. r=abr
authorMike de Boer <mdeboer@mozilla.com>
Thu, 02 Oct 2014 12:37:38 +0200
changeset 218127 c17712a1a6537483348c9209e26f32aabf5bb843
parent 218126 dd6fa9cd5b4b1b54ca653b7eb404e825b033bc03
child 218128 4ba581107e6f84c822fcdc7e5f4f7337f1eb71f7
push id2
push usergszorc@mozilla.com
push dateWed, 12 Nov 2014 19:43:22 +0000
treeherderfig@7a5f4d72e05d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersabr
bugs1069816
milestone34.0a2
Bug 1069816: implement Google contacts import class. r=abr
browser/app/profile/firefox.js
browser/components/loop/GoogleImporter.jsm
browser/components/loop/LoopContacts.jsm
browser/components/loop/MozLoopAPI.jsm
browser/components/loop/moz.build
configure.in
toolkit/components/urlformatter/Makefile.in
toolkit/components/urlformatter/nsURLFormatter.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1595,16 +1595,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);
 
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
@@ -199,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: {
--- 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/configure.in
+++ b/configure.in
@@ -3950,16 +3950,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/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;