// Copyright 2016 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.cards; import android.annotation.SuppressLint; import android.graphics.Canvas; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.Adapter; import android.support.v7.widget.RecyclerView.ViewHolder; import android.support.v7.widget.helper.ItemTouchHelper; import android.view.View; import android.view.ViewGroup; import org.chromium.base.Callback; import org.chromium.base.Log; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.R; 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.snippets.CategoryInt; import org.chromium.chrome.browser.ntp.snippets.CategoryStatus; import org.chromium.chrome.browser.ntp.snippets.CategoryStatus.CategoryStatusEnum; import org.chromium.chrome.browser.ntp.snippets.SectionHeader; import org.chromium.chrome.browser.ntp.snippets.SectionHeaderViewHolder; import org.chromium.chrome.browser.ntp.snippets.SnippetArticle; import org.chromium.chrome.browser.ntp.snippets.SnippetArticleViewHolder; import org.chromium.chrome.browser.ntp.snippets.SnippetsBridge; import org.chromium.chrome.browser.ntp.snippets.SuggestionsSource; import org.chromium.chrome.browser.signin.SigninManager.SignInStateObserver; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * A class that handles merging above the fold elements and below the fold cards into an adapter * that will be used to back the NTP RecyclerView. The first element in the adapter should always be * the above-the-fold view (containing the logo, search box, and most visited tiles) and subsequent * elements will be the cards shown to the user */ public class NewTabPageAdapter extends Adapter<NewTabPageViewHolder> implements SuggestionsSource.Observer, ItemGroup.Observer { private static final String TAG = "Ntp"; private final NewTabPageManager mNewTabPageManager; private final View mAboveTheFoldView; private final UiConfig mUiConfig; private final ItemTouchCallbacks mItemTouchCallbacks = new ItemTouchCallbacks(); private NewTabPageRecyclerView mRecyclerView; /** * List of all item groups (which can themselves contain multiple items. When flattened, this * will be a list of all items the adapter exposes. */ private final List<ItemGroup> mGroups = new ArrayList<>(); private final AboveTheFoldItem mAboveTheFold = new AboveTheFoldItem(); private final SigninPromoItem mSigninPromo = new SigninPromoItem(); private final Footer mFooter = new Footer(); private final SpacingItem mBottomSpacer = new SpacingItem(); /** Maps suggestion categories to sections, with stable iteration ordering. */ private final Map<Integer, SuggestionsSection> mSections = new LinkedHashMap<>(); private class ItemTouchCallbacks extends ItemTouchHelper.Callback { @Override public void onSwiped(ViewHolder viewHolder, int direction) { mRecyclerView.onItemDismissStarted(viewHolder); NewTabPageAdapter.this.dismissItem(viewHolder.getAdapterPosition()); } @Override public void clearView(RecyclerView recyclerView, ViewHolder viewHolder) { // clearView() is called when an interaction with the item is finished, which does // not mean that the user went all the way and dismissed the item before releasing it. // We need to check that the item has been removed. if (viewHolder.getAdapterPosition() == RecyclerView.NO_POSITION) { mRecyclerView.onItemDismissFinished(viewHolder); } super.clearView(recyclerView, viewHolder); } @Override public boolean onMove(RecyclerView recyclerView, ViewHolder viewHolder, ViewHolder target) { assert false; // Drag and drop not supported, the method will never be called. return false; } @Override public int getMovementFlags(RecyclerView recyclerView, ViewHolder viewHolder) { assert viewHolder instanceof NewTabPageViewHolder; int swipeFlags = 0; if (((NewTabPageViewHolder) viewHolder).isDismissable()) { swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END; } return makeMovementFlags(0 /* dragFlags */, swipeFlags); } @Override public void onChildDraw(Canvas c, RecyclerView recyclerView, ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { assert viewHolder instanceof NewTabPageViewHolder; // The item has already been removed. We have nothing more to do. if (viewHolder.getAdapterPosition() == RecyclerView.NO_POSITION) return; // We use our own implementation of the dismissal animation, so we don't call the // parent implementation. (by default it changes the translation-X and elevation) mRecyclerView.updateViewStateForDismiss(dX, viewHolder); // If there is another item that should be animated at the same time, do the same to it. ViewHolder siblingViewHolder = getDismissSibling(viewHolder); if (siblingViewHolder != null) { mRecyclerView.updateViewStateForDismiss(dX, siblingViewHolder); } } } /** * Creates the adapter that will manage all the cards to display on the NTP. * * @param manager the NewTabPageManager to use to interact with the rest of the system. * @param aboveTheFoldView the layout encapsulating all the above-the-fold elements * (logo, search box, most visited tiles) * @param uiConfig the NTP UI configuration, to be passed to created views. */ public static NewTabPageAdapter create( NewTabPageManager manager, View aboveTheFoldView, UiConfig uiConfig) { NewTabPageAdapter adapter = new NewTabPageAdapter(manager, aboveTheFoldView, uiConfig); adapter.finishInitialization(); return adapter; } /** * Constructor for {@link NewTabPageAdapter}. The object is not completely ready to be used * until {@link #finishInitialization()} is called. Usage reserved for testing, prefer calling * {@link NewTabPageAdapter#create(NewTabPageManager, View, UiConfig)} in production code. */ @VisibleForTesting NewTabPageAdapter(NewTabPageManager manager, View aboveTheFoldView, UiConfig uiConfig) { mNewTabPageManager = manager; mAboveTheFoldView = aboveTheFoldView; mUiConfig = uiConfig; } /** * Initialises the sections to be handled by this adapter. Events about categories for which * a section has not been registered at this point will be ignored. */ @VisibleForTesting void finishInitialization() { mSigninPromo.setObserver(this); resetSections(); mNewTabPageManager.getSuggestionsSource().setObserver(this); mNewTabPageManager.registerSignInStateObserver(new SignInStateObserver() { @Override public void onSignedIn() { mSigninPromo.hide(); resetSections(); } @Override public void onSignedOut() { mSigninPromo.maybeShow(); } }); } /** Resets the sections, reloading the whole new tab page content. */ private void resetSections() { mSections.clear(); SuggestionsSource suggestionsSource = mNewTabPageManager.getSuggestionsSource(); int[] categories = suggestionsSource.getCategories(); int[] suggestionsPerCategory = new int[categories.length]; int i = 0; for (int category : categories) { int categoryStatus = suggestionsSource.getCategoryStatus(category); assert categoryStatus != CategoryStatus.NOT_PROVIDED; if (categoryStatus == CategoryStatus.LOADING_ERROR || categoryStatus == CategoryStatus.CATEGORY_EXPLICITLY_DISABLED) continue; suggestionsPerCategory[i++] = resetSection(category, categoryStatus); } mNewTabPageManager.trackSnippetsPageImpression(categories, suggestionsPerCategory); updateGroups(); } private int resetSection(@CategoryInt int category, @CategoryStatusEnum int categoryStatus) { SuggestionsSource suggestionsSource = mNewTabPageManager.getSuggestionsSource(); List<SnippetArticle> suggestions = suggestionsSource.getSuggestionsForCategory(category); // Create the new section. SuggestionsCategoryInfo info = suggestionsSource.getCategoryInfo(category); if (suggestions.isEmpty() && !info.showIfEmpty()) { mSections.remove(category); return 0; } SuggestionsSection section = mSections.get(category); if (section == null) { section = new SuggestionsSection(info, this); mSections.put(category, section); } // Add the new suggestions. setSuggestions(category, suggestions, categoryStatus); return suggestions.size(); } /** Returns callbacks to configure the interactions with the RecyclerView's items. */ public ItemTouchHelper.Callback getItemTouchCallbacks() { return mItemTouchCallbacks; } @Override public void onNewSuggestions(@CategoryInt int category) { // We never want to add suggestions from unknown categories. if (!mSections.containsKey(category)) return; // We never want to refresh the suggestions if we already have some content. if (mSections.get(category).hasSuggestions()) return; // The status may have changed while the suggestions were loading, perhaps they should not // be displayed any more. @CategoryStatusEnum int status = mNewTabPageManager.getSuggestionsSource().getCategoryStatus(category); if (!SnippetsBridge.isCategoryEnabled(status)) { Log.w(TAG, "Received suggestions for a disabled category (id=%d, status=%d)", category, status); return; } List<SnippetArticle> suggestions = mNewTabPageManager.getSuggestionsSource().getSuggestionsForCategory(category); Log.d(TAG, "Received %d new suggestions for category %d.", suggestions.size(), category); // At first, there might be no suggestions available, we wait until they have been fetched. if (suggestions.isEmpty()) return; setSuggestions(category, suggestions, status); NewTabPageUma.recordSnippetAction(NewTabPageUma.SNIPPETS_ACTION_SHOWN); } @Override public void onCategoryStatusChanged(@CategoryInt int category, @CategoryStatusEnum int status) { // Observers should not be registered for this state. assert status != CategoryStatus.ALL_SUGGESTIONS_EXPLICITLY_DISABLED; // If there is no section for this category there is nothing to do. if (!mSections.containsKey(category)) return; switch (status) { case CategoryStatus.NOT_PROVIDED: // The section provider has gone away. Keep open UIs as they are. return; case CategoryStatus.CATEGORY_EXPLICITLY_DISABLED: case CategoryStatus.LOADING_ERROR: // Need to remove the entire section from the UI immediately. removeSection(mSections.get(category)); return; case CategoryStatus.SIGNED_OUT: resetSection(category, status); return; default: mSections.get(category).setStatus(status); return; } } @Override public void onSuggestionInvalidated(@CategoryInt int category, String idWithinCategory) { if (!mSections.containsKey(category)) return; mSections.get(category).removeSuggestionById(idWithinCategory); } @Override @NewTabPageItem.ViewType public int getItemViewType(int position) { return getItems().get(position).getType(); } @Override public NewTabPageViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { assert parent == mRecyclerView; if (viewType == NewTabPageItem.VIEW_TYPE_ABOVE_THE_FOLD) { return new NewTabPageViewHolder(mAboveTheFoldView); } if (viewType == NewTabPageItem.VIEW_TYPE_HEADER) { return new SectionHeaderViewHolder(mRecyclerView, mUiConfig); } if (viewType == NewTabPageItem.VIEW_TYPE_SNIPPET) { return new SnippetArticleViewHolder(mRecyclerView, mNewTabPageManager, mUiConfig); } if (viewType == NewTabPageItem.VIEW_TYPE_SPACING) { return new NewTabPageViewHolder(SpacingItem.createView(parent)); } if (viewType == NewTabPageItem.VIEW_TYPE_STATUS) { return new StatusCardViewHolder(mRecyclerView, mUiConfig); } if (viewType == NewTabPageItem.VIEW_TYPE_PROGRESS) { return new ProgressViewHolder(mRecyclerView); } if (viewType == NewTabPageItem.VIEW_TYPE_ACTION) { return new ActionItem.ViewHolder(mRecyclerView, mNewTabPageManager, mUiConfig); } if (viewType == NewTabPageItem.VIEW_TYPE_PROMO) { return new SigninPromoItem.ViewHolder(mRecyclerView, mUiConfig); } if (viewType == NewTabPageItem.VIEW_TYPE_FOOTER) { return new Footer.ViewHolder(mRecyclerView, mNewTabPageManager); } return null; } @Override public void onBindViewHolder(NewTabPageViewHolder holder, final int position) { getItems().get(position).onBindViewHolder(holder); } @Override public int getItemCount() { return getItems().size(); } public int getAboveTheFoldPosition() { return getGroupPositionOffset(mAboveTheFold); } public int getFirstHeaderPosition() { List<NewTabPageItem> items = getItems(); for (int i = 0; i < items.size(); i++) { if (items.get(i) instanceof SectionHeader) return i; } return RecyclerView.NO_POSITION; } public int getFirstCardPosition() { for (int i = 0; i < getItemCount(); ++i) { if (CardViewHolder.isCard(getItemViewType(i))) return i; } return RecyclerView.NO_POSITION; } public int getLastContentItemPosition() { return getGroupPositionOffset(mFooter); } public int getBottomSpacerPosition() { return getGroupPositionOffset(mBottomSpacer); } public int getSuggestionPosition(SnippetArticle article) { List<NewTabPageItem> items = getItems(); for (int i = 0; i < items.size(); i++) { NewTabPageItem item = items.get(i); if (article.equals(item)) return i; } return RecyclerView.NO_POSITION; } /** Start a request for new snippets. */ public void reloadSnippets() { SnippetsBridge.fetchSnippets(/*forceRequest=*/true); } private void setSuggestions(@CategoryInt int category, List<SnippetArticle> suggestions, @CategoryStatusEnum int status) { // Count the number of suggestions before this category. int globalPositionOffset = 0; for (Map.Entry<Integer, SuggestionsSection> entry : mSections.entrySet()) { if (entry.getKey() == category) break; globalPositionOffset += entry.getValue().getSuggestionsCount(); } // Assign global indices to the new suggestions. for (SnippetArticle suggestion : suggestions) { suggestion.mGlobalPosition = globalPositionOffset + suggestion.mPosition; } mSections.get(category).setSuggestions(suggestions, status); } private void updateGroups() { mGroups.clear(); mGroups.add(mAboveTheFold); mGroups.addAll(mSections.values()); mGroups.add(mSigninPromo); if (hasVisibleBelowTheFoldItems()) { mGroups.add(mFooter); mGroups.add(mBottomSpacer); } notifyDataSetChanged(); } private void removeSection(SuggestionsSection section) { mSections.remove(section.getCategory()); int startPos = getGroupPositionOffset(section); mGroups.remove(section); int removedItems = section.getItems().size(); notifyItemRangeRemoved(startPos, removedItems); if (!hasVisibleBelowTheFoldItems()) { mGroups.remove(mFooter); mGroups.remove(mBottomSpacer); notifyItemRangeRemoved(startPos + removedItems, 2); } else { notifyItemChanged(getItems().size() - 1); // Refresh the spacer too. } } @Override public void notifyGroupChanged(ItemGroup group, int itemCountBefore, int itemCountAfter) { if (mGroups.isEmpty()) return; // The sections have not been initialised yet. int startPos = getGroupPositionOffset(group); if (group instanceof SuggestionsSection) { // The header is stable in sections. Don't notify about it. ++startPos; --itemCountBefore; --itemCountAfter; } if (itemCountBefore < itemCountAfter) { notifyItemRangeChanged(startPos, itemCountBefore); notifyItemRangeInserted(startPos + itemCountBefore, itemCountAfter - itemCountBefore); } else { notifyItemRangeChanged(startPos, itemCountAfter); notifyItemRangeRemoved(startPos + itemCountAfter, itemCountBefore - itemCountAfter); } notifyItemChanged(getItems().size() - 1); // Refresh the spacer too. } @Override public void notifyItemInserted(ItemGroup group, int itemPosition) { if (mGroups.isEmpty()) return; // The sections have not been initialised yet. notifyItemInserted(getGroupPositionOffset(group) + itemPosition); notifyItemChanged(getItems().size() - 1); // Refresh the spacer too. } @Override public void notifyItemRemoved(ItemGroup group, int itemPosition) { if (mGroups.isEmpty()) return; // The sections have not been initialised yet. notifyItemRemoved(getGroupPositionOffset(group) + itemPosition); notifyItemChanged(getItems().size() - 1); // Refresh the spacer too. } @Override public void onAttachedToRecyclerView(RecyclerView recyclerView) { super.onAttachedToRecyclerView(recyclerView); // We are assuming for now that the adapter is used with a single RecyclerView. // Getting the reference as we are doing here is going to be broken if that changes. assert mRecyclerView == null; // FindBugs chokes on the cast below when not checked, raising BC_UNCONFIRMED_CAST assert recyclerView instanceof NewTabPageRecyclerView; mRecyclerView = (NewTabPageRecyclerView) recyclerView; } /** * Dismisses the item at the provided adapter position. Can also cause the dismissal of other * items or even entire sections. */ // TODO(crbug.com/635567): Fix this properly. @SuppressLint("SwitchIntDef") public void dismissItem(int position) { int itemViewType = getItemViewType(position); // TODO(dgn): Polymorphism is supposed to allow to avoid that kind of stuff. switch (itemViewType) { case NewTabPageItem.VIEW_TYPE_STATUS: case NewTabPageItem.VIEW_TYPE_ACTION: dismissSection((SuggestionsSection) getGroup(position)); return; case NewTabPageItem.VIEW_TYPE_SNIPPET: dismissSuggestion(position); return; case NewTabPageItem.VIEW_TYPE_PROMO: dismissPromo(); return; default: Log.wtf(TAG, "Unsupported dismissal of item of type %d", itemViewType); return; } } private void dismissSection(SuggestionsSection section) { mNewTabPageManager.getSuggestionsSource().dismissCategory(section.getCategory()); removeSection(section); } private void dismissSuggestion(int position) { SnippetArticle suggestion = (SnippetArticle) getItems().get(position); SuggestionsSource suggestionsSource = mNewTabPageManager.getSuggestionsSource(); if (suggestionsSource == null) { // It is possible for this method to be called after the NewTabPage has had destroy() // called. This can happen when NewTabPageRecyclerView.dismissWithAnimation() is called // and the animation ends after the user has navigated away. In this case we cannot // inform the native side that the snippet has been dismissed (http://crbug.com/649299). return; } suggestionsSource.getSuggestionVisited(suggestion, new Callback<Boolean>() { @Override public void onResult(Boolean result) { NewTabPageUma.recordSnippetAction(result ? NewTabPageUma.SNIPPETS_ACTION_DISMISSED_VISITED : NewTabPageUma.SNIPPETS_ACTION_DISMISSED_UNVISITED); } }); announceItemRemoved(suggestion.mTitle); suggestionsSource.dismissSuggestion(suggestion); SuggestionsSection section = (SuggestionsSection) getGroup(position); section.removeSuggestion(suggestion); } private void dismissPromo() { // TODO(dgn): accessibility announcement. mSigninPromo.dismiss(); if (!hasVisibleBelowTheFoldItems()) { int footerPosition = getLastContentItemPosition(); mGroups.remove(mFooter); mGroups.remove(mBottomSpacer); notifyItemRangeRemoved(footerPosition, 2); } } /** * Returns an unmodifiable list containing all items in the adapter. */ private List<NewTabPageItem> getItems() { List<NewTabPageItem> items = new ArrayList<>(); for (ItemGroup group : mGroups) { items.addAll(group.getItems()); } return Collections.unmodifiableList(items); } /** * Returns another view holder that should be dismissed at the same time as the provided one. */ public ViewHolder getDismissSibling(ViewHolder viewHolder) { int swipePos = viewHolder.getAdapterPosition(); ItemGroup group = getGroup(swipePos); if (!(group instanceof SuggestionsSection)) return null; SuggestionsSection section = (SuggestionsSection) group; int siblingPosDelta = section.getDismissSiblingPosDelta(getItems().get(swipePos)); if (siblingPosDelta == 0) return null; return mRecyclerView.findViewHolderForAdapterPosition(siblingPosDelta + swipePos); } private boolean hasVisibleBelowTheFoldItems() { return !mSections.isEmpty() || mSigninPromo.isShown(); } @VisibleForTesting ItemGroup getGroup(int itemPosition) { int itemsSkipped = 0; for (ItemGroup group : mGroups) { List<NewTabPageItem> items = group.getItems(); itemsSkipped += items.size(); if (itemPosition < itemsSkipped) return group; } return null; } @VisibleForTesting List<ItemGroup> getGroups() { return Collections.unmodifiableList(mGroups); } @VisibleForTesting int getGroupPositionOffset(ItemGroup group) { int positionOffset = 0; for (ItemGroup candidateGroup : mGroups) { if (candidateGroup == group) return positionOffset; positionOffset += candidateGroup.getItems().size(); } Log.d(TAG, "Group not found: %s", group); return RecyclerView.NO_POSITION; } @VisibleForTesting SnippetArticle getSuggestionAt(int position) { return (SnippetArticle) getItems().get(position); } @VisibleForTesting void announceItemRemoved(String suggestionTitle) { mRecyclerView.announceForAccessibility(mRecyclerView.getResources().getString( R.string.ntp_accessibility_item_removed, suggestionTitle)); } }