chromium/chrome/android/feed/core/java/src/org/chromium/chrome/browser/feed/FeedSurfaceMediator.java

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.feed;

import static org.chromium.components.browser_ui.widget.BrowserUiListMenuUtils.buildMenuListItem;

import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.os.Handler;
import android.view.View;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.LayoutManager;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.Callback;
import org.chromium.base.MemoryPressureListener;
import org.chromium.base.ObserverList;
import org.chromium.base.memory.MemoryPressureCallback;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.app.feed.feedmanagement.FeedManagementActivity;
import org.chromium.chrome.browser.feed.Stream.ContentChangedListener;
import org.chromium.chrome.browser.feed.sections.OnSectionHeaderSelectedListener;
import org.chromium.chrome.browser.feed.sections.SectionHeaderListProperties;
import org.chromium.chrome.browser.feed.sections.SectionHeaderProperties;
import org.chromium.chrome.browser.feed.sections.ViewVisibility;
import org.chromium.chrome.browser.feed.sort_ui.FeedOptionsCoordinator;
import org.chromium.chrome.browser.feed.sort_ui.FeedOptionsCoordinator.OptionChangedListener;
import org.chromium.chrome.browser.feed.v2.ContentOrder;
import org.chromium.chrome.browser.feed.v2.FeedUserActionType;
import org.chromium.chrome.browser.feed.webfeed.WebFeedBridge;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.new_tab_url.DseNewTabUrlManager;
import org.chromium.chrome.browser.ntp.NewTabPageLaunchOrigin;
import org.chromium.chrome.browser.ntp.cards.SignInPromo;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.preferences.PrefChangeRegistrar;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.search_engines.TemplateUrlServiceFactory;
import org.chromium.chrome.browser.signin.SigninAndHistorySyncActivityLauncherImpl;
import org.chromium.chrome.browser.signin.SyncConsentActivityLauncherImpl;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.signin.services.SigninManager;
import org.chromium.chrome.browser.suggestions.SuggestionsMetrics;
import org.chromium.chrome.browser.ui.native_page.TouchEnabledDelegate;
import org.chromium.chrome.browser.ui.signin.PersonalizedSigninPromoView;
import org.chromium.chrome.browser.ui.signin.SyncPromoController;
import org.chromium.chrome.browser.ui.signin.account_picker.AccountPickerBottomSheetStrings;
import org.chromium.chrome.browser.xsurface.ListLayoutHelper;
import org.chromium.chrome.browser.xsurface.feed.StreamType;
import org.chromium.components.browser_ui.widget.displaystyle.DisplayStyleObserver;
import org.chromium.components.browser_ui.widget.displaystyle.HorizontalDisplayStyle;
import org.chromium.components.browser_ui.widget.displaystyle.UiConfig;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.search_engines.TemplateUrlService;
import org.chromium.components.search_engines.TemplateUrlService.TemplateUrlServiceObserver;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.signin.identitymanager.PrimaryAccountChangeEvent;
import org.chromium.components.signin.metrics.SigninAccessPoint;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.listmenu.ListMenu;
import org.chromium.ui.listmenu.ListMenuItemProperties;
import org.chromium.ui.modelutil.MVCListAdapter;
import org.chromium.ui.modelutil.MVCListAdapter.ModelList;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyListModel;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.mojom.WindowOpenDisposition;

import java.util.HashMap;
import java.util.List;
import java.util.Locale;

/**
 * A mediator for the {@link FeedSurfaceCoordinator} responsible for interacting with the
 * native library and handling business logic.
 */
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public class FeedSurfaceMediator
        implements FeedSurfaceScrollDelegate,
                TouchEnabledDelegate,
                TemplateUrlServiceObserver,
                ListMenu.Delegate,
                IdentityManager.Observer,
                OptionChangedListener {

    // Position of the in-feed header for the for-you and supervised-user feed.
    private static final int PRIMARY_FEED_HEADER_POSITION = 0;

    private class FeedSurfaceHeaderSelectedCallback implements OnSectionHeaderSelectedListener {
        @Override
        public void onSectionHeaderSelected(int index) {
            switchToStream(index);
        }

        @Override
        public void onSectionHeaderUnselected(int index) {
            PropertyListModel<PropertyModel, PropertyKey> headerList =
                    mSectionHeaderModel.get(SectionHeaderListProperties.SECTION_HEADERS_KEY);
            PropertyModel headerModel = headerList.get(index);
            if (mTabToStreamMap.get(index).supportsOptions()) {
                headerModel.set(
                        SectionHeaderProperties.OPTIONS_INDICATOR_VISIBILITY_KEY,
                        ViewVisibility.INVISIBLE);
                headerModel.set(SectionHeaderProperties.OPTIONS_INDICATOR_IS_OPEN_KEY, false);
            }
            mOptionsCoordinator.ensureGone();
        }

        @Override
        public void onSectionHeaderReselected(int index) {
            Stream stream = mTabToStreamMap.get(index);
            if (!stream.supportsOptions()) return;

            PropertyListModel<PropertyModel, PropertyKey> headerList =
                    mSectionHeaderModel.get(SectionHeaderListProperties.SECTION_HEADERS_KEY);
            PropertyModel headerModel = headerList.get(index);
            headerModel.set(
                    SectionHeaderProperties.OPTIONS_INDICATOR_IS_OPEN_KEY,
                    !headerModel.get(SectionHeaderProperties.OPTIONS_INDICATOR_IS_OPEN_KEY));
            // Reselected toggles the visibility of the options view.
            mOptionsCoordinator.toggleVisibility();
        }
    }

    /**
     * The {@link SignInPromo} for the Feed.
     * TODO(huayinz): Update content and visibility through a ModelChangeProcessor.
     */
    private class FeedSignInPromo extends SignInPromo {
        FeedSignInPromo(SigninManager signinManager, SyncPromoController syncPromoController) {
            super(signinManager, syncPromoController);
            maybeUpdateSignInPromo();
        }

        @Override
        protected void setVisibilityInternal(boolean visible) {
            if (isVisible() == visible) return;

            super.setVisibilityInternal(visible);
            mCoordinator.updateHeaderViews(visible);
            maybeUpdateSignInPromo();
        }

        @Override
        protected void notifyDataChanged() {
            maybeUpdateSignInPromo();
        }

        /** Update the content displayed in {@link PersonalizedSigninPromoView}. */
        private void maybeUpdateSignInPromo() {
            // Only call #setupPromoViewFromCache() if SignInPromo is visible to avoid potentially
            // blocking the UI thread for several seconds if the accounts cache is not populated
            // yet.
            if (isVisible()) {
                mSyncPromoController.setUpSyncPromoView(
                        mProfileDataCache,
                        mCoordinator
                                .getSigninPromoView()
                                .findViewById(R.id.signin_promo_view_container),
                        this::onDismissPromo);
            }
        }

        @Override
        public void onDismissPromo() {
            super.onDismissPromo();
            mCoordinator.updateHeaderViews(false);
        }
    }

    /** Internal implementation of Stream.StreamsMediator. */
    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    public class StreamsMediatorImpl implements Stream.StreamsMediator {
        @Override
        public void switchToStreamKind(@StreamKind int streamKind) {
            int headerIndex = getTabIdForSection(streamKind);
            assert headerIndex != -1 : "Invalid header index for streamKind=" + streamKind;
            if (headerIndex != -1) {
                FeedSurfaceMediator.this.switchToStream(headerIndex);
            }
        }

        @Override
        public void refreshStream() {
            mCoordinator.nonSwipeRefresh();
        }
    }

    public static void setPrefForTest(
            PrefChangeRegistrar prefChangeRegistrar, PrefService prefService) {
        sTestPrefChangeRegistar = prefChangeRegistrar;
        sPrefServiceForTest = prefService;
    }

    private static PrefChangeRegistrar sTestPrefChangeRegistar;
    private static PrefService sPrefServiceForTest;
    private static final int SPAN_COUNT_SMALL_WIDTH = 1;
    private static final int SPAN_COUNT_LARGE_WIDTH = 2;
    private static final int SMALL_WIDTH_DP = 600;

    private final FeedSurfaceCoordinator mCoordinator;
    private final Context mContext;
    private final @Nullable SnapScrollHelper mSnapScrollHelper;
    private final Profile mProfile;
    private final PrefChangeRegistrar mPrefChangeRegistrar;
    private final SigninManager mSigninManager;
    private final TemplateUrlService mTemplateUrlService;
    private final PropertyModel mSectionHeaderModel;
    private final FeedActionDelegate mActionDelegate;
    private final FeedOptionsCoordinator mOptionsCoordinator;

    // It is non-null for NTP on tablets.
    private @Nullable final UiConfig mUiConfig;
    private final DisplayStyleObserver mDisplayStyleObserver = this::onDisplayStyleChanged;

    private @Nullable RecyclerView.OnScrollListener mStreamScrollListener;
    private final ObserverList<ScrollListener> mScrollListeners = new ObserverList<>();
    private HasContentListener mHasContentListener;
    private ContentChangedListener mStreamContentChangedListener;
    private MemoryPressureCallback mMemoryPressureCallback;
    private @Nullable SignInPromo mSignInPromo;
    private RecyclerViewAnimationFinishDetector mRecyclerViewAnimationFinishDetector =
            new RecyclerViewAnimationFinishDetector();

    private boolean mFeedEnabled;
    private boolean mTouchEnabled = true;
    private boolean mStreamContentChanged;
    private int mThumbnailWidth;
    private int mThumbnailHeight;
    private int mThumbnailScrollY;
    private int mRestoreTabId;
    private int mHeaderCount;

    /** The model representing feed-related cog menu items. */
    private ModelList mFeedMenuModel;

    /** Whether the Feed content is loading. */
    private boolean mIsLoadingFeed;

    private FeedScrollState mRestoreScrollState;

    private final HashMap<Integer, Stream> mTabToStreamMap = new HashMap<>();
    private Stream mCurrentStream;
    // Whether we're currently adding the streams. If this is true, streams should not be bound yet.
    // This avoids automatically binding the first stream when it's added.
    private boolean mSettingUpStreams;
    private boolean mIsNewTabSearchEngineUrlAndroidEnabled;
    private boolean mIsPropertiesInitializedForStream;

    /**
     * @param coordinator The {@link FeedSurfaceCoordinator} that interacts with this class.
     * @param context The current context.
     * @param snapScrollHelper The {@link SnapScrollHelper} that handles snap scrolling.
     * @param headerModel The {@link PropertyModel} that contains this mediator should work with.
     * @param openingTabId The {@link FeedSurfaceCoordinator.StreamTabId} the feed should open to.
     * @param optionsCoordinator The {@link FeedOptionsCoordinator} for the feed.
     * @param uiConfig The {@link UiConfig} for screen display.
     * @param profile The {@link Profile} for the current user.
     */
    FeedSurfaceMediator(
            FeedSurfaceCoordinator coordinator,
            Context context,
            @Nullable SnapScrollHelper snapScrollHelper,
            PropertyModel headerModel,
            @FeedSurfaceCoordinator.StreamTabId int openingTabId,
            FeedActionDelegate actionDelegate,
            FeedOptionsCoordinator optionsCoordinator,
            @Nullable UiConfig uiConfig,
            Profile profile) {
        mCoordinator = coordinator;
        mHasContentListener = coordinator;
        mContext = context;
        mSnapScrollHelper = snapScrollHelper;
        mProfile = profile;
        mSigninManager = IdentityServicesProvider.get().getSigninManager(mProfile);
        mTemplateUrlService = TemplateUrlServiceFactory.getForProfile(mProfile);
        mActionDelegate = actionDelegate;
        mOptionsCoordinator = optionsCoordinator;
        mOptionsCoordinator.setOptionsListener(this);
        mIsNewTabSearchEngineUrlAndroidEnabled =
                DseNewTabUrlManager.isNewTabSearchEngineUrlAndroidEnabled();
        mUiConfig = uiConfig;

        /**
         * When feature flag isNewTabSearchEngineUrlAndroidEnabled is enabled, the Feeds may be
         * hidden without showing its header. Therefore, FeedSurfaceMediator needs to observe
         * whether the DSE is changed and update Pref.ENABLE_SNIPPETS_BY_DSE even when Feeds isn't
         * visible.
         */
        mTemplateUrlService.addObserver(this);
        // It is possible that the default search engine has been changed before any NTP or
        // Start is showing, update the value of Pref.ENABLE_SNIPPETS_BY_DSE here. The
        // value should be updated before adding an observer to prevent an extra call of
        // updateContent().
        getPrefService()
                .setBoolean(
                        Pref.ENABLE_SNIPPETS_BY_DSE,
                        !mIsNewTabSearchEngineUrlAndroidEnabled
                                || mTemplateUrlService.isDefaultSearchEngineGoogle());

        if (sTestPrefChangeRegistar != null) {
            mPrefChangeRegistrar = sTestPrefChangeRegistar;
        } else {
            mPrefChangeRegistrar = new PrefChangeRegistrar();
        }
        mPrefChangeRegistrar.addObserver(Pref.ENABLE_SNIPPETS, this::updateContent);
        mPrefChangeRegistrar.addObserver(Pref.ENABLE_SNIPPETS_BY_DSE, this::updateContent);

        if (openingTabId == FeedSurfaceCoordinator.StreamTabId.DEFAULT) {
            mRestoreTabId = FeedFeatures.getFeedTabIdToRestore(mProfile);
        } else {
            mRestoreTabId = openingTabId;
        }

        mSectionHeaderModel = headerModel;
        // This works around the bug that the out-of-screen toolbar is not brought back together
        // with the new tab page view when it slides down. This is because the RecyclerView
        // animation may not finish when content changed event is triggered and thus the new tab
        // page layout view may still be partially off screen.
        mStreamContentChangedListener =
                contents ->
                        mRecyclerViewAnimationFinishDetector.runWhenAnimationComplete(
                                this::onContentsChanged);

        if (mUiConfig != null) {
            mUiConfig.addObserver(mDisplayStyleObserver);
            onDisplayStyleChanged(mUiConfig.getCurrentDisplayStyle());
        }

        initialize();
    }

    @Override
    public void onOptionChanged() {
        updateLayout(false);
    }

    private void updateLayout(boolean isSmallLayoutWidth) {
        ListLayoutHelper listLayoutHelper =
                mCoordinator.getHybridListRenderer().getListLayoutHelper();
        if (!DeviceFormFactor.isNonMultiDisplayContextOnTablet(mContext)
                || listLayoutHelper == null
                || mCurrentStream == null) {
            return;
        }
        int spanCount =
                shouldUseSingleSpan(isSmallLayoutWidth)
                        ? SPAN_COUNT_SMALL_WIDTH
                        : SPAN_COUNT_LARGE_WIDTH;
        boolean res = listLayoutHelper.setColumnCount(spanCount);
        assert res : "Failed to set column count on Feed";
    }

    private boolean shouldUseSingleSpan(boolean isSmallLayoutWidth) {
        boolean supportsOptions = mCurrentStream.supportsOptions();
        boolean isFollowingFeedSortDisabled =
                (!ChromeFeatureList.isEnabled(ChromeFeatureList.WEB_FEED_SORT)
                        && mCurrentStream.getStreamKind() == StreamKind.FOLLOWING);
        boolean isFeedSortedBySite = false;
        if (supportsOptions) {
            @ContentOrder int selectedOption = mOptionsCoordinator.getSelectedOptionId();
            // Use single span count when showing following feed sorted by site.
            isFeedSortedBySite = (ContentOrder.GROUPED == selectedOption);
        }
        return isFollowingFeedSortDisabled || isSmallLayoutWidth || isFeedSortedBySite;
    }

    private void switchToStream(int headerIndex) {
        PropertyListModel<PropertyModel, PropertyKey> headerList =
                mSectionHeaderModel.get(SectionHeaderListProperties.SECTION_HEADERS_KEY);
        mSectionHeaderModel.set(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY, headerIndex);

        // Proactively disable the unread content. Waiting for observers is too slow.
        headerList.get(headerIndex).set(SectionHeaderProperties.UNREAD_CONTENT_KEY, false);

        FeedFeatures.setLastSeenFeedTabId(mProfile, headerIndex);

        Stream newStream = mTabToStreamMap.get(headerIndex);
        if (newStream.supportsOptions()) {
            headerList
                    .get(headerIndex)
                    .set(
                            SectionHeaderProperties.OPTIONS_INDICATOR_VISIBILITY_KEY,
                            ViewVisibility.VISIBLE);
        }
        if (!mSettingUpStreams) {
            logSwitchedFeeds(newStream);
            bindStream(newStream);
            if (newStream.getStreamKind() == StreamKind.FOLLOWING) {
                FeedFeatures.updateFollowingFeedSeen(mProfile);
            }
        }
    }

    /** Clears any dependencies. */
    void destroy() {
        destroyPropertiesForStream();
        mPrefChangeRegistrar.destroy();
        mTemplateUrlService.removeObserver(this);
        if (mUiConfig != null) {
            mUiConfig.removeObserver(mDisplayStyleObserver);
        }
    }

    public void destroyForTesting() {
        destroy();
    }

    private void initialize() {
        if (mSnapScrollHelper == null) return;

        // Listen for layout changes on the NewTabPageView itself to catch changes in scroll
        // position that are due to layout changes after e.g. device rotation. This contrasts with
        // regular scrolling, which is observed through an OnScrollListener.
        mCoordinator
                .getView()
                .addOnLayoutChangeListener(
                        (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
                            mCoordinator.getView().postOnAnimation(mSnapScrollHelper::handleScroll);
                            float pixelToDp = mContext.getResources().getDisplayMetrics().density;
                            int widthDp = (int) ((right - left) / pixelToDp);
                            updateLayout(widthDp < SMALL_WIDTH_DP);
                        });
    }

    /** Update the content based on supervised user or enterprise policy. */
    void updateContent() {
        // See https://crbug.com/1498004.
        if (ApplicationStatus.isEveryActivityDestroyed()) return;

        mFeedEnabled = FeedFeatures.isFeedEnabled(mProfile);
        if (mFeedEnabled && !mTabToStreamMap.isEmpty()) {
            return;
        }

        RecyclerView recyclerView = mCoordinator.getRecyclerView();
        if (mSnapScrollHelper != null && recyclerView != null) {
            mSnapScrollHelper.setView(recyclerView);
        }

        if (mFeedEnabled) {
            mIsLoadingFeed = true;
            mCoordinator.setupHeaders(/* feedEnabled= */ true);

            // Only set up stream if recycler view initiation did not fail.
            if (recyclerView != null) {
                initializePropertiesForStream();
            }
        } else {
            mCoordinator.setupHeaders(/* feedEnabled= */ false);
            destroyPropertiesForStream();
        }
    }

    /** Gets the current state, for restoring later. */
    String getSavedInstanceString() {
        FeedScrollState state = new FeedScrollState();
        int tabId = mSectionHeaderModel.get(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY);
        state.tabId = tabId;
        LayoutManager layoutManager = null;
        if (mCoordinator.getRecyclerView() != null) {
            layoutManager = mCoordinator.getRecyclerView().getLayoutManager();
        }
        if (layoutManager != null) {
            ListLayoutHelper layoutHelper =
                    mCoordinator.getHybridListRenderer().getListLayoutHelper();
            state.position = layoutHelper.findFirstVisibleItemPosition();
            state.lastPosition = layoutHelper.findLastVisibleItemPosition();
            if (state.position != RecyclerView.NO_POSITION) {
                View firstVisibleView = layoutManager.findViewByPosition(state.position);
                if (firstVisibleView != null) {
                    state.offset = firstVisibleView.getTop();
                }
            }
            if (mCurrentStream != null) {
                state.feedContentState = mCurrentStream.getContentState();
            }
        }
        return state.toJson();
    }

    /** Restores a previously saved state. */
    void restoreSavedInstanceState(String json) {
        FeedScrollState state = FeedScrollState.fromJson(json);
        if (state == null) return;
        mRestoreTabId = state.tabId;
        if (mSectionHeaderModel != null) {
            mSectionHeaderModel.set(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY, state.tabId);
        }
        if (mCurrentStream == null) {
            mRestoreScrollState = state;
        } else {
            mCurrentStream.restoreSavedInstanceState(state);
        }
    }

    /**
     * Sets the current tab to {@code tabId}.
     *
     * <p>Called when the the mediator is already initialized in Start Surface, but the feed is
     * being shown again with a different {@link NewTabPageLaunchOrigin}.
     */
    void setTabId(@FeedSurfaceCoordinator.StreamTabId int tabId) {
        if (tabId == FeedSurfaceCoordinator.StreamTabId.DEFAULT) {
            tabId = FeedFeatures.getFeedTabIdToRestore(mProfile);
        }
        if (mTabToStreamMap.size() <= tabId) tabId = 0;
        mSectionHeaderModel.set(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY, tabId);
    }

    /**
     * Initialize properties for UI components in the {@link NewTabPage}.
     * TODO(huayinz): Introduce a Model for these properties.
     */
    private void initializePropertiesForStream() {
        assert !mSettingUpStreams;
        mSettingUpStreams = true;
        mSectionHeaderModel.set(
                SectionHeaderListProperties.ON_TAB_SELECTED_CALLBACK_KEY,
                new FeedSurfaceHeaderSelectedCallback());

        mPrefChangeRegistrar.addObserver(Pref.ARTICLES_LIST_VISIBLE, this::updateSectionHeader);

        boolean suggestionsVisible = isSuggestionsVisible();

        @StreamKind
        int streamKind =
                mCoordinator.shouldDisplaySupervisedFeed()
                        ? StreamKind.SUPERVISED_USER
                        : StreamKind.FOR_YOU;

        addHeaderAndStream(
                getInterestFeedHeaderText(suggestionsVisible, streamKind),
                mCoordinator.createFeedStream(streamKind, new StreamsMediatorImpl()));
        setHeaderIndicatorState(suggestionsVisible);

        // Build menu after section enabled key is set.
        mFeedMenuModel = buildMenuItems();

        mCoordinator.initializeBubbleTriggering();
        mSigninManager.getIdentityManager().addObserver(this);

        mSectionHeaderModel.set(SectionHeaderListProperties.MENU_MODEL_LIST_KEY, mFeedMenuModel);
        mSectionHeaderModel.set(
                SectionHeaderListProperties.MENU_DELEGATE_KEY, this::onItemSelected);

        setUpWebFeedTab();

        // Set the current tab index to what restoreSavedInstanceState had.
        if (mTabToStreamMap.size() <= mRestoreTabId) mRestoreTabId = 0;
        mSectionHeaderModel.set(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY, mRestoreTabId);
        mSettingUpStreams = false;

        if (mSectionHeaderModel.get(SectionHeaderListProperties.IS_SECTION_ENABLED_KEY)) {
            bindStream(
                    mTabToStreamMap.get(
                            mSectionHeaderModel.get(
                                    SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY)));
        } else {
            unbindStream();
        }

        mStreamScrollListener =
                new RecyclerView.OnScrollListener() {
                    @Override
                    public void onScrollStateChanged(
                            @NonNull RecyclerView recyclerView, int newState) {
                        for (ScrollListener listener : mScrollListeners) {
                            listener.onScrollStateChanged(newState);
                        }
                    }

                    @Override
                    public void onScrolled(RecyclerView v, int dx, int dy) {
                        mCoordinator
                                .getView()
                                .postOnAnimation(
                                        () -> {
                                            if (mSnapScrollHelper != null) {
                                                mSnapScrollHelper.handleScroll();
                                            }
                                            for (ScrollListener listener : mScrollListeners) {
                                                listener.onScrolled(dx, dy);
                                            }
                                        });
                    }
                };
        mCoordinator.getRecyclerView().addOnScrollListener(mStreamScrollListener);

        initStreamHeaderViews();

        mMemoryPressureCallback =
                pressure -> mCoordinator.getRecyclerView().getRecycledViewPool().clear();
        MemoryPressureListener.addCallback(mMemoryPressureCallback);

        mIsPropertiesInitializedForStream = true;
    }

    void addScrollListener(ScrollListener listener) {
        mScrollListeners.addObserver(listener);
    }

    void removeScrollListener(ScrollListener listener) {
        mScrollListeners.removeObserver(listener);
    }

    private void addHeaderAndStream(String headerText, Stream stream) {
        int tabId = mSectionHeaderModel.get(SectionHeaderListProperties.SECTION_HEADERS_KEY).size();
        mTabToStreamMap.put(tabId, stream);

        PropertyModel headerModel = SectionHeaderProperties.createSectionHeader(headerText);
        ViewVisibility indicatorVisibility;
        // Keeping the indicator in place for the "Following" header, so it allows a fixed width of
        // the "Following" header.
        if (stream.supportsOptions() || stream.getStreamKind() == StreamKind.FOLLOWING) {
            indicatorVisibility = ViewVisibility.INVISIBLE;
        } else {
            indicatorVisibility = ViewVisibility.GONE;
        }
        headerModel.set(
                SectionHeaderProperties.OPTIONS_INDICATOR_VISIBILITY_KEY, indicatorVisibility);
        headerModel.set(SectionHeaderProperties.OPTIONS_INDICATOR_IS_OPEN_KEY, false);
        mSectionHeaderModel.get(SectionHeaderListProperties.SECTION_HEADERS_KEY).add(headerModel);

        // Update UNREAD_CONTENT_KEY and HEADER_ACCESSIBILITY_TEXT_KEY now, and any time
        // hasUnreadContent() changes.
        Callback<Boolean> callback =
                hasUnreadContent -> {
                    headerModel.set(SectionHeaderProperties.UNREAD_CONTENT_KEY, hasUnreadContent);
                    mHasContentListener.hasContentChanged(stream.getStreamKind(), hasUnreadContent);
                };
        callback.onResult(stream.hasUnreadContent().addObserver(callback));
    }

    private int getTabIdForSection(@StreamKind int streamKind) {
        for (int tabId : mTabToStreamMap.keySet()) {
            if (mTabToStreamMap.get(tabId).getStreamKind() == streamKind) {
                return tabId;
            }
        }
        return -1;
    }

    /** Adds WebFeed tab if we need it. */
    private void setUpWebFeedTab() {
        // Skip if the for-you tab hasn't been added yet.
        if (getTabIdForSection(StreamKind.FOR_YOU) == -1) {
            return;
        }
        int tabId = getTabIdForSection(StreamKind.FOLLOWING);
        boolean hasWebFeedTab = tabId != -1;
        boolean shouldHaveWebFeedTab = FeedFeatures.isWebFeedUIEnabled(mProfile);
        if (hasWebFeedTab == shouldHaveWebFeedTab) return;
        if (shouldHaveWebFeedTab) {
            addHeaderAndStream(
                    mContext.getResources().getString(R.string.ntp_following),
                    mCoordinator.createFeedStream(StreamKind.FOLLOWING, new StreamsMediatorImpl()));
            if (FeedFeatures.shouldUseNewIndicator(mProfile)) {
                PropertyModel followingHeaderModel =
                        mSectionHeaderModel
                                .get(SectionHeaderListProperties.SECTION_HEADERS_KEY)
                                .get(getTabIdForSection(StreamKind.FOLLOWING));
                followingHeaderModel.set(
                        SectionHeaderProperties.BADGE_TEXT_KEY,
                        mContext.getResources().getString(R.string.ntp_new));

                // Set up a content changed listener on the main feed to start animation
                // after main feed loads more than 1 feed card.
                Stream mainFeedStream = mTabToStreamMap.get(getTabIdForSection(StreamKind.FOR_YOU));
                mainFeedStream.addOnContentChangedListener(
                        new ContentChangedListener() {
                            @Override
                            public void onContentChanged(
                                    List<FeedListContentManager.FeedContent> feedContents) {
                                if (feedContents.size() > mHeaderCount + 1) {
                                    followingHeaderModel.set(
                                            SectionHeaderProperties.ANIMATION_START_KEY, true);
                                    FeedFeatures.updateNewIndicatorTimestamp(mProfile);
                                    mainFeedStream.removeOnContentChangedListener(this);
                                }
                            }
                        });
            }
        }
    }

    /**
     * Binds a stream to the {@link FeedListContentManager}. Unbinds currently active stream if
     * different from new stream. Once bound, the stream can add/remove contents.
     */
    @VisibleForTesting
    void bindStream(Stream stream) {
        if (mCurrentStream == stream) return;
        if (mCurrentStream != null) {
            unbindStream(/* shouldPlaceSpacer= */ true, /* switchingStream= */ true);
        }
        // Don't bind before the coordinator is active, or when the feed should not show.
        if (!mCoordinator.isActive()
                || !mSectionHeaderModel.get(SectionHeaderListProperties.IS_SECTION_ENABLED_KEY)) {
            return;
        }
        mCurrentStream = stream;
        updateLayout(false);
        mCurrentStream.addOnContentChangedListener(mStreamContentChangedListener);

        FeedReliabilityLogger reliabilityLogger = mCoordinator.getReliabilityLogger();
        mCurrentStream.bind(
                mCoordinator.getRecyclerView(),
                mCoordinator.getContentManager(),
                mRestoreScrollState,
                mCoordinator.getSurfaceScope(),
                mCoordinator.getHybridListRenderer(),
                reliabilityLogger,
                mHeaderCount);
        mRestoreScrollState = null;
        mCoordinator.getHybridListRenderer().onSurfaceOpened();
    }

    void onContentsChanged() {
        if (mSnapScrollHelper != null) mSnapScrollHelper.resetSearchBoxOnScroll(true);

        mActionDelegate.onContentsChanged();

        mIsLoadingFeed = false;
        mStreamContentChanged = true;
    }

    public boolean isLoadingFeed() {
        return mIsLoadingFeed;
    }

    /** Unbinds the stream and clear all the stream's contents. */
    private void unbindStream() {
        unbindStream(false, false);
    }

    /** Unbinds the stream with option for stream to put a placeholder for its contents. */
    private void unbindStream(boolean shouldPlaceSpacer, boolean switchingStream) {
        if (mCurrentStream == null) return;
        mCoordinator.getHybridListRenderer().onSurfaceClosed();
        mCurrentStream.unbind(shouldPlaceSpacer, switchingStream);
        mCurrentStream.removeOnContentChangedListener(mStreamContentChangedListener);
        mCurrentStream = null;
    }

    void onSurfaceOpened() {
        rebindStream();
    }

    void onSurfaceClosed() {
        unbindStream();
    }

    /** @return The stream that represents the 1st tab. */
    boolean hasStreams() {
        return !mTabToStreamMap.isEmpty();
    }

    long getLastFetchTimeMsForCurrentStream() {
        if (mCurrentStream == null) return 0;
        return mCurrentStream.getLastFetchTimeMs();
    }

    Stream getCurrentStreamForTesting() {
        return mCurrentStream;
    }

    private void rebindStream() {
        // If a stream is already bound, then do nothing.
        if (mCurrentStream != null) return;
        // Find the stream that should be bound and bind it. If no stream matches, then we haven't
        // fully set up yet. This will be taken care of by setup.
        Stream stream =
                mTabToStreamMap.get(
                        mSectionHeaderModel.get(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY));
        if (stream != null) {
            bindStream(stream);
        }
    }

    /**
     * Notifies a bound stream of new header count number.
     * @param newHeaderCount Number of headers in the {@link RecyclerView}.
     */
    void notifyHeadersChanged(int newHeaderCount) {
        mHeaderCount = newHeaderCount;
        if (mCurrentStream != null) {
            mCurrentStream.notifyNewHeaderCount(newHeaderCount);
        }
    }

    private void initStreamHeaderViews() {
        boolean signInPromoVisible = shouldShowSigninPromo();
        mCoordinator.updateHeaderViews(signInPromoVisible);
    }

    /**
     * Determines whether a signin promo should be shown.
     *
     * @return Whether the SignPromo should be visible.
     */
    private boolean shouldShowSigninPromo() {
        SyncPromoController.resetNtpSyncPromoLimitsIfHiddenForTooLong();
        AccountPickerBottomSheetStrings bottomSheetStrings =
                new AccountPickerBottomSheetStrings.Builder(
                                R.string.signin_account_picker_bottom_sheet_title)
                        .build();
        SyncPromoController promoController =
                new SyncPromoController(
                        mProfile,
                        bottomSheetStrings,
                        SigninAccessPoint.NTP_FEED_TOP_PROMO,
                        SyncConsentActivityLauncherImpl.get(),
                        SigninAndHistorySyncActivityLauncherImpl.get());
        if (!SignInPromo.shouldCreatePromo() || !promoController.canShowSyncPromo()) {
            return false;
        }
        if (mSignInPromo == null) {
            mSignInPromo = new FeedSignInPromo(mSigninManager, promoController);
            mSignInPromo.setCanShowPersonalizedSuggestions(isSuggestionsVisible());
        }
        return mSignInPromo.isVisible();
    }

    /** Clear any dependencies related to the {@link Stream}. */
    @VisibleForTesting
    void destroyPropertiesForStream() {
        if (mTabToStreamMap.isEmpty()) return;

        if (mStreamScrollListener != null) {
            mCoordinator.getRecyclerView().removeOnScrollListener(mStreamScrollListener);
            mStreamScrollListener = null;
        }

        MemoryPressureListener.removeCallback(mMemoryPressureCallback);
        mMemoryPressureCallback = null;

        if (mSignInPromo != null) {
            mSignInPromo.destroy();
            mSignInPromo = null;
        }

        unbindStream();
        for (Stream s : mTabToStreamMap.values()) {
            s.removeOnContentChangedListener(mStreamContentChangedListener);
            s.destroy();
        }
        mTabToStreamMap.clear();
        mStreamContentChangedListener = null;

        mPrefChangeRegistrar.removeObserver(Pref.ARTICLES_LIST_VISIBLE);
        mSigninManager.getIdentityManager().removeObserver(this);

        mSectionHeaderModel.get(SectionHeaderListProperties.SECTION_HEADERS_KEY).clear();
        mIsPropertiesInitializedForStream = false;

        if (mCoordinator.getSurfaceScope() != null) {
            mCoordinator.getSurfaceScope().getLaunchReliabilityLogger().cancelPendingEvents();
        }
    }

    private void setHeaderIndicatorState(boolean suggestionsVisible) {
        boolean isSignedIn = FeedServiceBridge.isSignedIn();
        boolean isTabMode =
                isSignedIn && FeedFeatures.isWebFeedUIEnabled(mProfile) && suggestionsVisible;
        // If we're in tab mode now, make sure webfeed tab is set up.
        if (isTabMode) {
            setUpWebFeedTab();
        }
        mSectionHeaderModel.set(SectionHeaderListProperties.IS_TAB_MODE_KEY, isTabMode);

        // If not in tab mode, make sure we are on the for-you or the supervised-user feed.
        if (!isTabMode) {
            mSectionHeaderModel.set(
                    SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY,
                    PRIMARY_FEED_HEADER_POSITION);
        }

        boolean isGoogleSearchEngine = mTemplateUrlService.isDefaultSearchEngineGoogle();
        // When Google is not the default search engine, we need to show the Logo.
        mSectionHeaderModel.set(
                SectionHeaderListProperties.IS_LOGO_KEY,
                !isGoogleSearchEngine && isSignedIn && suggestionsVisible);
        ViewVisibility indicatorState;
        if (!isTabMode) {
            // Gone when the following/for you tab switcher header is not shown
            indicatorState = ViewVisibility.GONE;
        } else if (!isGoogleSearchEngine) {
            // Visible when Google is not the search engine (show logo).
            indicatorState = ViewVisibility.VISIBLE;
        } else {
            // Invisible when we have centered text (signed in and not shown). This
            // counterbalances the gear icon so text is properly centered.
            indicatorState = ViewVisibility.INVISIBLE;
        }
        mSectionHeaderModel.set(
                SectionHeaderListProperties.INDICATOR_VIEW_VISIBILITY_KEY, indicatorState);

        // Make sure to collapse option panel if not shown.
        if (!suggestionsVisible) {
            mOptionsCoordinator.ensureGone();
        }

        // Set enabled last because it makes the animation smoother.
        mSectionHeaderModel.set(
                SectionHeaderListProperties.IS_SECTION_ENABLED_KEY, suggestionsVisible);
    }

    /**
     * Update whether the section header should be expanded.
     *
     * Called when a settings change or update to this/another NTP caused the feed to show/hide.
     */
    void updateSectionHeader() {
        // It is possible that updateSectionHeader() is called when the surface which contains the
        // Feeds isn't visible or headers of streams haven't been added, returns here.
        // See https://crbug.com/1485070 and https://crbug.com/1488210.
        // TODO(crbug.com/40934702): Figure out the root cause of setting
        // SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY to -1 and fix it.
        if (!mIsPropertiesInitializedForStream
                || mSectionHeaderModel.get(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY) < 0) {
            return;
        }

        boolean suggestionsVisible = isSuggestionsVisible();
        mSectionHeaderModel
                .get(SectionHeaderListProperties.SECTION_HEADERS_KEY)
                .get(PRIMARY_FEED_HEADER_POSITION)
                .set(
                        SectionHeaderProperties.HEADER_TEXT_KEY,
                        getInterestFeedHeaderText(
                                suggestionsVisible, mTabToStreamMap.get(0).getStreamKind()));

        setHeaderIndicatorState(suggestionsVisible);

        // Update toggleswitch item, which is last item in list.
        mSectionHeaderModel.set(SectionHeaderListProperties.MENU_MODEL_LIST_KEY, buildMenuItems());

        if (mSignInPromo != null) {
            mSignInPromo.setCanShowPersonalizedSuggestions(suggestionsVisible);
        }
        if (suggestionsVisible) mCoordinator.getSurfaceLifecycleManager().show();
        mStreamContentChanged = true;

        PropertyModel currentStreamHeaderModel =
                mSectionHeaderModel
                        .get(SectionHeaderListProperties.SECTION_HEADERS_KEY)
                        .get(
                                mSectionHeaderModel.get(
                                        SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY));
        Stream currentStream =
                mTabToStreamMap.get(
                        mSectionHeaderModel.get(SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY));

        // If feed turned on, we bind the last stream that was visible. Else unbind it.
        if (suggestionsVisible) {
            if (currentStream.supportsOptions()) {
                currentStreamHeaderModel.set(
                        SectionHeaderProperties.OPTIONS_INDICATOR_VISIBILITY_KEY,
                        ViewVisibility.VISIBLE);
            }
            rebindStream();
        } else {
            if (currentStream.supportsOptions()) {
                currentStreamHeaderModel.set(
                        SectionHeaderProperties.OPTIONS_INDICATOR_VISIBILITY_KEY,
                        ViewVisibility.INVISIBLE);
                currentStreamHeaderModel.set(
                        SectionHeaderProperties.OPTIONS_INDICATOR_IS_OPEN_KEY, false);
            }
            unbindStream();
        }
    }

    /**
     * Callback on section header toggled. This will update the visibility of the Feed and the
     * expand icon on the section header view.
     */
    private void onSectionHeaderToggled() {
        boolean isExpanded =
                !mSectionHeaderModel.get(SectionHeaderListProperties.IS_SECTION_ENABLED_KEY);

        // Record in prefs and UMA.
        // Model and stream visibility set in {@link #updateSectionHeader}
        // which is called by the prefService observer.
        getPrefService().setBoolean(Pref.ARTICLES_LIST_VISIBLE, isExpanded);
        FeedUma.recordFeedControlsAction(FeedUma.CONTROLS_ACTION_TOGGLED_FEED);
        SuggestionsMetrics.recordArticlesListVisible(mProfile);

        int streamType =
                mTabToStreamMap
                        .get(
                                mSectionHeaderModel.get(
                                        SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY))
                        .getStreamKind();
        FeedServiceBridge.reportOtherUserAction(
                streamType,
                isExpanded
                        ? FeedUserActionType.TAPPED_TURN_ON
                        : FeedUserActionType.TAPPED_TURN_OFF);
    }

    /**
     * Returns the interest feed header text based on the type of user (supervised or
     * non-supervised) and the selected default search engine
     */
    private String getInterestFeedHeaderText(boolean isExpanded, @StreamKind int streamKind) {
        Resources res = mContext.getResources();
        final boolean isDefaultSearchEngineGoogle =
                mTemplateUrlService.isDefaultSearchEngineGoogle();

        if (streamKind == StreamKind.SUPERVISED_USER) {
            if (isDefaultSearchEngineGoogle) {
                return isExpanded
                        ? res.getString(R.string.supervised_user_ntp_discover_on)
                        : res.getString(R.string.supervised_user_ntp_discover_off);
            } else {
                return isExpanded
                        ? res.getString(R.string.supervised_user_ntp_discover_on_branded)
                        : res.getString(R.string.supervised_user_ntp_discover_off_branded);
            }
        }

        if (WebFeedBridge.isWebFeedEnabled() && FeedServiceBridge.isSignedIn() && isExpanded) {
            return res.getString(R.string.ntp_discover_on);
        } else if (isDefaultSearchEngineGoogle) {
            return isExpanded
                    ? res.getString(R.string.ntp_discover_on)
                    : res.getString(R.string.ntp_discover_off);
        }
        return isExpanded
                ? res.getString(R.string.ntp_discover_on_branded)
                : res.getString(R.string.ntp_discover_off_branded);
    }

    private ModelList buildMenuItems() {
        ModelList itemList = new ModelList();
        int iconId = 0;

        // Do not display Manage menu items for the supervised-user feed.
        if (FeedServiceBridge.isSignedIn() && !mCoordinator.shouldDisplaySupervisedFeed()) {
            if (WebFeedBridge.isWebFeedEnabled()) {
                itemList.add(
                        buildMenuListItem(
                                R.string.ntp_manage_feed,
                                R.id.ntp_feed_header_menu_item_manage,
                                iconId));
            } else {
                itemList.add(
                        buildMenuListItem(
                                R.string.ntp_manage_my_activity,
                                R.id.ntp_feed_header_menu_item_activity,
                                iconId));
                itemList.add(
                        buildMenuListItem(
                                R.string.ntp_manage_interests,
                                R.id.ntp_feed_header_menu_item_interest,
                                iconId));
                if (ChromeFeatureList.isEnabled(ChromeFeatureList.INTEREST_FEED_V2_HEARTS)) {
                    itemList.add(
                            buildMenuListItem(
                                    R.string.ntp_manage_reactions,
                                    R.id.ntp_feed_header_menu_item_reactions,
                                    iconId));
                }
            }
        }
        itemList.add(
                buildMenuListItem(
                        R.string.learn_more, R.id.ntp_feed_header_menu_item_learn, iconId));
        itemList.add(
                getMenuToggleSwitch(
                        mSectionHeaderModel.get(SectionHeaderListProperties.IS_SECTION_ENABLED_KEY),
                        iconId));
        return itemList;
    }

    /**
     * Returns the menu list item that represents turning the feed on/off.
     *
     * @param isEnabled Whether the feed section is currently enabled.
     * @param iconId IconId for the list item if any.
     */
    private MVCListAdapter.ListItem getMenuToggleSwitch(boolean isEnabled, int iconId) {
        if (isEnabled) {
            return buildMenuListItem(
                    R.string.ntp_turn_off_feed,
                    R.id.ntp_feed_header_menu_item_toggle_switch,
                    iconId);
        }
        return buildMenuListItem(
                R.string.ntp_turn_on_feed, R.id.ntp_feed_header_menu_item_toggle_switch, iconId);
    }

    /** Whether a new thumbnail should be captured. */
    boolean shouldCaptureThumbnail() {
        return mStreamContentChanged
                || mCoordinator.getView().getWidth() != mThumbnailWidth
                || mCoordinator.getView().getHeight() != mThumbnailHeight
                || getVerticalScrollOffset() != mThumbnailScrollY;
    }

    /** Reset all the properties for thumbnail capturing after a new thumbnail is captured. */
    void onThumbnailCaptured() {
        mThumbnailWidth = mCoordinator.getView().getWidth();
        mThumbnailHeight = mCoordinator.getView().getHeight();
        mThumbnailScrollY = getVerticalScrollOffset();
        mStreamContentChanged = false;
    }

    /**
     * @return Whether the touch events are enabled.
     * TODO(huayinz): Move this method to a Model once a Model is introduced.
     */
    boolean getTouchEnabled() {
        return mTouchEnabled;
    }

    // TODO(carlosk): replace with FeedFeatures.getPrefService().
    private PrefService getPrefService() {
        if (sPrefServiceForTest != null) return sPrefServiceForTest;
        return UserPrefs.get(mProfile);
    }

    // TouchEnabledDelegate interface.
    @Override
    public void setTouchEnabled(boolean enabled) {
        mTouchEnabled = enabled;
    }

    // ScrollDelegate interface.
    @Override
    public boolean isScrollViewInitialized() {
        RecyclerView recyclerView = mCoordinator.getRecyclerView();
        return recyclerView != null && recyclerView.getHeight() > 0;
    }

    @Override
    public int getVerticalScrollOffset() {
        // This method returns a valid vertical scroll offset only when the first header view in the
        // Stream is visible.
        if (!isScrollViewInitialized()) return 0;

        if (!isChildVisibleAtPosition(0)) {
            return Integer.MIN_VALUE;
        }

        LayoutManager layoutManager = mCoordinator.getRecyclerView().getLayoutManager();
        if (layoutManager == null) {
            return Integer.MIN_VALUE;
        }

        View view = layoutManager.findViewByPosition(0);
        if (view == null) {
            return Integer.MIN_VALUE;
        }

        return -view.getTop();
    }

    @Override
    public boolean isChildVisibleAtPosition(int position) {
        if (!isScrollViewInitialized()) return false;

        ListLayoutHelper layoutHelper = mCoordinator.getHybridListRenderer().getListLayoutHelper();
        if (layoutHelper == null) {
            return false;
        }

        int firstItemPosition = layoutHelper.findFirstVisibleItemPosition();
        int lastItemPosition = layoutHelper.findLastVisibleItemPosition();
        if (firstItemPosition == RecyclerView.NO_POSITION
                || lastItemPosition == RecyclerView.NO_POSITION) {
            return false;
        }

        return firstItemPosition <= position && position <= lastItemPosition;
    }

    @Override
    public void snapScroll() {
        if (mSnapScrollHelper == null) return;
        if (!isScrollViewInitialized()) return;

        int initialScroll = getVerticalScrollOffset();
        int scrollTo = mSnapScrollHelper.calculateSnapPosition(initialScroll);

        // Calculating the snap position should be idempotent.
        assert scrollTo == mSnapScrollHelper.calculateSnapPosition(scrollTo);

        mCoordinator.getRecyclerView().smoothScrollBy(0, scrollTo - initialScroll);
    }

    /**
     * Scrolls the page to show the view at the given {@code viewPosition} if not already visible.
     * @param viewPosition The position of the view that should be visible or scrolled to.
     */
    void scrollToViewIfNecessary(int viewPosition) {
        if (!isScrollViewInitialized()) return;
        if (!isChildVisibleAtPosition(viewPosition)) {
            mCoordinator.getRecyclerView().scrollToPosition(viewPosition);
        }
    }

    @Override
    public void onTemplateURLServiceChanged() {
        if (mIsNewTabSearchEngineUrlAndroidEnabled) {
            getPrefService()
                    .setBoolean(
                            Pref.ENABLE_SNIPPETS_BY_DSE,
                            mTemplateUrlService.isDefaultSearchEngineGoogle());
            return;
        }
        updateSectionHeader();
    }

    @Override
    public void onItemSelected(PropertyModel item) {
        int itemId = item.get(ListMenuItemProperties.MENU_ITEM_ID);
        int feedType =
                mTabToStreamMap
                        .get(
                                mSectionHeaderModel.get(
                                        SectionHeaderListProperties.CURRENT_TAB_INDEX_KEY))
                        .getStreamKind();
        if (itemId == R.id.ntp_feed_header_menu_item_manage) {
            Intent intent = new Intent(mContext, FeedManagementActivity.class);
            intent.putExtra(FeedManagementActivity.INITIATING_STREAM_TYPE_EXTRA, feedType);
            FeedServiceBridge.reportOtherUserAction(feedType, FeedUserActionType.TAPPED_MANAGE);
            FeedUma.recordFeedControlsAction(FeedUma.CONTROLS_ACTION_CLICKED_MANAGE);
            mContext.startActivity(intent);
        } else if (itemId == R.id.ntp_feed_header_menu_item_activity) {
            mActionDelegate.openUrl(
                    WindowOpenDisposition.CURRENT_TAB,
                    new LoadUrlParams("https://myactivity.google.com/myactivity?product=50"));
            FeedServiceBridge.reportOtherUserAction(
                    feedType, FeedUserActionType.TAPPED_MANAGE_ACTIVITY);
            FeedUma.recordFeedControlsAction(FeedUma.CONTROLS_ACTION_CLICKED_MY_ACTIVITY);
        } else if (itemId == R.id.ntp_feed_header_menu_item_interest) {
            mActionDelegate.openUrl(
                    WindowOpenDisposition.CURRENT_TAB,
                    new LoadUrlParams("https://www.google.com/preferences/interests"));
            FeedServiceBridge.reportOtherUserAction(
                    feedType, FeedUserActionType.TAPPED_MANAGE_INTERESTS);
            FeedUma.recordFeedControlsAction(FeedUma.CONTROLS_ACTION_CLICKED_MANAGE_INTERESTS);
        } else if (itemId == R.id.ntp_feed_header_menu_item_reactions) {
            mActionDelegate.openUrl(
                    WindowOpenDisposition.CURRENT_TAB,
                    new LoadUrlParams("https://www.google.com/search/contributions/reactions"));
            FeedServiceBridge.reportOtherUserAction(
                    feedType, FeedUserActionType.TAPPED_MANAGE_REACTIONS);
            FeedUma.recordFeedControlsAction(FeedUma.CONTROLS_ACTION_CLICKED_MANAGE_INTERESTS);
        } else if (itemId == R.id.ntp_feed_header_menu_item_learn) {
            mActionDelegate.openHelpPage();
            FeedServiceBridge.reportOtherUserAction(feedType, FeedUserActionType.TAPPED_LEARN_MORE);
            FeedUma.recordFeedControlsAction(FeedUma.CONTROLS_ACTION_CLICKED_LEARN_MORE);
        } else if (itemId == R.id.ntp_feed_header_menu_item_toggle_switch) {
            onSectionHeaderToggled();
        } else {
            assert false
                    : String.format(
                            Locale.ENGLISH,
                            "Cannot handle action for item in the menu with id %d",
                            itemId);
        }
    }

    // IdentityManager.Observer interface.

    @Override
    public void onPrimaryAccountChanged(PrimaryAccountChangeEvent eventDetails) {
        updateSectionHeader();
    }

    public SignInPromo getSignInPromoForTesting() {
        return mSignInPromo;
    }

    void manualRefresh(Callback<Boolean> callback) {
        if (mCurrentStream != null) {
            mCurrentStream.triggerRefresh(callback);
        } else {
            callback.onResult(false);
        }
    }

    private FeedScrollState getScrollStateForAutoScrollToTop() {
        FeedScrollState state = new FeedScrollState();
        state.position = 1;
        state.lastPosition = 5;
        return state;
    }

    // Detects animation finishes in RecyclerView.
    // https://stackoverflow.com/questions/33710605/detect-animation-finish-in-androids-recyclerview
    private class RecyclerViewAnimationFinishDetector
            implements RecyclerView.ItemAnimator.ItemAnimatorFinishedListener {
        private Runnable mFinishedCallback;

        /**
         * Asynchronously waits for the animation to finish. If there's already a callback waiting,
         * this replaces the existing callback.
         *
         * @param finishedCallback Callback to invoke when the animation finishes.
         */
        public void runWhenAnimationComplete(Runnable finishedCallback) {
            if (mCoordinator.getRecyclerView() == null) {
                return;
            }
            mFinishedCallback = finishedCallback;

            // The RecyclerView has not started animating yet, so post a message to the
            // message queue that will be run after the RecyclerView has started animating.
            new Handler()
                    .post(
                            () -> {
                                checkFinish();
                            });
        }

        private void checkFinish() {
            RecyclerView recyclerView = mCoordinator.getRecyclerView();

            if (recyclerView != null && recyclerView.isAnimating()) {
                // The RecyclerView is still animating, try again when the animation has finished.
                recyclerView.getItemAnimator().isRunning(this);
                return;
            }

            // The RecyclerView has animated all it's views.
            onFinished();
        }

        private void onFinished() {
            if (mFinishedCallback != null) {
                mFinishedCallback.run();
                mFinishedCallback = null;
            }
        }

        @Override
        public void onAnimationsFinished() {
            // There might still be more items that will be animated after this one.
            new Handler()
                    .post(
                            () -> {
                                checkFinish();
                            });
        }
    }

    private @StreamType int getStreamType(Stream stream) {
        switch (stream.getStreamKind()) {
            case StreamKind.FOR_YOU:
                return StreamType.FOR_YOU;
            case StreamKind.FOLLOWING:
                return StreamType.WEB_FEED;
            case StreamKind.SUPERVISED_USER:
                return StreamType.SUPERVISED_USER_FEED;
            default:
                return StreamType.UNSPECIFIED;
        }
    }

    private void logSwitchedFeeds(Stream switchedToStream) {
        // Log the end of an ongoing launch and the beginning of a new one.
        FeedReliabilityLogger reliabilityLogger = mCoordinator.getReliabilityLogger();
        if (reliabilityLogger != null) {
            reliabilityLogger.onSwitchStream(getStreamType(switchedToStream));
        }
    }

    private boolean isSuggestionsVisible() {
        return getPrefService().getBoolean(Pref.ARTICLES_LIST_VISIBLE);
    }

    OnSectionHeaderSelectedListener getOrCreateSectionHeaderListenerForTesting() {
        OnSectionHeaderSelectedListener listener =
                mSectionHeaderModel.get(SectionHeaderListProperties.ON_TAB_SELECTED_CALLBACK_KEY);
        if (listener == null) {
            listener = new FeedSurfaceHeaderSelectedCallback();
        }
        return listener;
    }

    void setStreamForTesting(int key, Stream stream) {
        mTabToStreamMap.put(key, stream);
    }

    int getTabToStreamSizeForTesting() {
        return mTabToStreamMap.size();
    }

    private void onDisplayStyleChanged(UiConfig.DisplayStyle newDisplayStyle) {
        mSectionHeaderModel.set(
                SectionHeaderListProperties.IS_NARROW_WINDOW_ON_TABLET_KEY,
                newDisplayStyle.horizontal < HorizontalDisplayStyle.WIDE);
    }
}