org.chromium.chrome.browser.ntp.snippets.SnippetArticleViewHolder.java Source code

Java tutorial

Introduction

Here is the source code for org.chromium.chrome.browser.ntp.snippets.SnippetArticleViewHolder.java

Source

// Copyright 2015 The Chromium Authors. All rights reserved.
// 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.ntp.snippets;

import android.annotation.SuppressLint;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.TransitionDrawable;
import android.media.ThumbnailUtils;
import android.os.StrictMode;
import android.os.SystemClock;
import android.support.v4.text.BidiFormatter;
import android.support.v4.view.ViewCompat;
import android.text.format.DateUtils;
import android.view.ContextMenu;
import android.view.Menu;
import android.view.MenuItem;
import android.view.MenuItem.OnMenuItemClickListener;
import android.view.View;
import android.view.View.MeasureSpec;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.Callback;
import org.chromium.base.annotations.SuppressFBWarnings;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.favicon.FaviconHelper.FaviconImageCallback;
import org.chromium.chrome.browser.favicon.FaviconHelper.IconAvailabilityCallback;
import org.chromium.chrome.browser.ntp.DisplayStyleObserver;
import org.chromium.chrome.browser.ntp.NewTabPageUma;
import org.chromium.chrome.browser.ntp.NewTabPageView.NewTabPageManager;
import org.chromium.chrome.browser.ntp.UiConfig;
import org.chromium.chrome.browser.ntp.cards.CardViewHolder;
import org.chromium.chrome.browser.ntp.cards.CardsVariationParameters;
import org.chromium.chrome.browser.ntp.cards.DisplayStyleObserverAdapter;
import org.chromium.chrome.browser.ntp.cards.ImpressionTracker;
import org.chromium.chrome.browser.ntp.cards.NewTabPageRecyclerView;
import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
import org.chromium.ui.mojom.WindowOpenDisposition;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.concurrent.TimeUnit;

/**
 * A class that represents the view for a single card snippet.
 */
public class SnippetArticleViewHolder extends CardViewHolder implements ImpressionTracker.Listener {
    private static final String PUBLISHER_FORMAT_STRING = "%s - %s";
    private static final int FADE_IN_ANIMATION_TIME_MS = 300;
    private static final int[] FAVICON_SERVICE_SUPPORTED_SIZES = { 16, 24, 32, 48, 64 };
    private static final String FAVICON_SERVICE_FORMAT = "https://s2.googleusercontent.com/s2/favicons?domain=%s&src=chrome_newtab_mobile&sz=%d&alt=404";

    // ContextMenu item ids. These must be unique.
    private static final int ID_OPEN_IN_NEW_WINDOW = 0;
    private static final int ID_OPEN_IN_NEW_TAB = 1;
    private static final int ID_OPEN_IN_INCOGNITO_TAB = 2;
    private static final int ID_SAVE_FOR_OFFLINE = 3;
    private static final int ID_REMOVE = 4;

    private final NewTabPageManager mNewTabPageManager;
    private final TextView mHeadlineTextView;
    private final TextView mPublisherTextView;
    private final TextView mArticleSnippetTextView;
    private final ImageView mThumbnailView;

    private FetchImageCallback mImageCallback;
    private SnippetArticle mArticle;
    private int mPublisherFaviconSizePx;

    private final boolean mUseFaviconService;
    private final UiConfig mUiConfig;

    @SuppressFBWarnings("URF_UNREAD_FIELD")
    private ImpressionTracker mImpressionTracker;

    /**
     * Listener for when the context menu is created.
     */
    public interface OnCreateContextMenuListener {
        /** Called when the context menu is created. */
        void onCreateContextMenu();
    }

    private static class ContextMenuItemClickListener implements OnMenuItemClickListener {
        private final SnippetArticle mArticle;
        private final NewTabPageManager mManager;
        private final NewTabPageRecyclerView mRecyclerView;

        public ContextMenuItemClickListener(SnippetArticle article, NewTabPageManager newTabPageManager,
                NewTabPageRecyclerView newTabPageRecyclerView) {
            mArticle = article;
            mManager = newTabPageManager;
            mRecyclerView = newTabPageRecyclerView;
        }

        @Override
        public boolean onMenuItemClick(MenuItem item) {
            // If the user clicks a snippet then immediately long presses they will create a context
            // menu while the snippet's URL loads in the background. This means that when they press
            // an item on context menu the NTP will not actually be open. We add this check here to
            // prevent taking any action if the user has already left the NTP.
            // https://crbug.com/640468.
            // TODO(peconn): Instead, close the context menu when a snippet is clicked.
            if (!ViewCompat.isAttachedToWindow(mRecyclerView))
                return true;

            // The UMA is used to compare how the user views the article linked from a snippet.
            switch (item.getItemId()) {
            case ID_OPEN_IN_NEW_WINDOW:
                NewTabPageUma.recordOpenSnippetMethod(NewTabPageUma.OPEN_SNIPPET_METHODS_NEW_WINDOW);
                mManager.openSnippet(WindowOpenDisposition.NEW_WINDOW, mArticle);
                return true;
            case ID_OPEN_IN_NEW_TAB:
                NewTabPageUma.recordOpenSnippetMethod(NewTabPageUma.OPEN_SNIPPET_METHODS_NEW_TAB);
                mManager.openSnippet(WindowOpenDisposition.NEW_FOREGROUND_TAB, mArticle);
                return true;
            case ID_OPEN_IN_INCOGNITO_TAB:
                NewTabPageUma.recordOpenSnippetMethod(NewTabPageUma.OPEN_SNIPPET_METHODS_INCOGNITO);
                mManager.openSnippet(WindowOpenDisposition.OFF_THE_RECORD, mArticle);
                return true;
            case ID_SAVE_FOR_OFFLINE:
                NewTabPageUma.recordOpenSnippetMethod(NewTabPageUma.OPEN_SNIPPET_METHODS_SAVE_FOR_OFFLINE);
                mManager.openSnippet(WindowOpenDisposition.SAVE_TO_DISK, mArticle);
                return true;
            case ID_REMOVE:
                // UMA is recorded during dismissal.
                mRecyclerView.dismissItemWithAnimation(mArticle);
                return true;
            default:
                return false;
            }
        }
    }

    /**
     * Constructs a SnippetCardItemView item used to display snippets
     *
     * @param parent The ViewGroup that is going to contain the newly created view.
     * @param manager The NTPManager object used to open an article
     * @param suggestionsSource The source used to retrieve the thumbnails.
     * @param uiConfig The NTP UI configuration object used to adjust the article UI.
     */
    public SnippetArticleViewHolder(NewTabPageRecyclerView parent, NewTabPageManager manager, UiConfig uiConfig) {
        super(R.layout.new_tab_page_snippets_card, parent, uiConfig);

        mNewTabPageManager = manager;
        mThumbnailView = (ImageView) itemView.findViewById(R.id.article_thumbnail);
        mHeadlineTextView = (TextView) itemView.findViewById(R.id.article_headline);
        mPublisherTextView = (TextView) itemView.findViewById(R.id.article_publisher);
        mArticleSnippetTextView = (TextView) itemView.findViewById(R.id.article_snippet);

        mImpressionTracker = new ImpressionTracker(itemView, this);

        mUiConfig = uiConfig;
        new DisplayStyleObserverAdapter(itemView, uiConfig, new DisplayStyleObserver() {
            @Override
            public void onDisplayStyleChanged(@UiConfig.DisplayStyle int newDisplayStyle) {
                updateLayout();
            }
        });

        mUseFaviconService = CardsVariationParameters.isFaviconServiceEnabled();
    }

    @Override
    public void onImpression() {
        if (mArticle != null && mArticle.trackImpression()) {
            mNewTabPageManager.trackSnippetImpression(mArticle);
        }
    }

    @Override
    public void onCardTapped() {
        mNewTabPageManager.openSnippet(WindowOpenDisposition.CURRENT_TAB, mArticle);
        mArticle.trackClick();
    }

    @Override
    protected void createContextMenu(ContextMenu menu) {
        RecordHistogram.recordSparseSlowlyHistogram("NewTabPage.Snippets.CardLongPressed", mArticle.mPosition);
        mArticle.recordAgeAndScore("NewTabPage.Snippets.CardLongPressed");

        OnMenuItemClickListener listener = new ContextMenuItemClickListener(mArticle, mNewTabPageManager,
                getRecyclerView());

        // Create a context menu akin to the one shown for MostVisitedItems.
        if (mNewTabPageManager.isOpenInNewWindowEnabled()) {
            addContextMenuItem(menu, ID_OPEN_IN_NEW_WINDOW, R.string.contextmenu_open_in_other_window, listener);
        }

        addContextMenuItem(menu, ID_OPEN_IN_NEW_TAB, R.string.contextmenu_open_in_new_tab, listener);

        if (mNewTabPageManager.isOpenInIncognitoEnabled()) {
            addContextMenuItem(menu, ID_OPEN_IN_INCOGNITO_TAB, R.string.contextmenu_open_in_incognito_tab,
                    listener);
        }

        // TODO(peconn): Only show 'Save for Offline' for appropriate snippet types.
        if (SnippetsConfig.isSaveToOfflineEnabled() && OfflinePageBridge.canSavePage(mArticle.mUrl)) {
            addContextMenuItem(menu, ID_SAVE_FOR_OFFLINE, R.string.contextmenu_save_link, listener);
        }

        addContextMenuItem(menu, ID_REMOVE, R.string.remove, listener);

        // Disable touch events on the RecyclerView while the context menu is open. This is to
        // prevent the user long pressing to get the context menu then on the same press scrolling
        // or swiping to dismiss an item (eg. https://crbug.com/638854, 638555, 636296)
        final NewTabPageRecyclerView recyclerView = (NewTabPageRecyclerView) itemView.getParent();
        recyclerView.setTouchEnabled(false);

        mNewTabPageManager.addContextMenuCloseCallback(new Callback<Menu>() {
            @Override
            public void onResult(Menu result) {
                recyclerView.setTouchEnabled(true);
                mNewTabPageManager.removeContextMenuCloseCallback(this);
            }
        });
    }

    /**
     * Convenience method to reduce multi-line function call to single line.
     */
    private static void addContextMenuItem(ContextMenu menu, int id, int resourceId,
            OnMenuItemClickListener listener) {
        menu.add(Menu.NONE, id, Menu.NONE, resourceId).setOnMenuItemClickListener(listener);
    }

    /**
     * Updates the layout taking into account screen dimensions and the type of snippet displayed.
     */
    private void updateLayout() {
        boolean narrow = mUiConfig.getCurrentDisplayStyle() == UiConfig.DISPLAY_STYLE_NARROW;
        boolean minimal = mArticle.mCardLayout == ContentSuggestionsCardLayout.MINIMAL_CARD;

        // If the screen is narrow or we are using the minimal layout, hide the article snippet.
        boolean hideSnippet = narrow || minimal;
        mArticleSnippetTextView.setVisibility(hideSnippet ? View.GONE : View.VISIBLE);

        // If we are using minimal layout, hide the thumbnail.
        boolean hideThumbnail = minimal;
        mThumbnailView.setVisibility(hideThumbnail ? View.GONE : View.VISIBLE);

        // If the screen is narrow, increase the number of lines in the header.
        mHeadlineTextView.setMaxLines(narrow ? 4 : 2);

        // If the screen is narrow, ensure a minimum number of lines to prevent overlap between the
        // publisher and the header.
        mHeadlineTextView.setMinLines((narrow && !hideThumbnail) ? 3 : 1);

        // If we aren't showing the article snippet, reduce the top margin for publisher text.
        RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) mPublisherTextView.getLayoutParams();

        int topMargin = mPublisherTextView.getResources()
                .getDimensionPixelSize(hideSnippet ? R.dimen.snippets_publisher_margin_top_without_article_snippet
                        : R.dimen.snippets_publisher_margin_top_with_article_snippet);

        params.setMargins(params.leftMargin, topMargin, params.rightMargin, params.bottomMargin);

        mPublisherTextView.setLayoutParams(params);
    }

    public void onBindViewHolder(SnippetArticle article) {
        super.onBindViewHolder();

        mArticle = article;
        updateLayout();

        mHeadlineTextView.setText(mArticle.mTitle);

        // DateUtils.getRelativeTimeSpanString(...) calls through to TimeZone.getDefault(). If this
        // has never been called before it loads the current time zone from disk. In most likelihood
        // this will have been called previously and the current time zone will have been cached,
        // but in some cases (eg instrumentation tests) it will cause a strict mode violation.
        StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
        try {
            long time = SystemClock.elapsedRealtime();
            CharSequence relativeTimeSpan = DateUtils.getRelativeTimeSpanString(
                    mArticle.mPublishTimestampMilliseconds, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS);
            RecordHistogram.recordTimesHistogram("Android.StrictMode.SnippetUIBuildTime",
                    SystemClock.elapsedRealtime() - time, TimeUnit.MILLISECONDS);

            // We format the publisher here so that having a publisher name in an RTL language
            // doesn't mess up the formatting on an LTR device and vice versa.
            String publisherAttribution = String.format(PUBLISHER_FORMAT_STRING,
                    BidiFormatter.getInstance().unicodeWrap(mArticle.mPublisher), relativeTimeSpan);
            mPublisherTextView.setText(publisherAttribution);
        } finally {
            StrictMode.setThreadPolicy(oldPolicy);
        }

        // The favicon of the publisher should match the textview height.
        int widthSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        mPublisherTextView.measure(widthSpec, heightSpec);
        mPublisherFaviconSizePx = mPublisherTextView.getMeasuredHeight();

        mArticleSnippetTextView.setText(mArticle.mPreviewText);

        // If there's still a pending thumbnail fetch, cancel it.
        cancelImageFetch();

        // If the article has a thumbnail already, reuse it. Otherwise start a fetch.
        // mThumbnailView's visibility is modified in updateLayout().
        if (mThumbnailView.getVisibility() == View.VISIBLE) {
            if (mArticle.getThumbnailBitmap() != null) {
                mThumbnailView.setImageBitmap(mArticle.getThumbnailBitmap());
            } else {
                mThumbnailView.setImageResource(R.drawable.ic_snippet_thumbnail_placeholder);
                mImageCallback = new FetchImageCallback(this, mArticle);
                mNewTabPageManager.getSuggestionsSource().fetchSuggestionImage(mArticle, mImageCallback);
            }
        }

        // Set the favicon of the publisher.
        try {
            fetchFaviconFromLocalCache(new URI(mArticle.mUrl), true);
        } catch (URISyntaxException e) {
            setDefaultFaviconOnView();
        }
    }

    private static class FetchImageCallback extends Callback<Bitmap> {
        private SnippetArticleViewHolder mViewHolder;
        private final SnippetArticle mSnippet;

        public FetchImageCallback(SnippetArticleViewHolder viewHolder, SnippetArticle snippet) {
            mViewHolder = viewHolder;
            mSnippet = snippet;
        }

        @Override
        public void onResult(Bitmap image) {
            if (mViewHolder == null)
                return;
            mViewHolder.fadeThumbnailIn(mSnippet, image);
        }

        public void cancel() {
            // TODO(treib): Pass the "cancel" on to the actual image fetcher.
            mViewHolder = null;
        }
    }

    private void cancelImageFetch() {
        if (mImageCallback != null) {
            mImageCallback.cancel();
            mImageCallback = null;
        }
    }

    private void fadeThumbnailIn(SnippetArticle snippet, Bitmap thumbnail) {
        mImageCallback = null;
        if (thumbnail == null)
            return; // Nothing to do, we keep the placeholder.

        // We need to crop and scale the downloaded bitmap, as the ImageView we set it on won't be
        // able to do so when using a TransitionDrawable (as opposed to the straight bitmap).
        // That's a limitation of TransitionDrawable, which doesn't handle layers of varying sizes.
        Resources res = mThumbnailView.getResources();
        int targetSize = res.getDimensionPixelSize(R.dimen.snippets_thumbnail_size);
        Bitmap scaledThumbnail = ThumbnailUtils.extractThumbnail(thumbnail, targetSize, targetSize,
                ThumbnailUtils.OPTIONS_RECYCLE_INPUT);

        // Store the bitmap to skip the download task next time we display this snippet.
        snippet.setThumbnailBitmap(scaledThumbnail);

        // Cross-fade between the placeholder and the thumbnail.
        Drawable[] layers = { mThumbnailView.getDrawable(),
                new BitmapDrawable(mThumbnailView.getResources(), scaledThumbnail) };
        TransitionDrawable transitionDrawable = new TransitionDrawable(layers);
        mThumbnailView.setImageDrawable(transitionDrawable);
        transitionDrawable.startTransition(FADE_IN_ANIMATION_TIME_MS);
    }

    private void fetchFaviconFromLocalCache(final URI snippetUri, final boolean fallbackToService) {
        mNewTabPageManager.getLocalFaviconImageForURL(getSnippetDomain(snippetUri), mPublisherFaviconSizePx,
                new FaviconImageCallback() {
                    @Override
                    public void onFaviconAvailable(Bitmap image, String iconUrl) {
                        if (image == null && fallbackToService) {
                            fetchFaviconFromService(snippetUri);
                            return;
                        }
                        setFaviconOnView(image);
                    }
                });
    }

    // TODO(crbug.com/635567): Fix this properly.
    @SuppressLint("DefaultLocale")
    private void fetchFaviconFromService(final URI snippetUri) {
        // Show the default favicon immediately.
        setDefaultFaviconOnView();

        if (!mUseFaviconService)
            return;
        int sizePx = getFaviconServiceSupportedSize();
        if (sizePx == 0)
            return;

        // Replace the default icon by another one from the service when it is fetched.
        mNewTabPageManager.ensureIconIsAvailable(getSnippetDomain(snippetUri), // Store to the cache for the whole domain.
                String.format(FAVICON_SERVICE_FORMAT, snippetUri.getHost(), sizePx), /*useLargeIcon=*/false,
                /*isTemporary=*/true, new IconAvailabilityCallback() {
                    @Override
                    public void onIconAvailabilityChecked(boolean newlyAvailable) {
                        if (!newlyAvailable)
                            return;
                        // The download succeeded, the favicon is in the cache; fetch it.
                        fetchFaviconFromLocalCache(snippetUri, /*fallbackToService=*/false);
                    }
                });
    }

    private int getFaviconServiceSupportedSize() {
        // Take the smallest size larger than mFaviconSizePx.
        for (int size : FAVICON_SERVICE_SUPPORTED_SIZES) {
            if (size > mPublisherFaviconSizePx)
                return size;
        }
        // Or at least the largest available size (unless too small).
        int largestSize = FAVICON_SERVICE_SUPPORTED_SIZES[FAVICON_SERVICE_SUPPORTED_SIZES.length - 1];
        if (mPublisherFaviconSizePx <= largestSize * 1.5)
            return largestSize;
        return 0;
    }

    private String getSnippetDomain(URI snippetUri) {
        return String.format("%s://%s", snippetUri.getScheme(), snippetUri.getHost());
    }

    private void setDefaultFaviconOnView() {
        setFaviconOnView(ApiCompatibilityUtils.getDrawable(mPublisherTextView.getContext().getResources(),
                R.drawable.default_favicon));
    }

    private void setFaviconOnView(Bitmap image) {
        setFaviconOnView(new BitmapDrawable(mPublisherTextView.getContext().getResources(), image));
    }

    private void setFaviconOnView(Drawable drawable) {
        drawable.setBounds(0, 0, mPublisherFaviconSizePx, mPublisherFaviconSizePx);
        ApiCompatibilityUtils.setCompoundDrawablesRelative(mPublisherTextView, drawable, null, null, null);
        mPublisherTextView.setVisibility(View.VISIBLE);
    }

    @Override
    public boolean isDismissable() {
        return !isPeeking();
    }
}