// 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();
}
}