blob: c31f79673d88e955e4a88bce09f2e921af85d4d9 [file] [log] [blame]
// 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.support.annotation.Nullable;
import android.text.TextUtils;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.browser.ntp.NewTabPageUma;
import org.chromium.chrome.browser.ntp.cards.NewTabPageViewHolder.PartialBindCallback;
import org.chromium.chrome.browser.ntp.snippets.CategoryInt;
import org.chromium.chrome.browser.ntp.snippets.CategoryStatus;
import org.chromium.chrome.browser.ntp.snippets.KnownCategories;
import org.chromium.chrome.browser.ntp.snippets.SectionHeader;
import org.chromium.chrome.browser.ntp.snippets.SnippetArticle;
import org.chromium.chrome.browser.ntp.snippets.SnippetArticleViewHolder;
import org.chromium.chrome.browser.ntp.snippets.SnippetsBridge;
import org.chromium.chrome.browser.ntp.snippets.SuggestionsSource;
import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
import org.chromium.chrome.browser.offlinepages.OfflinePageItem;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.preferences.PrefServiceBridge;
import org.chromium.chrome.browser.suggestions.SuggestionsConfig;
import org.chromium.chrome.browser.suggestions.SuggestionsOfflineModelObserver;
import org.chromium.chrome.browser.suggestions.SuggestionsRanker;
import org.chromium.chrome.browser.suggestions.SuggestionsUiDelegate;
import org.chromium.ui.modelutil.ListModelBase;
import org.chromium.ui.modelutil.ListObservable;
import org.chromium.ui.modelutil.PropertyListModel;
import org.chromium.ui.modelutil.SimpleRecyclerViewMcpBase;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* A group of suggestions, with a header, a status card, and a progress indicator. This is
* responsible for tracking whether its suggestions have been saved offline.
*/
public class SuggestionsSection extends InnerNode<NewTabPageViewHolder, PartialBindCallback> {
private static final String TAG = "NtpCards";
private final Delegate mDelegate;
private final SuggestionsCategoryInfo mCategoryInfo;
private final OfflineModelObserver mOfflineModelObserver;
private final SuggestionsSource mSuggestionsSource;
private final SuggestionsRanker mSuggestionsRanker;
private final PropertyListModel<SnippetArticle, PartialBindCallback> mSuggestions =
new PropertyListModel<>();
// Children
private final @Nullable SectionHeader mHeader;
private final @Nullable SignInPromo mSigninPromo;
private final SuggestionsList mSuggestionsList;
private final StatusItem mStatus;
private final ActionItem mMoreButton;
/**
* Stores whether any suggestions have been appended to the list. In this case the list can
* generally be longer than what is served by the Source. Thus, the list should never be
* replaced again.
*/
private boolean mHasAppended;
/**
* Whether the data displayed by this section is not the latest available and should be updated
* when the user stops interacting with this UI surface.
*/
private boolean mIsDataStale;
/**
* Whether the section has been destroyed.
*/
private boolean mIsDestroyed;
/**
* Delegate interface that allows dismissing this section without introducing
* a circular dependency.
*/
public interface Delegate {
/**
* Dismisses a section.
* @param section The section to be dismissed.
*/
void dismissSection(SuggestionsSection section);
}
public SuggestionsSection(Delegate delegate, SuggestionsUiDelegate uiDelegate,
SuggestionsRanker ranker, OfflinePageBridge offlinePageBridge,
SuggestionsCategoryInfo info) {
mDelegate = delegate;
mCategoryInfo = info;
mSuggestionsSource = uiDelegate.getSuggestionsSource();
mSuggestionsRanker = ranker;
boolean isExpandable = getCategory() == KnownCategories.ARTICLES;
boolean isExpanded =
PrefServiceBridge.getInstance().getBoolean(Pref.NTP_ARTICLES_LIST_VISIBLE);
// No header when touchless. The header allows users to choose to collapse/hide suggestions,
// but when touchless collapsing the suggestions shouldn't be necessary. There is no omnibox
// to distract from.
if (SuggestionsConfig.isTouchless()) {
mHeader = isExpandable ? new SectionHeader(info.getTitle(), isExpanded,
this::updateSuggestionsVisibilityForExpandableHeader)
: new SectionHeader(info.getTitle());
} else {
mHeader = null;
}
if (isExpandable && SignInPromo.shouldCreatePromo()) {
mSigninPromo = new SignInPromo();
mSigninPromo.setCanShowPersonalizedSuggestions(shouldShowSuggestions());
} else {
mSigninPromo = null;
}
mSuggestionsList =
new SuggestionsList(mSuggestionsSource, mSuggestions, this::bindSuggestion);
mMoreButton = new ActionItem(this, ranker);
mStatus = StatusItem.createNoSuggestionsItem(info);
mStatus.setVisible(shouldShowStatusItem());
if (mHeader != null) addChildren(mHeader);
if (mSigninPromo != null) addChildren(mSigninPromo);
addChildren(mSuggestionsList, mStatus, mMoreButton);
mOfflineModelObserver = new OfflineModelObserver(offlinePageBridge);
uiDelegate.addDestructionObserver(mOfflineModelObserver);
}
private static class SuggestionsList extends SimpleRecyclerViewMcpBase<SnippetArticle,
NewTabPageViewHolder, PartialBindCallback> {
private final SuggestionsSource mSuggestionsSource;
private final ListModelBase<SnippetArticle, PartialBindCallback> mSuggestions;
private boolean mIsDestroyed;
public SuggestionsList(SuggestionsSource suggestionsSource,
PropertyListModel<SnippetArticle, PartialBindCallback> suggestions,
ViewBinder<SnippetArticle, NewTabPageViewHolder, PartialBindCallback> viewBinder) {
super(ignored -> ItemViewType.SNIPPET, viewBinder, suggestions);
mSuggestionsSource = suggestionsSource;
mSuggestions = suggestions;
}
@Override
public String describeItemForTesting(int position) {
return String.format(
Locale.US, "SUGGESTION(%1.42s)", mSuggestions.get(position).mTitle);
}
@Override
public Set<Integer> getItemDismissalGroup(int position) {
return Collections.singleton(position);
}
@Override
public void dismissItem(int position, Callback<String> itemRemovedCallback) {
if (mIsDestroyed) {
// 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;
}
SnippetArticle suggestion = mSuggestions.removeAt(position);
mSuggestionsSource.dismissSuggestion(suggestion);
itemRemovedCallback.onResult(suggestion.mTitle);
}
public void updateSuggestionOfflineId(
SnippetArticle article, Long newId, boolean isPrefetched) {
int position = this.mSuggestions.indexOf(article);
// The suggestions could have been removed / replaced in the meantime.
if (position == -1) return;
Long oldId = article.getOfflinePageOfflineId();
article.setOfflinePageOfflineId(newId);
article.setIsPrefetched(isPrefetched);
// TODO(bauerb): This notification should be sent by the article itself.
if ((oldId == null) == (newId == null)) return;
notifyItemChanged(position, SnippetArticleViewHolder::refreshOfflineBadgeVisibility);
}
public void destroy() {
assert !mIsDestroyed;
mIsDestroyed = true;
}
}
public void destroy() {
assert !mIsDestroyed;
mOfflineModelObserver.onDestroy();
if (mSigninPromo != null) mSigninPromo.destroy();
mSuggestionsList.destroy();
mIsDestroyed = true;
}
private void onSuggestionsListCountChanged(int oldSuggestionsCount) {
int newSuggestionsCount = getSuggestionsCount();
if ((newSuggestionsCount == 0) == (oldSuggestionsCount == 0)) return;
mStatus.setVisible(shouldShowStatusItem());
// When the ActionItem stops being dismissable, it is possible that it was being
// interacted with. We need to reset the view's related property changes.
mMoreButton.maybeResetForDismiss();
}
@Override
public void onItemRangeRemoved(ListObservable child, int index, int count) {
super.onItemRangeRemoved(child, index, count);
if (child == mSuggestionsList) onSuggestionsListCountChanged(getSuggestionsCount() + count);
}
@Override
public void onItemRangeInserted(ListObservable child, int index, int count) {
super.onItemRangeInserted(child, index, count);
if (child == mSuggestionsList) {
onSuggestionsListCountChanged(getSuggestionsCount() - count);
}
}
@Override
protected void notifyItemRangeInserted(int index, int count) {
super.notifyItemRangeInserted(index, count);
notifyNeighboursModified(index - 1, index + count);
}
@Override
protected void notifyItemRangeRemoved(int index, int count) {
super.notifyItemRangeRemoved(index, count);
notifyNeighboursModified(index - 1, index);
}
/** Sends a notification to the items at the provided indices to refresh their background. */
private void notifyNeighboursModified(int aboveNeighbour, int belowNeighbour) {
assert aboveNeighbour < belowNeighbour;
if (aboveNeighbour >= 0) {
notifyItemChanged(aboveNeighbour, NewTabPageViewHolder::updateLayoutParams);
}
if (belowNeighbour < getItemCount()) {
notifyItemChanged(belowNeighbour, NewTabPageViewHolder::updateLayoutParams);
}
}
/**
* Removes a suggestion. Does nothing if the ID is unknown.
* @param idWithinCategory The ID of the suggestion to remove.
*/
public void removeSuggestionById(String idWithinCategory) {
int i = 0;
for (SnippetArticle suggestion : mSuggestions) {
if (suggestion.mIdWithinCategory.equals(idWithinCategory)) {
mSuggestions.removeAt(i);
return;
}
i++;
}
}
private int getNumberOfSuggestionsExposed() {
int exposedCount = 0;
int suggestionsCount = 0;
for (SnippetArticle suggestion : mSuggestions) {
++suggestionsCount;
// We treat all suggestions preceding an exposed suggestion as exposed too.
if (suggestion.mExposed) exposedCount = suggestionsCount;
}
return exposedCount;
}
private boolean hasSuggestions() {
return mSuggestions.size() != 0;
}
public int getSuggestionsCount() {
return mSuggestions.size();
}
public SnippetArticle getSuggestionForTesting(int index) {
return mSuggestions.get(index);
}
public boolean isDataStale() {
return mIsDataStale;
}
/** Whether the section is waiting for content to be loaded. */
public boolean isLoading() {
return mMoreButton.getState() == ActionItem.State.LOADING;
}
/**
* @return Whether the section is showing content cards. The placeholder is included in this
* check, as it's standing for content, but the status card is not.
*/
public boolean hasCards() {
return hasSuggestions();
}
private String[] getDisplayedSuggestionIds() {
String[] suggestionIds = new String[mSuggestions.size()];
for (int i = 0; i < mSuggestions.size(); ++i) {
suggestionIds[i] = mSuggestions.get(i).mIdWithinCategory;
}
return suggestionIds;
}
/**
* Requests the section to update itself. If possible, it will retrieve suggestions from the
* backend and use them to replace the current ones. This call may have no or only partial
* effect if changing the list of suggestions is not allowed (e.g. because the user has already
* seen the suggestions). In that case, the section will be flagged as stale.
* (see {@link #isDataStale()})
* Note, that this method also gets called if the user hits the "More" button on an empty list
* (either because all suggestions got dismissed or because they were removed due to privacy
* reasons; e.g. a user clearing their history).
*/
public void updateSuggestions() {
int numberOfSuggestionsExposed = getNumberOfSuggestionsExposed();
if (!canUpdateSuggestions(numberOfSuggestionsExposed)) {
mIsDataStale = true;
Log.d(TAG, "updateModels: Category %d is stale, it can't replace suggestions.",
getCategory());
return;
}
List<SnippetArticle> suggestions =
mSuggestionsSource.getSuggestionsForCategory(getCategory());
Log.d(TAG, "Received %d new suggestions for category %d, had %d previously.",
suggestions.size(), getCategory(), mSuggestions.size());
// Nothing to append, we can just exit now.
// TODO(dgn): Distinguish the init case where we have to wait? (https://crbug.com/711457)
if (suggestions.isEmpty()) return;
if (numberOfSuggestionsExposed > 0) {
mIsDataStale = true;
Log.d(TAG, "updateModels: Category %d is stale, will keep already seen suggestions.",
getCategory());
}
appendSuggestions(suggestions, /* keepSectionSize = */ true,
/* reportPrefetchedSuggestionsCount = */ false);
}
/**
* Adds the provided suggestions to the ones currently displayed by the section.
*
* @param suggestions The suggestions to be added at the end of the current list.
* @param keepSectionSize Whether the section size should stay the same -- will be enforced by
* replacing not-yet-seen suggestions with the new suggestions.
* @param reportPrefetchedSuggestionsCount Whether to report the number of prefetched article
* suggestions.
*/
public void appendSuggestions(List<SnippetArticle> suggestions, boolean keepSectionSize,
boolean reportPrefetchedSuggestionsCount) {
if (!shouldShowSuggestions()) return;
int numberOfSuggestionsExposed = getNumberOfSuggestionsExposed();
if (keepSectionSize) {
Log.d(TAG, "updateModels: keeping the first %d suggestion", numberOfSuggestionsExposed);
int numSuggestionsToAppend =
Math.max(0, suggestions.size() - numberOfSuggestionsExposed);
int itemCount = mSuggestions.size();
if (itemCount > numberOfSuggestionsExposed) {
mSuggestions.removeRange(
numberOfSuggestionsExposed, itemCount - numberOfSuggestionsExposed);
}
trimIncomingSuggestions(suggestions, /* targetSize = */ numSuggestionsToAppend);
}
if (!suggestions.isEmpty()) {
mSuggestions.addAll(suggestions);
}
mOfflineModelObserver.updateAllSuggestionsOfflineAvailability(
reportPrefetchedSuggestionsCount);
if (!keepSectionSize) {
NewTabPageUma.recordUIUpdateResult(
NewTabPageUma.ContentSuggestionsUIUpdateResult.SUCCESS_APPENDED);
mHasAppended = true;
} else {
NewTabPageUma.recordNumberOfSuggestionsSeenBeforeUIUpdateSuccess(
numberOfSuggestionsExposed);
NewTabPageUma.recordUIUpdateResult(
NewTabPageUma.ContentSuggestionsUIUpdateResult.SUCCESS_REPLACED);
}
}
/**
* De-duplicates the new suggestions with the ones kept in {@link #mSuggestionsList} and removes
* the excess of incoming items to make sure that the merged list has at most as many items as
* the incoming list.
*/
private void trimIncomingSuggestions(List<SnippetArticle> suggestions, int targetSize) {
for (SnippetArticle suggestion : mSuggestions) {
suggestions.remove(suggestion);
}
if (suggestions.size() > targetSize) {
Log.d(TAG, "trimIncomingSuggestions: removing %d excess elements from the end",
suggestions.size() - targetSize);
suggestions.subList(targetSize, suggestions.size()).clear();
}
}
/**
* Returns whether the list of suggestions can be updated at the moment.
*/
private boolean canUpdateSuggestions(int numberOfSuggestionsExposed) {
if (!shouldShowSuggestions()) return false;
if (!hasSuggestions()) return true; // If we don't have any, we always accept updates.
if (CardsVariationParameters.ignoreUpdatesForExistingSuggestions()) {
Log.d(TAG, "updateModels: replacing existing suggestion disabled");
NewTabPageUma.recordUIUpdateResult(
NewTabPageUma.ContentSuggestionsUIUpdateResult.FAIL_DISABLED);
return false;
}
if (numberOfSuggestionsExposed >= getSuggestionsCount() || mHasAppended) {
// In case that suggestions got removed, we assume they already were seen. This might
// be over-simplifying things, but given the rare occurences it should be good enough.
Log.d(TAG, "updateModels: replacing existing suggestion not possible, all seen");
NewTabPageUma.recordUIUpdateResult(
NewTabPageUma.ContentSuggestionsUIUpdateResult.FAIL_ALL_SEEN);
return false;
}
return true;
}
/**
* Fetches additional suggestions only for this section.
* @param onFailure A {@link Runnable} that will be run if the fetch fails.
* @param onNoNewSuggestions A {@link Runnable} that will be run if the fetch succeeds but
* provides no new suggestions.
*/
public void fetchSuggestions(@Nullable final Runnable onFailure,
@Nullable Runnable onNoNewSuggestions) {
assert !isLoading();
if (getSuggestionsCount() == 0 && getCategoryInfo().isRemote()) {
// Trigger a full refresh of the section to ensure we persist content locally.
// If a fetch can be made, the status will be synchronously updated from the backend.
mSuggestionsSource.fetchRemoteSuggestions();
return;
}
mMoreButton.updateState(ActionItem.State.LOADING);
mSuggestionsSource.fetchSuggestions(mCategoryInfo.getCategory(),
getDisplayedSuggestionIds(), suggestions -> { /* successCallback */
if (mIsDestroyed) return; // The section has been dismissed.
mMoreButton.updateState(ActionItem.State.BUTTON);
appendSuggestions(suggestions, /* keepSectionSize = */ false,
/* reportPrefetchedSuggestionsCount = */ false);
if (onNoNewSuggestions != null && suggestions.size() == 0) {
onNoNewSuggestions.run();
}
}, () -> { /* failureRunnable */
if (mIsDestroyed) return; // The section has been dismissed.
mMoreButton.updateState(ActionItem.State.BUTTON);
if (onFailure != null) onFailure.run();
});
}
/** Sets the status for the section. Some statuses can cause the suggestions to be cleared. */
public void setStatus(@CategoryStatus int status) {
if (!SnippetsBridge.isCategoryStatusAvailable(status)) {
clearData();
Log.d(TAG, "setStatus: unavailable status, cleared suggestions.");
}
boolean isLoading = SnippetsBridge.isCategoryLoading(status);
mMoreButton.updateState(!shouldShowSuggestions()
? ActionItem.State.HIDDEN
: (isLoading ? ActionItem.State.LOADING : ActionItem.State.BUTTON));
if (mSigninPromo != null) {
mSigninPromo.setCanShowPersonalizedSuggestions(shouldShowSuggestions());
}
}
/** Clears the suggestions and related data, resetting the state of the section. */
public void clearData() {
mSuggestions.set(Collections.emptyList());
mHasAppended = false;
mIsDataStale = false;
}
@CategoryInt
public int getCategory() {
return mCategoryInfo.getCategory();
}
/**
* Sets the visibility of this section's header. Note this will not work when header is not
* added to view hierarchy as a result of {@link SuggestionsConfig#isTouchless(boolean)}.
*/
public void setHeaderVisibility(boolean headerVisibility) {
if (mHeader != null) {
mHeader.setVisible(headerVisibility);
}
}
/**
* @return Whether or not the suggestions should be shown in this section.
*/
private boolean shouldShowSuggestions() {
if (mHeader == null) return true;
return !mHeader.isExpandable() || mHeader.isExpanded();
}
/**
* @return Whether or not the {@link StatusItem} should be shown in this section.
*/
private boolean shouldShowStatusItem() {
return shouldShowSuggestions() && !hasSuggestions();
}
/**
* Update the expandable header state to match the preference value if necessary. This can
* happen when the preference is updated by a user click on another new tab page.
*/
void updateExpandableHeader() {
if (mHeader != null && mHeader.isExpandable()
&& mHeader.isExpanded()
!= PrefServiceBridge.getInstance().getBoolean(
Pref.NTP_ARTICLES_LIST_VISIBLE)) {
mHeader.toggleHeader();
}
}
/**
* Update the visibility of the suggestions based on whether the header is expanded. This is
* called when the section header is toggled.
*/
private void updateSuggestionsVisibilityForExpandableHeader() {
assert mHeader != null;
assert mHeader.isExpandable();
PrefServiceBridge.getInstance().setBoolean(
Pref.NTP_ARTICLES_LIST_VISIBLE, mHeader.isExpanded());
clearData();
if (mHeader.isExpanded()) updateSuggestions();
setStatus(mSuggestionsSource.getCategoryStatus(getCategory()));
mStatus.setVisible(shouldShowStatusItem());
}
public SuggestionsCategoryInfo getCategoryInfo() {
return mCategoryInfo;
}
ActionItem getActionItemForTesting() {
return mMoreButton;
}
public SectionHeader getHeaderItemForTesting() {
return mHeader;
}
private class OfflineModelObserver extends SuggestionsOfflineModelObserver<SnippetArticle> {
public OfflineModelObserver(OfflinePageBridge bridge) {
super(bridge);
}
@Override
public void onSuggestionOfflineIdChanged(SnippetArticle suggestion, OfflinePageItem item) {
boolean isPrefetched = item != null
&& TextUtils.equals(item.getClientId().getNamespace(),
OfflinePageBridge.SUGGESTED_ARTICLES_NAMESPACE);
mSuggestionsList.updateSuggestionOfflineId(
suggestion, item == null ? null : item.getOfflineId(), isPrefetched);
}
@Override
public Iterable<SnippetArticle> getOfflinableSuggestions() {
return mSuggestions;
}
}
private void bindSuggestion(NewTabPageViewHolder holder, SnippetArticle suggestion,
@Nullable PartialBindCallback callback) {
if (callback != null) {
callback.onResult(holder);
return;
}
mSuggestionsRanker.rankSuggestion(suggestion);
((SnippetArticleViewHolder) holder).onBindViewHolder(suggestion, mCategoryInfo);
}
@VisibleForTesting
SignInPromo getSignInPromoForTesting() {
return mSigninPromo;
}
}