Bug 1319274 - Part 3: Allow pinning and unpinning of both Top Sites and Highlights r=ahunt draft
authorGrisha Kruglov <gkruglov@mozilla.com>
Wed, 14 Dec 2016 16:23:40 -0800
changeset 449767 a86893a235ee7c6c7d2215b9c8a3b56f986480a9
parent 449766 e4a2f46f91c85bb4a7071361ba9da7d69f4d93da
child 449778 afb826015a2196144457d48370ce94b77a450cb0
push id38649
push userbmo:gkruglov@mozilla.com
push dateThu, 15 Dec 2016 00:24:34 +0000
reviewersahunt
bugs1319274
milestone53.0a1
Bug 1319274 - Part 3: Allow pinning and unpinning of both Top Sites and Highlights r=ahunt This commit introduces a special type of a pin, "Activity Stream pin". It's identified by a fixed position of -1. Activity Stream pins are displayed inline with top sites, at the very front. They are "non-positioned", as opposed to regular pins which have a position on Top Sites grid. This approach was selected (as opposed to creating a new kind of a "non-positioned pins bookmark folder") because it is simpler, does not involve any migrations or sync changes, and is thus preferred in light of a moving target that is the current vision for Activity Stream. Two types of pins, regular ones and Activity Stream pins, are independent of each other. Due to the fact that pins and bookmarks are almost the same thing, we can only figure our, based on the underlying queries, the following ahead of time: - pinned state of a pinned top site item (trivial case) - pinned state of a non-pinned top site item (trivial case) - bookmark state of a "bookmarked" hightlight item (trivial case) - bookmark state of a non-pinned top site item For all other combinations, states have to be looked up when user opens a context menu for an item. MozReview-Commit-ID: 3KbOp9S4Pz7
mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
mobile/android/base/resources/menu/activitystream_contextmenu.xml
mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testActivityStreamContextMenu.java
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserContract.java
@@ -40,23 +40,23 @@ public class BrowserContract {
 
     public static final String LOGINS_AUTHORITY = AppConstants.ANDROID_PACKAGE_NAME + ".db.logins";
     public static final Uri LOGINS_AUTHORITY_URI = Uri.parse("content://" + LOGINS_AUTHORITY);
 
     public static final String PARAM_PROFILE = "profile";
     public static final String PARAM_PROFILE_PATH = "profilePath";
     public static final String PARAM_LIMIT = "limit";
     public static final String PARAM_SUGGESTEDSITES_LIMIT = "suggestedsites_limit";
-    public static final String PARAM_TOPSITES_DISABLE_PINNED = "topsites_disable_pinned";
     public static final String PARAM_IS_SYNC = "sync";
     public static final String PARAM_SHOW_DELETED = "show_deleted";
     public static final String PARAM_IS_TEST = "test";
     public static final String PARAM_INSERT_IF_NEEDED = "insert_if_needed";
     public static final String PARAM_INCREMENT_VISITS = "increment_visits";
     public static final String PARAM_INCREMENT_REMOTE_AGGREGATES = "increment_remote_aggregates";
+    public static final String PARAM_NON_POSITIONED_PINS = "non_positioned_pins";
     public static final String PARAM_EXPIRE_PRIORITY = "priority";
     public static final String PARAM_DATASET_ID = "dataset_id";
     public static final String PARAM_GROUP_BY = "group_by";
 
     static public enum ExpirePriority {
         NORMAL,
         AGGRESSIVE
     }
@@ -237,16 +237,20 @@ public class BrowserContract {
 
         public static final int FIXED_ROOT_ID = 0;
         public static final int FAKE_DESKTOP_FOLDER_ID = -1;
         public static final int FIXED_READING_LIST_ID = -2;
         public static final int FIXED_PINNED_LIST_ID = -3;
         public static final int FIXED_SCREENSHOT_FOLDER_ID = -4;
         public static final int FAKE_READINGLIST_SMARTFOLDER_ID = -5;
 
+        // Fixed position used by Activity Stream pins. A-S displays pins in front of other top sites,
+        // so position is constant for all pins.
+        public static final int FIXED_AS_PIN_POSITION = -1;
+
         /**
          * This ID and the following negative IDs are reserved for bookmarks from Android's partner
          * bookmark provider.
          */
         public static final long FAKE_PARTNER_BOOKMARKS_START = -1000;
 
         public static final String MOBILE_FOLDER_GUID = "mobile";
         public static final String PLACES_FOLDER_GUID = "places";
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserDB.java
@@ -156,19 +156,27 @@ public abstract class BrowserDB {
             Collection<ContentProviderOperation> operations, String url,
             String title, long date, int visits);
 
     public abstract void updateBookmarkInBatch(ContentResolver cr,
             Collection<ContentProviderOperation> operations, String url,
             String title, String guid, long parent, long added, long modified,
             long position, String keyword, int type);
 
+    // Used by regular top sites, which observe pinning position.
     public abstract void pinSite(ContentResolver cr, String url, String title, int position);
     public abstract void unpinSite(ContentResolver cr, int position);
 
+    // Used by activity stream top sites, which ignore position - it's always 0.
+    // Pins show up in front of other top sites.
+    public abstract void pinSiteForAS(ContentResolver cr, String url, String title);
+    public abstract void unpinSiteForAS(ContentResolver cr, String url);
+
+    public abstract boolean isPinnedForAS(ContentResolver cr, String url);
+
     public abstract boolean hideSuggestedSite(String url);
     public abstract void setSuggestedSites(SuggestedSites suggestedSites);
     public abstract SuggestedSites getSuggestedSites();
     public abstract boolean hasSuggestedImageUrl(String url);
     public abstract String getSuggestedImageUrlForUrl(String url);
     public abstract int getSuggestedBackgroundColorForUrl(String url);
 
     /**
--- a/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/BrowserProvider.java
@@ -891,35 +891,49 @@ public class BrowserProvider extends Sha
         // 2. The topsites list is larger, hence we use a temporary table, which automatically provides rowids.
 
         final SQLiteDatabase db = getWritableDatabase(uri);
 
         final String TABLE_TOPSITES = "topsites";
 
         final String limitParam = uri.getQueryParameter(BrowserContract.PARAM_LIMIT);
         final String gridLimitParam = uri.getQueryParameter(BrowserContract.PARAM_SUGGESTEDSITES_LIMIT);
+        final String nonPositionedPins = uri.getQueryParameter(BrowserContract.PARAM_NON_POSITIONED_PINS);
 
         final int totalLimit;
         final int suggestedGridLimit;
 
         if (limitParam == null) {
             totalLimit = 50;
         } else {
             totalLimit = Integer.parseInt(limitParam, 10);
         }
 
         if (gridLimitParam == null) {
             suggestedGridLimit = getContext().getResources().getInteger(R.integer.number_of_top_sites);
         } else {
             suggestedGridLimit = Integer.parseInt(gridLimitParam, 10);
         }
 
-        final String pinnedSitesFromClause = "FROM " + TABLE_BOOKMARKS + " WHERE " +
-                                             Bookmarks.PARENT + " == " + Bookmarks.FIXED_PINNED_LIST_ID +
-                                             " AND " + Bookmarks.IS_DELETED + " IS NOT 1";
+        // We have two types of pinned sites, positioned and non-positioned. Positioned pins are used
+        // by regular Top Sites, where position in the grid is of importance. Non-positioned pins are
+        // used by Activity Stream Top Sites, where pins are displayed in front of other top site items.
+        // Non-positioned pins all have the same special position value which is used to identify them.
+        // An alternative to this is creating a separate special folder for non-positioned pins, introducing
+        // a database migration, adjusting sync code, etc. While on some level this might
+        // be a cleaner solution, a "position hack" is simpler to implement and manage over time in light
+        // of A-S being either a likely replacement for regular Top Sites, or being scrapped.
+        String pinnedSitesFromClause = "FROM " + TABLE_BOOKMARKS + " WHERE " +
+                Bookmarks.PARENT + " = " + Bookmarks.FIXED_PINNED_LIST_ID +
+                " AND " + Bookmarks.IS_DELETED + " IS NOT 1";
+        if (nonPositionedPins != null) {
+            pinnedSitesFromClause += " AND " + Bookmarks.POSITION + " = " + Bookmarks.FIXED_AS_PIN_POSITION;
+        } else {
+            pinnedSitesFromClause += " AND " + Bookmarks.POSITION + " != " + Bookmarks.FIXED_AS_PIN_POSITION;
+        }
 
         // Ideally we'd use a recursive CTE to generate our sequence, e.g. something like this worked at one point:
         // " WITH RECURSIVE" +
         // " cnt(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM cnt WHERE x < 6)" +
         // However that requires SQLite >= 3.8.3 (available on Android >= 5.0), so in the meantime
         // we use a temporary numbers table.
         // Note: SQLite rowids are 1-indexed, whereas we're expecting 0-indexed values for the position. Our numbers
         // table starts at position = 0, which ensures the correct results here.
@@ -1124,17 +1138,19 @@ public class BrowserProvider extends Sha
                         " -1 AS " + TopSites.HISTORY_ID + ", " +
                         Bookmarks.URL + ", " +
                         Bookmarks.TITLE + ", " +
                         Bookmarks.POSITION + ", " +
                         "NULL AS " + Combined.HISTORY_ID + ", " +
                         TopSites.TYPE_PINNED + " as " + TopSites.TYPE +
                         " " + pinnedSitesFromClause +
 
-                        " ORDER BY " + Bookmarks.POSITION,
+                        // In case position is non-unique (as in Activity Stream pins, whose position
+                        // is always zero), we need to ensure we get stable ordering.
+                        " ORDER BY " + Bookmarks.POSITION + ", " + Bookmarks.URL,
 
                         null);
 
             c.setNotificationUri(getContext().getContentResolver(),
                                  BrowserContract.AUTHORITY_URI);
 
             // Force the cursor to be compiled and the cursor-window filled now:
             // (A) without compiling the cursor now we won't have access to the TEMP table which
@@ -1161,51 +1177,59 @@ public class BrowserProvider extends Sha
 
         final long threeDaysAgo = System.currentTimeMillis() - (1000 * 60 * 60 * 24 * 3);
         final long bookmarkLimit = 1;
 
         // Select recent bookmarks that have not been visited much
         final String bookmarksQuery = "SELECT * FROM (SELECT " +
                 "-1 AS " + Combined.HISTORY_ID + ", " +
                 DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks._ID) + " AS " + Combined.BOOKMARK_ID + ", " +
+                DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.PARENT) + " AS " + Bookmarks.PARENT + ", " +
+                DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.POSITION) + " AS " + Bookmarks.POSITION + ", " +
                 DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + ", " +
                 DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.TITLE) + ", " +
                 DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " AS " + Highlights.DATE + " " +
                 "FROM " + Bookmarks.TABLE_NAME + " " +
                 "LEFT JOIN " + History.TABLE_NAME + " ON " +
                     DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + " = " +
                     DBUtils.qualifyColumn(History.TABLE_NAME, History.URL) + " " +
                 "WHERE " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " > " + threeDaysAgo + " " +
                 "AND (" + DBUtils.qualifyColumn(History.TABLE_NAME, History.VISITS) + " <= 3 " +
                   "OR " + DBUtils.qualifyColumn(History.TABLE_NAME, History.VISITS) + " IS NULL) " +
                 "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.IS_DELETED)  + " = 0 " +
                 "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.TYPE) + " = " + Bookmarks.TYPE_BOOKMARK + " " +
+                "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.PARENT) + " >= " + Bookmarks.FIXED_ROOT_ID + " " +
                 "AND " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + " NOT IN (SELECT " + ActivityStreamBlocklist.URL + " FROM " + ActivityStreamBlocklist.TABLE_NAME + " )" +
                 "ORDER BY " + DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.DATE_CREATED) + " DESC " +
                 "LIMIT " + bookmarkLimit + ")";
 
         final long last30Minutes = System.currentTimeMillis() - (1000 * 60 * 30);
         final long historyLimit = totalLimit - bookmarkLimit;
 
         // Select recent history that has not been visited much.
         final String historyQuery = "SELECT * FROM (SELECT " +
-                History._ID + " AS " + Combined.HISTORY_ID + ", " +
+                DBUtils.qualifyColumn(History.TABLE_NAME, History._ID) + " AS " + Combined.HISTORY_ID + ", " +
                 "-1 AS " + Combined.BOOKMARK_ID + ", " +
-                History.URL + ", " +
-                History.TITLE + ", " +
-                History.DATE_LAST_VISITED + " AS " + Highlights.DATE + " " +
+                DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.PARENT) + " AS " + Bookmarks.PARENT + ", " +
+                DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.POSITION) + " AS " + Bookmarks.POSITION + ", " +
+                DBUtils.qualifyColumn(History.TABLE_NAME, History.URL) + ", " +
+                DBUtils.qualifyColumn(History.TABLE_NAME, History.TITLE) + ", " +
+                DBUtils.qualifyColumn(History.TABLE_NAME, History.DATE_LAST_VISITED) + " AS " + Highlights.DATE + " " +
                 "FROM " + History.TABLE_NAME + " " +
-                "WHERE " + History.DATE_LAST_VISITED + " < " + last30Minutes + " " +
-                "AND " + History.VISITS + " <= 3 " +
-                "AND " + History.TITLE + " NOT NULL AND " + History.TITLE + " != '' " +
-                "AND " + History.IS_DELETED + " = 0 " +
-                "AND " + History.URL + " NOT IN (SELECT " + ActivityStreamBlocklist.URL + " FROM " + ActivityStreamBlocklist.TABLE_NAME + " )" +
+                "LEFT JOIN " + Bookmarks.TABLE_NAME + " ON " +
+                    DBUtils.qualifyColumn(History.TABLE_NAME, History.URL) + " = " +
+                    DBUtils.qualifyColumn(Bookmarks.TABLE_NAME, Bookmarks.URL) + " " +
+                "WHERE " + DBUtils.qualifyColumn(History.TABLE_NAME, History.DATE_LAST_VISITED) + " < " + last30Minutes + " " +
+                "AND " + DBUtils.qualifyColumn(History.TABLE_NAME, History.VISITS) + " <= 3 " +
+                "AND " + DBUtils.qualifyColumn(History.TABLE_NAME, History.TITLE) + " NOT NULL AND " + DBUtils.qualifyColumn(History.TABLE_NAME, History.TITLE) + " != '' " +
+                "AND " + DBUtils.qualifyColumn(History.TABLE_NAME, History.IS_DELETED) + " = 0 " +
+                "AND " + DBUtils.qualifyColumn(History.TABLE_NAME, History.URL) + " NOT IN (SELECT " + ActivityStreamBlocklist.URL + " FROM " + ActivityStreamBlocklist.TABLE_NAME + " )" +
                 // TODO: Implement domain black list (bug 1298786)
                 // TODO: Group by host (bug 1298785)
-                "ORDER BY " + History.DATE_LAST_VISITED + " DESC " +
+                "ORDER BY " + DBUtils.qualifyColumn(History.TABLE_NAME, History.DATE_LAST_VISITED) + " DESC " +
                 "LIMIT " + historyLimit + ")";
 
         final String query = "SELECT DISTINCT * " +
                 "FROM (" + bookmarksQuery + " " +
                 "UNION ALL " + historyQuery + ") " +
                 "GROUP BY " + Combined.URL + ";";
 
         final Cursor cursor = db.rawQuery(query, null);
--- a/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
+++ b/mobile/android/base/java/org/mozilla/gecko/db/LocalBrowserDB.java
@@ -1725,16 +1725,65 @@ public class LocalBrowserDB extends Brow
                   Bookmarks.PARENT + " == ? AND " + Bookmarks.POSITION + " = ?",
                   new String[] {
                       String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID),
                       Integer.toString(position)
                   });
     }
 
     @Override
+    public void pinSiteForAS(ContentResolver cr, String url, String title) {
+        ContentValues values = new ContentValues();
+        final long now = System.currentTimeMillis();
+        values.put(Bookmarks.TITLE, title);
+        values.put(Bookmarks.URL, url);
+        values.put(Bookmarks.PARENT, Bookmarks.FIXED_PINNED_LIST_ID);
+        values.put(Bookmarks.DATE_MODIFIED, now);
+        values.put(Bookmarks.POSITION, Bookmarks.FIXED_AS_PIN_POSITION);
+        values.put(Bookmarks.IS_DELETED, 0);
+
+        cr.insert(mBookmarksUriWithProfile, values);
+    }
+
+    @Override
+    public void unpinSiteForAS(ContentResolver cr, String url) {
+        cr.delete(mBookmarksUriWithProfile,
+                Bookmarks.PARENT + " == ? AND " +
+                Bookmarks.POSITION + " == ? AND " +
+                Bookmarks.URL + " = ?",
+                new String[] {
+                        String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID),
+                        String.valueOf(Bookmarks.FIXED_AS_PIN_POSITION),
+                        url
+                });
+    }
+
+    @Override
+    public boolean isPinnedForAS(ContentResolver cr, String url) {
+        final Cursor c = cr.query(bookmarksUriWithLimit(1),
+                new String[] { Bookmarks._ID },
+                Bookmarks.URL + " = ? AND " + Bookmarks.PARENT + " = ? AND " + Bookmarks.POSITION + " = ?",
+                new String[] {
+                        url,
+                        String.valueOf(Bookmarks.FIXED_PINNED_LIST_ID),
+                        String.valueOf(Bookmarks.FIXED_AS_PIN_POSITION)
+                }, null);
+
+        if (c == null) {
+            throw new IllegalStateException("Null cursor in isPinnedByUrl");
+        }
+
+        try {
+            return c.getCount() > 0;
+        } finally {
+            c.close();
+        }
+    }
+
+    @Override
     @RobocopTarget
     public Cursor getBookmarkForUrl(ContentResolver cr, String url) {
         Cursor c = cr.query(bookmarksUriWithLimit(1),
                             new String[] { Bookmarks._ID,
                                            Bookmarks.URL,
                                            Bookmarks.TITLE,
                                            Bookmarks.KEYWORD },
                             Bookmarks.URL + " = ?",
@@ -1852,16 +1901,18 @@ public class LocalBrowserDB extends Brow
     }
 
     public CursorLoader getActivityStreamTopSites(Context context, int suggestedRangeLimit, int limit) {
         final Uri uri = mTopSitesUriWithProfile.buildUpon()
                 .appendQueryParameter(BrowserContract.PARAM_LIMIT,
                         String.valueOf(limit))
                 .appendQueryParameter(BrowserContract.PARAM_SUGGESTEDSITES_LIMIT,
                         String.valueOf(suggestedRangeLimit))
+                .appendQueryParameter(BrowserContract.PARAM_NON_POSITIONED_PINS,
+                        String.valueOf(true))
                 .build();
 
         return new TelemetrisedCursorLoader(context,
                 uri,
                 new String[]{ Combined._ID,
                         Combined.URL,
                         Combined.TITLE,
                         Combined.BOOKMARK_ID,
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/StreamItem.java
@@ -4,16 +4,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 package org.mozilla.gecko.home.activitystream;
 
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.content.res.Resources;
 import android.database.Cursor;
 import android.graphics.Color;
+import android.support.annotation.Nullable;
 import android.support.v4.view.ViewPager;
 import android.support.v7.widget.RecyclerView;
 import android.text.TextUtils;
 import android.text.format.DateUtils;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewStub;
 import android.widget.Button;
@@ -129,19 +130,27 @@ public abstract class StreamItem extends
             layoutParams.height = tilesHeight + tilesMargin + textHeight;
             topSitesPager.setLayoutParams(layoutParams);
         }
     }
 
     public static class HighlightItem extends StreamItem implements IconCallback {
         public static final int LAYOUT_ID = R.layout.activity_stream_card_history_item;
 
+        enum HighlightSource {
+            VISITED,
+            BOOKMARKED
+        }
+
         String title;
         String url;
 
+        @Nullable Boolean isPinned;
+        @Nullable Boolean isBookmarked;
+
         final FaviconView vIconView;
         final TextView vLabel;
         final TextView vTimeSince;
         final TextView vSourceView;
         final TextView vPageView;
         final ImageView vSourceIconView;
 
         private Future<IconResponse> ongoingIconLoad;
@@ -169,17 +178,18 @@ public abstract class StreamItem extends
             TouchTargetUtil.ensureTargetHitArea(menuButton, itemView);
 
             menuButton.setOnClickListener(new View.OnClickListener() {
                 @Override
                 public void onClick(View v) {
                     ActivityStreamContextMenu.show(v.getContext(),
                             menuButton,
                             ActivityStreamContextMenu.MenuMode.HIGHLIGHT,
-                            title, url, onUrlOpenListener, onUrlOpenInBackgroundListener,
+                            title, url, isBookmarked, isPinned,
+                            onUrlOpenListener, onUrlOpenInBackgroundListener,
                             vIconView.getWidth(), vIconView.getHeight());
                 }
             });
 
             ViewUtil.enableTouchRipple(menuButton);
         }
 
         public void bind(Cursor cursor, int tilesWidth, int tilesHeight) {
@@ -193,57 +203,92 @@ public abstract class StreamItem extends
             vLabel.setText(title);
             vTimeSince.setText(ago);
 
             ViewGroup.LayoutParams layoutParams = vIconView.getLayoutParams();
             layoutParams.width = tilesWidth - tilesMargin;
             layoutParams.height = tilesHeight;
             vIconView.setLayoutParams(layoutParams);
 
-            updateSource(cursor);
+            final HighlightSource source = highlightSource(cursor);
+
+            updateStateForSource(source, cursor);
+            updateUiForSource(source);
             updatePage(url);
 
             if (ongoingIconLoad != null) {
                 ongoingIconLoad.cancel(true);
             }
 
             ongoingIconLoad = Icons.with(itemView.getContext())
                     .pageUrl(url)
                     .skipNetwork()
                     .build()
                     .execute(this);
         }
 
-        private void updateSource(final Cursor cursor) {
-            final boolean isBookmark = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID));
-            final boolean isHistory = -1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
+        private void updateStateForSource(HighlightSource source, Cursor cursor) {
+            // We can only be certain of bookmark state if an item is a bookmark item.
+            // Otherwise, due to the underlying highlights query, we have to look up states when
+            // menus are displayed.
+            switch (source) {
+                case BOOKMARKED:
+                    isBookmarked = true;
+                    isPinned = null;
+                    break;
+                case VISITED:
+                    isBookmarked = null;
+                    isPinned = null;
+                    break;
+                default:
+                    throw new IllegalArgumentException("Unknown source: " + source);
+            }
+        }
 
-            if (isBookmark) {
-                vSourceView.setText(R.string.activity_stream_highlight_label_bookmarked);
-                vSourceView.setVisibility(View.VISIBLE);
-                vSourceIconView.setImageResource(R.drawable.ic_as_bookmarked);
-            } else if (isHistory) {
-                vSourceView.setText(R.string.activity_stream_highlight_label_visited);
-                vSourceView.setVisibility(View.VISIBLE);
-                vSourceIconView.setImageResource(R.drawable.ic_as_visited);
-            } else {
-                vSourceView.setVisibility(View.INVISIBLE);
-                vSourceIconView.setImageResource(0);
+        private void updateUiForSource(HighlightSource source) {
+            switch (source) {
+                case BOOKMARKED:
+                    vSourceView.setText(R.string.activity_stream_highlight_label_bookmarked);
+                    vSourceView.setVisibility(View.VISIBLE);
+                    vSourceIconView.setImageResource(R.drawable.ic_as_bookmarked);
+                    break;
+                case VISITED:
+                    vSourceView.setText(R.string.activity_stream_highlight_label_visited);
+                    vSourceView.setVisibility(View.VISIBLE);
+                    vSourceIconView.setImageResource(R.drawable.ic_as_visited);
+                    break;
+                default:
+                    vSourceView.setVisibility(View.INVISIBLE);
+                    vSourceIconView.setImageResource(0);
+                    break;
             }
 
-            vSourceView.setText(vSourceView.getText());
+            // TODO Why?
+            // vSourceView.setText(vSourceView.getText());
         }
 
         private void updatePage(final String url) {
             extractLabel(itemView.getContext(), url, false, new LabelCallback() {
                 @Override
                 public void onLabelExtracted(String label) {
                     vPageView.setText(TextUtils.isEmpty(label) ? url : label);
                 }
             });
         }
 
         @Override
         public void onIconResponse(IconResponse response) {
             vIconView.updateImage(response);
         }
     }
+
+    private static HighlightItem.HighlightSource highlightSource(final Cursor cursor) {
+        if (-1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID))) {
+            return HighlightItem.HighlightSource.BOOKMARKED;
+        }
+
+        if (-1 != cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID))) {
+            return HighlightItem.HighlightSource.VISITED;
+        }
+
+        throw new IllegalArgumentException("Unknown highlight source.");
+    }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/ActivityStreamContextMenu.java
@@ -3,16 +3,17 @@
  * 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.activitystream.menu;
 
 import android.content.Context;
 import android.content.Intent;
 import android.database.Cursor;
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.design.widget.NavigationView;
 import android.view.MenuItem;
 import android.view.View;
 
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.IntentHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Telemetry;
@@ -37,79 +38,118 @@ public abstract class ActivityStreamCont
         TOPSITE
     }
 
     final Context context;
 
     final String title;
     final String url;
 
+    // We might not know bookmarked/pinned states, so we allow for null values.
+    private @Nullable Boolean isBookmarked;
+    private @Nullable Boolean isPinned;
+
     final HomePager.OnUrlOpenListener onUrlOpenListener;
     final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
 
-    boolean isAlreadyBookmarked; // default false;
 
     public abstract MenuItem getItemByID(int id);
 
     public abstract void show();
 
     public abstract void dismiss();
 
     final MenuMode mode;
 
     /* package-private */ ActivityStreamContextMenu(final Context context,
                                                     final MenuMode mode,
                                                     final String title, @NonNull final String url,
+                                                    @Nullable final Boolean isBookmarked, @Nullable final Boolean isPinned,
                                                     HomePager.OnUrlOpenListener onUrlOpenListener,
                                                     HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
         this.context = context;
 
         this.mode = mode;
 
         this.title = title;
         this.url = url;
+        this.isBookmarked = isBookmarked;
+        this.isPinned = isPinned;
         this.onUrlOpenListener = onUrlOpenListener;
         this.onUrlOpenInBackgroundListener = onUrlOpenInBackgroundListener;
     }
 
     /**
      * Must be called before the menu is shown.
      * <p/>
      * Your implementation must be ready to return items from getItemByID() before postInit() is
      * called, i.e. you should probably inflate your menu items before this call.
      */
     protected void postInit() {
+        final MenuItem bookmarkItem = getItemByID(R.id.bookmark);
+        if (Boolean.TRUE.equals(this.isBookmarked)) {
+            bookmarkItem.setTitle(R.string.bookmark_remove);
+        }
+
+        final MenuItem pinItem = getItemByID(R.id.pin);
+        if (Boolean.TRUE.equals(this.isPinned)) {
+            pinItem.setTitle(R.string.contextmenu_top_sites_unpin);
+        }
+
         // Disable "dismiss" for topsites until we have decided on its behaviour for topsites
         // (currently "dismiss" adds the URL to a highlights-specific blocklist, which the topsites
         // query has no knowledge of).
         if (mode == MenuMode.TOPSITE) {
             final MenuItem dismissItem = getItemByID(R.id.dismiss);
             dismissItem.setVisible(false);
         }
 
-        // Disable the bookmark item until we know its bookmark state
-        final MenuItem bookmarkItem = getItemByID(R.id.bookmark);
-        bookmarkItem.setEnabled(false);
+        if (isBookmarked == null) {
+            // Disable the bookmark item until we know its bookmark state
+            bookmarkItem.setEnabled(false);
 
-        (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
-            @Override
-            protected Void doInBackground() {
-                isAlreadyBookmarked = BrowserDB.from(context).isBookmark(context.getContentResolver(), url);
-                return null;
-            }
-
-            @Override
-            protected void onPostExecute(Void aVoid) {
-                if (isAlreadyBookmarked) {
-                    bookmarkItem.setTitle(R.string.bookmark_remove);
+            (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+                @Override
+                protected Void doInBackground() {
+                    isBookmarked = BrowserDB.from(context).isBookmark(context.getContentResolver(), url);
+                    return null;
                 }
 
-                bookmarkItem.setEnabled(true);
-            }
-        }).execute();
+                @Override
+                protected void onPostExecute(Void aVoid) {
+                    if (isBookmarked) {
+                        bookmarkItem.setTitle(R.string.bookmark_remove);
+                    }
+
+                    bookmarkItem.setEnabled(true);
+                }
+            }).execute();
+        }
+
+        if (isPinned == null) {
+            // Disable the pin item until we know its pinned state
+            pinItem.setEnabled(false);
+
+            (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
+                @Override
+                protected Void doInBackground() {
+                    isPinned = BrowserDB.from(context).isPinnedForAS(context.getContentResolver(), url);
+                    return null;
+                }
+
+                @Override
+                protected void onPostExecute(Void aVoid) {
+                    if (isPinned) {
+                        pinItem.setTitle(R.string.contextmenu_top_sites_unpin);
+                    }
+
+                    pinItem.setEnabled(true);
+                }
+            }).execute();
+        }
 
         // Only show the "remove from history" item if a page actually has history
         final MenuItem deleteHistoryItem = getItemByID(R.id.delete);
         deleteHistoryItem.setVisible(false);
 
         (new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) {
             boolean hasHistory;
 
@@ -151,17 +191,17 @@ public abstract class ActivityStreamCont
             case R.id.bookmark:
                 ThreadUtils.postToBackgroundThread(new Runnable() {
                     @Override
                     public void run() {
                         final BrowserDB db = BrowserDB.from(context);
 
                         final TelemetryContract.Event telemetryEvent;
                         final String telemetryExtra;
-                        if (isAlreadyBookmarked) {
+                        if (isBookmarked) {
                             db.removeBookmarksWithURL(context.getContentResolver(), url);
 
                             SavedReaderViewHelper rch = SavedReaderViewHelper.getSavedReaderViewHelper(context);
                             final boolean isReaderViewPage = rch.isURLCached(url);
 
                             telemetryEvent = TelemetryContract.Event.UNSAVE;
 
                             if (isReaderViewPage) {
@@ -178,16 +218,30 @@ public abstract class ActivityStreamCont
                             telemetryExtra = "as_bookmark";
                         }
 
                         Telemetry.sendUIEvent(telemetryEvent, TelemetryContract.Method.CONTEXT_MENU, telemetryExtra);
                     }
                 });
                 break;
 
+            case R.id.pin:
+                ThreadUtils.postToBackgroundThread(new Runnable() {
+                    @Override
+                    public void run() {
+                        final BrowserDB db = BrowserDB.from(context);
+
+                        if (isPinned) {
+                            db.unpinSiteForAS(context.getContentResolver(), url);
+                        } else {
+                            db.pinSiteForAS(context.getContentResolver(), url, title);
+                        }
+                    }
+                });
+
             case R.id.copy_url:
                 Clipboard.setText(url);
                 break;
 
             case R.id.add_homescreen:
                 GeckoAppShell.createShortcut(title, url);
 
                 Telemetry.sendUIEvent(TelemetryContract.Event.ACTION, TelemetryContract.Method.CONTEXT_MENU, "as_add_to_launcher");
@@ -236,31 +290,32 @@ public abstract class ActivityStreamCont
     }
 
 
     @RobocopTarget
     public static ActivityStreamContextMenu show(Context context,
                                                       View anchor,
                                                       final MenuMode menuMode,
                                                       final String title, @NonNull final String url,
+                                                      @Nullable final Boolean isBookmarked, @Nullable final Boolean isPinned,
                                                       HomePager.OnUrlOpenListener onUrlOpenListener,
                                                       HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener,
                                                       final int tilesWidth, final int tilesHeight) {
         final ActivityStreamContextMenu menu;
 
         if (!HardwareUtils.isTablet()) {
             menu = new BottomSheetContextMenu(context,
                     menuMode,
-                    title, url,
+                    title, url, isBookmarked, isPinned,
                     onUrlOpenListener, onUrlOpenInBackgroundListener,
                     tilesWidth, tilesHeight);
         } else {
             menu = new PopupContextMenu(context,
                     anchor,
                     menuMode,
-                    title, url,
+                    title, url, isBookmarked, isPinned,
                     onUrlOpenListener, onUrlOpenInBackgroundListener);
         }
 
         menu.show();
         return menu;
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/BottomSheetContextMenu.java
@@ -1,16 +1,17 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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.home.activitystream.menu;
 
 import android.content.Context;
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.design.widget.BottomSheetBehavior;
 import android.support.design.widget.BottomSheetDialog;
 import android.support.design.widget.NavigationView;
 import android.view.LayoutInflater;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.TextView;
@@ -29,26 +30,29 @@ import static org.mozilla.gecko.activity
         extends ActivityStreamContextMenu {
 
 
     private final BottomSheetDialog bottomSheetDialog;
 
     private final NavigationView navigationView;
 
     public BottomSheetContextMenu(final Context context,
-                           final MenuMode mode,
-                           final String title, @NonNull final String url,
-                           HomePager.OnUrlOpenListener onUrlOpenListener,
-                           HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener,
-                           final int tilesWidth, final int tilesHeight) {
+                                  final MenuMode mode,
+                                  final String title, @NonNull final String url,
+                                  @Nullable final Boolean isBookmarked, @Nullable final Boolean isPinned,
+                                  HomePager.OnUrlOpenListener onUrlOpenListener,
+                                  HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener,
+                                  final int tilesWidth, final int tilesHeight) {
 
         super(context,
                 mode,
                 title,
                 url,
+                isBookmarked,
+                isPinned,
                 onUrlOpenListener,
                 onUrlOpenInBackgroundListener);
 
         final LayoutInflater inflater = LayoutInflater.from(context);
         final View content = inflater.inflate(R.layout.activity_stream_contextmenu_bottomsheet, null);
 
         bottomSheetDialog = new BottomSheetDialog(context);
         bottomSheetDialog.setContentView(content);
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/menu/PopupContextMenu.java
@@ -1,19 +1,19 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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.home.activitystream.menu;
 
 import android.content.Context;
-import android.content.Intent;
 import android.graphics.Color;
 import android.graphics.drawable.ColorDrawable;
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.design.widget.NavigationView;
 import android.view.LayoutInflater;
 import android.view.MenuItem;
 import android.view.View;
 import android.widget.PopupWindow;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.home.HomePager;
@@ -26,22 +26,26 @@ import org.mozilla.gecko.home.HomePager;
 
     private final View anchor;
 
     public PopupContextMenu(final Context context,
                             View anchor,
                             final MenuMode mode,
                             final String title,
                             @NonNull final String url,
+                            @Nullable final Boolean isBookmarked,
+                            @Nullable final Boolean isPinned,
                             HomePager.OnUrlOpenListener onUrlOpenListener,
                             HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
         super(context,
                 mode,
                 title,
                 url,
+                isBookmarked,
+                isPinned,
                 onUrlOpenListener,
                 onUrlOpenInBackgroundListener);
 
         this.anchor = anchor;
 
         final LayoutInflater inflater = LayoutInflater.from(context);
 
         View card = inflater.inflate(R.layout.activity_stream_contextmenu_popupmenu, null);
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesCard.java
@@ -1,15 +1,16 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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.home.activitystream.topsites;
 
 import android.graphics.Color;
+import android.support.annotation.Nullable;
 import android.support.v4.widget.TextViewCompat;
 import android.support.v7.widget.RecyclerView;
 import android.view.View;
 import android.widget.FrameLayout;
 import android.widget.ImageView;
 import android.widget.TextView;
 
 import org.mozilla.gecko.R;
@@ -34,16 +35,18 @@ class TopSitesCard extends RecyclerView.
         implements IconCallback, View.OnClickListener {
     private final FaviconView faviconView;
 
     private final TextView title;
     private final ImageView menuButton;
     private Future<IconResponse> ongoingIconLoad;
 
     private String url;
+    private int type;
+    @Nullable private Boolean isBookmarked;
 
     private final HomePager.OnUrlOpenListener onUrlOpenListener;
     private final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener;
 
     public TopSitesCard(FrameLayout card, final HomePager.OnUrlOpenListener onUrlOpenListener, final HomePager.OnUrlOpenInBackgroundListener onUrlOpenInBackgroundListener) {
         super(card);
 
         faviconView = (FaviconView) card.findViewById(R.id.favicon);
@@ -66,29 +69,30 @@ class TopSitesCard extends RecyclerView.
         ActivityStream.extractLabel(itemView.getContext(), topSite.url, true, new ActivityStream.LabelCallback() {
             @Override
             public void onLabelExtracted(String label) {
                 title.setText(label);
             }
         });
 
         this.url = topSite.url;
+        this.type = topSite.type;
+        this.isBookmarked = topSite.isBookmarked;
 
         if (ongoingIconLoad != null) {
             ongoingIconLoad.cancel(true);
         }
 
         ongoingIconLoad = Icons.with(itemView.getContext())
                 .pageUrl(topSite.url)
                 .skipNetwork()
                 .build()
                 .execute(this);
 
-        final int pinResourceId = (topSite.type == BrowserContract.TopSites.TYPE_PINNED ?
-                R.drawable.pin : 0);
+        final int pinResourceId = (isPinned(this.type) ? R.drawable.pin : 0);
         TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, pinResourceId, 0, 0, 0);
     }
 
     @Override
     public void onIconResponse(IconResponse response) {
         faviconView.updateImage(response);
 
         final int tintColor = !response.hasColor() || response.getColor() == Color.WHITE ? Color.LTGRAY : Color.WHITE;
@@ -103,15 +107,22 @@ class TopSitesCard extends RecyclerView.
             onUrlOpenListener.onUrlOpen(url, EnumSet.noneOf(HomePager.OnUrlOpenListener.Flags.class));
 
             Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, TelemetryContract.Method.LIST_ITEM, "as_top_sites");
         } else if (clickedView == menuButton) {
             ActivityStreamContextMenu.show(clickedView.getContext(),
                     menuButton,
                     ActivityStreamContextMenu.MenuMode.TOPSITE,
                     title.getText().toString(), url,
+
+                    isBookmarked, isPinned(type),
+
                     onUrlOpenListener, onUrlOpenInBackgroundListener,
                     faviconView.getWidth(), faviconView.getHeight());
 
             Telemetry.sendUIEvent(TelemetryContract.Event.SHOW, TelemetryContract.Method.CONTEXT_MENU, "as_top_sites");
         }
     }
+
+    private static boolean isPinned(int type) {
+        return type == BrowserContract.TopSites.TYPE_PINNED;
+    }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
+++ b/mobile/android/base/java/org/mozilla/gecko/home/activitystream/topsites/TopSitesPageAdapter.java
@@ -1,16 +1,17 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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.home.activitystream.topsites;
 
 import android.content.Context;
 import android.database.Cursor;
+import android.support.annotation.Nullable;
 import android.support.annotation.UiThread;
 import android.support.v7.widget.RecyclerView;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 
 import org.mozilla.gecko.R;
@@ -20,22 +21,24 @@ import org.mozilla.gecko.home.HomePager;
 import java.util.ArrayList;
 import java.util.List;
 
 public class TopSitesPageAdapter extends RecyclerView.Adapter<TopSitesCard> {
     static final class TopSite {
         public final long id;
         public final String url;
         public final String title;
+        @Nullable public final Boolean isBookmarked;
         public final int type;
 
-        TopSite(long id, String url, String title, int type) {
+        TopSite(long id, String url, String title, @Nullable Boolean isBookmarked, int type) {
             this.id = id;
             this.url = url;
             this.title = title;
+            this.isBookmarked = isBookmarked;
             this.type = type;
         }
     }
 
     private List<TopSite> topSites;
     private int tiles;
     private int tilesWidth;
     private int tilesHeight;
@@ -76,17 +79,22 @@ public class TopSitesPageAdapter extends
 
             // The Combined View only contains pages that have been visited at least once, i.e. any
             // page in the TopSites query will contain a HISTORY_ID. _ID however will be 0 for all rows.
             final long id = cursor.getLong(cursor.getColumnIndexOrThrow(BrowserContract.Combined.HISTORY_ID));
             final String url = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.URL));
             final String title = cursor.getString(cursor.getColumnIndexOrThrow(BrowserContract.Combined.TITLE));
             final int type = cursor.getInt(cursor.getColumnIndexOrThrow(BrowserContract.TopSites.TYPE));
 
-            topSites.add(new TopSite(id, url, title, type));
+            // We can't figure out bookmark state of a pin, so we leave it as unknown to be queried later.
+            Boolean isBookmarked = null;
+            if (type != BrowserContract.TopSites.TYPE_PINNED) {
+                isBookmarked = !cursor.isNull(cursor.getColumnIndexOrThrow(BrowserContract.Combined.BOOKMARK_ID));
+            }
+            topSites.add(new TopSite(id, url, title, isBookmarked, type));
         }
 
         notifyDataSetChanged();
     }
 
     @Override
     public void onBindViewHolder(TopSitesCard holder, int position) {
         holder.bind(topSites.get(position));
--- a/mobile/android/base/resources/menu/activitystream_contextmenu.xml
+++ b/mobile/android/base/resources/menu/activitystream_contextmenu.xml
@@ -2,16 +2,20 @@
 <menu xmlns:android="http://schemas.android.com/apk/res/android">
     <!-- Group ID's are required, otherwise NavigationView won't show any dividers. The ID's are unused, but still required. -->
     <group android:id="@+id/group0">
         <item
             android:id="@+id/bookmark"
             android:icon="@drawable/as_bookmark"
             android:title="@string/bookmark"/>
         <item
+            android:id="@+id/pin"
+            android:icon="@drawable/pin"
+            android:title="@string/contextmenu_top_sites_pin"/>
+        <item
             android:id="@+id/share"
             android:icon="@drawable/as_share"
             android:title="@string/share"/>
         <item
             android:id="@+id/copy_url"
             android:icon="@drawable/as_copy"
             android:title="@string/contextmenu_copyurl"/>
         <item
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testActivityStreamContextMenu.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testActivityStreamContextMenu.java
@@ -1,69 +1,90 @@
 /* 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.tests;
 
-import android.support.design.widget.NavigationView;
-import android.support.v4.app.Fragment;
-import android.view.KeyEvent;
 import android.view.MenuItem;
 import android.view.View;
 
 import com.robotium.solo.Condition;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.db.BrowserDB;
-import org.mozilla.gecko.home.activitystream.ActivityStream;
 import org.mozilla.gecko.home.activitystream.menu.ActivityStreamContextMenu;
 
 /**
  * This test is unfortunately closely coupled to the current implementation, however it is still
- * useful in that it tests the bookmark/history state specific menu items for correctness.
+ * useful in that it tests the bookmark/history/pinned state specific menu items for correctness.
  */
 public class testActivityStreamContextMenu extends BaseTest {
     public void testActivityStreamContextMenu() {
         blockForGeckoReady();
 
         final String testURL = "http://mozilla.org";
 
         BrowserDB db = BrowserDB.from(getActivity());
         db.removeHistoryEntry(getActivity().getContentResolver(), testURL);
         db.removeBookmarksWithURL(getActivity().getContentResolver(), testURL);
 
-        testMenuForUrl(testURL, false, false);
+        testMenuForUrl(testURL, null, false, null, false, false);
 
         db.addBookmark(getActivity().getContentResolver(), "foobar", testURL);
-        testMenuForUrl(testURL, true, false);
+        testMenuForUrl(testURL, null, true, null, false, false);
+        testMenuForUrl(testURL, null, true, Boolean.FALSE, false, false);
+        testMenuForUrl(testURL, Boolean.TRUE, true, null, false, false);
+        testMenuForUrl(testURL, Boolean.TRUE, true, Boolean.FALSE, false, false);
 
         db.updateVisitedHistory(getActivity().getContentResolver(), testURL);
-        testMenuForUrl(testURL, true, true);
+        testMenuForUrl(testURL, null, true, null, false, true);
+        testMenuForUrl(testURL, null, true, Boolean.FALSE, false, true);
+        testMenuForUrl(testURL, Boolean.TRUE, true, null, false, true);
+        testMenuForUrl(testURL, Boolean.TRUE, true, Boolean.FALSE, false, true);
 
         db.removeBookmarksWithURL(getActivity().getContentResolver(), testURL);
-        testMenuForUrl(testURL, false, true);
+        testMenuForUrl(testURL, null, false, null, false, true);
+        testMenuForUrl(testURL, Boolean.FALSE, false, null, false, true);
+
+        db.pinSiteForAS(getActivity().getContentResolver(), testURL, "test title");
+        testMenuForUrl(testURL, null, false, null, true, true);
+        testMenuForUrl(testURL, null, false, Boolean.TRUE, true, true);
+
+        db.addBookmark(getActivity().getContentResolver(), "foobar", testURL);
+        testMenuForUrl(testURL, null, true, null, true, true);
+        testMenuForUrl(testURL, Boolean.TRUE, true, Boolean.TRUE, true, true);
     }
 
     /**
      * Test that the menu shows the expected menu items for a given URL, and that these items have
      * the correct state.
      */
-    private void testMenuForUrl(final String url, final boolean isBookmarked, final boolean isVisited) {
+    private void testMenuForUrl(final String url, final Boolean isBookmarkedKnownState, final boolean isBookmarked, final Boolean isPinnedKnownState, final boolean isPinned, final boolean isVisited) {
         final View anchor = new View(getActivity());
 
-        final ActivityStreamContextMenu menu = ActivityStreamContextMenu.show(getActivity(), anchor, ActivityStreamContextMenu.MenuMode.HIGHLIGHT, "foobar", url, null, null, 100, 100);
+        final ActivityStreamContextMenu menu = ActivityStreamContextMenu.show(getActivity(), anchor, ActivityStreamContextMenu.MenuMode.HIGHLIGHT, "foobar", url, isBookmarkedKnownState, isPinnedKnownState, null, null, 100, 100);
 
         final int expectedBookmarkString;
         if (isBookmarked) {
             expectedBookmarkString = R.string.bookmark_remove;
         } else {
             expectedBookmarkString = R.string.bookmark;
         }
 
+        final int expectedPinnedString;
+        if (isPinned) {
+            expectedPinnedString = R.string.contextmenu_top_sites_unpin;
+        } else {
+            expectedPinnedString = R.string.contextmenu_top_sites_pin;
+        }
+
+        final MenuItem pinItem = menu.getItemByID(R.id.pin);
+        assertMenuItemHasString(pinItem, expectedPinnedString);
+
         final MenuItem bookmarkItem = menu.getItemByID(R.id.bookmark);
         assertMenuItemHasString(bookmarkItem, expectedBookmarkString);
 
         final MenuItem deleteItem = menu.getItemByID(R.id.delete);
         assertMenuItemIsVisible(deleteItem, isVisited);
 
         menu.dismiss();
     }