michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: const { classes: Cc, interfaces: Ci, utils: Cu } = Components; michael@0: michael@0: Cu.import("resource://gre/modules/Accounts.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Home", "resource://gre/modules/Home.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm"); michael@0: michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gEncoder", function() { return new gChromeWin.TextEncoder(); }); michael@0: XPCOMUtils.defineLazyGetter(this, "gDecoder", function() { return new gChromeWin.TextDecoder(); }); michael@0: michael@0: // URL to fetch snippets, in the urlFormatter service format. michael@0: const SNIPPETS_UPDATE_URL_PREF = "browser.snippets.updateUrl"; michael@0: michael@0: // URL to send stats data to metrics. michael@0: const SNIPPETS_STATS_URL_PREF = "browser.snippets.statsUrl"; michael@0: michael@0: // URL to fetch country code, a value that's cached and refreshed once per month. michael@0: const SNIPPETS_GEO_URL_PREF = "browser.snippets.geoUrl"; michael@0: michael@0: // Timestamp when we last updated the user's country code. michael@0: const SNIPPETS_GEO_LAST_UPDATE_PREF = "browser.snippets.geoLastUpdate"; michael@0: michael@0: // Pref where we'll cache the user's country. michael@0: const SNIPPETS_COUNTRY_CODE_PREF = "browser.snippets.countryCode"; michael@0: michael@0: // Pref where we store an array IDs of snippets that should not be shown again michael@0: const SNIPPETS_REMOVED_IDS_PREF = "browser.snippets.removedIds"; michael@0: michael@0: // How frequently we update the user's country code from the server (30 days). michael@0: const SNIPPETS_GEO_UPDATE_INTERVAL_MS = 86400000*30; michael@0: michael@0: // Should be bumped up if the snippets content format changes. michael@0: const SNIPPETS_VERSION = 1; michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gSnippetsURL", function() { michael@0: let updateURL = Services.prefs.getCharPref(SNIPPETS_UPDATE_URL_PREF).replace("%SNIPPETS_VERSION%", SNIPPETS_VERSION); michael@0: return Services.urlFormatter.formatURL(updateURL); michael@0: }); michael@0: michael@0: // Where we cache snippets data michael@0: XPCOMUtils.defineLazyGetter(this, "gSnippetsPath", function() { michael@0: return OS.Path.join(OS.Constants.Path.profileDir, "snippets.json"); michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gStatsURL", function() { michael@0: return Services.prefs.getCharPref(SNIPPETS_STATS_URL_PREF); michael@0: }); michael@0: michael@0: // Where we store stats about which snippets have been shown michael@0: XPCOMUtils.defineLazyGetter(this, "gStatsPath", function() { michael@0: return OS.Path.join(OS.Constants.Path.profileDir, "snippets-stats.txt"); michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gGeoURL", function() { michael@0: return Services.prefs.getCharPref(SNIPPETS_GEO_URL_PREF); michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gCountryCode", function() { michael@0: try { michael@0: return Services.prefs.getCharPref(SNIPPETS_COUNTRY_CODE_PREF); michael@0: } catch (e) { michael@0: // Return an empty string if the country code pref isn't set yet. michael@0: return ""; michael@0: } michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gChromeWin", function() { michael@0: return Services.wm.getMostRecentWindow("navigator:browser"); michael@0: }); michael@0: michael@0: /** michael@0: * Updates snippet data and country code (if necessary). michael@0: */ michael@0: function update() { michael@0: // Check to see if we should update the user's country code from the geo server. michael@0: let lastUpdate = 0; michael@0: try { michael@0: lastUpdate = parseFloat(Services.prefs.getCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF)); michael@0: } catch (e) {} michael@0: michael@0: if (Date.now() - lastUpdate > SNIPPETS_GEO_UPDATE_INTERVAL_MS) { michael@0: // We should update the snippets after updating the country code, michael@0: // so that we can filter snippets to add to the banner. michael@0: updateCountryCode(updateSnippets); michael@0: } else { michael@0: updateSnippets(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Fetches the user's country code from the geo server and stores the value in a pref. michael@0: * michael@0: * @param callback function called once country code is updated michael@0: */ michael@0: function updateCountryCode(callback) { michael@0: _httpGetRequest(gGeoURL, function(responseText) { michael@0: // Store the country code in a pref. michael@0: let data = JSON.parse(responseText); michael@0: Services.prefs.setCharPref(SNIPPETS_COUNTRY_CODE_PREF, data.country_code); michael@0: michael@0: // Set last update time. michael@0: Services.prefs.setCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF, Date.now()); michael@0: michael@0: callback(); michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Loads snippets from snippets server, caches the response, and michael@0: * updates the home banner with the new set of snippets. michael@0: */ michael@0: function updateSnippets() { michael@0: _httpGetRequest(gSnippetsURL, function(responseText) { michael@0: try { michael@0: let messages = JSON.parse(responseText); michael@0: updateBanner(messages); michael@0: michael@0: // Only cache the response if it is valid JSON. michael@0: cacheSnippets(responseText); michael@0: } catch (e) { michael@0: Cu.reportError("Error parsing snippets responseText: " + e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Caches snippets server response text to `snippets.json` in profile directory. michael@0: * michael@0: * @param response responseText returned from snippets server michael@0: */ michael@0: function cacheSnippets(response) { michael@0: let data = gEncoder.encode(response); michael@0: let promise = OS.File.writeAtomic(gSnippetsPath, data, { tmpPath: gSnippetsPath + ".tmp" }); michael@0: promise.then(null, e => Cu.reportError("Error caching snippets: " + e)); michael@0: } michael@0: michael@0: /** michael@0: * Loads snippets from cached `snippets.json`. michael@0: */ michael@0: function loadSnippetsFromCache() { michael@0: let promise = OS.File.read(gSnippetsPath); michael@0: promise.then(array => { michael@0: let messages = JSON.parse(gDecoder.decode(array)); michael@0: updateBanner(messages); michael@0: }, e => { michael@0: if (e instanceof OS.File.Error && e.becauseNoSuchFile) { michael@0: Services.console.logStringMessage("Couldn't show snippets because cache does not exist yet."); michael@0: } else { michael@0: Cu.reportError("Error loading snippets from cache: " + e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: // Array of the message ids added to the home banner, used to remove michael@0: // older set of snippets when new ones are available. michael@0: var gMessageIds = []; michael@0: michael@0: /** michael@0: * Updates set of snippets in the home banner message rotation. michael@0: * michael@0: * @param messages JSON array of message data JSON objects. michael@0: * Each message object should have the following properties: michael@0: * - id (?): Unique identifier for this snippets message michael@0: * - text (string): Text to show as banner message michael@0: * - url (string): URL to open when banner is clicked michael@0: * - icon (data URI): Icon to appear in banner michael@0: * - target_geo (string): Country code for where this message should be shown (e.g. "US") michael@0: */ michael@0: function updateBanner(messages) { michael@0: // Remove the current messages, if there are any. michael@0: gMessageIds.forEach(function(id) { michael@0: Home.banner.remove(id); michael@0: }) michael@0: gMessageIds = []; michael@0: michael@0: try { michael@0: let removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF)); michael@0: messages = messages.filter(function(message) { michael@0: // Only include the snippet if it has not been previously removed. michael@0: return removedSnippetIds.indexOf(message.id) === -1; michael@0: }); michael@0: } catch (e) { michael@0: // If the pref doesn't exist, there aren't any snippets to filter out. michael@0: } michael@0: michael@0: messages.forEach(function(message) { michael@0: // Don't add this message to the banner if it's not supposed to be shown in this country. michael@0: if ("target_geo" in message && message.target_geo != gCountryCode) { michael@0: return; michael@0: } michael@0: let id = Home.banner.add({ michael@0: text: message.text, michael@0: icon: message.icon, michael@0: onclick: function() { michael@0: let parentId = gChromeWin.BrowserApp.selectedTab.id; michael@0: gChromeWin.BrowserApp.addTab(message.url, { parentId: parentId }); michael@0: UITelemetry.addEvent("action.1", "banner", null, message.id); michael@0: }, michael@0: ondismiss: function() { michael@0: // Remove this snippet from the banner, and store its id so we'll never show it again. michael@0: Home.banner.remove(id); michael@0: removeSnippet(message.id); michael@0: UITelemetry.addEvent("cancel.1", "banner", null, message.id); michael@0: }, michael@0: onshown: function() { michael@0: // 10% of the time, record the snippet id and a timestamp michael@0: if (Math.random() < .1) { michael@0: writeStat(message.id, new Date().toISOString()); michael@0: } michael@0: } michael@0: }); michael@0: // Keep track of the message we added so that we can remove it later. michael@0: gMessageIds.push(id); michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Appends snippet id to the end of `snippets-removed.txt` michael@0: * michael@0: * @param snippetId unique id for snippet, sent from snippets server michael@0: */ michael@0: function removeSnippet(snippetId) { michael@0: let removedSnippetIds; michael@0: try { michael@0: removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF)); michael@0: } catch (e) { michael@0: removedSnippetIds = []; michael@0: } michael@0: michael@0: removedSnippetIds.push(snippetId); michael@0: Services.prefs.setCharPref(SNIPPETS_REMOVED_IDS_PREF, JSON.stringify(removedSnippetIds)); michael@0: } michael@0: michael@0: /** michael@0: * Appends snippet id and timestamp to the end of `snippets-stats.txt`. michael@0: * michael@0: * @param snippetId unique id for snippet, sent from snippets server michael@0: * @param timestamp in ISO8601 michael@0: */ michael@0: function writeStat(snippetId, timestamp) { michael@0: let data = gEncoder.encode(snippetId + "," + timestamp + ";"); michael@0: michael@0: Task.spawn(function() { michael@0: try { michael@0: let file = yield OS.File.open(gStatsPath, { append: true, write: true }); michael@0: try { michael@0: yield file.write(data); michael@0: } finally { michael@0: yield file.close(); michael@0: } michael@0: } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) { michael@0: // If the file doesn't exist yet, create it. michael@0: yield OS.File.writeAtomic(gStatsPath, data, { tmpPath: gStatsPath + ".tmp" }); michael@0: } michael@0: }).then(null, e => Cu.reportError("Error writing snippets stats: " + e)); michael@0: } michael@0: michael@0: /** michael@0: * Reads snippets stats data from `snippets-stats.txt` and sends the data to metrics. michael@0: */ michael@0: function sendStats() { michael@0: let promise = OS.File.read(gStatsPath); michael@0: promise.then(array => sendStatsRequest(gDecoder.decode(array)), e => { michael@0: if (e instanceof OS.File.Error && e.becauseNoSuchFile) { michael@0: // If the file doesn't exist, there aren't any stats to send. michael@0: } else { michael@0: Cu.reportError("Error eading snippets stats: " + e); michael@0: } michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Sends stats to metrics about which snippets have been shown. michael@0: * Appends snippet ids and timestamps as parameters to a GET request. michael@0: * e.g. https://snippets-stats.mozilla.org/mobile?s1=3825&t1=2013-11-17T18:27Z&s2=6326&t2=2013-11-18T18:27Z michael@0: * michael@0: * @param data contents of stats data file michael@0: */ michael@0: function sendStatsRequest(data) { michael@0: let params = []; michael@0: let stats = data.split(";"); michael@0: michael@0: // The last item in the array will be an empty string, so stop before then. michael@0: for (let i = 0; i < stats.length - 1; i++) { michael@0: let stat = stats[i].split(","); michael@0: params.push("s" + i + "=" + encodeURIComponent(stat[0])); michael@0: params.push("t" + i + "=" + encodeURIComponent(stat[1])); michael@0: } michael@0: michael@0: let url = gStatsURL + "?" + params.join("&"); michael@0: michael@0: // Remove the file after succesfully sending the data. michael@0: _httpGetRequest(url, removeStats); michael@0: } michael@0: michael@0: /** michael@0: * Removes text file where we store snippets stats. michael@0: */ michael@0: function removeStats() { michael@0: let promise = OS.File.remove(gStatsPath); michael@0: promise.then(null, e => Cu.reportError("Error removing snippets stats: " + e)); michael@0: } michael@0: michael@0: /** michael@0: * Helper function to make HTTP GET requests. michael@0: * michael@0: * @param url where we send the request michael@0: * @param callback function that is called with the xhr responseText michael@0: */ michael@0: function _httpGetRequest(url, callback) { michael@0: let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); michael@0: try { michael@0: xhr.open("GET", url, true); michael@0: } catch (e) { michael@0: Cu.reportError("Error opening request to " + url + ": " + e); michael@0: return; michael@0: } michael@0: xhr.onerror = function onerror(e) { michael@0: Cu.reportError("Error making request to " + url + ": " + e.error); michael@0: } michael@0: xhr.onload = function onload(event) { michael@0: if (xhr.status !== 200) { michael@0: Cu.reportError("Request to " + url + " returned status " + xhr.status); michael@0: return; michael@0: } michael@0: if (callback) { michael@0: callback(xhr.responseText); michael@0: } michael@0: } michael@0: xhr.send(null); michael@0: } michael@0: michael@0: function loadSyncPromoBanner() { michael@0: Accounts.anySyncAccountsExist().then( michael@0: (exist) => { michael@0: // Don't show the banner if sync accounts exist. michael@0: if (exist) { michael@0: return; michael@0: } michael@0: michael@0: let stringBundle = Services.strings.createBundle("chrome://browser/locale/sync.properties"); michael@0: let text = stringBundle.GetStringFromName("promoBanner.message.text"); michael@0: let link = stringBundle.GetStringFromName("promoBanner.message.link"); michael@0: michael@0: let id = Home.banner.add({ michael@0: text: text + "" + link + "", michael@0: icon: "drawable://sync_promo", michael@0: onclick: function() { michael@0: // Remove the message, so that it won't show again for the rest of the app lifetime. michael@0: Home.banner.remove(id); michael@0: Accounts.launchSetup(); michael@0: michael@0: UITelemetry.addEvent("action.1", "banner", null, "syncpromo"); michael@0: }, michael@0: ondismiss: function() { michael@0: // Remove the sync promo message from the banner and never try to show it again. michael@0: Home.banner.remove(id); michael@0: Services.prefs.setBoolPref("browser.snippets.syncPromo.enabled", false); michael@0: michael@0: UITelemetry.addEvent("cancel.1", "banner", null, "syncpromo"); michael@0: } michael@0: }); michael@0: }, michael@0: (err) => { michael@0: Cu.reportError("Error checking whether sync account exists: " + err); michael@0: } michael@0: ); michael@0: } michael@0: michael@0: function Snippets() {} michael@0: michael@0: Snippets.prototype = { michael@0: QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsITimerCallback]), michael@0: classID: Components.ID("{a78d7e59-b558-4321-a3d6-dffe2f1e76dd}"), michael@0: michael@0: observe: function(subject, topic, data) { michael@0: switch(topic) { michael@0: case "profile-after-change": michael@0: Services.obs.addObserver(this, "browser-delayed-startup-finished", false); michael@0: break; michael@0: case "browser-delayed-startup-finished": michael@0: Services.obs.removeObserver(this, "browser-delayed-startup-finished", false); michael@0: if (Services.prefs.getBoolPref("browser.snippets.syncPromo.enabled")) { michael@0: loadSyncPromoBanner(); michael@0: } michael@0: michael@0: if (Services.prefs.getBoolPref("browser.snippets.enabled")) { michael@0: loadSnippetsFromCache(); michael@0: } michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: // By default, this timer fires once every 24 hours. See the "browser.snippets.updateInterval" pref. michael@0: notify: function(timer) { michael@0: if (!Services.prefs.getBoolPref("browser.snippets.enabled")) { michael@0: return; michael@0: } michael@0: update(); michael@0: sendStats(); michael@0: } michael@0: }; michael@0: michael@0: this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Snippets]);