summaryrefslogtreecommitdiffstats
path: root/mobile/android/components/Snippets.js
blob: 92639236f2e197a5d05089bfe17cd53b2b31a3be (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
/* 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/. */

const { classes: Cc, interfaces: Ci, utils: Cu } = Components;

Cu.import("resource://gre/modules/Accounts.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "Home", "resource://gre/modules/Home.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "UITelemetry", "resource://gre/modules/UITelemetry.jsm");


XPCOMUtils.defineLazyGetter(this, "gEncoder", function() { return new gChromeWin.TextEncoder(); });
XPCOMUtils.defineLazyGetter(this, "gDecoder", function() { return new gChromeWin.TextDecoder(); });

// URL to fetch snippets, in the urlFormatter service format.
const SNIPPETS_UPDATE_URL_PREF = "browser.snippets.updateUrl";

// URL to send stats data to metrics.
const SNIPPETS_STATS_URL_PREF = "browser.snippets.statsUrl";

// URL to fetch country code, a value that's cached and refreshed once per month.
const SNIPPETS_GEO_URL_PREF = "browser.snippets.geoUrl";

// Timestamp when we last updated the user's country code.
const SNIPPETS_GEO_LAST_UPDATE_PREF = "browser.snippets.geoLastUpdate";

// Pref where we'll cache the user's country.
const SNIPPETS_COUNTRY_CODE_PREF = "browser.snippets.countryCode";

// Pref where we store an array IDs of snippets that should not be shown again
const SNIPPETS_REMOVED_IDS_PREF = "browser.snippets.removedIds";

// How frequently we update the user's country code from the server (30 days).
const SNIPPETS_GEO_UPDATE_INTERVAL_MS = 86400000*30;

// Should be bumped up if the snippets content format changes.
const SNIPPETS_VERSION = 1;

XPCOMUtils.defineLazyGetter(this, "gSnippetsURL", function() {
  let updateURL = Services.prefs.getCharPref(SNIPPETS_UPDATE_URL_PREF).replace("%SNIPPETS_VERSION%", SNIPPETS_VERSION);
  return Services.urlFormatter.formatURL(updateURL);
});

// Where we cache snippets data
XPCOMUtils.defineLazyGetter(this, "gSnippetsPath", function() {
  return OS.Path.join(OS.Constants.Path.profileDir, "snippets.json");
});

XPCOMUtils.defineLazyGetter(this, "gStatsURL", function() {
  return Services.prefs.getCharPref(SNIPPETS_STATS_URL_PREF);
});

// Where we store stats about which snippets have been shown
XPCOMUtils.defineLazyGetter(this, "gStatsPath", function() {
  return OS.Path.join(OS.Constants.Path.profileDir, "snippets-stats.txt");
});

XPCOMUtils.defineLazyGetter(this, "gGeoURL", function() {
  return Services.prefs.getCharPref(SNIPPETS_GEO_URL_PREF);
});

XPCOMUtils.defineLazyGetter(this, "gCountryCode", function() {
  try {
    return Services.prefs.getCharPref(SNIPPETS_COUNTRY_CODE_PREF);
  } catch (e) {
    // Return an empty string if the country code pref isn't set yet.
    return "";
  }
});

XPCOMUtils.defineLazyGetter(this, "gChromeWin", function() {
  return Services.wm.getMostRecentWindow("navigator:browser");
});

/**
 * Updates snippet data and country code (if necessary).
 */
function update() {
  // Check to see if we should update the user's country code from the geo server.
  let lastUpdate = 0;
  try {
    lastUpdate = parseFloat(Services.prefs.getCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF));
  } catch (e) {}

  if (Date.now() - lastUpdate > SNIPPETS_GEO_UPDATE_INTERVAL_MS) {
    // We should update the snippets after updating the country code,
    // so that we can filter snippets to add to the banner.
    updateCountryCode(updateSnippets);
  } else {
    updateSnippets();
  }
}

/**
 * Fetches the user's country code from the geo server and stores the value in a pref.
 *
 * @param callback function called once country code is updated
 */
function updateCountryCode(callback) {
  _httpGetRequest(gGeoURL, function(responseText) {
    // Store the country code in a pref.
    let data = JSON.parse(responseText);
    Services.prefs.setCharPref(SNIPPETS_COUNTRY_CODE_PREF, data.country_code);

    // Set last update time.
    Services.prefs.setCharPref(SNIPPETS_GEO_LAST_UPDATE_PREF, Date.now());

    callback();
  });
}

/**
 * Loads snippets from snippets server, caches the response, and
 * updates the home banner with the new set of snippets.
 */
function updateSnippets() {
  _httpGetRequest(gSnippetsURL, function(responseText) {
    try {
      let messages = JSON.parse(responseText);
      updateBanner(messages);

      // Only cache the response if it is valid JSON.
      cacheSnippets(responseText);
    } catch (e) {
      Cu.reportError("Error parsing snippets responseText: " + e);
    }
  });
}

/**
 * Caches snippets server response text to `snippets.json` in profile directory.
 *
 * @param response responseText returned from snippets server
 */
function cacheSnippets(response) {
  let data = gEncoder.encode(response);
  let promise = OS.File.writeAtomic(gSnippetsPath, data, { tmpPath: gSnippetsPath + ".tmp" });
  promise.then(null, e => Cu.reportError("Error caching snippets: " + e));
}

/**
 * Loads snippets from cached `snippets.json`.
 */
function loadSnippetsFromCache() {
  let promise = OS.File.read(gSnippetsPath);
  promise.then(array => {
    let messages = JSON.parse(gDecoder.decode(array));
    updateBanner(messages);
  }, e => {
    if (e instanceof OS.File.Error && e.becauseNoSuchFile) {
      Services.console.logStringMessage("Couldn't show snippets because cache does not exist yet.");
    } else {
      Cu.reportError("Error loading snippets from cache: " + e);
    }
  });
}

// Array of the message ids added to the home banner, used to remove
// older set of snippets when new ones are available.
var gMessageIds = [];

/**
 * Updates set of snippets in the home banner message rotation.
 *
 * @param messages JSON array of message data JSON objects.
 *   Each message object should have the following properties:
 *     - id (?): Unique identifier for this snippets message
 *     - text (string): Text to show as banner message
 *     - url (string): URL to open when banner is clicked
 *     - icon (data URI): Icon to appear in banner
 *     - countries (list of strings): Country codes for where this message should be shown (e.g. ["US", "GR"])
 */
function updateBanner(messages) {
  // Remove the current messages, if there are any.
  gMessageIds.forEach(function(id) {
    Home.banner.remove(id);
  })
  gMessageIds = [];

  try {
    let removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF));
    messages = messages.filter(function(message) {
      // Only include the snippet if it has not been previously removed.
      return removedSnippetIds.indexOf(message.id) === -1;
    });
  } catch (e) {
    // If the pref doesn't exist, there aren't any snippets to filter out.
  }

  messages.forEach(function(message) {
    // Don't add this message to the banner if it's not supposed to be shown in this country.
    if ("countries" in message && message.countries.indexOf(gCountryCode) === -1) {
      return;
    }

    let id = Home.banner.add({
      text: message.text,
      icon: message.icon,
      weight: message.weight,
      onclick: function() {
        gChromeWin.BrowserApp.loadURI(message.url);
        removeSnippet(id, message.id);
        UITelemetry.addEvent("action.1", "banner", null, message.id);
      },
      ondismiss: function() {
        removeSnippet(id, message.id);
        UITelemetry.addEvent("cancel.1", "banner", null, message.id);
      },
      onshown: function() {
        // 10% of the time, record the snippet id and a timestamp
        if (Math.random() < .1) {
          writeStat(message.id, new Date().toISOString());
        }
      }
    });
    // Keep track of the message we added so that we can remove it later.
    gMessageIds.push(id);
  });
}

/**
 * Removes a snippet message from the home banner rotation, and stores its
 * snippet id in a pref so we'll never show it again.
 *
 * @param messageId unique id for home banner message, returned from Home.banner API
 * @param snippetId unique id for snippet, sent from snippets server
 */
function removeSnippet(messageId, snippetId) {
  // Remove the message from the home banner rotation.
  Home.banner.remove(messageId);

  // Remove the message from the stored message ids.
  gMessageIds.splice(gMessageIds.indexOf(messageId), 1);

  let removedSnippetIds;
  try {
    removedSnippetIds = JSON.parse(Services.prefs.getCharPref(SNIPPETS_REMOVED_IDS_PREF));
  } catch (e) {
    removedSnippetIds = [];
  }

  removedSnippetIds.push(snippetId);
  Services.prefs.setCharPref(SNIPPETS_REMOVED_IDS_PREF, JSON.stringify(removedSnippetIds));
}

/**
 * Appends snippet id and timestamp to the end of `snippets-stats.txt`.
 *
 * @param snippetId unique id for snippet, sent from snippets server
 * @param timestamp in ISO8601
 */
function writeStat(snippetId, timestamp) {
  let data = gEncoder.encode(snippetId + "," + timestamp + ";");

  Task.spawn(function() {
    try {
      let file = yield OS.File.open(gStatsPath, { append: true, write: true });
      try {
        yield file.write(data);
      } finally {
        yield file.close();
      }
    } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
      // If the file doesn't exist yet, create it.
      yield OS.File.writeAtomic(gStatsPath, data, { tmpPath: gStatsPath + ".tmp" });
    }
  }).then(null, e => Cu.reportError("Error writing snippets stats: " + e));
}

/**
 * Reads snippets stats data from `snippets-stats.txt` and sends the data to metrics.
 */
function sendStats() {
  let promise = OS.File.read(gStatsPath);
  promise.then(array => sendStatsRequest(gDecoder.decode(array)), e => {
    if (e instanceof OS.File.Error && e.becauseNoSuchFile) {
      // If the file doesn't exist, there aren't any stats to send.
    } else {
      Cu.reportError("Error eading snippets stats: " + e);
    }
  });
}

/**
 * Sends stats to metrics about which snippets have been shown.
 * Appends snippet ids and timestamps as parameters to a GET request.
 * e.g. https://snippets-stats.mozilla.org/mobile?s1=3825&t1=2013-11-17T18:27Z&s2=6326&t2=2013-11-18T18:27Z
 *
 * @param data contents of stats data file
 */
function sendStatsRequest(data) {
  let params = [];
  let stats = data.split(";");

  // The last item in the array will be an empty string, so stop before then.
  for (let i = 0; i < stats.length - 1; i++) {
    let stat = stats[i].split(",");
    params.push("s" + i + "=" + encodeURIComponent(stat[0]));
    params.push("t" + i + "=" + encodeURIComponent(stat[1]));
  }

  let url = gStatsURL + "?" + params.join("&");

  // Remove the file after succesfully sending the data.
  _httpGetRequest(url, removeStats);
}

/**
 * Removes text file where we store snippets stats.
 */
function removeStats() {
  let promise = OS.File.remove(gStatsPath);
  promise.then(null, e => Cu.reportError("Error removing snippets stats: " + e));
}

/**
 * Helper function to make HTTP GET requests.
 *
 * @param url where we send the request
 * @param callback function that is called with the xhr responseText
 */
function _httpGetRequest(url, callback) {
  let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
  try {
    xhr.open("GET", url, true);
  } catch (e) {
    Cu.reportError("Error opening request to " + url + ": " + e);
    return;
  }
  xhr.onerror = function onerror(e) {
    Cu.reportError("Error making request to " + url + ": " + e.error);
  }
  xhr.onload = function onload(event) {
    if (xhr.status !== 200) {
      Cu.reportError("Request to " + url + " returned status " + xhr.status);
      return;
    }
    if (callback) {
      callback(xhr.responseText);
    }
  }
  xhr.send(null);
}

function loadSyncPromoBanner() {
  Accounts.anySyncAccountsExist().then(
    (exist) => {
      // Don't show the banner if sync accounts exist.
      if (exist) {
        return;
      }

      let stringBundle = Services.strings.createBundle("chrome://browser/locale/sync.properties");
      let text = stringBundle.GetStringFromName("promoBanner.message.text");
      let link = stringBundle.GetStringFromName("promoBanner.message.link");

      let id = Home.banner.add({
        text: text + "<a href=\"#\">" + link + "</a>",
        icon: "drawable://sync_promo",
        onclick: function() {
          // Remove the message, so that it won't show again for the rest of the app lifetime.
          Home.banner.remove(id);
          Accounts.launchSetup();

          UITelemetry.addEvent("action.1", "banner", null, "syncpromo");
        },
        ondismiss: function() {
          // Remove the sync promo message from the banner and never try to show it again.
          Home.banner.remove(id);
          Services.prefs.setBoolPref("browser.snippets.syncPromo.enabled", false);

          UITelemetry.addEvent("cancel.1", "banner", null, "syncpromo");
        }
      });
    },
    (err) => {
      Cu.reportError("Error checking whether sync account exists: " + err);
    }
  );
}

function loadHomePanelsBanner() {
  let stringBundle = Services.strings.createBundle("chrome://browser/locale/aboutHome.properties");
  let text = stringBundle.GetStringFromName("banner.firstrunHomepage.text");

  let id = Home.banner.add({
    text: text,
    icon: "drawable://homepage_banner_firstrun",
    onclick: function() {
      // Remove the message, so that it won't show again for the rest of the app lifetime.
      Home.banner.remove(id);
      // User has interacted with this snippet so don't show it again.
      Services.prefs.setBoolPref("browser.snippets.firstrunHomepage.enabled", false);

      UITelemetry.addEvent("action.1", "banner", null, "firstrun-homepage");
    },
    ondismiss: function() {
      Home.banner.remove(id);
      Services.prefs.setBoolPref("browser.snippets.firstrunHomepage.enabled", false);

      UITelemetry.addEvent("cancel.1", "banner", null, "firstrun-homepage");
    }
  });
}

function Snippets() {}

Snippets.prototype = {
  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsITimerCallback]),
  classID: Components.ID("{a78d7e59-b558-4321-a3d6-dffe2f1e76dd}"),

  observe: function(subject, topic, data) {
    switch(topic) {
      case "browser-delayed-startup-finished":
        // Add snippets to be cycled through.
        if (Services.prefs.getBoolPref("browser.snippets.firstrunHomepage.enabled")) {
          loadHomePanelsBanner();
        }

        if (Services.prefs.getBoolPref("browser.snippets.syncPromo.enabled")) {
          loadSyncPromoBanner();
        }

        if (Services.prefs.getBoolPref("browser.snippets.enabled")) {
          loadSnippetsFromCache();
        }
        break;
    }
  },

  // By default, this timer fires once every 24 hours. See the "browser.snippets.updateInterval" pref.
  notify: function(timer) {
    if (!Services.prefs.getBoolPref("browser.snippets.enabled")) {
      return;
    }
    update();
    sendStats();
  }
};

this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Snippets]);