mobile/android/components/Snippets.js

Wed, 31 Dec 2014 07:22:50 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 07:22:50 +0100
branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
permissions
-rw-r--r--

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]);

mercurial