mobile/android/components/Snippets.js

changeset 0
6474c204b198
     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]);

mercurial