Wed, 31 Dec 2014 07:22:50 +0100
Correct previous dual key logic pending first delivery installment.
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
7 Cu.import("resource://gre/modules/Accounts.jsm");
8 Cu.import("resource://gre/modules/Services.jsm");
9 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
11 XPCOMUtils.defineLazyModuleGetter(this, "Home", "resource://gre/modules/Home.jsm");
12 XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
13 XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
14 XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm");
17 XPCOMUtils.defineLazyGetter(this, "gEncoder", function() { return new gChromeWin.TextEncoder(); });
18 XPCOMUtils.defineLazyGetter(this, "gDecoder", function() { return new gChromeWin.TextDecoder(); });
20 // URL to fetch snippets, in the urlFormatter service format.
21 const SNIPPETS_UPDATE_URL_PREF = "browser.snippets.updateUrl";
23 // URL to send stats data to metrics.
24 const SNIPPETS_STATS_URL_PREF = "browser.snippets.statsUrl";
26 // URL to fetch country code, a value that's cached and refreshed once per month.
27 const SNIPPETS_GEO_URL_PREF = "browser.snippets.geoUrl";
29 // Timestamp when we last updated the user's country code.
30 const SNIPPETS_GEO_LAST_UPDATE_PREF = "browser.snippets.geoLastUpdate";
32 // Pref where we'll cache the user's country.
33 const SNIPPETS_COUNTRY_CODE_PREF = "browser.snippets.countryCode";
35 // Pref where we store an array IDs of snippets that should not be shown again
36 const SNIPPETS_REMOVED_IDS_PREF = "browser.snippets.removedIds";
38 // How frequently we update the user's country code from the server (30 days).
39 const SNIPPETS_GEO_UPDATE_INTERVAL_MS = 86400000*30;
41 // Should be bumped up if the snippets content format changes.
42 const SNIPPETS_VERSION = 1;
44 XPCOMUtils.defineLazyGetter(this, "gSnippetsURL", function() {
45 let updateURL = Services.prefs.getCharPref(SNIPPETS_UPDATE_URL_PREF).replace("%SNIPPETS_VERSION%", SNIPPETS_VERSION);
46 return Services.urlFormatter.formatURL(updateURL);
47 });
49 // Where we cache snippets data
50 XPCOMUtils.defineLazyGetter(this, "gSnippetsPath", function() {
51 return OS.Path.join(OS.Constants.Path.profileDir, "snippets.json");
52 });
54 XPCOMUtils.defineLazyGetter(this, "gStatsURL", function() {
55 return Services.prefs.getCharPref(SNIPPETS_STATS_URL_PREF);
56 });
58 // Where we store stats about which snippets have been shown
59 XPCOMUtils.defineLazyGetter(this, "gStatsPath", function() {
60 return OS.Path.join(OS.Constants.Path.profileDir, "snippets-stats.txt");
61 });
63 XPCOMUtils.defineLazyGetter(this, "gGeoURL", function() {
64 return Services.prefs.getCharPref(SNIPPETS_GEO_URL_PREF);
65 });
67 XPCOMUtils.defineLazyGetter(this, "gCountryCode", function() {
68 try {
69 return Services.prefs.getCharPref(SNIPPETS_COUNTRY_CODE_PREF);
70 } catch (e) {
71 // Return an empty string if the country code pref isn't set yet.
72 return "";
73 }
74 });
76 XPCOMUtils.defineLazyGetter(this, "gChromeWin", function() {
77 return Services.wm.getMostRecentWindow("navigator:browser");
78 });
80 /**
81 * Updates snippet data and country code (if necessary).
82 */
83 function update() {
84 // Check to see if we should update the user's country code from the geo server.
85 let lastUpdate = 0;
86 try {
87 lastUpdate = parseFloat(Services.prefs.getCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF));
88 } catch (e) {}
90 if (Date.now() - lastUpdate > SNIPPETS_GEO_UPDATE_INTERVAL_MS) {
91 // We should update the snippets after updating the country code,
92 // so that we can filter snippets to add to the banner.
93 updateCountryCode(updateSnippets);
94 } else {
95 updateSnippets();
96 }
97 }
99 /**
100 * Fetches the user's country code from the geo server and stores the value in a pref.
101 *
102 * @param callback function called once country code is updated
103 */
104 function updateCountryCode(callback) {
105 _httpGetRequest(gGeoURL, function(responseText) {
106 // Store the country code in a pref.
107 let data = JSON.parse(responseText);
108 Services.prefs.setCharPref(SNIPPETS_COUNTRY_CODE_PREF, data.country_code);
110 // Set last update time.
111 Services.prefs.setCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF, Date.now());
113 callback();
114 });
115 }
117 /**
118 * Loads snippets from snippets server, caches the response, and
119 * updates the home banner with the new set of snippets.
120 */
121 function updateSnippets() {
122 _httpGetRequest(gSnippetsURL, function(responseText) {
123 try {
124 let messages = JSON.parse(responseText);
125 updateBanner(messages);
127 // Only cache the response if it is valid JSON.
128 cacheSnippets(responseText);
129 } catch (e) {
130 Cu.reportError("Error parsing snippets responseText: " + e);
131 }
132 });
133 }
135 /**
136 * Caches snippets server response text to `snippets.json` in profile directory.
137 *
138 * @param response responseText returned from snippets server
139 */
140 function cacheSnippets(response) {
141 let data = gEncoder.encode(response);
142 let promise = OS.File.writeAtomic(gSnippetsPath, data, { tmpPath: gSnippetsPath + ".tmp" });
143 promise.then(null, e => Cu.reportError("Error caching snippets: " + e));
144 }
146 /**
147 * Loads snippets from cached `snippets.json`.
148 */
149 function loadSnippetsFromCache() {
150 let promise = OS.File.read(gSnippetsPath);
151 promise.then(array => {
152 let messages = JSON.parse(gDecoder.decode(array));
153 updateBanner(messages);
154 }, e => {
155 if (e instanceof OS.File.Error && e.becauseNoSuchFile) {
156 Services.console.logStringMessage("Couldn't show snippets because cache does not exist yet.");
157 } else {
158 Cu.reportError("Error loading snippets from cache: " + e);
159 }
160 });
161 }
163 // Array of the message ids added to the home banner, used to remove
164 // older set of snippets when new ones are available.
165 var gMessageIds = [];
167 /**
168 * Updates set of snippets in the home banner message rotation.
169 *
170 * @param messages JSON array of message data JSON objects.
171 * Each message object should have the following properties:
172 * - id (?): Unique identifier for this snippets message
173 * - text (string): Text to show as banner message
174 * - url (string): URL to open when banner is clicked
175 * - icon (data URI): Icon to appear in banner
176 * - target_geo (string): Country code for where this message should be shown (e.g. "US")
177 */
178 function updateBanner(messages) {
179 // Remove the current messages, if there are any.
180 gMessageIds.forEach(function(id) {
181 Home.banner.remove(id);
182 })
183 gMessageIds = [];
185 try {
186 let removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF));
187 messages = messages.filter(function(message) {
188 // Only include the snippet if it has not been previously removed.
189 return removedSnippetIds.indexOf(message.id) === -1;
190 });
191 } catch (e) {
192 // If the pref doesn't exist, there aren't any snippets to filter out.
193 }
195 messages.forEach(function(message) {
196 // Don't add this message to the banner if it's not supposed to be shown in this country.
197 if ("target_geo" in message && message.target_geo != gCountryCode) {
198 return;
199 }
200 let id = Home.banner.add({
201 text: message.text,
202 icon: message.icon,
203 onclick: function() {
204 let parentId = gChromeWin.BrowserApp.selectedTab.id;
205 gChromeWin.BrowserApp.addTab(message.url, { parentId: parentId });
206 UITelemetry.addEvent("action.1", "banner", null, message.id);
207 },
208 ondismiss: function() {
209 // Remove this snippet from the banner, and store its id so we'll never show it again.
210 Home.banner.remove(id);
211 removeSnippet(message.id);
212 UITelemetry.addEvent("cancel.1", "banner", null, message.id);
213 },
214 onshown: function() {
215 // 10% of the time, record the snippet id and a timestamp
216 if (Math.random() < .1) {
217 writeStat(message.id, new Date().toISOString());
218 }
219 }
220 });
221 // Keep track of the message we added so that we can remove it later.
222 gMessageIds.push(id);
223 });
224 }
226 /**
227 * Appends snippet id to the end of `snippets-removed.txt`
228 *
229 * @param snippetId unique id for snippet, sent from snippets server
230 */
231 function removeSnippet(snippetId) {
232 let removedSnippetIds;
233 try {
234 removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF));
235 } catch (e) {
236 removedSnippetIds = [];
237 }
239 removedSnippetIds.push(snippetId);
240 Services.prefs.setCharPref(SNIPPETS_REMOVED_IDS_PREF, JSON.stringify(removedSnippetIds));
241 }
243 /**
244 * Appends snippet id and timestamp to the end of `snippets-stats.txt`.
245 *
246 * @param snippetId unique id for snippet, sent from snippets server
247 * @param timestamp in ISO8601
248 */
249 function writeStat(snippetId, timestamp) {
250 let data = gEncoder.encode(snippetId + "," + timestamp + ";");
252 Task.spawn(function() {
253 try {
254 let file = yield OS.File.open(gStatsPath, { append: true, write: true });
255 try {
256 yield file.write(data);
257 } finally {
258 yield file.close();
259 }
260 } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
261 // If the file doesn't exist yet, create it.
262 yield OS.File.writeAtomic(gStatsPath, data, { tmpPath: gStatsPath + ".tmp" });
263 }
264 }).then(null, e => Cu.reportError("Error writing snippets stats: " + e));
265 }
267 /**
268 * Reads snippets stats data from `snippets-stats.txt` and sends the data to metrics.
269 */
270 function sendStats() {
271 let promise = OS.File.read(gStatsPath);
272 promise.then(array => sendStatsRequest(gDecoder.decode(array)), e => {
273 if (e instanceof OS.File.Error && e.becauseNoSuchFile) {
274 // If the file doesn't exist, there aren't any stats to send.
275 } else {
276 Cu.reportError("Error eading snippets stats: " + e);
277 }
278 });
279 }
281 /**
282 * Sends stats to metrics about which snippets have been shown.
283 * Appends snippet ids and timestamps as parameters to a GET request.
284 * e.g. https://snippets-stats.mozilla.org/mobile?s1=3825&t1=2013-11-17T18:27Z&s2=6326&t2=2013-11-18T18:27Z
285 *
286 * @param data contents of stats data file
287 */
288 function sendStatsRequest(data) {
289 let params = [];
290 let stats = data.split(";");
292 // The last item in the array will be an empty string, so stop before then.
293 for (let i = 0; i < stats.length - 1; i++) {
294 let stat = stats[i].split(",");
295 params.push("s" + i + "=" + encodeURIComponent(stat[0]));
296 params.push("t" + i + "=" + encodeURIComponent(stat[1]));
297 }
299 let url = gStatsURL + "?" + params.join("&");
301 // Remove the file after succesfully sending the data.
302 _httpGetRequest(url, removeStats);
303 }
305 /**
306 * Removes text file where we store snippets stats.
307 */
308 function removeStats() {
309 let promise = OS.File.remove(gStatsPath);
310 promise.then(null, e => Cu.reportError("Error removing snippets stats: " + e));
311 }
313 /**
314 * Helper function to make HTTP GET requests.
315 *
316 * @param url where we send the request
317 * @param callback function that is called with the xhr responseText
318 */
319 function _httpGetRequest(url, callback) {
320 let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
321 try {
322 xhr.open("GET", url, true);
323 } catch (e) {
324 Cu.reportError("Error opening request to " + url + ": " + e);
325 return;
326 }
327 xhr.onerror = function onerror(e) {
328 Cu.reportError("Error making request to " + url + ": " + e.error);
329 }
330 xhr.onload = function onload(event) {
331 if (xhr.status !== 200) {
332 Cu.reportError("Request to " + url + " returned status " + xhr.status);
333 return;
334 }
335 if (callback) {
336 callback(xhr.responseText);
337 }
338 }
339 xhr.send(null);
340 }
342 function loadSyncPromoBanner() {
343 Accounts.anySyncAccountsExist().then(
344 (exist) => {
345 // Don't show the banner if sync accounts exist.
346 if (exist) {
347 return;
348 }
350 let stringBundle = Services.strings.createBundle("chrome://browser/locale/sync.properties");
351 let text = stringBundle.GetStringFromName("promoBanner.message.text");
352 let link = stringBundle.GetStringFromName("promoBanner.message.link");
354 let id = Home.banner.add({
355 text: text + "<a href=\"#\">" + link + "</a>",
356 icon: "drawable://sync_promo",
357 onclick: function() {
358 // Remove the message, so that it won't show again for the rest of the app lifetime.
359 Home.banner.remove(id);
360 Accounts.launchSetup();
362 UITelemetry.addEvent("action.1", "banner", null, "syncpromo");
363 },
364 ondismiss: function() {
365 // Remove the sync promo message from the banner and never try to show it again.
366 Home.banner.remove(id);
367 Services.prefs.setBoolPref("browser.snippets.syncPromo.enabled", false);
369 UITelemetry.addEvent("cancel.1", "banner", null, "syncpromo");
370 }
371 });
372 },
373 (err) => {
374 Cu.reportError("Error checking whether sync account exists: " + err);
375 }
376 );
377 }
379 function Snippets() {}
381 Snippets.prototype = {
382 QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsITimerCallback]),
383 classID: Components.ID("{a78d7e59-b558-4321-a3d6-dffe2f1e76dd}"),
385 observe: function(subject, topic, data) {
386 switch(topic) {
387 case "profile-after-change":
388 Services.obs.addObserver(this, "browser-delayed-startup-finished", false);
389 break;
390 case "browser-delayed-startup-finished":
391 Services.obs.removeObserver(this, "browser-delayed-startup-finished", false);
392 if (Services.prefs.getBoolPref("browser.snippets.syncPromo.enabled")) {
393 loadSyncPromoBanner();
394 }
396 if (Services.prefs.getBoolPref("browser.snippets.enabled")) {
397 loadSnippetsFromCache();
398 }
399 break;
400 }
401 },
403 // By default, this timer fires once every 24 hours. See the "browser.snippets.updateInterval" pref.
404 notify: function(timer) {
405 if (!Services.prefs.getBoolPref("browser.snippets.enabled")) {
406 return;
407 }
408 update();
409 sendStats();
410 }
411 };
413 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Snippets]);