merge fx-team to mozilla-central
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Fri, 21 Feb 2014 13:04:51 +0100
changeset 170208 c281177594d634ede8d9161f7e245853b35dfd43
parent 170173 2d935f3e9587681e81bc32feabfee0e8044021fb (current diff)
parent 170207 14d9c2321e0e0f1f5a85a890364864103ed92e03 (diff)
child 170236 04bdb7369ba40e78b4ec2b5bfe2a17c1bc05bc8e
push id270
push userpvanderbeken@mozilla.com
push dateThu, 06 Mar 2014 09:24:21 +0000
milestone30.0a1
merge fx-team to mozilla-central
toolkit/mozapps/extensions/AddonLogging.jsm
toolkit/mozapps/extensions/AddonRepository.jsm
toolkit/mozapps/extensions/AddonRepository_SQLiteMigrator.jsm
toolkit/mozapps/extensions/AddonUpdateChecker.jsm
toolkit/mozapps/extensions/LightweightThemeImageOptimizer.jsm
toolkit/mozapps/extensions/PluginProvider.jsm
toolkit/mozapps/extensions/SpellCheckDictionaryBootstrap.js
toolkit/mozapps/extensions/XPIProvider.jsm
toolkit/mozapps/extensions/XPIProviderUtils.js
widget/qt/nsClipboard.cpp
--- a/browser/base/content/tabbrowser.xml
+++ b/browser/base/content/tabbrowser.xml
@@ -2487,16 +2487,25 @@
       <method name="replaceTabWithWindow">
         <parameter name="aTab"/>
         <parameter name="aOptions"/>
         <body>
           <![CDATA[
             if (this.tabs.length == 1)
               return null;
 
+            let event = new CustomEvent("TabBecomingWindow", {
+              bubbles: true,
+              cancelable: true
+            });
+            aTab.dispatchEvent(event);
+            if (event.defaultPrevented) {
+              return null;
+            }
+
             var options = "chrome,dialog=no,all";
             for (var name in aOptions)
               options += "," + name + "=" + aOptions[name];
 
             // tell a new window to take the "dropped" tab
             return window.openDialog(getBrowserURL(), "_blank", options, aTab);
           ]]>
         </body>
--- a/browser/components/customizableui/content/panelUI.js
+++ b/browser/components/customizableui/content/panelUI.js
@@ -28,29 +28,31 @@ const PanelUI = {
       multiView: "PanelUI-multiView",
       helpView: "PanelUI-helpView",
       menuButton: "PanelUI-menu-button",
       panel: "PanelUI-popup",
       scroller: "PanelUI-contents-scroller"
     };
   },
 
+  _initialized: false,
   init: function() {
     for (let [k, v] of Iterator(this.kElements)) {
       // Need to do fresh let-bindings per iteration
       let getKey = k;
       let id = v;
       this.__defineGetter__(getKey, function() {
         delete this[getKey];
         return this[getKey] = document.getElementById(id);
       });
     }
 
     this.menuButton.addEventListener("mousedown", this);
     this.menuButton.addEventListener("keypress", this);
+    this._initialized = true;
   },
 
   _eventListenersAdded: false,
   _ensureEventListenersAdded: function() {
     if (this._eventListenersAdded)
       return;
     this._addEventListeners();
   },
@@ -196,16 +198,28 @@ const PanelUI = {
    *
    * @return a Promise that resolves once the panel is ready to roll.
    */
   ensureReady: function(aCustomizing=false) {
     if (this._readyPromise) {
       return this._readyPromise;
     }
     this._readyPromise = Task.spawn(function() {
+      if (!this._initialized) {
+        let delayedStartupDeferred = Promise.defer();
+        let delayedStartupObserver = (aSubject, aTopic, aData) => {
+          if (aSubject == window) {
+            Services.obs.removeObserver(delayedStartupObserver, "browser-delayed-startup-finished");
+            delayedStartupDeferred.resolve();
+          }
+        };
+        Services.obs.addObserver(delayedStartupObserver, "browser-delayed-startup-finished", false);
+        yield delayedStartupDeferred.promise;
+      }
+
       this.contents.setAttributeNS("http://www.w3.org/XML/1998/namespace", "lang",
                                    getLocale());
       if (!this._scrollWidth) {
         // In order to properly center the contents of the panel, while ensuring
         // that we have enough space on either side to show a scrollbar, we have to
         // do a bit of hackery. In particular, we calculate a new width for the
         // scroller, based on the system scrollbar width.
         this._scrollWidth =
--- a/browser/modules/UITour.jsm
+++ b/browser/modules/UITour.jsm
@@ -37,21 +37,27 @@ const BUCKET_TIMESTEPS    = [
 ];
 
 
 
 this.UITour = {
   seenPageIDs: new Set(),
   pageIDSourceTabs: new WeakMap(),
   pageIDSourceWindows: new WeakMap(),
+  /* Map from browser windows to a set of tabs in which a tour is open */
   originTabs: new WeakMap(),
+  /* Map from browser windows to a set of pinned tabs opened by (a) tour(s) */
   pinnedTabs: new WeakMap(),
   urlbarCapture: new WeakMap(),
   appMenuOpenForAnnotation: new Set(),
 
+  _detachingTab: false,
+  _queuedEvents: [],
+  _pendingDoc: null,
+
   highlightEffects: ["random", "wobble", "zoom", "color"],
   targets: new Map([
     ["accountStatus", {
       query: (aDocument) => {
         let statusButton = aDocument.getElementById("PanelUI-fxa-status");
         return aDocument.getAnonymousElementByAttribute(statusButton,
                                                         "class",
                                                         "toolbarbutton-icon");
@@ -133,17 +139,30 @@ this.UITour = {
     if (typeof action != "string" || !action)
       return false;
 
     let data = aEvent.detail.data;
     if (typeof data != "object")
       return false;
 
     let window = this.getChromeWindow(contentDocument);
+    // Do this before bailing if there's no tab, so later we can pick up the pieces:
+    window.gBrowser.tabContainer.addEventListener("TabSelect", this);
     let tab = window.gBrowser._getTabForContentWindow(contentDocument.defaultView);
+    if (!tab) {
+      // This should only happen while detaching a tab:
+      if (this._detachingTab) {
+        this._queuedEvents.push(aEvent);
+        this._pendingDoc = Cu.getWeakReference(contentDocument);
+        return;
+      }
+      Cu.reportError("Discarding tabless UITour event (" + action + ") while not detaching a tab." +
+                     "This shouldn't happen!");
+      return;
+    }
 
     switch (action) {
       case "registerPageID": {
         // We don't want to allow BrowserUITelemetry.BUCKET_SEPARATOR in the
         // pageID, as it could make parsing the telemetry bucket name difficult.
         if (typeof data.pageID == "string" &&
             !data.pageID.contains(BrowserUITelemetry.BUCKET_SEPARATOR)) {
           this.seenPageIDs.add(data.pageID);
@@ -294,33 +313,36 @@ this.UITour = {
 
         this.getConfiguration(contentDocument, data.configuration, data.callbackID);
         break;
       }
     }
 
     if (!this.originTabs.has(window))
       this.originTabs.set(window, new Set());
+
     this.originTabs.get(window).add(tab);
-
     tab.addEventListener("TabClose", this);
-    window.gBrowser.tabContainer.addEventListener("TabSelect", this);
+    tab.addEventListener("TabBecomingWindow", this);
     window.addEventListener("SSWindowClosing", this);
 
     return true;
   },
 
   handleEvent: function(aEvent) {
     switch (aEvent.type) {
       case "pagehide": {
         let window = this.getChromeWindow(aEvent.target);
         this.teardownTour(window);
         break;
       }
 
+      case "TabBecomingWindow":
+        this._detachingTab = true;
+        // Fall through
       case "TabClose": {
         let tab = aEvent.target;
         if (this.pageIDSourceTabs.has(tab)) {
           let pageID = this.pageIDSourceTabs.get(tab);
 
           // Delete this from the window cache, so if the window is closed we
           // don't expire this page ID twice.
           let window = tab.ownerDocument.defaultView;
@@ -341,23 +363,44 @@ this.UITour = {
 
           if (this.pageIDSourceTabs.has(previousTab)) {
             let pageID = this.pageIDSourceTabs.get(previousTab);
             this.setExpiringTelemetryBucket(pageID, "inactive");
           }
         }
 
         let window = aEvent.target.ownerDocument.defaultView;
+        let selectedTab = window.gBrowser.selectedTab;
         let pinnedTab = this.pinnedTabs.get(window);
-        if (pinnedTab && pinnedTab.tab == window.gBrowser.selectedTab)
+        if (pinnedTab && pinnedTab.tab == selectedTab)
           break;
         let originTabs = this.originTabs.get(window);
-        if (originTabs && originTabs.has(window.gBrowser.selectedTab))
+        if (originTabs && originTabs.has(selectedTab))
           break;
 
+        let pendingDoc;
+        if (this._detachingTab && this._pendingDoc && (pendingDoc = this._pendingDoc.get())) {
+          if (selectedTab.linkedBrowser.contentDocument == pendingDoc) {
+            if (!this.originTabs.get(window)) {
+              this.originTabs.set(window, new Set());
+            }
+            this.originTabs.get(window).add(selectedTab);
+            this.pendingDoc = null;
+            this._detachingTab = false;
+            while (this._queuedEvents.length) {
+              try {
+                this.onPageEvent(this._queuedEvents.shift());
+              } catch (ex) {
+                Cu.reportError(ex);
+              }
+            }
+            break;
+          }
+        }
+
         this.teardownTour(window);
         break;
       }
 
       case "SSWindowClosing": {
         let window = aEvent.target;
         if (this.pageIDSourceWindows.has(window)) {
           let pageID = this.pageIDSourceWindows.get(window);
@@ -408,18 +451,20 @@ this.UITour = {
   teardownTour: function(aWindow, aWindowClosing = false) {
     aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
     aWindow.PanelUI.panel.removeEventListener("popuphiding", this.hidePanelAnnotations);
     aWindow.PanelUI.panel.removeEventListener("ViewShowing", this.hidePanelAnnotations);
     aWindow.removeEventListener("SSWindowClosing", this);
 
     let originTabs = this.originTabs.get(aWindow);
     if (originTabs) {
-      for (let tab of originTabs)
+      for (let tab of originTabs) {
         tab.removeEventListener("TabClose", this);
+        tab.removeEventListener("TabBecomingWindow", this);
+      }
     }
     this.originTabs.delete(aWindow);
 
     if (!aWindowClosing) {
       this.hideHighlight(aWindow);
       this.hideInfo(aWindow);
       aWindow.PanelUI.panel.removeAttribute("noautohide");
       this.recreatePopup(aWindow.PanelUI.panel);
--- a/browser/modules/test/browser.ini
+++ b/browser/modules/test/browser.ini
@@ -7,12 +7,13 @@ support-files =
 [browser_BrowserUITelemetry_buckets.js]
 [browser_NetworkPrioritizer.js]
 [browser_SignInToWebsite.js]
 [browser_UITour.js]
 skip-if = os == "linux" # Intermittent failures, bug 951965
 [browser_UITour2.js]
 [browser_UITour3.js]
 [browser_UITour_panel_close_annotation.js]
+[browser_UITour_detach_tab.js]
 [browser_UITour_registerPageID.js]
 [browser_UITour_sync.js]
 [browser_taskbar_preview.js]
 run-if = os == "win"
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_UITour_detach_tab.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests that annotations disappear when their target is hidden.
+ */
+
+"use strict";
+
+let gTestTab;
+let gContentAPI;
+let gContentWindow;
+let gContentDoc;
+let highlight = document.getElementById("UITourHighlight");
+let tooltip = document.getElementById("UITourTooltip");
+
+Components.utils.import("resource:///modules/UITour.jsm");
+
+function test() {
+  registerCleanupFunction(function() {
+    gContentDoc = null;
+  });
+  UITourTest();
+}
+
+let tests = [
+  function test_move_tab_to_new_window(done) {
+    let gOpenedWindow;
+    let onVisibilityChange = (aEvent) => {
+      if (!document.hidden && window != UITour.getChromeWindow(aEvent.target)) {
+        gContentAPI.showHighlight("appMenu");
+      }
+    };
+    let onDOMWindowDestroyed = (aWindow, aTopic, aData) => {
+      if (gOpenedWindow && aWindow == gOpenedWindow) {
+        Services.obs.removeObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
+        done();
+      }
+    };
+    let onBrowserDelayedStartup = (aWindow, aTopic, aData) => {
+      gOpenedWindow = aWindow;
+      Services.obs.removeObserver(onBrowserDelayedStartup, "browser-delayed-startup-finished");
+      try {
+        let newWindowHighlight = gOpenedWindow.document.getElementById("UITourHighlight");
+        let selectedTab = aWindow.gBrowser.selectedTab;
+        is(selectedTab.linkedBrowser && selectedTab.linkedBrowser.contentDocument, gContentDoc, "Document should be selected in new window");
+        ok(UITour.originTabs && UITour.originTabs.has(aWindow), "Window should be known");
+        ok(UITour.originTabs.get(aWindow).has(selectedTab), "Tab should be known");
+        waitForElementToBeVisible(newWindowHighlight, function checkHighlightIsThere() {
+          gContentAPI.showMenu("appMenu");
+          isnot(aWindow.PanelUI.panel.state, "closed", "Panel should be open");
+          ok(aWindow.PanelUI.contents.children.length > 0, "Panel contents should have children");
+          gContentAPI.hideHighlight();
+          gContentAPI.hideMenu("appMenu");
+          gTestTab = null;
+          aWindow.close();
+        }, "Highlight should be shown in new window.");
+      } catch (ex) {
+        Cu.reportError(ex);
+        ok(false, "An error occurred running UITour tab detach test.");
+      } finally {
+        gContentDoc.removeEventListener("visibilitychange", onVisibilityChange, false);
+        Services.obs.addObserver(onDOMWindowDestroyed, "dom-window-destroyed", false);
+      }
+    };
+
+    Services.obs.addObserver(onBrowserDelayedStartup, "browser-delayed-startup-finished", false);
+    // NB: we're using this rather than gContentWindow.document because the latter wouldn't
+    // have an XRayWrapper, and we need to compare this to the doc we get using this method
+    // later on...
+    gContentDoc = gBrowser.selectedTab.linkedBrowser.contentDocument;
+    gContentDoc.addEventListener("visibilitychange", onVisibilityChange, false);
+    gContentAPI.showHighlight("appMenu");
+    waitForElementToBeVisible(highlight, function checkForInitialHighlight() {
+      gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+    });
+
+  },
+];
+
--- a/browser/modules/test/head.js
+++ b/browser/modules/test/head.js
@@ -73,17 +73,17 @@ function waitForPopupAtAnchor(popup, anc
 }
 
 function is_element_hidden(element, msg) {
   isnot(element, null, "Element should not be null, when checking visibility");
   ok(is_hidden(element), msg);
 }
 
 function loadUITourTestPage(callback, host = "https://example.com/") {
-   if (gTestTab)
+  if (gTestTab)
     gBrowser.removeTab(gTestTab);
 
   let url = getRootDirectory(gTestPath) + "uitour.html";
   url = url.replace("chrome://mochitests/content/", host);
 
   gTestTab = gBrowser.addTab(url);
   gBrowser.selectedTab = gTestTab;
 
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -638,52 +638,16 @@ abstract public class BrowserApp extends
 
     @Override
     public void onPause() {
         super.onPause();
         // Register for Prompt:ShowTop so we can foreground this activity even if it's hidden.
         registerEventListener("Prompt:ShowTop");
     }
 
-    private void showBookmarkDialog() {
-        final Tab tab = Tabs.getInstance().getSelectedTab();
-        final Prompt ps = new Prompt(this, new Prompt.PromptCallback() {
-            @Override
-            public void onPromptFinished(String result) {
-                int itemId = -1;
-                try {
-                  itemId = new JSONObject(result).getInt("button");
-                } catch(JSONException ex) {
-                    Log.e(LOGTAG, "Exception reading bookmark prompt result", ex);
-                }
-
-                if (tab == null)
-                    return;
-
-                if (itemId == 0) {
-                    new EditBookmarkDialog(BrowserApp.this).show(tab.getURL());
-                } else if (itemId == 1) {
-                    String url = tab.getURL();
-                    String title = tab.getDisplayTitle();
-                    Bitmap favicon = tab.getFavicon();
-                    if (url != null && title != null) {
-                        GeckoAppShell.createShortcut(title, url, url, favicon, "");
-                    }
-                }
-            }
-        });
-
-        final Prompt.PromptListItem[] items = new Prompt.PromptListItem[2];
-        Resources res = getResources();
-        items[0] = new Prompt.PromptListItem(res.getString(R.string.contextmenu_edit_bookmark));
-        items[1] = new Prompt.PromptListItem(res.getString(R.string.contextmenu_add_to_launcher));
-
-        ps.show("", "", items, false);
-    }
-
     private void setDynamicToolbarEnabled(boolean enabled) {
         if (enabled) {
             if (mLayerView != null) {
                 mLayerView.getLayerClient().setOnMetricsChangedListener(this);
             }
             setToolbarMargin(0);
             mHomePagerContainer.setPadding(0, mViewFlipper.getHeight(), 0, 0);
         } else {
@@ -740,26 +704,17 @@ abstract public class BrowserApp extends
         }
 
         if (itemId == R.id.share) {
             shareCurrentUrl();
             return true;
         }
 
         if (itemId == R.id.subscribe) {
-            Tab tab = Tabs.getInstance().getSelectedTab();
-            if (tab != null && tab.hasFeeds()) {
-                JSONObject args = new JSONObject();
-                try {
-                    args.put("tabId", tab.getId());
-                } catch (JSONException e) {
-                    Log.e(LOGTAG, "error building json arguments");
-                }
-                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Feeds:Subscribe", args.toString()));
-            }
+            subscribeToFeeds(Tabs.getInstance().getSelectedTab());
             return true;
         }
 
         if (itemId == R.id.add_search_engine) {
             Tab tab = Tabs.getInstance().getSelectedTab();
             if (tab != null && tab.hasOpenSearch()) {
                 JSONObject args = new JSONObject();
                 try {
@@ -779,37 +734,16 @@ abstract public class BrowserApp extends
                 String url = tab.getURL();
                 if (url != null) {
                     Clipboard.setText(url);
                 }
             }
             return true;
         }
 
-        if (itemId == R.id.add_to_launcher) {
-            Tab tab = Tabs.getInstance().getSelectedTab();
-            if (tab == null) {
-                return true;
-            }
-
-            final String url = tab.getURL();
-            final String title = tab.getDisplayTitle();
-            if (url == null || title == null) {
-                return true;
-            }
-
-            final OnFaviconLoadedListener listener = new GeckoAppShell.CreateShortcutFaviconLoadedListener(url, title);
-            Favicons.getSizedFavicon(url,
-                    tab.getFaviconURL(),
-                    Integer.MAX_VALUE,
-                    LoadFaviconTask.FLAG_PERSIST,
-                    listener);
-            return true;
-        }
-
         return false;
     }
 
     @Override
     public void setAccessibilityEnabled(boolean enabled) {
         if (mAccessibilityEnabled == enabled) {
             return;
         }
@@ -2150,16 +2084,19 @@ abstract public class BrowserApp extends
         MenuItem forward = aMenu.findItem(R.id.forward);
         MenuItem share = aMenu.findItem(R.id.share);
         MenuItem saveAsPDF = aMenu.findItem(R.id.save_as_pdf);
         MenuItem charEncoding = aMenu.findItem(R.id.char_encoding);
         MenuItem findInPage = aMenu.findItem(R.id.find_in_page);
         MenuItem desktopMode = aMenu.findItem(R.id.desktop_mode);
         MenuItem enterGuestMode = aMenu.findItem(R.id.new_guest_session);
         MenuItem exitGuestMode = aMenu.findItem(R.id.exit_guest_session);
+        MenuItem subscribe = aMenu.findItem(R.id.subscribe);
+        MenuItem addToReadingList = aMenu.findItem(R.id.reading_list_add);
+        MenuItem save = aMenu.findItem(R.id.save);
 
         // Only show the "Quit" menu item on pre-ICS or television devices.
         // In ICS+, it's easy to kill an app through the task switcher.
         aMenu.findItem(R.id.quit).setVisible(Build.VERSION.SDK_INT < 14 || HardwareUtils.isTelevision());
 
         if (tab == null || tab.getURL() == null) {
             bookmark.setEnabled(false);
             back.setEnabled(false);
@@ -2174,21 +2111,20 @@ abstract public class BrowserApp extends
             MenuUtils.safeSetEnabled(aMenu, R.id.subscribe, false);
             MenuUtils.safeSetEnabled(aMenu, R.id.add_search_engine, false);
             MenuUtils.safeSetEnabled(aMenu, R.id.site_settings, false);
             MenuUtils.safeSetEnabled(aMenu, R.id.add_to_launcher, false);
 
             return true;
         }
 
+        save.setVisible(!GeckoProfile.get(this).inGuestMode());
+
         bookmark.setEnabled(!AboutPages.isAboutReader(tab.getURL()));
-        bookmark.setVisible(!GeckoProfile.get(this).inGuestMode());
-        bookmark.setCheckable(true);
         bookmark.setChecked(tab.isBookmark());
-        bookmark.setIcon(tab.isBookmark() ? R.drawable.ic_menu_bookmark_remove : R.drawable.ic_menu_bookmark_add);
 
         back.setEnabled(tab.canDoBack());
         forward.setEnabled(tab.canDoForward());
         desktopMode.setChecked(tab.getDesktopMode());
         desktopMode.setIcon(tab.getDesktopMode() ? R.drawable.ic_menu_desktop_mode_on : R.drawable.ic_menu_desktop_mode_off);
 
         String url = tab.getURL();
         if (AboutPages.isAboutReader(url)) {
@@ -2268,43 +2204,47 @@ abstract public class BrowserApp extends
 
         charEncoding.setVisible(GeckoPreferences.getCharEncodingState());
 
         if (mProfile.inGuestMode())
             exitGuestMode.setVisible(true);
         else
             enterGuestMode.setVisible(true);
 
+        addToReadingList.setChecked(tab.isReadingListItem());
+        addToReadingList.setEnabled(tab.getReaderEnabled());
+
+        subscribe.setEnabled(tab.hasFeeds());
+
         return true;
     }
 
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
-        Tab tab = null;
+        final Tab tab = Tabs.getInstance().getSelectedTab();
         Intent intent = null;
 
         final int itemId = item.getItemId();
 
         if (itemId == R.id.bookmark) {
-            tab = Tabs.getInstance().getSelectedTab();
             if (tab != null) {
                 if (item.isChecked()) {
                     tab.removeBookmark();
                     Toast.makeText(this, R.string.bookmark_removed, Toast.LENGTH_SHORT).show();
                     item.setIcon(R.drawable.ic_menu_bookmark_add);
                 } else {
                     tab.addBookmark();
                     getButtonToast().show(false,
                         getResources().getString(R.string.bookmark_added),
-                        getResources().getString(R.string.bookmark_options),
+                        getResources().getString(R.string.contextmenu_edit_bookmark),
                         null,
                         new ButtonToast.ToastListener() {
                             @Override
                             public void onButtonClicked() {
-                                showBookmarkDialog();
+                                new EditBookmarkDialog(BrowserApp.this).show(tab.getURL());
                             }
 
                             @Override
                             public void onToastHidden(ButtonToast.ReasonHidden reason) { }
                         });
                     item.setIcon(R.drawable.ic_menu_bookmark_remove);
                 }
             }
@@ -2312,31 +2252,28 @@ abstract public class BrowserApp extends
         }
 
         if (itemId == R.id.share) {
             shareCurrentUrl();
             return true;
         }
 
         if (itemId == R.id.reload) {
-            tab = Tabs.getInstance().getSelectedTab();
             if (tab != null)
                 tab.doReload();
             return true;
         }
 
         if (itemId == R.id.back) {
-            tab = Tabs.getInstance().getSelectedTab();
             if (tab != null)
                 tab.doBack();
             return true;
         }
 
         if (itemId == R.id.forward) {
-            tab = Tabs.getInstance().getSelectedTab();
             if (tab != null)
                 tab.doForward();
             return true;
         }
 
         if (itemId == R.id.save_as_pdf) {
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("SaveAs:PDF", null));
             return true;
@@ -2369,23 +2306,22 @@ abstract public class BrowserApp extends
         }
 
         if (itemId == R.id.find_in_page) {
             mFindInPageBar.show();
             return true;
         }
 
         if (itemId == R.id.desktop_mode) {
-            Tab selectedTab = Tabs.getInstance().getSelectedTab();
-            if (selectedTab == null)
+            if (tab == null)
                 return true;
             JSONObject args = new JSONObject();
             try {
                 args.put("desktopMode", !item.isChecked());
-                args.put("tabId", selectedTab.getId());
+                args.put("tabId", tab.getId());
             } catch (JSONException e) {
                 Log.e(LOGTAG, "error building json arguments");
             }
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("DesktopMode:Change", args.toString()));
             return true;
         }
 
         if (itemId == R.id.new_tab) {
@@ -2410,16 +2346,35 @@ abstract public class BrowserApp extends
 
         // We have a few menu items that can also be in the context menu. If
         // we have not already handled the item, give the context menu handler
         // a chance.
         if (onContextItemSelected(item)) {
             return true;
         }
 
+        if (itemId == R.id.launcher_add) {
+            addToLauncher(tab.getURL(), tab.getTitle(), tab.getFaviconURL());
+            return true;
+        }
+
+        if (itemId == R.id.reading_list_add) {
+            if (item.isChecked()) {
+                ReaderModeUtils.removeFromReadingList(tab.getURL());
+            } else {
+                ReaderModeUtils.addToReadingList(tab);
+            }
+            return true;
+        }
+
+        if (itemId == R.id.subscribe) {
+            subscribeToFeeds(tab);
+            return true;
+        }
+
         return super.onOptionsItemSelected(item);
     }
 
     private void showGuestModeDialog(final GuestModeDialog type) {
         final Prompt ps = new Prompt(this, new Prompt.PromptCallback() {
             @Override
             public void onPromptFinished(String result) {
                 try {
@@ -2454,16 +2409,43 @@ abstract public class BrowserApp extends
         } else {
             titleString = R.string.exit_guest_session_title;
             msgString = R.string.exit_guest_session_text;
         }
 
         ps.show(res.getString(titleString), res.getString(msgString), null, false);
     }
 
+    public void subscribeToFeeds(Tab tab) {
+        if (!tab.hasFeeds()) {
+            return;
+        }
+
+        JSONObject args = new JSONObject();
+        try {
+            args.put("tabId", tab.getId());
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "JSON error", e);
+        }
+        GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Feeds:Subscribe", args.toString()));
+    }
+
+    private void addToLauncher(String url, String title, String faviconUrl) {
+        if (url == null || title == null) {
+            return;
+        }
+
+        final OnFaviconLoadedListener listener = new GeckoAppShell.CreateShortcutFaviconLoadedListener(url, title);
+        Favicons.getSizedFavicon(url,
+                faviconUrl,
+                Integer.MAX_VALUE,
+                LoadFaviconTask.FLAG_PERSIST,
+                listener);
+    }
+
     /**
      * This will detect if the key pressed is back. If so, will show the history.
      */
     @Override
     public boolean onKeyLongPress(int keyCode, KeyEvent event) {
         if (keyCode == KeyEvent.KEYCODE_BACK) {
             Tab tab = Tabs.getInstance().getSelectedTab();
             if (tab != null) {
--- a/mobile/android/base/ReaderModeUtils.java
+++ b/mobile/android/base/ReaderModeUtils.java
@@ -1,17 +1,20 @@
 /* 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;
 
+import org.json.JSONException;
+import org.json.JSONObject;
 import org.mozilla.gecko.util.StringUtils;
 
 import android.net.Uri;
+import android.util.Log;
 
 public class ReaderModeUtils {
     private static final String LOGTAG = "ReaderModeUtils";
 
     public static String getUrlFromAboutReader(String aboutReaderUrl) {
         return StringUtils.getQueryParameter(aboutReaderUrl, "url");
     }
 
@@ -40,9 +43,35 @@ public class ReaderModeUtils {
         String aboutReaderUrl = AboutPages.READER + "?url=" + Uri.encode(url);
 
         if (tabId >= 0) {
             aboutReaderUrl += "&tabId=" + tabId;
         }
 
         return aboutReaderUrl;
     }
+
+    public static void addToReadingList(Tab tab) {
+        if (!tab.getReaderEnabled()) {
+            return;
+        }
+
+        JSONObject json = new JSONObject();
+        try {
+            json.put("tabID", String.valueOf(tab.getId()));
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "JSON error - failing to add to reading list", e);
+            return;
+        }
+
+        GeckoEvent e = GeckoEvent.createBroadcastEvent("Reader:Add", json.toString());
+        GeckoAppShell.sendEventToGecko(e);
+    }
+
+    public static void removeFromReadingList(String url) {
+        if (url == null) {
+            return;
+        }
+
+        GeckoEvent e = GeckoEvent.createBroadcastEvent("Reader:Remove", url);
+        GeckoAppShell.sendEventToGecko(e);
+    }
 }
--- a/mobile/android/base/Tab.java
+++ b/mobile/android/base/Tab.java
@@ -3,16 +3,19 @@
  * 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;
 
 import org.mozilla.gecko.SiteIdentity.SecurityMode;
 import org.mozilla.gecko.db.BrowserContract.Bookmarks;
 import org.mozilla.gecko.db.BrowserDB;
+import org.mozilla.gecko.favicons.Favicons;
+import org.mozilla.gecko.favicons.LoadFaviconTask;
+import org.mozilla.gecko.favicons.OnFaviconLoadedListener;
 import org.mozilla.gecko.gfx.Layer;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import android.content.ContentResolver;
 import android.content.Context;
@@ -448,32 +451,16 @@ public class Tab {
                 if (url == null)
                     return;
 
                 BrowserDB.removeBookmarksWithURL(getContentResolver(), url);
             }
         });
     }
 
-    public void addToReadingList() {
-        if (!mReaderEnabled)
-            return;
-
-        JSONObject json = new JSONObject();
-        try {
-            json.put("tabID", String.valueOf(getId()));
-        } catch (JSONException e) {
-            Log.e(LOGTAG, "JSON error - failing to add to reading list", e);
-            return;
-        }
-
-        GeckoEvent e = GeckoEvent.createBroadcastEvent("Reader:Add", json.toString());
-        GeckoAppShell.sendEventToGecko(e);
-    }
-
     public void toggleReaderMode() {
         if (AboutPages.isAboutReader(mUrl)) {
             Tabs.getInstance().loadUrl(ReaderModeUtils.getUrlFromAboutReader(mUrl));
         } else if (mReaderEnabled) {
             mEnteringReaderMode = true;
             Tabs.getInstance().loadUrl(ReaderModeUtils.getAboutReaderForUrl(mUrl, mId));
         }
     }
--- a/mobile/android/base/db/BrowserContract.java
+++ b/mobile/android/base/db/BrowserContract.java
@@ -328,16 +328,17 @@ public class BrowserContract {
         public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/homeitem";
 
         public static final String DATASET_ID = "dataset_id";
         public static final String URL = "url";
         public static final String TITLE = "title";
         public static final String DESCRIPTION = "description";
         public static final String IMAGE_URL = "image_url";
         public static final String CREATED = "created";
+        public static final String FILTER = "filter";
     }
 
     /*
      * Contains names and schema definitions for tables and views
      * no longer being used by current ContentProviders. These values are used
      * to make incremental updates to the schema during a database upgrade. Will be
      * removed with bug 947018.
      */
--- a/mobile/android/base/db/HomeProvider.java
+++ b/mobile/android/base/db/HomeProvider.java
@@ -99,30 +99,32 @@ public class HomeProvider extends SQLite
         }
 
         final String[] itemsColumns = new String[] {
             HomeItems._ID,
             HomeItems.DATASET_ID,
             HomeItems.URL,
             HomeItems.TITLE,
             HomeItems.DESCRIPTION,
-            HomeItems.IMAGE_URL
+            HomeItems.IMAGE_URL,
+            HomeItems.FILTER
         };
 
         final MatrixCursor c = new MatrixCursor(itemsColumns);
         for (int i = 0; i < items.length(); i++) {
             try {
                 final JSONObject item = items.getJSONObject(i);
                 c.addRow(new Object[] {
                     item.getInt("id"),
                     item.getString("dataset_id"),
                     item.getString("url"),
                     item.getString("title"),
                     item.getString("description"),
-                    item.getString("image_url")
+                    item.getString("image_url"),
+                    item.getString("filter")
                 });
             } catch (JSONException e) {
                 Log.e(LOGTAG, "Error creating cursor row for fake home item", e);
             }
         }
         return c;
     }
 
--- a/mobile/android/base/home/DynamicPanel.java
+++ b/mobile/android/base/home/DynamicPanel.java
@@ -6,16 +6,18 @@
 package org.mozilla.gecko.home;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.db.BrowserContract.HomeItems;
 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
 import org.mozilla.gecko.home.HomeConfig.PanelConfig;
 import org.mozilla.gecko.home.PanelLayout.DatasetHandler;
+import org.mozilla.gecko.home.PanelLayout.DatasetRequest;
+import org.mozilla.gecko.db.DBUtils;
 
 import android.app.Activity;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.res.Configuration;
 import android.database.Cursor;
 import android.net.Uri;
 import android.os.Bundle;
@@ -49,17 +51,17 @@ import java.util.EnumSet;
  *
  * See {@code PanelLayout} for more details on how {@code DynamicPanel}
  * receives dataset requests and delivers them back to the {@code PanelLayout}.
  */
 public class DynamicPanel extends HomeFragment {
     private static final String LOGTAG = "GeckoDynamicPanel";
 
     // Dataset ID to be used by the loader
-    private static final String DATASET_ID = "dataset_id";
+    private static final String DATASET_REQUEST = "dataset_request";
 
     // The panel layout associated with this panel
     private PanelLayout mLayout;
 
     // The configuration associated with this panel
     private PanelConfig mPanelConfig;
 
     // Callbacks used for the loader
@@ -162,30 +164,30 @@ public class DynamicPanel extends HomeFr
     }
 
     /**
      * Used by the PanelLayout to make load and reset requests to
      * the holding fragment.
      */
     private class PanelDatasetHandler implements DatasetHandler {
         @Override
-        public void requestDataset(String datasetId) {
-            Log.d(LOGTAG, "Requesting dataset: " + datasetId);
+        public void requestDataset(DatasetRequest request) {
+            Log.d(LOGTAG, "Requesting request: " + request);
 
             // Ignore dataset requests while the fragment is not
             // allowed to load its content.
             if (!getCanLoadHint()) {
                 return;
             }
 
             final Bundle bundle = new Bundle();
-            bundle.putString(DATASET_ID, datasetId);
+            bundle.putParcelable(DATASET_REQUEST, request);
 
             // Ensure one loader per dataset
-            final int loaderId = generateLoaderId(datasetId);
+            final int loaderId = generateLoaderId(request.datasetId);
             getLoaderManager().restartLoader(loaderId, bundle, mLoaderCallbacks);
         }
 
         @Override
         public void resetDataset(String datasetId) {
             Log.d(LOGTAG, "Resetting dataset: " + datasetId);
 
             final LoaderManager lm = getLoaderManager();
@@ -199,61 +201,75 @@ public class DynamicPanel extends HomeFr
             }
         }
     }
 
     /**
      * Cursor loader for the panel datasets.
      */
     private static class PanelDatasetLoader extends SimpleCursorLoader {
-        private final String mDatasetId;
+        private final DatasetRequest mRequest;
 
-        public PanelDatasetLoader(Context context, String datasetId) {
+        public PanelDatasetLoader(Context context, DatasetRequest request) {
             super(context);
-            mDatasetId = datasetId;
+            mRequest = request;
         }
 
-        public String getDatasetId() {
-            return mDatasetId;
+        public DatasetRequest getRequest() {
+            return mRequest;
         }
 
         @Override
         public Cursor loadCursor() {
             final ContentResolver cr = getContext().getContentResolver();
 
-            final String selection = HomeItems.DATASET_ID + " = ?";
-            final String[] selectionArgs = new String[] { mDatasetId };
+            final String selection;
+            final String[] selectionArgs;
+
+            // Null represents the root filter
+            if (mRequest.filter == null) {
+                selection = DBUtils.concatenateWhere(HomeItems.DATASET_ID + " = ?", HomeItems.FILTER + " IS NULL");
+                selectionArgs = new String[] { mRequest.datasetId };
+            } else {
+                selection = DBUtils.concatenateWhere(HomeItems.DATASET_ID + " = ?", HomeItems.FILTER + " = ?");
+                selectionArgs = new String[] { mRequest.datasetId, mRequest.filter };
+            }
 
             // XXX: You can use CONTENT_FAKE_URI for development to pull items from fake_home_items.json.
             return cr.query(HomeItems.CONTENT_URI, null, selection, selectionArgs, null);
         }
     }
 
     /**
      * LoaderCallbacks implementation that interacts with the LoaderManager.
      */
     private class PanelLoaderCallbacks implements LoaderCallbacks<Cursor> {
         @Override
         public Loader<Cursor> onCreateLoader(int id, Bundle args) {
-            final String datasetId = args.getString(DATASET_ID);
+            final DatasetRequest request = (DatasetRequest) args.getParcelable(DATASET_REQUEST);
 
-            Log.d(LOGTAG, "Creating loader for dataset: " + datasetId);
-            return new PanelDatasetLoader(getActivity(), datasetId);
+            Log.d(LOGTAG, "Creating loader for request: " + request);
+            return new PanelDatasetLoader(getActivity(), request);
         }
 
         @Override
         public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
-            final PanelDatasetLoader datasetLoader = (PanelDatasetLoader) loader;
+            final DatasetRequest request = getRequestFromLoader(loader);
 
-            Log.d(LOGTAG, "Finished loader for dataset: " + datasetLoader.getDatasetId());
-            mLayout.deliverDataset(datasetLoader.getDatasetId(), cursor);
+            Log.d(LOGTAG, "Finished loader for request: " + request);
+            mLayout.deliverDataset(request, cursor);
         }
 
         @Override
         public void onLoaderReset(Loader<Cursor> loader) {
-            final PanelDatasetLoader datasetLoader = (PanelDatasetLoader) loader;
-            Log.d(LOGTAG, "Resetting loader for dataset: " + datasetLoader.getDatasetId());
+            final DatasetRequest request = getRequestFromLoader(loader);
+            Log.d(LOGTAG, "Resetting loader for request: " + request);
             if (mLayout != null) {
-                mLayout.releaseDataset(datasetLoader.getDatasetId());
+                mLayout.releaseDataset(request.datasetId);
             }
         }
+
+        private DatasetRequest getRequestFromLoader(Loader<Cursor> loader) {
+            final PanelDatasetLoader datasetLoader = (PanelDatasetLoader) loader;
+            return datasetLoader.getRequest();
+        }
     }
 }
--- a/mobile/android/base/home/FramePanelLayout.java
+++ b/mobile/android/base/home/FramePanelLayout.java
@@ -33,13 +33,15 @@ class FramePanelLayout extends PanelLayo
         addView(mChildView);
     }
 
     @Override
     public void load() {
         Log.d(LOGTAG, "Loading");
 
         if (mChildView instanceof DatasetBacked) {
-            Log.d(LOGTAG, "Requesting child dataset: " + mChildConfig.getDatasetId());
-            requestDataset(mChildConfig.getDatasetId());
+            // TODO: get filter from ViewEntry
+            DatasetRequest request = new DatasetRequest(mChildConfig.getDatasetId(), null);
+            Log.d(LOGTAG, "Requesting child request: " + request);
+            requestDataset(request);
         }
     }
 }
--- a/mobile/android/base/home/PanelLayout.java
+++ b/mobile/android/base/home/PanelLayout.java
@@ -3,26 +3,33 @@
  * 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.home;
 
 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
 import org.mozilla.gecko.home.HomeConfig.PanelConfig;
 import org.mozilla.gecko.home.HomeConfig.ViewConfig;
+import org.mozilla.gecko.util.StringUtils;
 
 import android.content.Context;
 import android.database.Cursor;
+import android.os.Parcel;
+import android.os.Parcelable;
 import android.text.TextUtils;
 import android.util.Log;
+import android.view.KeyEvent;
 import android.view.View;
 import android.widget.FrameLayout;
 
-import java.util.ArrayList;
-import java.util.List;
+import java.util.Deque;
+import java.util.EnumSet;
+import java.util.LinkedList;
+import java.util.Map;
+import java.util.WeakHashMap;
 
 /**
  * {@code PanelLayout} is the base class for custom layouts to be
  * used in {@code DynamicPanel}. It provides the basic framework
  * that enables custom layouts to request and reset datasets and
  * create panel views. Furthermore, it automates most of the process
  * of binding panel views with their respective datasets.
  *
@@ -52,84 +59,128 @@ import java.util.List;
  * to create the views dynamically created based on {@code ViewConfig}. This
  * allows {@code PanelLayout} to auto-bind datasets with panel views.
  * {@code PanelLayout} subclasses are free to have any type of views to arrange
  * the panel views in different ways.
  */
 abstract class PanelLayout extends FrameLayout {
     private static final String LOGTAG = "GeckoPanelLayout";
 
-    private final List<ViewEntry> mViewEntries;
+    protected final Map<View, ViewState> mViewStateMap;
     private final DatasetHandler mDatasetHandler;
     private final OnUrlOpenListener mUrlOpenListener;
 
     /**
      * To be used by panel views to express that they are
      * backed by datasets.
      */
     public interface DatasetBacked {
         public void setDataset(Cursor cursor);
     }
 
     /**
+     * To be used by requests made to {@code DatasetHandler}s to couple dataset ID with current
+     * filter for queries on the database.
+     */
+    public static class DatasetRequest implements Parcelable {
+        public final String datasetId;
+        public final String filter;
+
+        private DatasetRequest(Parcel in) {
+            this.datasetId = in.readString();
+            this.filter = in.readString();
+        }
+
+        public DatasetRequest(String datasetId, String filter) {
+            this.datasetId = datasetId;
+            this.filter = filter;
+        }
+
+        @Override
+        public int describeContents() {
+            return 0;
+        }
+
+        @Override
+        public void writeToParcel(Parcel dest, int flags) {
+            dest.writeString(datasetId);
+            dest.writeString(filter);
+        }
+
+        public String toString() {
+            return "{dataset: " + datasetId + ", filter: " + filter + "}";
+        }
+
+        public static final Creator<DatasetRequest> CREATOR = new Creator<DatasetRequest>() {
+            public DatasetRequest createFromParcel(Parcel in) {
+                return new DatasetRequest(in);
+            }
+
+            public DatasetRequest[] newArray(int size) {
+                return new DatasetRequest[size];
+            }
+        };
+    }
+
+    /**
      * Defines the contract with the component that is responsible
      * for handling datasets requests.
      */
     public interface DatasetHandler {
         /**
          * Requests a dataset to be fetched and auto-bound to the
          * panel views backed by it.
          */
-        public void requestDataset(String datasetId);
+        public void requestDataset(DatasetRequest request);
 
         /**
          * Releases any resources associated with a previously loaded
          * dataset. It will do nothing if the dataset with the given ID
          * hasn't been loaded before.
          */
         public void resetDataset(String datasetId);
     }
 
     public interface PanelView {
         public void setOnUrlOpenListener(OnUrlOpenListener listener);
     }
 
     public PanelLayout(Context context, PanelConfig panelConfig, DatasetHandler datasetHandler, OnUrlOpenListener urlOpenListener) {
         super(context);
-        mViewEntries = new ArrayList<ViewEntry>();
+        mViewStateMap = new WeakHashMap<View, ViewState>();
         mDatasetHandler = datasetHandler;
         mUrlOpenListener = urlOpenListener;
     }
 
     /**
      * Delivers the dataset as a {@code Cursor} to be bound to the
      * panel views backed by it. This is used by the {@code DatasetHandler}
      * in response to a dataset request.
      */
-    public final void deliverDataset(String datasetId, Cursor cursor) {
-        Log.d(LOGTAG, "Delivering dataset: " + datasetId);
-        updateViewsWithDataset(datasetId, cursor);
+    public final void deliverDataset(DatasetRequest request, Cursor cursor) {
+        Log.d(LOGTAG, "Delivering request: " + request);
+        updateViewsWithDataset(request.datasetId, cursor);
     }
 
     /**
      * Releases any references to the given dataset from all
      * existing panel views.
      */
     public final void releaseDataset(String datasetId) {
         Log.d(LOGTAG, "Resetting dataset: " + datasetId);
         updateViewsWithDataset(datasetId, null);
     }
 
     /**
      * Requests a dataset to be loaded and bound to any existing
      * panel view backed by it.
      */
-    protected final void requestDataset(String datasetId) {
-        Log.d(LOGTAG, "Requesting dataset: " + datasetId);
-        mDatasetHandler.requestDataset(datasetId);
+    protected final void requestDataset(DatasetRequest request) {
+        Log.d(LOGTAG, "Requesting request: " + request);
+        mDatasetHandler.requestDataset(request);
     }
 
     /**
      * Releases any resources associated with a previously
      * loaded dataset e.g. close any associated {@code Cursor}.
      */
     protected final void resetDataset(String datasetId) {
         mDatasetHandler.resetDataset(datasetId);
@@ -154,54 +205,49 @@ abstract class PanelLayout extends Frame
             case GRID:
                 view = new PanelGridView(getContext(), viewConfig);
                 break;
 
             default:
                 throw new IllegalStateException("Unrecognized view type in " + getClass().getSimpleName());
         }
 
-        final ViewEntry entry = new ViewEntry(view, viewConfig);
-        mViewEntries.add(entry);
+        final ViewState state = new ViewState(viewConfig);
+        // TODO: Push initial filter here onto ViewState
+        mViewStateMap.put(view, state);
 
-        ((PanelView) view).setOnUrlOpenListener(mUrlOpenListener);
+        ((PanelView) view).setOnUrlOpenListener(new PanelUrlOpenListener(state));
+        view.setOnKeyListener(new PanelKeyListener(state));
 
         return view;
     }
 
     /**
      * Dispose any dataset references associated with the
      * given view.
      */
     protected final void disposePanelView(View view) {
         Log.d(LOGTAG, "Disposing panel view");
+        if (mViewStateMap.containsKey(view)) {
+            // Release any Cursor references from the view
+            // if it's backed by a dataset.
+            maybeSetDataset(view, null);
 
-        final int count = mViewEntries.size();
-        for (int i = 0; i < count; i++) {
-            final View entryView = mViewEntries.get(i).getView();
-            if (view == entryView) {
-                // Release any Cursor references from the view
-                // if it's backed by a dataset.
-                maybeSetDataset(entryView, null);
-
-                // Remove the view entry from the list
-                mViewEntries.remove(i);
-                break;
-            }
+            // Remove the view entry from the map
+            mViewStateMap.remove(view);
         }
     }
 
     private void updateViewsWithDataset(String datasetId, Cursor cursor) {
-        final int count = mViewEntries.size();
-        for (int i = 0; i < count; i++) {
-            final ViewEntry entry = mViewEntries.get(i);
+        for (Map.Entry<View, ViewState> entry : mViewStateMap.entrySet()) {
+            final ViewState detail = entry.getValue();
 
             // Update any views associated with the given dataset ID
-            if (TextUtils.equals(entry.getDatasetId(), datasetId)) {
-                final View view = entry.getView();
+            if (TextUtils.equals(detail.getDatasetId(), datasetId)) {
+                final View view = entry.getKey();
                 maybeSetDataset(view, cursor);
             }
         }
     }
 
     private void maybeSetDataset(View view, Cursor cursor) {
         if (view instanceof DatasetBacked) {
             final DatasetBacked dsb = (DatasetBacked) view;
@@ -213,28 +259,116 @@ abstract class PanelLayout extends Frame
      * Must be implemented by {@code PanelLayout} subclasses to define
      * what happens then the layout is first loaded. Should set initial
      * UI state and request any necessary datasets.
      */
     public abstract void load();
 
     /**
      * Represents a 'live' instance of a panel view associated with
-     * the {@code PanelLayout}.
+     * the {@code PanelLayout}. Is responsible for tracking the history stack of filters.
      */
-    private static class ViewEntry {
-        private final View mView;
+    protected static class ViewState {
         private final ViewConfig mViewConfig;
+        private Deque<String> mFilterStack;
 
-        public ViewEntry(View view, ViewConfig viewConfig) {
-            mView = view;
+        public ViewState(ViewConfig viewConfig) {
             mViewConfig = viewConfig;
         }
 
-        public View getView() {
-            return mView;
-        }
-
         public String getDatasetId() {
             return mViewConfig.getDatasetId();
         }
+
+        /**
+         * Used to find the current filter that this view is displaying, or null if none.
+         */
+        public String getCurrentFilter() {
+            if (mFilterStack == null) {
+                return null;
+            } else {
+                return mFilterStack.peek();
+            }
+        }
+
+        /**
+         * Adds a filter to the history stack for this view.
+         */
+        public void pushFilter(String filter) {
+            if (mFilterStack == null) {
+                mFilterStack = new LinkedList<String>();
+            }
+
+            mFilterStack.push(filter);
+        }
+
+        public String popFilter() {
+            if (getCurrentFilter() != null) {
+                mFilterStack.pop();
+            }
+
+            return getCurrentFilter();
+        }
+    }
+
+    /**
+     * Pushes filter to {@code ViewState}'s stack and makes request for new filter value.
+     */
+    private void pushFilterOnView(ViewState viewState, String filter) {
+        viewState.pushFilter(filter);
+        mDatasetHandler.requestDataset(new DatasetRequest(viewState.getDatasetId(), filter));
+    }
+
+    /**
+     * Pops filter from {@code ViewState}'s stack and makes request for previous filter value.
+     *
+     * @return whether the filter has changed
+     */
+    private boolean popFilterOnView(ViewState viewState) {
+        String currentFilter = viewState.getCurrentFilter();
+        String filter = viewState.popFilter();
+
+        if (!TextUtils.equals(currentFilter, filter)) {
+            mDatasetHandler.requestDataset(new DatasetRequest(viewState.getDatasetId(), filter));
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Custom listener so that we can intercept any filter URLs and make a new dataset request
+     * rather than forwarding them to the default listener.
+     */
+    private class PanelUrlOpenListener implements OnUrlOpenListener {
+        private ViewState mViewState;
+
+        public PanelUrlOpenListener(ViewState viewState) {
+            mViewState = viewState;
+        }
+
+        @Override
+        public void onUrlOpen(String url, EnumSet<Flags> flags) {
+            if (StringUtils.isFilterUrl(url)) {
+                pushFilterOnView(mViewState, StringUtils.getFilterFromUrl(url));
+            } else {
+                mUrlOpenListener.onUrlOpen(url, flags);
+            }
+        }
+    }
+
+    private class PanelKeyListener implements View.OnKeyListener {
+        private ViewState mViewState;
+
+        public PanelKeyListener(ViewState viewState) {
+            mViewState = viewState;
+        }
+
+        @Override
+        public boolean onKey(View v, int keyCode, KeyEvent event) {
+            if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+                return popFilterOnView(mViewState);
+            }
+
+            return false;
+        }
     }
 }
--- a/mobile/android/base/home/ReadingListPanel.java
+++ b/mobile/android/base/home/ReadingListPanel.java
@@ -36,16 +36,19 @@ import java.util.EnumSet;
 
 /**
  * Fragment that displays reading list contents in a ListView.
  */
 public class ReadingListPanel extends HomeFragment {
     // Cursor loader ID for reading list
     private static final int LOADER_ID_READING_LIST = 0;
 
+    // Formatted string in hint text to be replaced with an icon.
+    private final String MATCH_STRING = "%I";
+
     // Adapter for the list of reading list items
     private ReadingListAdapter mAdapter;
 
     // The view shown by the fragment
     private HomeListView mList;
 
     // Reference to the View to display when there are no results.
     private View mEmptyView;
@@ -166,27 +169,27 @@ public class ReadingListPanel extends Ho
         if ((c == null || c.getCount() == 0) && mEmptyView == null) {
             final ViewStub emptyViewStub = (ViewStub) mTopView.findViewById(R.id.home_empty_view_stub);
             mEmptyView = emptyViewStub.inflate();
 
             final TextView emptyHint = (TextView) mEmptyView.findViewById(R.id.home_empty_hint);
             String readingListHint = emptyHint.getText().toString();
 
             // Use an ImageSpan to include the reader icon in the "Tip".
-            int imageSpanIndex = readingListHint.indexOf("%I");
+            int imageSpanIndex = readingListHint.indexOf(MATCH_STRING);
             if (imageSpanIndex != -1) {
                 final ImageSpan readingListIcon = new ImageSpan(getActivity(), R.drawable.reader_cropped, ImageSpan.ALIGN_BOTTOM);
                 final SpannableStringBuilder hintBuilder = new SpannableStringBuilder(readingListHint);
 
                 // Add additional spacing.
-                hintBuilder.insert(imageSpanIndex + 2, " ");
+                hintBuilder.insert(imageSpanIndex + MATCH_STRING.length(), " ");
                 hintBuilder.insert(imageSpanIndex, " ");
 
                 // Add icon.
-                hintBuilder.setSpan(readingListIcon, imageSpanIndex + 1, imageSpanIndex + 3, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+                hintBuilder.setSpan(readingListIcon, imageSpanIndex + 1, imageSpanIndex + MATCH_STRING.length() + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
 
                 emptyHint.setText(hintBuilder, TextView.BufferType.SPANNABLE);
             }
 
             mList.setEmptyView(mEmptyView);
         }
     }
 
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -49,16 +49,17 @@
 <!ENTITY go "Go">
 <!ENTITY search "Search">
 <!ENTITY reload "Reload">
 <!ENTITY forward "Forward">
 <!ENTITY menu "Menu">
 <!ENTITY back "Back">
 <!ENTITY stop "Stop">
 <!ENTITY site_security "Site Security">
+<!ENTITY save "Save">
 
 <!ENTITY close_tab "Close Tab">
 <!ENTITY one_tab "1 tab">
 <!-- Localization note (num_tabs2) : Number of tabs is always more than one.
      We can't use android plural forms, sadly. See bug #753859. -->
 <!ENTITY num_tabs2 "&formatD; tabs">
 <!ENTITY new_tab_opened "New tab opened">
 
@@ -71,20 +72,22 @@
 <!ENTITY pref_category_privacy_short "Privacy">
 <!ENTITY pref_category_vendor "&vendorShortName;">
 <!ENTITY pref_category_datareporting "Data choices">
 <!ENTITY pref_category_installed_search_engines "Installed search engines">
 <!ENTITY pref_category_add_search_providers "Add more search providers">
 <!ENTITY pref_category_search_restore_defaults "Restore search engines">
 <!ENTITY pref_search_restore_defaults "Restore defaults">
 <!ENTITY pref_search_restore_defaults_summary "Restore defaults">
-<!-- Localization note (pref_search_tip) : "TIP" as in "hint", "clue" etc. Displayed as an
+<!-- Localization note (pref_search_hint) : "TIP" as in "hint", "clue" etc. Displayed as an
      advisory message on the customise search providers settings page explaining how to add new
-     search providers.-->
-<!ENTITY pref_search_tip "TIP: Add any website to your list of search providers by long-pressing on its search field.">
+     search providers.
+     The &formatI; in the string will be replaced by a small image of the icon described, and can be moved to wherever
+     it is applicable. -->
+<!ENTITY pref_search_hint "TIP: Add any website to your list of search providers by long-pressing on its search field and then tapping the &formatI; icon.">
 <!ENTITY pref_category_devtools "Developer tools">
 <!ENTITY pref_developer_remotedebugging "Remote debugging">
 <!ENTITY pref_developer_remotedebugging_docs "Learn more">
 <!ENTITY pref_remember_signons "Remember passwords">
 
 <!ENTITY pref_category_home "Home">
 <!ENTITY pref_category_home_panels "Panels">
 
@@ -269,16 +272,17 @@ size. -->
 
 <!-- Localization note (site_settings_*) : These strings are used in the "Site Settings"
      dialog that appears after selecting the "Edit Site Settings" context menu item. -->
 <!ENTITY site_settings_title3       "Site Settings">
 <!ENTITY site_settings_cancel       "Cancel">
 <!ENTITY site_settings_clear        "Clear">
 <!ENTITY site_settings_no_settings  "There are no settings to clear.">
 
+<!ENTITY reading_list_add "Add to Reading List">
 <!ENTITY reading_list_added "Page added to your Reading List">
 <!ENTITY reading_list_removed "Page removed from your Reading List">
 <!ENTITY reading_list_failed "Failed to add page to your Reading List">
 <!ENTITY reading_list_duplicate "Page already in your Reading List">
 
 <!-- Localization note : These strings are used as alternate text for accessibility.
      They are not visible in the UI. -->
 <!ENTITY page_action_dropmarker_description "Additional Actions">
@@ -306,19 +310,19 @@ size. -->
 <!ENTITY home_last_tabs_title "Tabs from last time">
 <!ENTITY home_last_tabs_open "Open all tabs from last time">
 <!ENTITY home_last_tabs_empty "Your recent tabs show up here.">
 <!ENTITY home_most_recent_title "Most recent">
 <!ENTITY home_most_recent_empty "Websites you visited most recently show up here.">
 <!ENTITY home_reading_list_empty "Articles you save for later show up here.">
 <!-- Localization note (home_reading_list_hint): The "TIP" string is synonymous to "hint", "clue", etc. This string is displayed
      as an advisory message on how to add content to the reading list when the reading list empty.
-     The "&#37;I" in the string will be replaced by a small image of the icon described, and can be moved to wherever
-     it is applicable. Please keep the spacing around the "&#37;I" string. -->
-<!ENTITY home_reading_list_hint "TIP: Save articles to your reading list by long pressing the &#37;I icon when it appears in the title bar.">
+     The placeholder &formatI; will be replaced by a small image of the icon described, and can be moved to wherever
+     it is applicable. -->
+<!ENTITY home_reading_list_hint2 "TIP: Save articles to your reading list by long pressing the &formatI; icon when it appears in the title bar.">
 <!-- Localization note (home_reading_list_hint_accessible): This string is used
      as alternate text for accessibility. It is not visible in the UI. -->
 <!ENTITY home_reading_list_hint_accessible "TIP: Save articles to your reading list by long pressing the reader mode button when it appears in the title bar.">
 
 <!ENTITY pin_site_dialog_hint "Enter a search keyword">
 
 <!ENTITY filepicker_title "Choose File">
 <!ENTITY filepicker_audio_title "Choose or record a sound">
--- a/mobile/android/base/menu/MenuItemActionBar.java
+++ b/mobile/android/base/menu/MenuItemActionBar.java
@@ -2,117 +2,48 @@
  * 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.menu;
 
 import org.mozilla.gecko.R;
 
 import android.content.Context;
-import android.content.res.Resources;
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.PorterDuff;
-import android.graphics.PorterDuffColorFilter;
-import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
-import android.view.ViewGroup;
 import android.widget.ImageButton;
 
 public class MenuItemActionBar extends ImageButton
                                implements GeckoMenuItem.Layout {
     private static final String LOGTAG = "GeckoMenuItemActionBar";
 
-    private static Bitmap sMoreIcon;
-    private static float sHalfIconWidth;
-    private static float sMoreWidth;
-    private static int sMoreOffset;
-    private static Paint sDisabledPaint;
-
-    private Drawable mIcon;
-    private boolean mHasSubMenu = false;
-
     public MenuItemActionBar(Context context) {
         this(context, null);
     }
 
     public MenuItemActionBar(Context context, AttributeSet attrs) {
         this(context, attrs, R.attr.menuItemActionBarStyle);
     }
 
     public MenuItemActionBar(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-
-        if (sMoreIcon == null) {
-            final Resources res = getResources();
-
-            BitmapDrawable drawable  = (BitmapDrawable) res.getDrawable(R.drawable.menu_item_more);
-            sMoreIcon = drawable.getBitmap();
-
-            // The icon has some space on the right. Taking half the size feels better.
-            sMoreWidth = getResources().getDimensionPixelSize(R.dimen.menu_item_state_icon) / 2.0f;
-            sMoreOffset = res.getDimensionPixelSize(R.dimen.menu_item_more_offset);
-
-            final int rowHeight = res.getDimensionPixelSize(R.dimen.menu_item_row_height);
-            final int padding = getPaddingTop() + getPaddingBottom();
-            sHalfIconWidth = (rowHeight - padding) / 2.0f;
-
-            sDisabledPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
-            sDisabledPaint.setColorFilter(new PorterDuffColorFilter(0xFF999999, PorterDuff.Mode.SRC_ATOP));
-        }
-    }
-
-    @Override
-    protected void onDraw(Canvas canvas) {
-        if (!mHasSubMenu) {
-            super.onDraw(canvas);
-            return;
-        }
-
-        final int count = canvas.save();
-
-        final float halfWidth = getMeasuredWidth() / 2.0f;
-        final float halfHeight = getMeasuredHeight() / 2.0f;
-
-        // If the width is small, the more icon might be pushed to the edges.
-        // Instead translate the canvas, so that both the icon + more is centered as a whole.
-        final boolean needsTranslation = (halfWidth < 1.5 * halfHeight);
-        final float translateX = needsTranslation ? (sMoreOffset + sMoreWidth) / 2.0f : 0.0f;
-
-        canvas.translate(-translateX, 0);
-
-        super.onDraw(canvas);
-
-        final float left = halfWidth + sHalfIconWidth + sMoreOffset - translateX;
-        final float top = halfHeight - sMoreWidth;
-
-        canvas.drawBitmap(sMoreIcon, left, top, isEnabled() ? null : sDisabledPaint);
-
-        canvas.translate(translateX, 0);
-
-        canvas.restoreToCount(count);
     }
 
     @Override
     public void initialize(GeckoMenuItem item) {
         if (item == null)
             return;
 
         setIcon(item.getIcon());
         setTitle(item.getTitle());
         setEnabled(item.isEnabled());
         setId(item.getItemId());
-        setSubMenuIndicator(item.hasSubMenu());
     }
 
     void setIcon(Drawable icon) {
-        mIcon = icon;
-
         if (icon == null) {
             setVisibility(GONE);
         } else {
             setVisibility(VISIBLE);
             setImageDrawable(icon);
         }
     }
 
@@ -130,16 +61,9 @@ public class MenuItemActionBar extends I
         super.setEnabled(enabled);
         setColorFilter(enabled ? 0 : 0xFF999999);
     }
 
     @Override
     public void setShowIcon(boolean show) {
         // Do nothing.
     }
-
-    private void setSubMenuIndicator(boolean hasSubMenu) {
-        if (mHasSubMenu != hasSubMenu) {
-            mHasSubMenu = hasSubMenu;
-            invalidate();
-        }
-    }
 }
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -279,16 +279,17 @@ gbjar.sources += [
     'preferences/AndroidImport.java',
     'preferences/AndroidImportPreference.java',
     'preferences/CustomListCategory.java',
     'preferences/CustomListPreference.java',
     'preferences/FontSizePreference.java',
     'preferences/GeckoPreferenceFragment.java',
     'preferences/GeckoPreferences.java',
     'preferences/LinkPreference.java',
+    'preferences/ModifiableHintPreference.java',
     'preferences/MultiChoicePreference.java',
     'preferences/PanelsPreference.java',
     'preferences/PanelsPreferenceCategory.java',
     'preferences/PrivateDataPreference.java',
     'preferences/SearchEnginePreference.java',
     'preferences/SearchPreferenceCategory.java',
     'preferences/SyncPreference.java',
     'PrefsHelper.java',
--- a/mobile/android/base/preferences/GeckoPreferences.java
+++ b/mobile/android/base/preferences/GeckoPreferences.java
@@ -53,16 +53,17 @@ import android.text.TextWatcher;
 import android.util.Log;
 import android.view.MenuItem;
 import android.view.View;
 import android.widget.AdapterView;
 import android.widget.EditText;
 import android.widget.LinearLayout;
 import android.widget.ListAdapter;
 import android.widget.ListView;
+import android.widget.TextView;
 import android.widget.Toast;
 
 import java.util.ArrayList;
 import java.util.List;
 
 public class GeckoPreferences
     extends PreferenceActivity
     implements OnPreferenceChangeListener, GeckoEventListener, GeckoActivityStatus
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/preferences/ModifiableHintPreference.java
@@ -0,0 +1,67 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.preferences;
+
+import org.mozilla.gecko.R;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.preference.Preference;
+import android.text.Spanned;
+import android.text.SpannableStringBuilder;
+import android.text.style.ImageSpan;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+class ModifiableHintPreference extends Preference {
+    private static final String LOGTAG = "ModifiableHintPref";
+    private final Context mContext;
+
+    private final String MATCH_STRING = "%I";
+    private final int RESID_TEXT_VIEW = R.id.label_search_hint;
+    private final int RESID_DRAWABLE = R.drawable.ab_add_search_engine;
+    private final double SCALE_FACTOR = 0.5;
+
+    public ModifiableHintPreference(Context context, AttributeSet attrs) {
+        super(context, attrs);
+        mContext = context;
+    }
+
+    public ModifiableHintPreference(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+        mContext = context;
+    }
+
+    @Override
+    protected View onCreateView(ViewGroup parent) {
+        View thisView = super.onCreateView(parent);
+        configurePreferenceView(thisView);
+        return thisView;
+    }
+
+    private void configurePreferenceView(View view) {
+        TextView textView = (TextView) view.findViewById(RESID_TEXT_VIEW);
+        String searchHint = textView.getText().toString();
+
+        // Use an ImageSpan to include the "add search" icon in the Tip.
+        int imageSpanIndex = searchHint.indexOf(MATCH_STRING);
+        if (imageSpanIndex != -1) {
+            // Scale the resource.
+            Drawable drawable = mContext.getResources().getDrawable(RESID_DRAWABLE);
+            drawable.setBounds(0, 0, (int) (drawable.getIntrinsicWidth() * SCALE_FACTOR),
+                               (int) (drawable.getIntrinsicHeight() * SCALE_FACTOR));
+
+            ImageSpan searchIcon = new ImageSpan(drawable);
+            final SpannableStringBuilder hintBuilder = new SpannableStringBuilder(searchHint);
+
+            // Insert the image.
+            hintBuilder.setSpan(searchIcon, imageSpanIndex, imageSpanIndex + MATCH_STRING.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+            textView.setText(hintBuilder, TextView.BufferType.SPANNABLE);
+        }
+    }
+}
--- a/mobile/android/base/resources/layout/preference_search_tip.xml
+++ b/mobile/android/base/resources/layout/preference_search_tip.xml
@@ -4,34 +4,28 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
               android:orientation="horizontal"
               android:layout_width="fill_parent"
               android:layout_height="fill_parent"
               android:paddingRight="?android:attr/scrollbarSize">
 
-    <RelativeLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:gravity="right"
-        android:layout_marginLeft="15dip"
-        android:layout_marginRight="6dip"
-        android:layout_marginTop="5dip"
-        android:layout_marginBottom="6dip"
-        android:paddingRight="6dip"
-        android:layout_weight="1">
-
-        <TextView android:layout_height="wrap_content"
-                  android:layout_width="wrap_content"
-                  android:paddingTop="8dp"
-                  android:paddingBottom="8dp"
-                  android:text="@string/pref_search_tip"/>
-
-    </RelativeLayout>
+    <TextView android:id="@+id/label_search_hint"
+              android:layout_height="wrap_content"
+              android:layout_width="wrap_content"
+              android:text="@string/pref_search_hint"
+              android:layout_marginTop="5dip"
+              android:layout_marginBottom="6dip"
+              android:layout_marginLeft="15dip"
+              android:layout_marginRight="6dip"
+              android:paddingTop="8dp"
+              android:paddingBottom="8dp"
+              android:paddingRight="6dip"
+              android:layout_weight="1"/>
 
     <ImageView android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:paddingRight="8dp"
                android:paddingTop="12dip"
                android:src="@drawable/tip_addsearch"/>
 
 </LinearLayout>
--- a/mobile/android/base/resources/menu-large-v11/browser_app_menu.xml
+++ b/mobile/android/base/resources/menu-large-v11/browser_app_menu.xml
@@ -15,20 +15,40 @@
           android:title="@string/back"
           android:visible="false"/>
 
     <item android:id="@+id/forward"
           android:icon="@drawable/ic_menu_forward"
           android:title="@string/forward"
           android:visible="false"/>
 
-    <item android:id="@+id/bookmark"
+    <item android:id="@+id/save"
           android:icon="@drawable/ic_menu_bookmark_add"
-          android:title="@string/bookmark"
-          android:showAsAction="ifRoom"/>
+          android:title="@string/save"
+          android:showAsAction="ifRoom">
+
+        <menu>
+
+            <item android:id="@+id/bookmark"
+                  android:title="@string/bookmark"
+                  android:checkable="true" />
+
+            <item android:id="@+id/reading_list_add"
+                  android:title="@string/reading_list_add"
+                  android:checkable="true" />
+
+            <item android:id="@+id/launcher_add"
+                  android:title="@string/contextmenu_add_to_launcher" />
+
+            <item android:id="@+id/subscribe"
+                  android:title="@string/contextmenu_subscribe" />
+
+        </menu>
+
+    </item>
 
     <item android:id="@+id/share"
           android:icon="@drawable/ic_menu_share"
           android:title="@string/share"
           android:showAsAction="ifRoom"/>
 
     <item android:id="@+id/new_tab"
           android:icon="@drawable/ic_menu_new_tab"
--- a/mobile/android/base/resources/menu-v11/browser_app_menu.xml
+++ b/mobile/android/base/resources/menu-v11/browser_app_menu.xml
@@ -15,20 +15,40 @@
           android:title="@string/forward"
           android:showAsAction="always"/>
 
     <item android:id="@+id/reload"
           android:icon="@drawable/ic_menu_reload"
           android:title="@string/reload"
           android:showAsAction="always"/>
 
-    <item android:id="@+id/bookmark"
+    <item android:id="@+id/save"
           android:icon="@drawable/ic_menu_bookmark_add"
-          android:title="@string/bookmark"
-          android:showAsAction="ifRoom"/>
+          android:title="@string/save"
+          android:showAsAction="ifRoom">
+
+        <menu>
+
+            <item android:id="@+id/bookmark"
+                  android:title="@string/bookmark"
+                  android:checkable="true" />
+
+            <item android:id="@+id/reading_list_add"
+                  android:title="@string/reading_list_add"
+                  android:checkable="true" />
+
+            <item android:id="@+id/launcher_add"
+                  android:title="@string/contextmenu_add_to_launcher" />
+
+            <item android:id="@+id/subscribe"
+                  android:title="@string/contextmenu_subscribe" />
+
+        </menu>
+
+    </item>
 
     <item android:id="@+id/share"
           android:icon="@drawable/ic_menu_share"
           android:title="@string/share"
           android:showAsAction="ifRoom"/>
 
     <item android:id="@+id/new_tab"
           android:icon="@drawable/ic_menu_new_tab"
--- a/mobile/android/base/resources/menu-xlarge-v11/browser_app_menu.xml
+++ b/mobile/android/base/resources/menu-xlarge-v11/browser_app_menu.xml
@@ -15,20 +15,40 @@
           android:title="@string/back"
           android:visible="false"/>
 
     <item android:id="@+id/forward"
           android:icon="@drawable/ic_menu_forward"
           android:title="@string/forward"
           android:visible="false"/>
 
-    <item android:id="@+id/bookmark"
+    <item android:id="@+id/save"
           android:icon="@drawable/ic_menu_bookmark_add"
-          android:title="@string/bookmark"
-          android:showAsAction="always"/>
+          android:title="@string/save"
+          android:showAsAction="always">
+
+        <menu>
+
+            <item android:id="@+id/bookmark"
+                  android:title="@string/bookmark"
+                  android:checkable="true" />
+
+            <item android:id="@+id/reading_list_add"
+                  android:title="@string/reading_list_add"
+                  android:checkable="true" />
+
+            <item android:id="@+id/launcher_add"
+                  android:title="@string/contextmenu_add_to_launcher" />
+
+            <item android:id="@+id/subscribe"
+                  android:title="@string/contextmenu_subscribe" />
+
+        </menu>
+
+    </item>
 
     <item android:id="@+id/share"
           android:icon="@drawable/ic_menu_share"
           android:title="@string/share"
           android:showAsAction="ifRoom"/>
 
     <item android:id="@+id/new_tab"
           android:icon="@drawable/ic_menu_new_tab"
--- a/mobile/android/base/resources/menu/browser_app_menu.xml
+++ b/mobile/android/base/resources/menu/browser_app_menu.xml
@@ -13,19 +13,39 @@
           android:icon="@drawable/ic_menu_back"
           android:title="@string/back"
           android:visible="false"/>
 
     <item android:id="@+id/forward"
           android:icon="@drawable/ic_menu_forward"
           android:title="@string/forward"/>
 
-    <item android:id="@+id/bookmark"
+    <item android:id="@+id/save"
           android:icon="@drawable/ic_menu_bookmark_add"
-          android:title="@string/bookmark"/>
+          android:title="@string/save">
+
+        <menu>
+
+            <item android:id="@+id/bookmark"
+                  android:title="@string/bookmark"
+                  android:checkable="true" />
+
+            <item android:id="@+id/reading_list_add"
+                  android:title="@string/reading_list_add"
+                  android:checkable="true" />
+
+            <item android:id="@+id/launcher_add"
+                  android:title="@string/contextmenu_add_to_launcher" />
+
+            <item android:id="@+id/subscribe"
+                  android:title="@string/contextmenu_subscribe" />
+
+        </menu>
+
+    </item>
 
     <item android:id="@+id/new_tab"
           android:icon="@drawable/ic_menu_new_tab"
           android:title="@string/new_tab"/>
 
     <item android:id="@+id/new_private_tab"
           android:icon="@drawable/ic_menu_new_private_tab"
           android:title="@string/new_private_tab"/>
--- a/mobile/android/base/resources/xml-v11/preferences_search.xml
+++ b/mobile/android/base/resources/xml-v11/preferences_search.xml
@@ -13,15 +13,16 @@
                         android:defaultValue="false"
                         android:persistent="false" />
 
     <org.mozilla.gecko.preferences.SearchPreferenceCategory
                         android:title="@string/pref_category_installed_search_engines"/>
 
     <PreferenceCategory android:title="@string/pref_category_add_search_providers">
 
-        <Preference android:layout="@layout/preference_search_tip"
+        <org.mozilla.gecko.preferences.ModifiableHintPreference
+                    android:layout="@layout/preference_search_tip"
                     android:enabled="false"
                     android:selectable="false"/>
 
     </PreferenceCategory>
 
 </PreferenceScreen>
--- a/mobile/android/base/resources/xml/preferences_search.xml
+++ b/mobile/android/base/resources/xml/preferences_search.xml
@@ -20,15 +20,16 @@
 
         <Preference android:key="android.not_a_preference.search.restore_defaults"
                     android:title="@string/pref_search_restore_defaults_summary" />
 
     </PreferenceCategory>
 
     <PreferenceCategory android:title="@string/pref_category_add_search_providers">
 
-        <Preference android:layout="@layout/preference_search_tip"
+        <org.mozilla.gecko.preferences.ModifiableHintPreference
+                    android:layout="@layout/preference_search_tip"
                     android:enabled="false"
                     android:selectable="false"/>
 
     </PreferenceCategory>
 
 </PreferenceScreen>
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -5,16 +5,17 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
 <!DOCTYPE resources [
 #includesubst @BRANDPATH@
 #includesubst @STRINGSPATH@
 #includesubst @SYNCSTRINGSPATH@
 
 <!-- C-style format strings. -->
+<!ENTITY formatI "&#037;I">
 <!ENTITY formatS "&#037;s">
 <!ENTITY formatS1 "&#037;1&#036;s">
 <!ENTITY formatS2 "&#037;2&#036;s">
 <!ENTITY formatS3 "&#037;3&#036;s">
 <!ENTITY formatD "&#037;d">
 ]>
 
 #includesubst @BOOKMARKSPATH@
@@ -99,17 +100,17 @@
   <string name="pref_category_privacy_short">&pref_category_privacy_short;</string>
   <string name="pref_category_vendor">&pref_category_vendor;</string>
   <string name="pref_category_datareporting">&pref_category_datareporting;</string>
   <string name="pref_category_installed_search_engines">&pref_category_installed_search_engines;</string>
   <string name="pref_category_add_search_providers">&pref_category_add_search_providers;</string>
   <string name="pref_category_search_restore_defaults">&pref_category_search_restore_defaults;</string>
   <string name="pref_search_restore_defaults">&pref_search_restore_defaults;</string>
   <string name="pref_search_restore_defaults_summary">&pref_search_restore_defaults_summary;</string>
-  <string name="pref_search_tip">&pref_search_tip;</string>
+  <string name="pref_search_hint">&pref_search_hint;</string>
 
   <string name="pref_category_devtools">&pref_category_devtools;</string>
   <string name="pref_developer_remotedebugging">&pref_developer_remotedebugging;</string>
   <string name="pref_developer_remotedebugging_docs">&pref_developer_remotedebugging_docs;</string>
 
   <string name="pref_category_home">&pref_category_home;</string>
   <string name="pref_category_home_panels">&pref_category_home_panels;</string>
 
@@ -217,22 +218,24 @@
   <string name="apps">&apps;</string>
   <string name="char_encoding">&char_encoding;</string>
   <string name="new_tab">&new_tab;</string>
   <string name="new_private_tab">&new_private_tab;</string>
   <string name="close_all_tabs">&close_all_tabs;</string>
   <string name="tabs_normal">&tabs_normal;</string>
   <string name="tabs_private">&tabs_private;</string>
   <string name="tabs_synced">&tabs_synced;</string>
+  <string name="save">&save;</string>
 
   <string name="site_settings_title">&site_settings_title3;</string>
   <string name="site_settings_cancel">&site_settings_cancel;</string>
   <string name="site_settings_clear">&site_settings_clear;</string>
   <string name="site_settings_no_settings">&site_settings_no_settings;</string>
 
+  <string name="reading_list_add">&reading_list_add;</string>
   <string name="reading_list_added">&reading_list_added;</string>
   <string name="reading_list_removed">&reading_list_removed;</string>
   <string name="reading_list_failed">&reading_list_failed;</string>
   <string name="reading_list_duplicate">&reading_list_duplicate;</string>
 
   <string name="page_action_dropmarker_description">&page_action_dropmarker_description;</string>
 
   <string name="contextmenu_open_new_tab">&contextmenu_open_new_tab;</string>
@@ -284,17 +287,17 @@
   <string name="home_history_title">&home_history_title;</string>
   <string name="home_bookmarks_empty">&home_bookmarks_empty;</string>
   <string name="home_last_tabs_title">&home_last_tabs_title;</string>
   <string name="home_last_tabs_open">&home_last_tabs_open;</string>
   <string name="home_last_tabs_empty">&home_last_tabs_empty;</string>
   <string name="home_most_recent_title">&home_most_recent_title;</string>
   <string name="home_most_recent_empty">&home_most_recent_empty;</string>
   <string name="home_reading_list_empty">&home_reading_list_empty;</string>
-  <string name="home_reading_list_hint">&home_reading_list_hint;</string>
+  <string name="home_reading_list_hint">&home_reading_list_hint2;</string>
   <string name="home_reading_list_hint_accessible">&home_reading_list_hint_accessible;</string>
   <string name="pin_site_dialog_hint">&pin_site_dialog_hint;</string>
 
   <string name="filepicker_title">&filepicker_title;</string>
   <string name="filepicker_audio_title">&filepicker_audio_title;</string>
   <string name="filepicker_image_title">&filepicker_image_title;</string>
   <string name="filepicker_video_title">&filepicker_video_title;</string>
 
--- a/mobile/android/base/toolbar/BrowserToolbar.java
+++ b/mobile/android/base/toolbar/BrowserToolbar.java
@@ -6,16 +6,17 @@
 package org.mozilla.gecko.toolbar;
 
 import org.mozilla.gecko.BrowserApp;
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.LightweightTheme;
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.ReaderModeUtils;
 import org.mozilla.gecko.SiteIdentity.SecurityMode;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.animation.PropertyAnimator;
 import org.mozilla.gecko.animation.PropertyAnimator.PropertyAnimationListener;
 import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.MenuPopup;
@@ -25,17 +26,16 @@ import org.mozilla.gecko.toolbar.Toolbar
 import org.mozilla.gecko.util.Clipboard;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.MenuUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.widget.GeckoImageButton;
 import org.mozilla.gecko.widget.GeckoImageView;
 import org.mozilla.gecko.widget.GeckoRelativeLayout;
-
 import org.json.JSONObject;
 
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.StateListDrawable;
 import android.os.Build;
@@ -1340,20 +1340,17 @@ public class BrowserToolbar extends Geck
     public void handleMessage(String event, JSONObject message) {
         Log.d(LOGTAG, "handleMessage: " + event);
         if (event.equals("Reader:Click")) {
             Tab tab = Tabs.getInstance().getSelectedTab();
             if (tab != null) {
                 tab.toggleReaderMode();
             }
         } else if (event.equals("Reader:LongClick")) {
-            Tab tab = Tabs.getInstance().getSelectedTab();
-            if (tab != null) {
-                tab.addToReadingList();
-            }
+            ReaderModeUtils.addToReadingList(Tabs.getInstance().getSelectedTab());
         }
     }
 
     @Override
     public void onLightweightThemeChanged() {
         Drawable drawable = mTheme.getDrawable(this);
         if (drawable == null)
             return;
--- a/mobile/android/base/util/StringUtils.java
+++ b/mobile/android/base/util/StringUtils.java
@@ -4,16 +4,19 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.util;
 
 import android.net.Uri;
 import android.text.TextUtils;
 
 public class StringUtils {
+
+    private static final String FILTER_URL_PREFIX = "filter://";
+
     /*
      * This method tries to guess if the given string could be a search query or URL,
      * and returns a previous result if there is ambiguity
      *
      * Search examples:
      *  foo
      *  foo bar.com
      *  foo http://bar.com
@@ -130,9 +133,25 @@ public class StringUtils {
             if (TextUtils.isEmpty(value)) {
                 return null;
             }
             return value;
         }
 
         return null;
     }
+
+    public static boolean isFilterUrl(String url) {
+        if (TextUtils.isEmpty(url)) {
+            return false;
+        }
+
+        return url.startsWith(FILTER_URL_PREFIX);
+    }
+
+    public static String getFilterFromUrl(String url) {
+        if (TextUtils.isEmpty(url)) {
+            return null;
+        }
+
+        return url.substring(FILTER_URL_PREFIX.length());
+    }
 }
--- a/mobile/android/components/Snippets.js
+++ b/mobile/android/components/Snippets.js
@@ -25,16 +25,19 @@ const SNIPPETS_STATS_URL_PREF = "browser
 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);
@@ -164,41 +167,74 @@ var gMessageIds = [];
 function updateBanner(response) {
   // Remove the current messages, if there are any.
   gMessageIds.forEach(function(id) {
     Home.banner.remove(id);
   })
   gMessageIds = [];
 
   let messages = JSON.parse(response);
+
+  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 ("target_geo" in message && message.target_geo != gCountryCode) {
       return;
     }
     let id = Home.banner.add({
       text: message.text,
       icon: message.icon,
       onclick: function() {
         let parentId = gChromeWin.BrowserApp.selectedTab.id;
         gChromeWin.BrowserApp.addTab(message.url, { parentId: parentId });
       },
+      ondismiss: function() {
+        // Remove this snippet from the banner, and store its id so we'll never show it again.
+        Home.banner.remove(id);
+        removeSnippet(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);
   });
 }
 
 /**
+ * Appends snippet id to the end of `snippets-removed.txt`
+ *
+ * @param snippetId unique id for snippet, sent from snippets server
+ */
+function removeSnippet(snippetId) {
+  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 + ";");
 
--- a/mobile/android/modules/HomeProvider.jsm
+++ b/mobile/android/modules/HomeProvider.jsm
@@ -46,22 +46,23 @@ const SQL = {
   createItemsTable:
     "CREATE TABLE items (" +
       "_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
       "dataset_id TEXT NOT NULL, " +
       "url TEXT," +
       "title TEXT," +
       "description TEXT," +
       "image_url TEXT," +
+      "filter TEXT," +
       "created INTEGER" +
     ")",
 
   insertItem:
-    "INSERT INTO items (dataset_id, url, title, description, image_url, created) " +
-      "VALUES (:dataset_id, :url, :title, :description, :image_url, :created)",
+    "INSERT INTO items (dataset_id, url, title, description, image_url, filter, created) " +
+      "VALUES (:dataset_id, :url, :title, :description, :image_url, :filter, :created)",
 
   deleteFromDataset:
     "DELETE FROM items WHERE dataset_id = :dataset_id"
 }
 
 /**
  * Technically this function checks to see if the user is on a local network,
  * but we express this as "wifi" to the user.
@@ -237,16 +238,17 @@ HomeStorage.prototype = {
         for (let item of data) {
           // XXX: Directly pass item as params? More validation for item? Batch insert?
           let params = {
             dataset_id: this.datasetId,
             url: item.url,
             title: item.title,
             description: item.description,
             image_url: item.image_url,
+            filter: item.filter,
             created: Date.now()
           };
           yield db.executeCached(SQL.insertItem, params);
         }
       } finally {
         yield db.close();
       }
     }.bind(this));
--- a/services/common/rest.js
+++ b/services/common/rest.js
@@ -777,13 +777,23 @@ HAWKAuthenticatedRESTRequest.prototype =
       };
       let header = CryptoUtils.computeHAWK(this.uri, method, options);
       this.setHeader("Authorization", header.field);
       this._log.trace("hawk auth header: " + header.field);
     }
 
     this.setHeader("Content-Type", contentType);
 
+    try {
+      let acceptLanguage = Services.prefs.getComplexValue(
+          "intl.accept_languages", Ci.nsIPrefLocalizedString).data;
+      if (acceptLanguage) {
+        this.setHeader("Accept-Language", acceptLanguage);
+      }
+    } catch (err) {
+      this._log.error("Error reading intl.accept_languages pref: " + CommonUtils.exceptionStr(err));
+    }
+
     return RESTRequest.prototype.dispatch.call(
       this, method, data, onComplete, onProgress
     );
   }
 };
--- a/services/common/tests/unit/test_restrequest.js
+++ b/services/common/tests/unit/test_restrequest.js
@@ -848,16 +848,21 @@ add_test(function test_hawk_authenticate
   // computation with the hawk timestamp easier, since hawk throws away the
   // millisecond values.
   let then = 34329600000;
 
   let clockSkew = 120000;
   let timeOffset = -1 * clockSkew;
   let localTime = then + clockSkew;
 
+  // Set the accept-languages pref to the Nepalese dialect of Zulu.
+  let acceptLanguage = Cc['@mozilla.org/supports-string;1'].createInstance(Ci.nsISupportsString);
+  acceptLanguage.data = 'zu-NP'; // omit trailing ';', which our HTTP libs snip
+  Services.prefs.setComplexValue('intl.accept_languages', Ci.nsISupportsString, acceptLanguage);
+
   let credentials = {
     id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
     key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
     algorithm: "sha256"
   };
 
   let server = httpd_setup({
     "/elysium": function(request, response) {
@@ -865,16 +870,24 @@ add_test(function test_hawk_authenticate
 
       // check that the header timestamp is our arbitrary system date, not
       // today's date.  Note that hawk header timestamps are in seconds, not
       // milliseconds.
       let authorization = request.getHeader("Authorization");
       let tsMS = parseInt(/ts="(\d+)"/.exec(authorization)[1], 10) * 1000;
       do_check_eq(tsMS, then);
 
+      // This testing can be a little wonky. In an environment where
+      //   pref("intl.accept_languages") === 'en-US, en'
+      // the header is sent as:
+      //   'en-US,en;q=0.5'
+      // hence our fake value for acceptLanguage.
+      let lang = request.getHeader('Accept-Language');
+      do_check_eq(lang, acceptLanguage);
+
       let message = "yay";
       response.setStatusLine(request.httpVersion, 200, "OK");
       response.bodyOutputStream.write(message, message.length);
     }
   });
 
   function onProgress() {
     onProgressCalled = true;
@@ -890,9 +903,14 @@ add_test(function test_hawk_authenticate
 
   let url = server.baseURI + "/elysium";
   let extra = {
     now: localTime,
     localtimeOffsetMsec: timeOffset
   };
   let request = new HAWKAuthenticatedRESTRequest(url, credentials, extra);
   request.post(postData, onComplete, onProgress);
+
+  Services.prefs.resetUserPrefs();
+  let pref = Services.prefs.getComplexValue('intl.accept_languages',
+                                            Ci.nsIPrefLocalizedString);
+  do_check_neq(acceptLanguage.data, pref.data);
 });