1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/components/Snippets.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,413 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; 1.9 + 1.10 +Cu.import("resource://gre/modules/Accounts.jsm"); 1.11 +Cu.import("resource://gre/modules/Services.jsm"); 1.12 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.13 + 1.14 +XPCOMUtils.defineLazyModuleGetter(this, "Home", "resource://gre/modules/Home.jsm"); 1.15 +XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); 1.16 +XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); 1.17 +XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm"); 1.18 + 1.19 + 1.20 +XPCOMUtils.defineLazyGetter(this, "gEncoder", function() { return new gChromeWin.TextEncoder(); }); 1.21 +XPCOMUtils.defineLazyGetter(this, "gDecoder", function() { return new gChromeWin.TextDecoder(); }); 1.22 + 1.23 +// URL to fetch snippets, in the urlFormatter service format. 1.24 +const SNIPPETS_UPDATE_URL_PREF = "browser.snippets.updateUrl"; 1.25 + 1.26 +// URL to send stats data to metrics. 1.27 +const SNIPPETS_STATS_URL_PREF = "browser.snippets.statsUrl"; 1.28 + 1.29 +// URL to fetch country code, a value that's cached and refreshed once per month. 1.30 +const SNIPPETS_GEO_URL_PREF = "browser.snippets.geoUrl"; 1.31 + 1.32 +// Timestamp when we last updated the user's country code. 1.33 +const SNIPPETS_GEO_LAST_UPDATE_PREF = "browser.snippets.geoLastUpdate"; 1.34 + 1.35 +// Pref where we'll cache the user's country. 1.36 +const SNIPPETS_COUNTRY_CODE_PREF = "browser.snippets.countryCode"; 1.37 + 1.38 +// Pref where we store an array IDs of snippets that should not be shown again 1.39 +const SNIPPETS_REMOVED_IDS_PREF = "browser.snippets.removedIds"; 1.40 + 1.41 +// How frequently we update the user's country code from the server (30 days). 1.42 +const SNIPPETS_GEO_UPDATE_INTERVAL_MS = 86400000*30; 1.43 + 1.44 +// Should be bumped up if the snippets content format changes. 1.45 +const SNIPPETS_VERSION = 1; 1.46 + 1.47 +XPCOMUtils.defineLazyGetter(this, "gSnippetsURL", function() { 1.48 + let updateURL = Services.prefs.getCharPref(SNIPPETS_UPDATE_URL_PREF).replace("%SNIPPETS_VERSION%", SNIPPETS_VERSION); 1.49 + return Services.urlFormatter.formatURL(updateURL); 1.50 +}); 1.51 + 1.52 +// Where we cache snippets data 1.53 +XPCOMUtils.defineLazyGetter(this, "gSnippetsPath", function() { 1.54 + return OS.Path.join(OS.Constants.Path.profileDir, "snippets.json"); 1.55 +}); 1.56 + 1.57 +XPCOMUtils.defineLazyGetter(this, "gStatsURL", function() { 1.58 + return Services.prefs.getCharPref(SNIPPETS_STATS_URL_PREF); 1.59 +}); 1.60 + 1.61 +// Where we store stats about which snippets have been shown 1.62 +XPCOMUtils.defineLazyGetter(this, "gStatsPath", function() { 1.63 + return OS.Path.join(OS.Constants.Path.profileDir, "snippets-stats.txt"); 1.64 +}); 1.65 + 1.66 +XPCOMUtils.defineLazyGetter(this, "gGeoURL", function() { 1.67 + return Services.prefs.getCharPref(SNIPPETS_GEO_URL_PREF); 1.68 +}); 1.69 + 1.70 +XPCOMUtils.defineLazyGetter(this, "gCountryCode", function() { 1.71 + try { 1.72 + return Services.prefs.getCharPref(SNIPPETS_COUNTRY_CODE_PREF); 1.73 + } catch (e) { 1.74 + // Return an empty string if the country code pref isn't set yet. 1.75 + return ""; 1.76 + } 1.77 +}); 1.78 + 1.79 +XPCOMUtils.defineLazyGetter(this, "gChromeWin", function() { 1.80 + return Services.wm.getMostRecentWindow("navigator:browser"); 1.81 +}); 1.82 + 1.83 +/** 1.84 + * Updates snippet data and country code (if necessary). 1.85 + */ 1.86 +function update() { 1.87 + // Check to see if we should update the user's country code from the geo server. 1.88 + let lastUpdate = 0; 1.89 + try { 1.90 + lastUpdate = parseFloat(Services.prefs.getCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF)); 1.91 + } catch (e) {} 1.92 + 1.93 + if (Date.now() - lastUpdate > SNIPPETS_GEO_UPDATE_INTERVAL_MS) { 1.94 + // We should update the snippets after updating the country code, 1.95 + // so that we can filter snippets to add to the banner. 1.96 + updateCountryCode(updateSnippets); 1.97 + } else { 1.98 + updateSnippets(); 1.99 + } 1.100 +} 1.101 + 1.102 +/** 1.103 + * Fetches the user's country code from the geo server and stores the value in a pref. 1.104 + * 1.105 + * @param callback function called once country code is updated 1.106 + */ 1.107 +function updateCountryCode(callback) { 1.108 + _httpGetRequest(gGeoURL, function(responseText) { 1.109 + // Store the country code in a pref. 1.110 + let data = JSON.parse(responseText); 1.111 + Services.prefs.setCharPref(SNIPPETS_COUNTRY_CODE_PREF, data.country_code); 1.112 + 1.113 + // Set last update time. 1.114 + Services.prefs.setCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF, Date.now()); 1.115 + 1.116 + callback(); 1.117 + }); 1.118 +} 1.119 + 1.120 +/** 1.121 + * Loads snippets from snippets server, caches the response, and 1.122 + * updates the home banner with the new set of snippets. 1.123 + */ 1.124 +function updateSnippets() { 1.125 + _httpGetRequest(gSnippetsURL, function(responseText) { 1.126 + try { 1.127 + let messages = JSON.parse(responseText); 1.128 + updateBanner(messages); 1.129 + 1.130 + // Only cache the response if it is valid JSON. 1.131 + cacheSnippets(responseText); 1.132 + } catch (e) { 1.133 + Cu.reportError("Error parsing snippets responseText: " + e); 1.134 + } 1.135 + }); 1.136 +} 1.137 + 1.138 +/** 1.139 + * Caches snippets server response text to `snippets.json` in profile directory. 1.140 + * 1.141 + * @param response responseText returned from snippets server 1.142 + */ 1.143 +function cacheSnippets(response) { 1.144 + let data = gEncoder.encode(response); 1.145 + let promise = OS.File.writeAtomic(gSnippetsPath, data, { tmpPath: gSnippetsPath + ".tmp" }); 1.146 + promise.then(null, e => Cu.reportError("Error caching snippets: " + e)); 1.147 +} 1.148 + 1.149 +/** 1.150 + * Loads snippets from cached `snippets.json`. 1.151 + */ 1.152 +function loadSnippetsFromCache() { 1.153 + let promise = OS.File.read(gSnippetsPath); 1.154 + promise.then(array => { 1.155 + let messages = JSON.parse(gDecoder.decode(array)); 1.156 + updateBanner(messages); 1.157 + }, e => { 1.158 + if (e instanceof OS.File.Error && e.becauseNoSuchFile) { 1.159 + Services.console.logStringMessage("Couldn't show snippets because cache does not exist yet."); 1.160 + } else { 1.161 + Cu.reportError("Error loading snippets from cache: " + e); 1.162 + } 1.163 + }); 1.164 +} 1.165 + 1.166 +// Array of the message ids added to the home banner, used to remove 1.167 +// older set of snippets when new ones are available. 1.168 +var gMessageIds = []; 1.169 + 1.170 +/** 1.171 + * Updates set of snippets in the home banner message rotation. 1.172 + * 1.173 + * @param messages JSON array of message data JSON objects. 1.174 + * Each message object should have the following properties: 1.175 + * - id (?): Unique identifier for this snippets message 1.176 + * - text (string): Text to show as banner message 1.177 + * - url (string): URL to open when banner is clicked 1.178 + * - icon (data URI): Icon to appear in banner 1.179 + * - target_geo (string): Country code for where this message should be shown (e.g. "US") 1.180 + */ 1.181 +function updateBanner(messages) { 1.182 + // Remove the current messages, if there are any. 1.183 + gMessageIds.forEach(function(id) { 1.184 + Home.banner.remove(id); 1.185 + }) 1.186 + gMessageIds = []; 1.187 + 1.188 + try { 1.189 + let removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF)); 1.190 + messages = messages.filter(function(message) { 1.191 + // Only include the snippet if it has not been previously removed. 1.192 + return removedSnippetIds.indexOf(message.id) === -1; 1.193 + }); 1.194 + } catch (e) { 1.195 + // If the pref doesn't exist, there aren't any snippets to filter out. 1.196 + } 1.197 + 1.198 + messages.forEach(function(message) { 1.199 + // Don't add this message to the banner if it's not supposed to be shown in this country. 1.200 + if ("target_geo" in message && message.target_geo != gCountryCode) { 1.201 + return; 1.202 + } 1.203 + let id = Home.banner.add({ 1.204 + text: message.text, 1.205 + icon: message.icon, 1.206 + onclick: function() { 1.207 + let parentId = gChromeWin.BrowserApp.selectedTab.id; 1.208 + gChromeWin.BrowserApp.addTab(message.url, { parentId: parentId }); 1.209 + UITelemetry.addEvent("action.1", "banner", null, message.id); 1.210 + }, 1.211 + ondismiss: function() { 1.212 + // Remove this snippet from the banner, and store its id so we'll never show it again. 1.213 + Home.banner.remove(id); 1.214 + removeSnippet(message.id); 1.215 + UITelemetry.addEvent("cancel.1", "banner", null, message.id); 1.216 + }, 1.217 + onshown: function() { 1.218 + // 10% of the time, record the snippet id and a timestamp 1.219 + if (Math.random() < .1) { 1.220 + writeStat(message.id, new Date().toISOString()); 1.221 + } 1.222 + } 1.223 + }); 1.224 + // Keep track of the message we added so that we can remove it later. 1.225 + gMessageIds.push(id); 1.226 + }); 1.227 +} 1.228 + 1.229 +/** 1.230 + * Appends snippet id to the end of `snippets-removed.txt` 1.231 + * 1.232 + * @param snippetId unique id for snippet, sent from snippets server 1.233 + */ 1.234 +function removeSnippet(snippetId) { 1.235 + let removedSnippetIds; 1.236 + try { 1.237 + removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF)); 1.238 + } catch (e) { 1.239 + removedSnippetIds = []; 1.240 + } 1.241 + 1.242 + removedSnippetIds.push(snippetId); 1.243 + Services.prefs.setCharPref(SNIPPETS_REMOVED_IDS_PREF, JSON.stringify(removedSnippetIds)); 1.244 +} 1.245 + 1.246 +/** 1.247 + * Appends snippet id and timestamp to the end of `snippets-stats.txt`. 1.248 + * 1.249 + * @param snippetId unique id for snippet, sent from snippets server 1.250 + * @param timestamp in ISO8601 1.251 + */ 1.252 +function writeStat(snippetId, timestamp) { 1.253 + let data = gEncoder.encode(snippetId + "," + timestamp + ";"); 1.254 + 1.255 + Task.spawn(function() { 1.256 + try { 1.257 + let file = yield OS.File.open(gStatsPath, { append: true, write: true }); 1.258 + try { 1.259 + yield file.write(data); 1.260 + } finally { 1.261 + yield file.close(); 1.262 + } 1.263 + } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) { 1.264 + // If the file doesn't exist yet, create it. 1.265 + yield OS.File.writeAtomic(gStatsPath, data, { tmpPath: gStatsPath + ".tmp" }); 1.266 + } 1.267 + }).then(null, e => Cu.reportError("Error writing snippets stats: " + e)); 1.268 +} 1.269 + 1.270 +/** 1.271 + * Reads snippets stats data from `snippets-stats.txt` and sends the data to metrics. 1.272 + */ 1.273 +function sendStats() { 1.274 + let promise = OS.File.read(gStatsPath); 1.275 + promise.then(array => sendStatsRequest(gDecoder.decode(array)), e => { 1.276 + if (e instanceof OS.File.Error && e.becauseNoSuchFile) { 1.277 + // If the file doesn't exist, there aren't any stats to send. 1.278 + } else { 1.279 + Cu.reportError("Error eading snippets stats: " + e); 1.280 + } 1.281 + }); 1.282 +} 1.283 + 1.284 +/** 1.285 + * Sends stats to metrics about which snippets have been shown. 1.286 + * Appends snippet ids and timestamps as parameters to a GET request. 1.287 + * e.g. https://snippets-stats.mozilla.org/mobile?s1=3825&t1=2013-11-17T18:27Z&s2=6326&t2=2013-11-18T18:27Z 1.288 + * 1.289 + * @param data contents of stats data file 1.290 + */ 1.291 +function sendStatsRequest(data) { 1.292 + let params = []; 1.293 + let stats = data.split(";"); 1.294 + 1.295 + // The last item in the array will be an empty string, so stop before then. 1.296 + for (let i = 0; i < stats.length - 1; i++) { 1.297 + let stat = stats[i].split(","); 1.298 + params.push("s" + i + "=" + encodeURIComponent(stat[0])); 1.299 + params.push("t" + i + "=" + encodeURIComponent(stat[1])); 1.300 + } 1.301 + 1.302 + let url = gStatsURL + "?" + params.join("&"); 1.303 + 1.304 + // Remove the file after succesfully sending the data. 1.305 + _httpGetRequest(url, removeStats); 1.306 +} 1.307 + 1.308 +/** 1.309 + * Removes text file where we store snippets stats. 1.310 + */ 1.311 +function removeStats() { 1.312 + let promise = OS.File.remove(gStatsPath); 1.313 + promise.then(null, e => Cu.reportError("Error removing snippets stats: " + e)); 1.314 +} 1.315 + 1.316 +/** 1.317 + * Helper function to make HTTP GET requests. 1.318 + * 1.319 + * @param url where we send the request 1.320 + * @param callback function that is called with the xhr responseText 1.321 + */ 1.322 +function _httpGetRequest(url, callback) { 1.323 + let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest); 1.324 + try { 1.325 + xhr.open("GET", url, true); 1.326 + } catch (e) { 1.327 + Cu.reportError("Error opening request to " + url + ": " + e); 1.328 + return; 1.329 + } 1.330 + xhr.onerror = function onerror(e) { 1.331 + Cu.reportError("Error making request to " + url + ": " + e.error); 1.332 + } 1.333 + xhr.onload = function onload(event) { 1.334 + if (xhr.status !== 200) { 1.335 + Cu.reportError("Request to " + url + " returned status " + xhr.status); 1.336 + return; 1.337 + } 1.338 + if (callback) { 1.339 + callback(xhr.responseText); 1.340 + } 1.341 + } 1.342 + xhr.send(null); 1.343 +} 1.344 + 1.345 +function loadSyncPromoBanner() { 1.346 + Accounts.anySyncAccountsExist().then( 1.347 + (exist) => { 1.348 + // Don't show the banner if sync accounts exist. 1.349 + if (exist) { 1.350 + return; 1.351 + } 1.352 + 1.353 + let stringBundle = Services.strings.createBundle("chrome://browser/locale/sync.properties"); 1.354 + let text = stringBundle.GetStringFromName("promoBanner.message.text"); 1.355 + let link = stringBundle.GetStringFromName("promoBanner.message.link"); 1.356 + 1.357 + let id = Home.banner.add({ 1.358 + text: text + "<a href=\"#\">" + link + "</a>", 1.359 + icon: "drawable://sync_promo", 1.360 + onclick: function() { 1.361 + // Remove the message, so that it won't show again for the rest of the app lifetime. 1.362 + Home.banner.remove(id); 1.363 + Accounts.launchSetup(); 1.364 + 1.365 + UITelemetry.addEvent("action.1", "banner", null, "syncpromo"); 1.366 + }, 1.367 + ondismiss: function() { 1.368 + // Remove the sync promo message from the banner and never try to show it again. 1.369 + Home.banner.remove(id); 1.370 + Services.prefs.setBoolPref("browser.snippets.syncPromo.enabled", false); 1.371 + 1.372 + UITelemetry.addEvent("cancel.1", "banner", null, "syncpromo"); 1.373 + } 1.374 + }); 1.375 + }, 1.376 + (err) => { 1.377 + Cu.reportError("Error checking whether sync account exists: " + err); 1.378 + } 1.379 + ); 1.380 +} 1.381 + 1.382 +function Snippets() {} 1.383 + 1.384 +Snippets.prototype = { 1.385 + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsITimerCallback]), 1.386 + classID: Components.ID("{a78d7e59-b558-4321-a3d6-dffe2f1e76dd}"), 1.387 + 1.388 + observe: function(subject, topic, data) { 1.389 + switch(topic) { 1.390 + case "profile-after-change": 1.391 + Services.obs.addObserver(this, "browser-delayed-startup-finished", false); 1.392 + break; 1.393 + case "browser-delayed-startup-finished": 1.394 + Services.obs.removeObserver(this, "browser-delayed-startup-finished", false); 1.395 + if (Services.prefs.getBoolPref("browser.snippets.syncPromo.enabled")) { 1.396 + loadSyncPromoBanner(); 1.397 + } 1.398 + 1.399 + if (Services.prefs.getBoolPref("browser.snippets.enabled")) { 1.400 + loadSnippetsFromCache(); 1.401 + } 1.402 + break; 1.403 + } 1.404 + }, 1.405 + 1.406 + // By default, this timer fires once every 24 hours. See the "browser.snippets.updateInterval" pref. 1.407 + notify: function(timer) { 1.408 + if (!Services.prefs.getBoolPref("browser.snippets.enabled")) { 1.409 + return; 1.410 + } 1.411 + update(); 1.412 + sendStats(); 1.413 + } 1.414 +}; 1.415 + 1.416 +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Snippets]);