diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index 086ddac446c..2c579aa343f 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1616,6 +1616,8 @@ 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/"); diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js index 127327b17f1..9b6407a20b3 100644 --- a/browser/base/content/browser.js +++ b/browser/base/content/browser.js @@ -694,7 +694,7 @@ function gKeywordURIFixup({ target: browser, data: fixupInfo }) { // 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; } @@ -2400,13 +2400,13 @@ let BrowserOnClick = { 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. @@ -2415,7 +2415,7 @@ let BrowserOnClick = { } }, - onAboutCertError: function (browser, elementId, isTopFrame, location, failedChannel) { + onAboutCertError: function (browser, elementId, isTopFrame, location, sslStatusAsString) { let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI"); switch (elementId) { @@ -2423,8 +2423,11 @@ let BrowserOnClick = { 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 }; diff --git a/browser/base/content/content.js b/browser/base/content/content.js index b728b908a47..711ac5d89bd 100644 --- a/browser/base/content/content.js +++ b/browser/base/content/content.js @@ -424,12 +424,23 @@ let ClickEventHandler = { 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 }); }, diff --git a/browser/base/content/test/general/browser.ini b/browser/base/content/test/general/browser.ini index 5d4f7e7a826..5da01bb25f6 100644 --- a/browser/base/content/test/general/browser.ini +++ b/browser/base/content/test/general/browser.ini @@ -104,15 +104,15 @@ skip-if = os == "linux" # Bug 924307 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] diff --git a/browser/components/loop/GoogleImporter.jsm b/browser/components/loop/GoogleImporter.jsm new file mode 100644 index 00000000000..5bc7984f3b3 --- /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); + } + } + }) +}; diff --git a/browser/components/loop/LoopContacts.jsm b/browser/components/loop/LoopContacts.jsm index 909e47e043a..b5537a0f5e3 100644 --- a/browser/components/loop/LoopContacts.jsm +++ b/browser/components/loop/LoopContacts.jsm @@ -10,8 +10,12 @@ 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(); @@ -324,7 +328,8 @@ let LoopContactsInternal = Object.freeze({ * Map of contact importer names to instances */ _importServices: { - "carddav": new CardDavImporter() + "carddav": new CardDavImporter(), + "google": new GoogleImporter() }, /** @@ -770,7 +775,7 @@ let LoopContactsInternal = Object.freeze({ * `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; @@ -779,7 +784,8 @@ let LoopContactsInternal = Object.freeze({ 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); }, /** @@ -858,14 +864,26 @@ this.LoopContacts = Object.freeze({ 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), diff --git a/browser/components/loop/MozLoopAPI.jsm b/browser/components/loop/MozLoopAPI.jsm index 617be16b75b..b6c3a5b221e 100644 --- a/browser/components/loop/MozLoopAPI.jsm +++ b/browser/components/loop/MozLoopAPI.jsm @@ -97,15 +97,6 @@ const injectObjectAPI = function(api, targetWindow) { 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). @@ -212,6 +203,25 @@ function injectLoopAPI(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 @@ -553,42 +563,6 @@ function injectLoopAPI(targetWindow) { 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) { diff --git a/browser/components/loop/MozLoopPushHandler.jsm b/browser/components/loop/MozLoopPushHandler.jsm index 378a630697f..6240480c67f 100644 --- a/browser/components/loop/MozLoopPushHandler.jsm +++ b/browser/components/loop/MozLoopPushHandler.jsm @@ -167,7 +167,7 @@ let MozLoopPushHandler = { 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); @@ -181,11 +181,11 @@ let MozLoopPushHandler = { case 409: this._registerCallback("error: PushServer ChannelID already in use"); - break; + break; default: this._registerCallback("error: PushServer registration failure, status = " + msg.status); - break; + break; } }, diff --git a/browser/components/loop/content/js/contacts.js b/browser/components/loop/content/js/contacts.js index d78c412c284..84f1fc3e000 100644 --- a/browser/components/loop/content/js/contacts.js +++ b/browser/components/loop/content/js/contacts.js @@ -131,6 +131,16 @@ loop.contacts = (function(_, mozL10n) { 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); @@ -149,19 +159,20 @@ loop.contacts = (function(_, mozL10n) { }; }, - 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() { @@ -181,9 +192,7 @@ loop.contacts = (function(_, mozL10n) { 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"})}), @@ -211,7 +220,8 @@ loop.contacts = (function(_, mozL10n) { const ContactsList = React.createClass({displayName: 'ContactsList', getInitialState: function() { return { - contacts: {} + contacts: {}, + importBusy: false }; }, @@ -227,11 +237,12 @@ loop.contacts = (function(_, mozL10n) { // 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); @@ -252,11 +263,13 @@ loop.contacts = (function(_, mozL10n) { }); }, - 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) { @@ -266,7 +279,7 @@ loop.contacts = (function(_, mozL10n) { return; } delete contacts[guid]; - this.setState({}); + this.forceUpdate(); }, handleContactRemoveAll: function() { @@ -274,6 +287,16 @@ loop.contacts = (function(_, mozL10n) { }, 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() { @@ -321,12 +344,15 @@ loop.contacts = (function(_, mozL10n) { 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}) diff --git a/browser/components/loop/content/js/contacts.jsx b/browser/components/loop/content/js/contacts.jsx index 6f4236762a1..a9ff7c974de 100644 --- a/browser/components/loop/content/js/contacts.jsx +++ b/browser/components/loop/content/js/contacts.jsx @@ -131,6 +131,16 @@ loop.contacts = (function(_, mozL10n) { 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); @@ -149,19 +159,20 @@ loop.contacts = (function(_, mozL10n) { }; }, - 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() { @@ -181,9 +192,7 @@ loop.contacts = (function(_, mozL10n) { return (
+ * 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
+ * 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;
@@ -3088,6 +3116,53 @@ public class BrowserApp extends GeckoApp
}
}
+ // HomePager.OnUrlOpenInBackgroundListener
+ @Override
+ public void onUrlOpenInBackground(final String url, EnumSet
+ * This is the HomeFragment
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