[NTP] Show Offline Badge when Article is available offline.

This is hidden behind the NTPOfflineBadge feature.

BUG=657358

Review-Url: https://codereview.chromium.org/2443183002
Cr-Commit-Position: refs/heads/master@{#427823}
diff --git a/chrome/android/java/res/layout/new_tab_page_snippets_card.xml b/chrome/android/java/res/layout/new_tab_page_snippets_card.xml
index 8938814..353974f 100644
--- a/chrome/android/java/res/layout/new_tab_page_snippets_card.xml
+++ b/chrome/android/java/res/layout/new_tab_page_snippets_card.xml
@@ -68,11 +68,11 @@
             android:id="@+id/offline_icon"
             android:layout_width="@dimen/snippets_offline_icon_size"
             android:layout_height="@dimen/snippets_offline_icon_size"
+            android:layout_marginStart="6dp"
             android:alpha="0.46"
-            android:paddingStart="6dp"
             android:src="@drawable/offline_pin_black"
             android:contentDescription="@string/accessibility_ntp_offline_badge"
-            android:visibility="invisible" />
+            android:visibility="gone" />
 
     </LinearLayout>
 
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ChromeFeatureList.java b/chrome/android/java/src/org/chromium/chrome/browser/ChromeFeatureList.java
index 2b87293..03adcd241 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ChromeFeatureList.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ChromeFeatureList.java
@@ -60,6 +60,7 @@
     public static final String NTP_FAKE_OMNIBOX_TEXT = "NTPFakeOmniboxText";
     public static final String NTP_SNIPPETS = "NTPSnippets";
     public static final String NTP_SNIPPETS_SAVE_TO_OFFLINE = "NTPSaveToOffline";
+    public static final String NTP_SNIPPETS_OFFLINE_BADGE = "NTPOfflineBadge";
     public static final String NTP_SUGGESTIONS_SECTION_DISMISSAL = "NTPSuggestionsSectionDismissal";
     public static final String SCAN_CARDS_IN_WEB_PAYMENTS = "ScanCardsInWebPayments";
     public static final String TAB_REPARENTING = "TabReparenting";
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPage.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPage.java
index bd8a4278..ac283629 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPage.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPage.java
@@ -23,6 +23,7 @@
 import org.chromium.base.Callback;
 import org.chromium.base.CommandLine;
 import org.chromium.base.Log;
+import org.chromium.base.ObserverList;
 import org.chromium.base.ThreadUtils;
 import org.chromium.base.VisibleForTesting;
 import org.chromium.base.metrics.RecordHistogram;
@@ -143,7 +144,7 @@
     // Whether destroy() has been called.
     private boolean mIsDestroyed;
 
-    private DestructionObserver mDestructionObserver;
+    private final ObserverList<DestructionObserver> mDestructionObservers = new ObserverList<>();
 
     /**
      * Allows clients to listen for updates to the scroll changes of the search box on the
@@ -623,10 +624,9 @@
         }
 
         @Override
-        public void setDestructionObserver(DestructionObserver destructionObserver) {
+        public void addDestructionObserver(DestructionObserver destructionObserver) {
             if (mIsDestroyed) return;
-            assert mDestructionObserver == null;
-            mDestructionObserver = destructionObserver;
+            mDestructionObservers.addObserver(destructionObserver);
         }
 
         @Override
@@ -933,9 +933,10 @@
         if (mMostVisitedItemRemovedController != null) {
             mTab.getSnackbarManager().dismissSnackbars(mMostVisitedItemRemovedController);
         }
-        if (mDestructionObserver != null) {
-            mDestructionObserver.onDestroy();
+        for (DestructionObserver observer : mDestructionObservers) {
+            observer.onDestroy();
         }
+        mDestructionObservers.clear();
         TemplateUrlService.getInstance().removeObserver(this);
         mTab.removeObserver(mTabObserver);
         mTabObserver = null;
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPageView.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPageView.java
index 88400a2..5a4d51df4 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPageView.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/NewTabPageView.java
@@ -57,7 +57,9 @@
 import org.chromium.chrome.browser.ntp.snippets.SnippetArticle;
 import org.chromium.chrome.browser.ntp.snippets.SnippetsConfig;
 import org.chromium.chrome.browser.ntp.snippets.SuggestionsSource;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
 import org.chromium.chrome.browser.profiles.MostVisitedSites.MostVisitedURLsObserver;
+import org.chromium.chrome.browser.profiles.Profile;
 import org.chromium.chrome.browser.util.MathUtils;
 import org.chromium.chrome.browser.util.ViewUtils;
 import org.chromium.chrome.browser.widget.RoundedIconGenerator;
@@ -299,7 +301,7 @@
         /**
          * Registers a {@link DestructionObserver}, notified when the New Tab Page goes away.
          */
-        void setDestructionObserver(DestructionObserver destructionObserver);
+        void addDestructionObserver(DestructionObserver destructionObserver);
 
         /**
          * @return whether the {@link NewTabPage} associated with this manager is the current page
@@ -389,7 +391,8 @@
 
         // Set up snippets
         if (mUseCardsUi) {
-            mNewTabPageAdapter = new NewTabPageAdapter(mManager, mNewTabPageLayout, mUiConfig);
+            mNewTabPageAdapter = new NewTabPageAdapter(mManager, mNewTabPageLayout, mUiConfig,
+                    OfflinePageBridge.getForProfile(Profile.getLastUsedProfile()));
             mRecyclerView.setAdapter(mNewTabPageAdapter);
 
             int scrollOffset;
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapter.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapter.java
index 4ba9f41..ba0890c 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapter.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapter.java
@@ -28,6 +28,7 @@
 import org.chromium.chrome.browser.ntp.snippets.SnippetsBridge;
 import org.chromium.chrome.browser.ntp.snippets.SnippetsConfig;
 import org.chromium.chrome.browser.ntp.snippets.SuggestionsSource;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -49,6 +50,7 @@
     private final View mAboveTheFoldView;
     private final UiConfig mUiConfig;
     private final ItemTouchCallbacks mItemTouchCallbacks = new ItemTouchCallbacks();
+    private final OfflinePageBridge mOfflineBridge;
     private NewTabPageRecyclerView mRecyclerView;
 
     /**
@@ -129,11 +131,16 @@
      * @param aboveTheFoldView the layout encapsulating all the above-the-fold elements
      *                         (logo, search box, most visited tiles)
      * @param uiConfig the NTP UI configuration, to be passed to created views.
+     * @param offlineBridge the OfflinePageBridge used to determine if articles are available
+     *                      offline.
+     *
      */
-    public NewTabPageAdapter(NewTabPageManager manager, View aboveTheFoldView, UiConfig uiConfig) {
+    public NewTabPageAdapter(NewTabPageManager manager, View aboveTheFoldView, UiConfig uiConfig,
+            OfflinePageBridge offlineBridge) {
         mNewTabPageManager = manager;
         mAboveTheFoldView = aboveTheFoldView;
         mUiConfig = uiConfig;
+        mOfflineBridge = offlineBridge;
         mRoot = new InnerNode(this) {
             @Override
             protected List<TreeNode> getChildren() {
@@ -161,7 +168,7 @@
 
         mSigninPromo = new SignInPromo(mRoot, this);
         DestructionObserver signInObserver = mSigninPromo.getObserver();
-        if (signInObserver != null) mNewTabPageManager.setDestructionObserver(signInObserver);
+        if (signInObserver != null) mNewTabPageManager.addDestructionObserver(signInObserver);
 
         resetSections(/*alwaysAllowEmptySections=*/false);
         mNewTabPageManager.getSuggestionsSource().setObserver(this);
@@ -221,7 +228,7 @@
         // Create the section if needed.
         SuggestionsSection section = mSections.get(category);
         if (section == null) {
-            section = new SuggestionsSection(mRoot, info);
+            section = new SuggestionsSection(mRoot, info, mNewTabPageManager, mOfflineBridge);
             mSections.put(category, section);
         }
 
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSection.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSection.java
index 433905d..d6876af7 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSection.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSection.java
@@ -4,19 +4,28 @@
 
 package org.chromium.chrome.browser.ntp.cards;
 
+import org.chromium.base.Callback;
+import org.chromium.base.Promise;
 import org.chromium.base.VisibleForTesting;
+import org.chromium.chrome.browser.ntp.NewTabPage.DestructionObserver;
+import org.chromium.chrome.browser.ntp.NewTabPageView.NewTabPageManager;
 import org.chromium.chrome.browser.ntp.snippets.CategoryInt;
 import org.chromium.chrome.browser.ntp.snippets.CategoryStatus.CategoryStatusEnum;
 import org.chromium.chrome.browser.ntp.snippets.SectionHeader;
 import org.chromium.chrome.browser.ntp.snippets.SnippetArticle;
 import org.chromium.chrome.browser.ntp.snippets.SnippetArticleViewHolder;
 import org.chromium.chrome.browser.ntp.snippets.SnippetsBridge;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge.OfflinePageModelObserver;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
- * A group of suggestions, with a header, a status card, and a progress indicator.
+ * A group of suggestions, with a header, a status card, and a progress indicator. This is
+ * responsible for tracking whether its suggestions have been saved offline.
  */
 public class SuggestionsSection extends InnerNode {
     private final List<TreeNode> mChildren = new ArrayList<>();
@@ -27,14 +36,32 @@
     private final ProgressItem mProgressIndicator = new ProgressItem();
     private final ActionItem mMoreButton;
     private final SuggestionsCategoryInfo mCategoryInfo;
+    private final OfflinePageBridge mOfflinePageBridge;
 
-    public SuggestionsSection(NodeParent parent, SuggestionsCategoryInfo info) {
+    public SuggestionsSection(NodeParent parent, SuggestionsCategoryInfo info,
+            NewTabPageManager manager, OfflinePageBridge offlineBridge) {
         super(parent);
         mHeader = new SectionHeader(info.getTitle());
         mCategoryInfo = info;
         mMoreButton = new ActionItem(info);
         mStatus = StatusItem.createNoSuggestionsItem(info);
         resetChildren();
+
+        mOfflinePageBridge = offlineBridge;
+        final OfflinePageModelObserver offlinePageObserver = new OfflinePageModelObserver() {
+            @Override
+            public void offlinePageModelChanged() {
+                markSnippetsAvailableOffline();
+            }
+        };
+
+        mOfflinePageBridge.addObserver(offlinePageObserver);
+        manager.addDestructionObserver(new DestructionObserver() {
+            @Override
+            public void onDestroy() {
+                mOfflinePageBridge.removeObserver(offlinePageObserver);
+            }
+        });
     }
 
     private class SuggestionsList extends ChildNode {
@@ -130,6 +157,8 @@
         mSuggestions.clear();
         mSuggestions.addAll(suggestions);
 
+        markSnippetsAvailableOffline();
+
         if (mMoreButton != null) {
             mMoreButton.setPosition(mSuggestions.size());
             mMoreButton.setDismissable(mSuggestions.isEmpty());
@@ -138,6 +167,28 @@
         notifySectionChanged(itemCountBefore);
     }
 
+
+    /** Checks which SnippetArticles are available offline, and updates them accordingly. */
+    private void markSnippetsAvailableOffline() {
+        final Set<String> urls = new HashSet<>();
+        Promise<Set<String>> promise = new Promise<>();
+
+        for (final SnippetArticle article : mSuggestions) {
+            urls.add(article.mUrl);
+            urls.add(article.mAmpUrl);
+
+            promise.then(new Callback<Set<String>>() {
+                @Override
+                public void onResult(Set<String> offlineUrls) {
+                    if (offlineUrls.contains(article.mUrl)) article.setAvailableOffline(true);
+                    if (offlineUrls.contains(article.mAmpUrl)) article.setAmpAvailableOffline(true);
+                }
+            });
+        }
+
+        mOfflinePageBridge.checkPagesExistOffline(urls, promise.fulfillmentCallback());
+    }
+
     /** Sets the status for the section. Some statuses can cause the suggestions to be cleared. */
     public void setStatus(@CategoryStatusEnum int status) {
         int itemCountBefore = getItemCount();
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticle.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticle.java
index 5f79c18..1b353aa 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticle.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticle.java
@@ -54,6 +54,15 @@
     /** Stores whether impression of this article has been tracked already. */
     private boolean mImpressionTracked;
 
+    /** Whether the linked article (normal URL) is available offline. */
+    private boolean mAvailableOffline;
+
+    /** Whether the linked AMP article is available offline. */
+    private boolean mAmpAvailableOffline;
+
+    /** To be run when the offline status of the article or AMP article changes. */
+    private Runnable mOfflineStatusChangeRunnable;
+
     /**
      * Creates a SnippetArticleListItem object that will hold the data.
      */
@@ -106,6 +115,44 @@
         return true;
     }
 
+    /** Sets whether the non-AMP URL is available offline. */
+    public void setAvailableOffline(boolean available) {
+        boolean previous = mAvailableOffline;
+        mAvailableOffline = available;
+
+        if (mOfflineStatusChangeRunnable != null && available != previous) {
+            mOfflineStatusChangeRunnable.run();
+        }
+    }
+
+    /** Sets whether the AMP URL is available offline. */
+    public void setAmpAvailableOffline(boolean available) {
+        boolean previous = mAmpAvailableOffline;
+        mAmpAvailableOffline = available;
+
+        if (mOfflineStatusChangeRunnable != null && available != previous) {
+            mOfflineStatusChangeRunnable.run();
+        }
+    }
+
+    /** Whether the non-AMP URL is available offline. */
+    public boolean isAvailableOffline() {
+        return mAvailableOffline;
+    }
+
+    /** Whether the AMP URL is available offline. */
+    public boolean isAmpAvailableOffline() {
+        return mAmpAvailableOffline;
+    }
+
+    /**
+     * Sets the {@link Runnable} to be run when the article's offline status changes.
+     * Pass null to wipe.
+     */
+    public void setOfflineStatusChangeRunnable(Runnable runnable) {
+        mOfflineStatusChangeRunnable = runnable;
+    }
+
     @Override
     public String toString() {
         // For debugging purposes. Displays the first 42 characters of the title.
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticleViewHolder.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticleViewHolder.java
index cf2012b..9db836b 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticleViewHolder.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetArticleViewHolder.java
@@ -60,6 +60,7 @@
     private final TextView mPublisherTextView;
     private final TextView mArticleSnippetTextView;
     private final ImageView mThumbnailView;
+    private final ImageView mOfflineBadge;
     private final View mPublisherBar;
 
     private FetchImageCallback mImageCallback;
@@ -86,6 +87,7 @@
         mPublisherTextView = (TextView) itemView.findViewById(R.id.article_publisher);
         mArticleSnippetTextView = (TextView) itemView.findViewById(R.id.article_snippet);
         mPublisherBar = itemView.findViewById(R.id.publisher_bar);
+        mOfflineBadge = (ImageView) itemView.findViewById(R.id.offline_icon);
 
         new ImpressionTracker(itemView, this);
 
@@ -176,6 +178,9 @@
     public void onBindViewHolder(SnippetArticle article) {
         super.onBindViewHolder();
 
+        // No longer listen for offline status changes to the old article.
+        if (mArticle != null) mArticle.setOfflineStatusChangeRunnable(null);
+
         mArticle = article;
         updateLayout();
 
@@ -233,6 +238,20 @@
         } catch (URISyntaxException e) {
             setDefaultFaviconOnView();
         }
+
+        mOfflineBadge.setVisibility(View.GONE);
+        if (SnippetsConfig.isOfflineBadgeEnabled()) {
+            Runnable offlineChecker = new Runnable() {
+                @Override
+                public void run() {
+                    if (mArticle.isAvailableOffline() || mArticle.isAmpAvailableOffline()) {
+                        mOfflineBadge.setVisibility(View.VISIBLE);
+                    }
+                }
+            };
+            mArticle.setOfflineStatusChangeRunnable(offlineChecker);
+            offlineChecker.run();
+        }
     }
 
     private static class FetchImageCallback extends Callback<Bitmap> {
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetsConfig.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetsConfig.java
index c2e95e5..a2749b29 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetsConfig.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/snippets/SnippetsConfig.java
@@ -23,6 +23,10 @@
                 && OfflinePageBridge.isBackgroundLoadingEnabled();
     }
 
+    public static boolean isOfflineBadgeEnabled() {
+        return ChromeFeatureList.isEnabled(ChromeFeatureList.NTP_SNIPPETS_OFFLINE_BADGE);
+    }
+
     public static boolean isSectionDismissalEnabled() {
         return ChromeFeatureList.isEnabled(ChromeFeatureList.NTP_SUGGESTIONS_SECTION_DISMISSAL);
     }
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/snippets/ArticleSnippetsTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/snippets/ArticleSnippetsTest.java
index 25dbd899..65cc0b4 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/snippets/ArticleSnippetsTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/snippets/ArticleSnippetsTest.java
@@ -29,7 +29,9 @@
 import org.chromium.chrome.browser.ntp.cards.NewTabPageAdapter;
 import org.chromium.chrome.browser.ntp.cards.NewTabPageRecyclerView;
 import org.chromium.chrome.browser.ntp.cards.SuggestionsCategoryInfo;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
 import org.chromium.chrome.browser.profiles.MostVisitedSites.MostVisitedURLsObserver;
+import org.chromium.chrome.browser.profiles.Profile;
 import org.chromium.chrome.test.ChromeActivityTestCaseBase;
 import org.chromium.chrome.test.util.RenderUtils.ViewRenderer;
 
@@ -76,7 +78,8 @@
                 View aboveTheFold = new View(getActivity());
 
                 mRecyclerView.setAboveTheFoldView(aboveTheFold);
-                mAdapter = new NewTabPageAdapter(mNtpManager, aboveTheFold, mUiConfig);
+                mAdapter = new NewTabPageAdapter(mNtpManager, aboveTheFold, mUiConfig,
+                        OfflinePageBridge.getForProfile(Profile.getLastUsedProfile()));
                 mRecyclerView.setAdapter(mAdapter);
             }
         });
@@ -346,7 +349,7 @@
         }
 
         @Override
-        public void setDestructionObserver(DestructionObserver destructionObserver) {}
+        public void addDestructionObserver(DestructionObserver destructionObserver) {}
 
         @Override
         public void closeContextMenu() {
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/ContentSuggestionsTestUtils.java b/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/ContentSuggestionsTestUtils.java
index acb95386..85206799 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/ContentSuggestionsTestUtils.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/ContentSuggestionsTestUtils.java
@@ -4,9 +4,11 @@
 
 package org.chromium.chrome.browser.ntp.cards;
 
+import org.chromium.chrome.browser.ntp.NewTabPageView.NewTabPageManager;
 import org.chromium.chrome.browser.ntp.snippets.CategoryInt;
 import org.chromium.chrome.browser.ntp.snippets.ContentSuggestionsCardLayout;
 import org.chromium.chrome.browser.ntp.snippets.SnippetArticle;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -32,9 +34,9 @@
                 category, "", ContentSuggestionsCardLayout.FULL_CARD, moreButton, showIfEmpty, "");
     }
 
-    public static SuggestionsSection createSection(
-            boolean moreButton, boolean showIfEmpty, NodeParent parent) {
+    public static SuggestionsSection createSection(boolean moreButton, boolean showIfEmpty,
+            NodeParent parent, NewTabPageManager manager, OfflinePageBridge bridge) {
         SuggestionsCategoryInfo info = createInfo(42, moreButton, showIfEmpty);
-        return new SuggestionsSection(parent, info);
+        return new SuggestionsSection(parent, info, manager, bridge);
     }
 }
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapterTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapterTest.java
index bb7d0bb..3fdba1f 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapterTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/NewTabPageAdapterTest.java
@@ -10,6 +10,7 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.reset;
@@ -19,16 +20,17 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
-import android.support.annotation.Nullable;
 import android.support.v7.widget.RecyclerView;
 import android.support.v7.widget.RecyclerView.AdapterDataObserver;
-import android.view.Menu;
 
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
 import org.robolectric.RuntimeEnvironment;
 import org.robolectric.annotation.Config;
 
@@ -36,17 +38,11 @@
 import static org.chromium.chrome.browser.ntp.cards.ContentSuggestionsTestUtils.createDummySuggestions;
 import static org.chromium.chrome.browser.ntp.cards.ContentSuggestionsTestUtils.createInfo;
 
-import org.chromium.base.Callback;
 import org.chromium.base.metrics.RecordHistogram;
 import org.chromium.base.metrics.RecordUserAction;
 import org.chromium.base.test.util.Feature;
 import org.chromium.chrome.browser.ChromeFeatureList;
 import org.chromium.chrome.browser.EnableFeatures;
-import org.chromium.chrome.browser.favicon.FaviconHelper.FaviconImageCallback;
-import org.chromium.chrome.browser.favicon.FaviconHelper.IconAvailabilityCallback;
-import org.chromium.chrome.browser.favicon.LargeIconBridge.LargeIconCallback;
-import org.chromium.chrome.browser.ntp.LogoBridge.LogoObserver;
-import org.chromium.chrome.browser.ntp.MostVisitedItem;
 import org.chromium.chrome.browser.ntp.NewTabPage.DestructionObserver;
 import org.chromium.chrome.browser.ntp.NewTabPageView.NewTabPageManager;
 import org.chromium.chrome.browser.ntp.cards.SignInPromo.SigninObserver;
@@ -56,9 +52,8 @@
 import org.chromium.chrome.browser.ntp.snippets.FakeSuggestionsSource;
 import org.chromium.chrome.browser.ntp.snippets.KnownCategories;
 import org.chromium.chrome.browser.ntp.snippets.SnippetArticle;
-import org.chromium.chrome.browser.ntp.snippets.SuggestionsSource;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
 import org.chromium.chrome.browser.preferences.ChromePreferenceManager;
-import org.chromium.chrome.browser.profiles.MostVisitedSites.MostVisitedURLsObserver;
 import org.chromium.chrome.browser.signin.SigninManager;
 import org.chromium.chrome.browser.signin.SigninManager.SignInAllowedObserver;
 import org.chromium.chrome.browser.signin.SigninManager.SignInStateObserver;
@@ -68,7 +63,6 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 
 /**
  * Unit tests for {@link NewTabPageAdapter}.
@@ -82,7 +76,8 @@
     private FakeSuggestionsSource mSource;
     private NewTabPageAdapter mAdapter;
     private SigninManager mMockSigninManager;
-    private MockNewTabPageManager mNtpManager;
+    @Mock private OfflinePageBridge mOfflinePageBridge;
+    @Mock private NewTabPageManager mNewTabPageManager;
 
     /**
      * Stores information about a section that should be present in the adapter.
@@ -225,6 +220,8 @@
 
     @Before
     public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
         // Initialise the sign in state. We will be signed in by default in the tests.
         assertFalse(ChromePreferenceManager.getInstance(RuntimeEnvironment.application)
                             .getNewTabPageSigninPromoDismissed());
@@ -241,8 +238,11 @@
         mSource = new FakeSuggestionsSource();
         mSource.setStatusForCategory(category, CategoryStatus.INITIALIZING);
         mSource.setInfoForCategory(category, createInfo(category, false, true));
-        mNtpManager = new MockNewTabPageManager(mSource);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(mSource);
+        when(mNewTabPageManager.isCurrentPage()).thenReturn(true);
+
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
     }
 
     @After
@@ -408,20 +408,20 @@
         assertItemsFor();
 
         // Same when loading a new NTP.
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor();
 
         // Same for CATEGORY_EXPLICITLY_DISABLED.
         mSource.setStatusForCategory(KnownCategories.ARTICLES, CategoryStatus.AVAILABLE);
         mSource.setSuggestionsForCategory(KnownCategories.ARTICLES, snippets);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor(section(5));
         mSource.setStatusForCategory(
                 KnownCategories.ARTICLES, CategoryStatus.CATEGORY_EXPLICITLY_DISABLED);
         assertItemsFor();
 
         // Same when loading a new NTP.
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor();
     }
 
@@ -442,7 +442,7 @@
         assertItemsFor(section(4));
 
         // But it disappears when loading a new NTP.
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor();
     }
 
@@ -463,8 +463,8 @@
         suggestionsSource.setInfoForCategory(category, createInfo(category, false, true));
 
         // 1.1 - Initial state
-        mNtpManager = new MockNewTabPageManager(suggestionsSource);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor(sectionWithStatusCard());
 
         // 1.2 - With suggestions
@@ -487,8 +487,8 @@
         suggestionsSource.setInfoForCategory(category, createInfo(category, false, false));
 
         // 2.1 - Initial state
-        mNtpManager = new MockNewTabPageManager(suggestionsSource);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor();
 
         // 2.2 - With suggestions
@@ -519,8 +519,8 @@
         suggestionsSource.setInfoForCategory(category, createInfo(category, true, true));
 
         // 1.1 - Initial state.
-        mNtpManager = new MockNewTabPageManager(suggestionsSource);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor(sectionWithStatusCardAndMoreButton());
 
         // 1.2 - With suggestions.
@@ -543,8 +543,8 @@
         suggestionsSource.setInfoForCategory(category, createInfo(category, false, true));
 
         // 2.1 - Initial state.
-        mNtpManager = new MockNewTabPageManager(suggestionsSource);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor(sectionWithStatusCard());
 
         // 2.2 - With suggestions.
@@ -602,7 +602,9 @@
         mSource.setInfoForCategory(dynamicCategory1, createInfo(dynamicCategory1, true, false));
         mSource.setStatusForCategory(dynamicCategory1, CategoryStatus.AVAILABLE);
         mSource.setSuggestionsForCategory(dynamicCategory1, dynamics1);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null); // Reload
+        // Reload
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
+
         assertItemsFor(section(3), sectionWithMoreButton(5));
 
         int dynamicCategory2 = 1011;
@@ -610,7 +612,8 @@
         mSource.setInfoForCategory(dynamicCategory2, createInfo(dynamicCategory1, false, false));
         mSource.setStatusForCategory(dynamicCategory2, CategoryStatus.AVAILABLE);
         mSource.setSuggestionsForCategory(dynamicCategory2, dynamics2);
-        mAdapter = new NewTabPageAdapter(mNtpManager, null, null); // Reload
+        // Reload
+        mAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertItemsFor(section(3), sectionWithMoreButton(5), section(11));
     }
 
@@ -627,8 +630,9 @@
         registerCategory(suggestionsSource, KnownCategories.PHYSICAL_WEB_PAGES, 0);
         registerCategory(suggestionsSource, KnownCategories.DOWNLOADS, 0);
 
-        NewTabPageAdapter ntpAdapter =
-                new NewTabPageAdapter(new MockNewTabPageManager(suggestionsSource), null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        NewTabPageAdapter ntpAdapter = new NewTabPageAdapter(
+                mNewTabPageManager, null, null, mOfflinePageBridge);
         List<TreeNode> children = ntpAdapter.getChildren();
 
         assertEquals(basicChildCount + 4, children.size());
@@ -649,8 +653,9 @@
         registerCategory(suggestionsSource, KnownCategories.DOWNLOADS, 0);
         registerCategory(suggestionsSource, KnownCategories.BOOKMARKS, 0);
 
-        ntpAdapter =
-                new NewTabPageAdapter(new MockNewTabPageManager(suggestionsSource), null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        ntpAdapter = new NewTabPageAdapter(
+                mNewTabPageManager, null, null, mOfflinePageBridge);
         children = ntpAdapter.getChildren();
 
         assertEquals(basicChildCount + 4, children.size());
@@ -670,8 +675,8 @@
         registerCategory(suggestionsSource, KnownCategories.PHYSICAL_WEB_PAGES, 0);
         registerCategory(suggestionsSource, KnownCategories.DOWNLOADS, 0);
 
-        ntpAdapter =
-                new NewTabPageAdapter(new MockNewTabPageManager(suggestionsSource), null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        ntpAdapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
 
         // The adapter is already initialised, it will not accept new categories anymore.
         registerCategory(suggestionsSource, 42, 1);
@@ -697,8 +702,9 @@
         doNothing().when(suggestionsSource).dismissSuggestion(any(SnippetArticle.class));
 
         registerCategory(suggestionsSource, KnownCategories.ARTICLES, 3);
-        NewTabPageAdapter adapter =
-                new NewTabPageAdapter(new MockNewTabPageManager(suggestionsSource), null, null);
+        when(mNewTabPageManager.getSuggestionsSource()).thenReturn(suggestionsSource);
+        NewTabPageAdapter adapter = new NewTabPageAdapter(
+                mNewTabPageManager, null, null, mOfflinePageBridge);
         AdapterDataObserver dataObserver = mock(AdapterDataObserver.class);
         adapter.registerAdapterDataObserver(dataObserver);
 
@@ -772,8 +778,13 @@
     public void testSigninPromo() {
         when(mMockSigninManager.isSignInAllowed()).thenReturn(true);
         when(mMockSigninManager.isSignedInOnNative()).thenReturn(false);
-        MockNewTabPageManager ntpManager = new MockNewTabPageManager(mSource);
-        NewTabPageAdapter adapter = new NewTabPageAdapter(ntpManager, null, null);
+        ArgumentCaptor<DestructionObserver> observers =
+                ArgumentCaptor.forClass(DestructionObserver.class);
+
+        doNothing().when(mNewTabPageManager).addDestructionObserver(observers.capture());
+
+        NewTabPageAdapter adapter =
+                new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
 
         TreeNode signinPromo = adapter.getChildren().get(2);
 
@@ -792,18 +803,33 @@
         assertEquals(1, signinPromo.getItemCount());
         assertEquals(ItemViewType.PROMO, signinPromo.getItemViewType(0));
 
-        ((SignInStateObserver) ntpManager.mDestructionObserver).onSignedIn();
+        // verify(mNewTabPageManager).addDestructionObserver(observers.capture());
+
+        // Note: As currently implemented, these two variables should point to the same object, a
+        // SignInPromo.SigninObserver
+        SignInStateObserver signInStateObserver = null;
+        SignInAllowedObserver signInAllowedObserver = null;
+        for (DestructionObserver observer : observers.getAllValues()) {
+            if (observer instanceof SignInStateObserver) {
+                signInStateObserver = (SignInStateObserver) observer;
+            }
+            if (observer instanceof SignInAllowedObserver) {
+                signInAllowedObserver = (SignInAllowedObserver) observer;
+            }
+        }
+
+        signInStateObserver.onSignedIn();
         assertEquals(0, signinPromo.getItemCount());
 
-        ((SignInStateObserver) ntpManager.mDestructionObserver).onSignedOut();
+        signInStateObserver.onSignedOut();
         assertEquals(1, signinPromo.getItemCount());
 
         when(mMockSigninManager.isSignInAllowed()).thenReturn(false);
-        ((SignInAllowedObserver) ntpManager.mDestructionObserver).onSignInAllowedChanged();
+        signInAllowedObserver.onSignInAllowedChanged();
         assertEquals(0, signinPromo.getItemCount());
 
         when(mMockSigninManager.isSignInAllowed()).thenReturn(true);
-        ((SignInAllowedObserver) ntpManager.mDestructionObserver).onSignInAllowedChanged();
+        signInAllowedObserver.onSignInAllowedChanged();
         assertEquals(1, signinPromo.getItemCount());
     }
 
@@ -814,8 +840,10 @@
         when(mMockSigninManager.isSignedInOnNative()).thenReturn(false);
         ChromePreferenceManager.getInstance(RuntimeEnvironment.application)
                 .setNewTabPageSigninPromoDismissed(false);
-        MockNewTabPageManager ntpManager = new MockNewTabPageManager(mSource);
-        NewTabPageAdapter adapter = new NewTabPageAdapter(ntpManager, null, null);
+
+        // TODO(peconn)
+        NewTabPageAdapter adapter =
+                new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         final int signInPromoIndex = 5;
 
         assertEquals(5, adapter.getChildren().size());
@@ -840,7 +868,7 @@
         assertTrue(ChromePreferenceManager.getInstance(RuntimeEnvironment.application)
                            .getNewTabPageSigninPromoDismissed());
 
-        adapter = new NewTabPageAdapter(ntpManager, null, null);
+        adapter = new NewTabPageAdapter(mNewTabPageManager, null, null, mOfflinePageBridge);
         assertEquals(5, adapter.getChildren().size());
         // The items below the signin promo move up, footer is now at the position of the promo.
         assertEquals(ItemViewType.FOOTER, adapter.getItemViewType(signInPromoIndex));
@@ -850,7 +878,17 @@
     @Feature({"Ntp"})
     @EnableFeatures(ChromeFeatureList.NTP_SUGGESTIONS_SECTION_DISMISSAL)
     public void testAllDismissedVisibility() {
-        SigninObserver signinObserver = (SigninObserver) mNtpManager.mDestructionObserver;
+        ArgumentCaptor<DestructionObserver> observers =
+                ArgumentCaptor.forClass(DestructionObserver.class);
+
+        verify(mNewTabPageManager, atLeastOnce()).addDestructionObserver(observers.capture());
+
+        SigninObserver signinObserver = null;
+        for (DestructionObserver observer : observers.getAllValues()) {
+            if (observer instanceof SigninObserver) {
+                signinObserver = (SigninObserver) observer;
+            }
+        }
 
         // By default, there is no All Dismissed item.
         // Adapter content:
@@ -960,174 +998,4 @@
     private int getCategory(TreeNode item) {
         return ((SuggestionsSection) item).getCategory();
     }
-
-    private static class MockNewTabPageManager implements NewTabPageManager {
-        SuggestionsSource mSuggestionsSource;
-        DestructionObserver mDestructionObserver;
-
-        public MockNewTabPageManager(SuggestionsSource suggestionsSource) {
-            mSuggestionsSource = suggestionsSource;
-        }
-
-        @Override
-        public void removeMostVisitedItem(MostVisitedItem item) {
-            throw new UnsupportedOperationException();
-
-        }
-
-        @Override
-        public void openMostVisitedItem(int windowDisposition, MostVisitedItem item) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public boolean isLocationBarShownInNTP() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public boolean isVoiceSearchEnabled() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public boolean isFakeOmniboxTextEnabledTablet() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public boolean isOpenInNewWindowEnabled() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public boolean isOpenInIncognitoEnabled() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void navigateToBookmarks() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void navigateToRecentTabs() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void navigateToDownloadManager() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void trackSnippetsPageImpression(int[] categories, int[] suggestionsPerCategory) {
-        }
-
-        @Override
-        public void trackSnippetImpression(SnippetArticle article) {
-        }
-
-        @Override
-        public void trackSnippetMenuOpened(SnippetArticle article) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void trackSnippetCategoryActionImpression(int category, int position) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void trackSnippetCategoryActionClick(int category, int position) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void openSnippet(int windowOpenDisposition, SnippetArticle article) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void focusSearchBox(boolean beginVoiceSearch, String pastedText) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void setMostVisitedURLsObserver(MostVisitedURLsObserver observer, int numResults) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void getLocalFaviconImageForURL(String url, int size,
-                FaviconImageCallback faviconCallback) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void getLargeIconForUrl(String url, int size, LargeIconCallback callback) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void ensureIconIsAvailable(String pageUrl, String iconUrl, boolean isLargeIcon,
-                boolean isTemporary, IconAvailabilityCallback callback) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void getUrlsAvailableOffline(Set<String> pageUrls, Callback<Set<String>> callback) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void onLogoClicked(boolean isAnimatedLogoShowing) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void getSearchProviderLogo(LogoObserver logoObserver) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void onLoadingComplete(MostVisitedItem[] mostVisitedItems) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void addContextMenuCloseCallback(Callback<Menu> callback) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void removeContextMenuCloseCallback(Callback<Menu> callback) {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void onLearnMoreClicked() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        @Nullable public SuggestionsSource getSuggestionsSource() {
-            return mSuggestionsSource;
-        }
-
-        @Override
-        public void closeContextMenu() {
-            throw new UnsupportedOperationException();
-        }
-
-        @Override
-        public void setDestructionObserver(DestructionObserver destructionObserver) {
-            mDestructionObserver  = destructionObserver;
-        }
-
-        @Override
-        public boolean isCurrentPage() {
-            return true;
-        }
-    }
 }
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSectionTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSectionTest.java
index def83db3..ac39cb5 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSectionTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/ntp/cards/SuggestionsSectionTest.java
@@ -5,9 +5,12 @@
 package org.chromium.chrome.browser.ntp.cards;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anySetOf;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -18,16 +21,26 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.annotation.Config;
 
+import org.chromium.base.Callback;
 import org.chromium.base.test.util.Feature;
+import org.chromium.chrome.browser.ntp.NewTabPageView.NewTabPageManager;
 import org.chromium.chrome.browser.ntp.snippets.CategoryStatus;
 import org.chromium.chrome.browser.ntp.snippets.SnippetArticle;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
+import org.chromium.chrome.browser.offlinepages.OfflinePageBridge.OfflinePageModelObserver;
 import org.chromium.testing.local.LocalRobolectricTestRunner;
 
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * Unit tests for {@link SuggestionsSection}.
@@ -40,8 +53,12 @@
      */
     private static final int EMPTY_SECTION_COUNT = 4;
 
-    @Mock
-    private NodeParent mParent;
+    @Mock private NodeParent mParent;
+    @Mock private OfflinePageBridge mBridge;
+    @Mock private NewTabPageManager mManager;
+
+    // This is a member so we can initialize it with the annotation and capture the generic type.
+    @Captor ArgumentCaptor<Callback<Set<String>>> mCallbacks;
 
     @Before
     public void setUp() {
@@ -54,7 +71,7 @@
         List<SnippetArticle> snippets = createDummySuggestions(3);
         SuggestionsSection section;
 
-        section = ContentSuggestionsTestUtils.createSection(true, true, mParent);
+        section = ContentSuggestionsTestUtils.createSection(true, true, mParent, mManager, mBridge);
         section.setStatus(CategoryStatus.AVAILABLE);
         assertNotNull(section.getActionItem());
 
@@ -76,7 +93,7 @@
         final int suggestionCount = 5;
         List<SnippetArticle> snippets = createDummySuggestions(suggestionCount);
 
-        SuggestionsSection section = createSection(false, true, mParent);
+        SuggestionsSection section = createSection(false, true, mParent, mManager, mBridge);
         // Note: when status is not initialised, we insert an item for the status card, but it's
         // null!
         assertEquals(EMPTY_SECTION_COUNT, section.getItemCount());
@@ -93,7 +110,7 @@
         final int suggestionCount = 5;
         List<SnippetArticle> snippets = createDummySuggestions(suggestionCount);
 
-        SuggestionsSection section = createSection(false, true, mParent);
+        SuggestionsSection section = createSection(false, true, mParent, mManager, mBridge);
 
         section.setStatus(CategoryStatus.AVAILABLE);
         verify(mParent).onItemRangeChanged(section, 1, EMPTY_SECTION_COUNT - 1);
@@ -119,7 +136,7 @@
         final int suggestionCount = 2;
         List<SnippetArticle> snippets = createDummySuggestions(suggestionCount);
 
-        SuggestionsSection section = createSection(false, true, mParent);
+        SuggestionsSection section = createSection(false, true, mParent, mManager, mBridge);
 
         section.removeSuggestion(snippets.get(0));
         verify(mParent, never())
@@ -145,7 +162,7 @@
         final int suggestionCount = 2;
         List<SnippetArticle> snippets = createDummySuggestions(suggestionCount);
 
-        SuggestionsSection section = createSection(true, true, mParent);
+        SuggestionsSection section = createSection(true, true, mParent, mManager, mBridge);
 
         section.removeSuggestion(snippets.get(0));
         verify(mParent, never())
@@ -164,4 +181,64 @@
         verify(mParent, times(2)).onItemRangeRemoved(section, 1, 1);
         verify(mParent).onItemRangeInserted(section, 1, 3);
     }
+
+    @Test
+    @Feature({"Ntp"})
+    public void testOfflineStatus() {
+        final int suggestionCount = 2;
+        List<SnippetArticle> snippets = createDummySuggestions(suggestionCount);
+
+        assertFalse(snippets.get(0).isAvailableOffline());
+        assertFalse(snippets.get(0).isAmpAvailableOffline());
+        assertFalse(snippets.get(1).isAvailableOffline());
+        assertFalse(snippets.get(1).isAmpAvailableOffline());
+
+        SuggestionsSection section = createSection(true, true, mParent, mManager, mBridge);
+        section.setSuggestions(snippets, CategoryStatus.AVAILABLE);
+        verify(mBridge).checkPagesExistOffline(anySetOf(String.class), mCallbacks.capture());
+
+        // The callback is asynchronous.
+        mCallbacks.getValue().onResult(new HashSet<>(Arrays.asList(
+                snippets.get(0).mUrl,
+                snippets.get(1).mAmpUrl)));
+
+        assertTrue(snippets.get(0).isAvailableOffline());
+        assertFalse(snippets.get(0).isAmpAvailableOffline());
+        assertFalse(snippets.get(1).isAvailableOffline());
+        assertTrue(snippets.get(1).isAmpAvailableOffline());
+    }
+
+    @Test
+    @Feature({"Ntp"})
+    public void testOfflineStatusUpdate() {
+        ArgumentCaptor<OfflinePageModelObserver> observer =
+                ArgumentCaptor.forClass(OfflinePageModelObserver.class);
+
+        final int suggestionCount = 1;
+        List<SnippetArticle> snippets = createDummySuggestions(suggestionCount);
+
+        assertFalse(snippets.get(0).isAvailableOffline());
+        assertFalse(snippets.get(0).isAmpAvailableOffline());
+
+        SuggestionsSection section = createSection(true, true, mParent, mManager, mBridge);
+        section.setSuggestions(snippets, CategoryStatus.AVAILABLE);
+
+        // The first callback, triggered when we called setSuggestions.
+        verify(mBridge).checkPagesExistOffline(anySetOf(String.class), mCallbacks.capture());
+        mCallbacks.getAllValues().get(0).onResult(Collections.<String>emptySet());
+
+        verify(mBridge).addObserver(observer.capture());
+        observer.getValue().offlinePageModelChanged();
+
+        // The second callback, triggered by the offlinePageModelChanged notification.
+        verify(mBridge, times(2))
+                .checkPagesExistOffline(anySetOf(String.class), mCallbacks.capture());
+
+        mCallbacks.getAllValues().get(1).onResult(new HashSet<>(Arrays.asList(
+                snippets.get(0).mUrl,
+                snippets.get(0).mAmpUrl)));
+
+        assertTrue(snippets.get(0).isAvailableOffline());
+        assertTrue(snippets.get(0).isAmpAvailableOffline());
+    }
 }
diff --git a/chrome/app/generated_resources.grd b/chrome/app/generated_resources.grd
index d6555dd..ed0cd72 100644
--- a/chrome/app/generated_resources.grd
+++ b/chrome/app/generated_resources.grd
@@ -15269,6 +15269,12 @@
       <message name="IDS_FLAGS_ENABLE_NTP_SAVE_TO_OFFLINE_DESCRIPTION" desc="Description for the flag to enable offline page suggestions on the New Tab page." translateable="false">
         If enabled, the Snippets context menu (see #enable-ntp-snippets) will contain the option to save linked page for offline viewing.
       </message>
+      <message name="IDS_FLAGS_ENABLE_NTP_OFFLINE_BADGE_NAME" desc="Name for the flag to enable offline badges for snippets on the New Tab page." translateable="false">
+        Show offline badge for offline available snippets on the ntp.
+      </message>
+      <message name="IDS_FLAGS_ENABLE_NTP_OFFLINE_BADGE_DESCRIPTION" desc="Description for the flag to enable offline badges for snippets on the New Tab page." translateable="false">
+        If enabled, Snippets that are available offline will have an offline badge.
+      </message>
       <message name="IDS_FLAGS_ENABLE_NTP_DOWNLOAD_SUGGESTIONS_NAME" desc="Name for the flag to enable downloads suggestions on the New Tab page." translateable="false">
         Show downloads on the New Tab page
       </message>
diff --git a/chrome/browser/about_flags.cc b/chrome/browser/about_flags.cc
index b04f658..a1c4291 100644
--- a/chrome/browser/about_flags.cc
+++ b/chrome/browser/about_flags.cc
@@ -1841,6 +1841,9 @@
     {"enable-ntp-save-to-offline", IDS_FLAGS_ENABLE_NTP_SAVE_TO_OFFLINE_NAME,
      IDS_FLAGS_ENABLE_NTP_SAVE_TO_OFFLINE_DESCRIPTION, kOsAndroid,
      FEATURE_VALUE_TYPE(ntp_snippets::kSaveToOfflineFeature)},
+    {"enable-ntp-offline-badge", IDS_FLAGS_ENABLE_NTP_OFFLINE_BADGE_NAME,
+     IDS_FLAGS_ENABLE_NTP_OFFLINE_BADGE_DESCRIPTION, kOsAndroid,
+     FEATURE_VALUE_TYPE(ntp_snippets::kOfflineBadgeFeature)},
     {"enable-ntp-recent-offline-tab-suggestions",
      IDS_FLAGS_ENABLE_NTP_RECENT_OFFLINE_TAB_SUGGESTIONS_NAME,
      IDS_FLAGS_ENABLE_NTP_RECENT_OFFLINE_TAB_SUGGESTIONS_DESCRIPTION,
diff --git a/chrome/browser/android/chrome_feature_list.cc b/chrome/browser/android/chrome_feature_list.cc
index 3359f17..07e5ef85 100644
--- a/chrome/browser/android/chrome_feature_list.cc
+++ b/chrome/browser/android/chrome_feature_list.cc
@@ -53,6 +53,7 @@
     &kTabReparenting,
     &kWebApks,
     &ntp_snippets::kContentSuggestionsFeature,
+    &ntp_snippets::kOfflineBadgeFeature,
     &ntp_snippets::kSaveToOfflineFeature,
     &ntp_snippets::kSectionDismissalFeature,
     &offline_pages::kBackgroundLoaderForDownloadsFeature,
diff --git a/components/ntp_snippets/features.cc b/components/ntp_snippets/features.cc
index b499f0e..f440c23 100644
--- a/components/ntp_snippets/features.cc
+++ b/components/ntp_snippets/features.cc
@@ -21,6 +21,9 @@
 const base::Feature kSaveToOfflineFeature{
     "NTPSaveToOffline", base::FEATURE_DISABLED_BY_DEFAULT};
 
+const base::Feature kOfflineBadgeFeature{
+    "NTPOfflineBadge", base::FEATURE_DISABLED_BY_DEFAULT};
+
 const base::Feature kDownloadSuggestionsFeature{
     "NTPDownloadSuggestions", base::FEATURE_DISABLED_BY_DEFAULT};
 
diff --git a/components/ntp_snippets/features.h b/components/ntp_snippets/features.h
index 1449d78..bf83e17 100644
--- a/components/ntp_snippets/features.h
+++ b/components/ntp_snippets/features.h
@@ -23,6 +23,9 @@
 // context menu.
 extern const base::Feature kSaveToOfflineFeature;
 
+// Feature to allow offline badges to appear on snippets.
+extern const base::Feature kOfflineBadgeFeature;
+
 // Feature to allow dismissing sections.
 extern const base::Feature kSectionDismissalFeature;
 
diff --git a/tools/metrics/histograms/histograms.xml b/tools/metrics/histograms/histograms.xml
index e97c34d..9e8ac86 100644
--- a/tools/metrics/histograms/histograms.xml
+++ b/tools/metrics/histograms/histograms.xml
@@ -90071,6 +90071,7 @@
   <int value="346711293" label="enable-save-password-bubble"/>
   <int value="348854923" label="v8-cache-strategies-for-cache-storage"/>
   <int value="358399482" label="enable-high-dpi-fixed-position-compositing"/>
+  <int value="360391863" label="NTPOfflineBadge:enabled"/>
   <int value="360599302" label="enable-gpu-rasterization"/>
   <int value="365467768" label="prefetch-search-results"/>
   <int value="368854020" label="ash-screen-rotation-animation"/>
@@ -90196,6 +90197,7 @@
   <int value="943319566" label="enable-intent-picker"/>
   <int value="952558794" label="enable-remote-assistance"/>
   <int value="980396200" label="enable-new-korean-ime"/>
+  <int value="982032277" label="NTPOfflineBadge:disabled"/>
   <int value="983311394" label="tab-management-experiment-type"/>
   <int value="1000706989" label="AutomaticTabDiscarding:disabled"/>
   <int value="1002585107" label="emphasize-titles-in-omnibox-dropdown"/>