Bug 1068425 - Implement tile reporting components. r=rnewman, a=lsblakk
authorBrian Nicholson <bnicholson@mozilla.com>
Wed, 05 Nov 2014 12:14:34 -0800
changeset 235175 e1a303b5e43f5cf29750713604e46df5acd4ff74
parent 235174 199744bc70440af89a974cf2ddc017b0de1b028e
child 235176 18c86a0bf10542096eac435f7ddddf0293eb6d68
push id611
push userraliiev@mozilla.com
push dateMon, 05 Jan 2015 23:23:16 +0000
treeherdermozilla-release@345cd3b9c445 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman, lsblakk
bugs1068425
milestone35.0a2
Bug 1068425 - Implement tile reporting components. r=rnewman, a=lsblakk
mobile/android/app/mobile.js
mobile/android/base/moz.build
mobile/android/base/tiles/Tile.java
mobile/android/base/tiles/TilesRecorder.java
mobile/android/chrome/content/browser.js
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -658,16 +658,21 @@ pref("urlclassifier.gethashnoise", 4);
 pref("urlclassifier.gethash.timeout_ms", 5000);
 
 // If an urlclassifier table has not been updated in this number of seconds,
 // a gethash request will be forced to check that the result is still in
 // the database.
 pref("urlclassifier.max-complete-age", 2700);
 #endif
 
+// URL for posting tiles metrics.
+#ifdef RELEASE_BUILD
+pref("browser.tiles.reportURL", "https://tiles.services.mozilla.com/v2/links/click");
+#endif
+
 // True if this is the first time we are showing about:firstrun
 pref("browser.firstrun.show.uidiscovery", true);
 pref("browser.firstrun.show.localepicker", false);
 
 // True if you always want dump() to work
 //
 // On Android, you also need to do the following for the output
 // to show up in logcat:
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -398,16 +398,18 @@ gbjar.sources += [
     'tabs/TabsListLayout.java',
     'tabs/TabsPanel.java',
     'TabsAccessor.java',
     'Telemetry.java',
     'TelemetryContract.java',
     'TextSelection.java',
     'TextSelectionHandle.java',
     'ThumbnailHelper.java',
+    'tiles/Tile.java',
+    'tiles/TilesRecorder.java',
     'toolbar/ActionBarViewFlipper.java',
     'toolbar/AutocompleteHandler.java',
     'toolbar/BackButton.java',
     'toolbar/BrowserToolbar.java',
     'toolbar/BrowserToolbarNewTablet.java',
     'toolbar/BrowserToolbarPhone.java',
     'toolbar/BrowserToolbarPhoneBase.java',
     'toolbar/BrowserToolbarPreHC.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/tiles/Tile.java
@@ -0,0 +1,18 @@
+/* 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/. */
+
+package org.mozilla.gecko.tiles;
+
+/**
+ * Metadata for a tile shown on the top sites grid.
+ */
+public class Tile {
+    public final int id;
+    public final boolean pinned;
+
+    public Tile(int id, boolean pinned) {
+        this.id = id;
+        this.pinned = pinned;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/tiles/TilesRecorder.java
@@ -0,0 +1,103 @@
+/* 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/. */
+
+package org.mozilla.gecko.tiles;
+
+import java.util.List;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.json.JSONException;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.Tab;
+
+import android.util.Log;
+
+public class TilesRecorder {
+    public static final String ACTION_CLICK = "click";
+
+    private static final String LOG_TAG = "GeckoTilesRecorder";
+    private static final String EVENT_TILES_CLICK = "Tiles:Click";
+
+    public void recordAction(Tab tab, String action, int index, List<Tile> tiles) {
+        final Tile clickedTile = tiles.get(index);
+
+        if (tab == null || clickedTile == null) {
+            throw new IllegalArgumentException("Tab and tile cannot be null");
+        }
+
+        if (clickedTile.id == -1) {
+            // User clicked a non-distribution tile, so we don't need to report it.
+            return;
+        }
+
+        try {
+            final JSONArray tilesJSON = new JSONArray();
+            int clickedTileIndex = -1;
+            int currentTileIndex = 0;
+
+            for (int i = 0; i < tiles.size(); i++) {
+                final Tile tile = tiles.get(i);
+                if (tile == null) {
+                    // Tiles may be null if there are pinned tiles with blank tiles in between.
+                    continue;
+                }
+
+                // jsonForTile will return {} if the tile isn't tracked or pinned.  That's fine
+                // as we still want to record that a tile exists (i.e., is not empty).
+                tilesJSON.put(jsonForTile(tile, currentTileIndex, i));
+
+                // The click index is relative to the tiles array we're building. That index is
+                // incremented whenever we hit a non-null tile. When we find the tile that was
+                // clicked, the index will match its position in the new array.
+                if (clickedTile == tile) {
+                    clickedTileIndex = currentTileIndex;
+                }
+
+                currentTileIndex++;
+            }
+
+            if (clickedTileIndex == -1) {
+                throw new IllegalStateException("Clicked tile index not set");
+            }
+
+            final JSONObject payload = new JSONObject();
+            payload.put(action, clickedTileIndex);
+            payload.put("tiles", tilesJSON);
+
+            final JSONObject data = new JSONObject();
+            data.put("tabId", tab.getId());
+            data.put("payload", payload.toString());
+
+            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent(EVENT_TILES_CLICK, data.toString()));
+        } catch (JSONException e) {
+            Log.e(LOG_TAG, "JSON error", e);
+        }
+    }
+
+    private JSONObject jsonForTile(Tile tile, int tileIndex, int viewIndex) throws JSONException {
+        final JSONObject tileJSON = new JSONObject();
+
+        // Set the ID only if it is a distribution tile with a tracking ID.
+        if (tile.id != -1) {
+            tileJSON.put("id", tile.id);
+        }
+
+        // Set pinned to true only if the tile is pinned.
+        if (tile.pinned) {
+            tileJSON.put("pin", true);
+        }
+
+        // If the tile's index in the new array does not match its index in the view grid, record
+        // its position in the view grid. This can happen if there are pinned tiles with blank tiles
+        // in between.
+        if (tileIndex != viewIndex) {
+            tileJSON.put("pos", viewIndex);
+        }
+
+        return tileJSON;
+    }
+}
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -498,16 +498,25 @@ var BrowserApp = {
           let uaLocale = this.getUALocalePref();
           this.computeAcceptLanguages(osLocale, uaLocale);
         }
       } catch (e) {
         // Phew.
       }
     }
 
+    try {
+      // Set the tiles click observer only if tiles reporting is enabled (that
+      // is, a report URL is set in prefs).
+      gTilesReportURL = Services.prefs.getCharPref("browser.tiles.reportURL");
+      Services.obs.addObserver(this, "Tiles:Click", false);
+    } catch (e) {
+      // Tiles reporting is disabled.
+    }
+
     // Notify Java that Gecko has loaded.
     Messaging.sendRequest({ type: "Gecko:Ready" });
   },
 
   get _startupStatus() {
     delete this._startupStatus;
 
     let savedMilestone = null;
@@ -1867,16 +1876,23 @@ var BrowserApp = {
           // This should never not be set at this point, but better safe than sorry.
           osLocale = Services.prefs.getCharPref("intl.locale.os");
         } catch (e) {
         }
 
         this.computeAcceptLanguages(osLocale, aData);
         break;
 
+      case "Tiles:Click":
+        // Set the click data for the given tab to be handled on the next page load.
+        let data = JSON.parse(aData);
+        let tab = this.getTabForId(data.tabId);
+        tab.tilesData = data.payload;
+        break;
+
       default:
         dump('BrowserApp.observe: unexpected topic "' + aTopic + '"\n');
         break;
 
     }
   },
 
   /**
@@ -3184,16 +3200,19 @@ let gScreenHeight = 1;
 let gReflowPending = null;
 
 // The margins that should be applied to the viewport for fixed position
 // children. This is used to avoid browser chrome permanently obscuring
 // fixed position content, and also to make sure window-sized pages take
 // into account said browser chrome.
 let gViewportMargins = { top: 0, right: 0, bottom: 0, left: 0};
 
+// The URL where suggested tile clicks are posted.
+let gTilesReportURL = null;
+
 function Tab(aURL, aParams) {
   this.browser = null;
   this.id = 0;
   this.lastTouchedAt = Date.now();
   this._zoom = 1.0;
   this._drawZoom = 1.0;
   this._restoreZoom = false;
   this._fixedMarginLeft = 0;
@@ -3212,16 +3231,17 @@ function Tab(aURL, aParams) {
   this.shouldShowPluginDoorhanger = true;
   this.clickToPlayPluginsActivated = false;
   this.desktopMode = false;
   this.originalURI = null;
   this.savedArticle = null;
   this.hasTouchListener = false;
   this.browserWidth = 0;
   this.browserHeight = 0;
+  this.tilesData = null;
 
   this.create(aURL, aParams);
 }
 
 /*
  * Sanity limit for URIs passed to UI code.
  *
  * 2000 is the typical industry limit, largely due to older IE versions.
@@ -4237,16 +4257,28 @@ Tab.prototype = {
         if (BrowserApp.selectedTab == this) {
           if (ExternalApps.shouldCheckUri(uri)) {
             ExternalApps.updatePageAction(uri, this.browser.contentDocument);
           } else {
             ExternalApps.clearPageAction();
           }
         }
 
+        // Upload any pending tile click events.
+        // Tiles data will be non-null for this tab only if:
+        // 1) the user just clicked a suggested site with a tracking ID, and
+        // 2) tiles reporting is enabled (gTilesReportURL != null).
+        if (this.tilesData) {
+          let xhr = new XMLHttpRequest();
+          xhr.open("POST", gTilesReportURL, true);
+          xhr.setRequestHeader("Content-Type", "application/json");
+          xhr.send(this.tilesData);
+          this.tilesData = null;
+        }
+
         if (!Reader.isEnabledForParseOnLoad)
           return;
 
         // Once document is fully loaded, parse it
         Reader.parseDocumentFromTab(this.id, function (article) {
           // The loaded page may have changed while we were parsing the document. 
           // Make sure we've got the current one.
           let uri = this.browser.currentURI;
@@ -4315,16 +4347,24 @@ Tab.prototype = {
       try {
         success = aRequest.QueryInterface(Components.interfaces.nsIHttpChannel).requestSucceeded;
       } catch (e) {
         // If the request does not handle the nsIHttpChannel interface, use nsIRequest's success
         // status. Used for local files. See bug 948849.
         success = aRequest.status == 0;
       }
 
+      // At this point, either:
+      // 1) the page loaded, the pageshow event fired, and the tilesData XHR has been posted, or
+      // 2) the page did not load, and we're loading a new page.
+      // Either way, we're done with the tiles data, so clear it out.
+      if (this.tilesData && (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP)) {
+        this.tilesData = null;
+      }
+
       // Check to see if we restoring the content from a previous presentation (session)
       // since there should be no real network activity
       let restoring = (aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) > 0;
 
       let message = {
         type: "Content:StateChange",
         tabID: this.id,
         uri: truncate(uri, MAX_URI_LENGTH),