blob: cec30ecb200db304d2f927a8d80fb41b6099cbd4 [file] [log] [blame]
// Copyright 2017 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.suggestions;
import android.graphics.Bitmap;
import android.support.annotation.IntDef;
import android.support.annotation.Nullable;
import android.util.SparseArray;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnCreateContextMenuListener;
import org.chromium.base.Callback;
import org.chromium.base.VisibleForTesting;
import org.chromium.chrome.browser.favicon.IconType;
import org.chromium.chrome.browser.favicon.LargeIconBridge;
import org.chromium.chrome.browser.native_page.ContextMenuManager;
import org.chromium.chrome.browser.native_page.ContextMenuManager.ContextMenuItemId;
import org.chromium.chrome.browser.offlinepages.OfflinePageBridge;
import org.chromium.chrome.browser.offlinepages.OfflinePageItem;
import org.chromium.ui.mojom.WindowOpenDisposition;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* The model and controller for a group of site suggestion tiles.
*/
public class TileGroup implements MostVisitedSites.Observer {
/**
* Performs work in other parts of the system that the {@link TileGroup} should not know about.
*/
public interface Delegate {
/**
* @param tile The tile corresponding to the most visited item to remove.
* @param removalUndoneCallback The callback to invoke if the removal is reverted. The
* callback's argument is the URL being restored.
*/
void removeMostVisitedItem(Tile tile, Callback<String> removalUndoneCallback);
void openMostVisitedItem(int windowDisposition, Tile tile);
/**
* Gets the list of most visited sites.
* @param observer The observer to be notified with the list of sites.
* @param maxResults The maximum number of sites to retrieve.
*/
void setMostVisitedSitesObserver(MostVisitedSites.Observer observer, int maxResults);
/**
* Called when the tile group has completely finished loading (all views will be inflated
* and any dependent resources will have been loaded).
* @param tiles The tiles owned by the {@link TileGroup}. Used to record metrics.
*/
void onLoadingComplete(List<Tile> tiles);
/**
* To be called before this instance is abandoned to the garbage collector so it can do any
* necessary cleanups. This instance must not be used after this method is called.
*/
void destroy();
}
/**
* An observer for events in the {@link TileGroup}.
*/
public interface Observer {
/**
* Called when the tile group is initialised and when any of the tile data has changed,
* such as an icon, url, or title.
*/
void onTileDataChanged();
/**
* Called when the number of tiles has changed.
*/
void onTileCountChanged();
/**
* Called when a tile icon has changed.
* @param tile The tile for which the icon has changed.
*/
void onTileIconChanged(Tile tile);
/**
* Called when the visibility of a tile's offline badge has changed.
* @param tile The tile for which the visibility of the offline badge has changed.
*/
void onTileOfflineBadgeVisibilityChanged(Tile tile);
}
/**
* A delegate to allow {@link TileRenderer} to setup behaviours for the newly created views
* associated to a Tile.
*/
public interface TileSetupDelegate {
/**
* Returns a delegate that will handle user interactions with the view created for the tile.
*/
TileInteractionDelegate createInteractionDelegate(Tile tile);
/**
* Returns a callback to be invoked when the icon for the provided tile is loaded. It will
* be responsible for updating the tile data and triggering the visual refresh.
*/
LargeIconBridge.LargeIconCallback createIconLoadCallback(Tile tile);
}
/**
* Constants used to track the current operations on the group and notify the {@link Delegate}
* when the expected sequence of potentially asynchronous operations is complete.
*/
@VisibleForTesting
@IntDef({TileTask.FETCH_DATA, TileTask.SCHEDULE_ICON_FETCH, TileTask.FETCH_ICON})
@Retention(RetentionPolicy.SOURCE)
@interface TileTask {
/**
* An event that should result in new data being loaded happened.
* Can be an asynchronous task, spanning from when the {@link Observer} is registered to
* when the initial load completes.
*/
int FETCH_DATA = 1;
/**
* New tile data has been loaded and we are expecting the related icons to be fetched.
* Can be an asynchronous task, as we rely on it being triggered by the embedder, some time
* after {@link Observer#onTileDataChanged()} is called.
*/
int SCHEDULE_ICON_FETCH = 2;
/**
* The icon for a tile is being fetched.
* Asynchronous task, that is started for each icon that needs to be loaded.
*/
int FETCH_ICON = 3;
}
private final SuggestionsUiDelegate mUiDelegate;
private final ContextMenuManager mContextMenuManager;
private final Delegate mTileGroupDelegate;
private final Observer mObserver;
private final TileRenderer mTileRenderer;
/**
* Tracks the tasks currently in flight.
*
* We only care about which ones are pending, not their order, and we can have multiple tasks
* pending of the same type. Hence exposing the type as Collection rather than List or Set.
*/
private final Collection<Integer> mPendingTasks = new ArrayList<>();
/** Access point to offline related features. */
private final OfflineModelObserver mOfflineModelObserver;
/**
* Source of truth for the tile data. Avoid keeping a reference to a tile in long running
* callbacks, as it might be thrown out before it is called. Use URL or site data to look it up
* at the right time instead.
* @see #findTile(SiteSuggestion)
* @see #findTilesForUrl(String)
*/
private SparseArray<List<Tile>> mTileSections = createEmptyTileData();
/** Most recently received tile data that has not been displayed yet. */
@Nullable
private List<SiteSuggestion> mPendingTiles;
/**
* URL of the most recently removed tile. Used to identify when a tile removal is confirmed by
* the tile backend.
*/
@Nullable
private String mPendingRemovalUrl;
/**
* URL of the most recently added tile. Used to identify when a given tile's insertion is
* confirmed by the tile backend. This is relevant when a previously existing tile is removed,
* then the user undoes the action and wants that tile back.
*/
@Nullable
private String mPendingInsertionUrl;
private boolean mHasReceivedData;
// TODO(dgn): Attempt to avoid cycling dependencies with TileRenderer. Is there a better way?
private final TileSetupDelegate mTileSetupDelegate = new TileSetupDelegate() {
@Override
public TileInteractionDelegate createInteractionDelegate(Tile tile) {
return new TileInteractionDelegate(tile.getData());
}
@Override
public LargeIconBridge.LargeIconCallback createIconLoadCallback(Tile tile) {
// TODO(dgn): We could save on fetches by avoiding a new one when there is one pending
// for the same URL, and applying the result to all matched URLs.
boolean trackLoad =
isLoadTracked() && tile.getSectionType() == TileSectionType.PERSONALIZED;
if (trackLoad) addTask(TileTask.FETCH_ICON);
return new LargeIconCallbackImpl(tile.getData(), trackLoad);
}
};
/**
* @param tileRenderer Used to render icons.
* @param uiDelegate Delegate used to interact with the rest of the system.
* @param contextMenuManager Used to handle context menu invocations on the tiles.
* @param tileGroupDelegate Used for interactions with the Most Visited backend.
* @param observer Will be notified of changes to the tile data.
* @param offlinePageBridge Used to update the offline badge of the tiles.
*/
public TileGroup(TileRenderer tileRenderer, SuggestionsUiDelegate uiDelegate,
ContextMenuManager contextMenuManager, Delegate tileGroupDelegate, Observer observer,
OfflinePageBridge offlinePageBridge) {
mUiDelegate = uiDelegate;
mContextMenuManager = contextMenuManager;
mTileGroupDelegate = tileGroupDelegate;
mObserver = observer;
mTileRenderer = tileRenderer;
mOfflineModelObserver = new OfflineModelObserver(offlinePageBridge);
mUiDelegate.addDestructionObserver(mOfflineModelObserver);
}
@Override
public void onSiteSuggestionsAvailable(List<SiteSuggestion> siteSuggestions) {
// Only transforms the incoming tiles and stores them in a buffer for when we decide to
// refresh the tiles in the UI.
boolean removalCompleted = mPendingRemovalUrl != null;
boolean insertionCompleted = mPendingInsertionUrl == null;
mPendingTiles = new ArrayList<>();
for (SiteSuggestion suggestion : siteSuggestions) {
mPendingTiles.add(suggestion);
// Only tiles in the personal section can be modified.
if (suggestion.sectionType != TileSectionType.PERSONALIZED) continue;
if (suggestion.url.equals(mPendingRemovalUrl)) removalCompleted = false;
if (suggestion.url.equals(mPendingInsertionUrl)) insertionCompleted = true;
}
boolean expectedChangeCompleted = false;
if (mPendingRemovalUrl != null && removalCompleted) {
mPendingRemovalUrl = null;
expectedChangeCompleted = true;
}
if (mPendingInsertionUrl != null && insertionCompleted) {
mPendingInsertionUrl = null;
expectedChangeCompleted = true;
}
if (!mHasReceivedData || !mUiDelegate.isVisible() || expectedChangeCompleted) loadTiles();
}
@Override
public void onIconMadeAvailable(String siteUrl) {
for (Tile tile : findTilesForUrl(siteUrl)) {
mTileRenderer.updateIcon(tile.getData(),
new LargeIconCallbackImpl(tile.getData(), /* trackLoadTask = */ false));
}
}
/**
* Instructs this instance to start listening for data. The {@link TileGroup.Observer} may be
* called immediately if new data is received synchronously.
* @param maxResults The maximum number of sites to retrieve.
*/
public void startObserving(int maxResults) {
addTask(TileTask.FETCH_DATA);
mTileGroupDelegate.setMostVisitedSitesObserver(this, maxResults);
}
/**
* Method to be called when a tile render has been triggered, to let the {@link TileGroup}
* update its internal task tracking status.
* @see Delegate#onLoadingComplete(List)
*/
public void notifyTilesRendered() {
// Icon fetch scheduling was done when building the tile views.
if (isLoadTracked()) removeTask(TileTask.SCHEDULE_ICON_FETCH);
}
/** @return the sites currently loaded in the group, grouped by vertical. */
public SparseArray<List<Tile>> getTileSections() {
return mTileSections;
}
public boolean hasReceivedData() {
return mHasReceivedData;
}
/** @return Whether the group has no sites to display. */
public boolean isEmpty() {
for (int i = 0; i < mTileSections.size(); i++) {
if (!mTileSections.valueAt(i).isEmpty()) return false;
}
return true;
}
/**
* To be called when the view displaying the tile group becomes visible.
* @param trackLoadTask whether the delegate should be notified that the load is completed
* through {@link Delegate#onLoadingComplete(List)}.
*/
public void onSwitchToForeground(boolean trackLoadTask) {
if (trackLoadTask) addTask(TileTask.FETCH_DATA);
if (mPendingTiles != null) loadTiles();
if (trackLoadTask) removeTask(TileTask.FETCH_DATA);
}
/** Loads tile data from {@link #mPendingTiles} and clears it afterwards. */
private void loadTiles() {
assert mPendingTiles != null;
boolean isInitialLoad = !mHasReceivedData;
mHasReceivedData = true;
boolean dataChanged = isInitialLoad;
List<Tile> personalisedTiles = mTileSections.get(TileSectionType.PERSONALIZED);
int oldPersonalisedTilesCount = personalisedTiles == null ? 0 : personalisedTiles.size();
SparseArray<List<Tile>> newSites = createEmptyTileData();
for (int i = 0; i < mPendingTiles.size(); ++i) {
SiteSuggestion suggestion = mPendingTiles.get(i);
Tile tile = findTile(suggestion);
if (tile == null) {
dataChanged = true;
tile = new Tile(suggestion, i);
}
List<Tile> sectionTiles = newSites.get(suggestion.sectionType);
if (sectionTiles == null) {
sectionTiles = new ArrayList<>();
newSites.append(suggestion.sectionType, sectionTiles);
}
// This is not supposed to happen but does. See https://crbug.com/703628
if (findTile(suggestion.url, sectionTiles) != null) continue;
sectionTiles.add(tile);
}
mTileSections = newSites;
mPendingTiles = null;
// TODO(dgn): change these events, maybe introduce new ones or just change semantics? This
// will depend on the UI to be implemented and the desired refresh behaviour.
List<Tile> personalizedTiles = mTileSections.get(TileSectionType.PERSONALIZED);
int numberOfPersonalizedTiles = personalizedTiles == null ? 0 : personalizedTiles.size();
boolean countChanged =
isInitialLoad || numberOfPersonalizedTiles != oldPersonalisedTilesCount;
dataChanged = dataChanged || countChanged;
if (!dataChanged) return;
mOfflineModelObserver.updateAllSuggestionsOfflineAvailability(
/* reportPrefetchedSuggestionsCount = */ false);
if (countChanged) mObserver.onTileCountChanged();
if (isLoadTracked()) addTask(TileTask.SCHEDULE_ICON_FETCH);
mObserver.onTileDataChanged();
if (isInitialLoad) removeTask(TileTask.FETCH_DATA);
}
@Nullable
private Tile findTile(SiteSuggestion suggestion) {
if (mTileSections.get(suggestion.sectionType) == null) return null;
for (Tile tile : mTileSections.get(suggestion.sectionType)) {
if (tile.getData().equals(suggestion)) return tile;
}
return null;
}
/**
* @param url The URL to search for.
* @param tiles The section to search in, represented by the contained list of tiles.
* @return A tile matching the provided URL and section, or {@code null} if none is found. */
private Tile findTile(String url, @Nullable List<Tile> tiles) {
if (tiles == null) return null;
for (Tile tile : tiles) {
if (tile.getUrl().equals(url)) return tile;
}
return null;
}
/** @return All tiles matching the provided URL, or an empty list if none is found. */
private List<Tile> findTilesForUrl(String url) {
List<Tile> tiles = new ArrayList<>();
for (int i = 0; i < mTileSections.size(); ++i) {
for (Tile tile : mTileSections.valueAt(i)) {
if (tile.getUrl().equals(url)) tiles.add(tile);
}
}
return tiles;
}
private void addTask(@TileTask int task) {
mPendingTasks.add(task);
}
private void removeTask(@TileTask int task) {
boolean removedTask = mPendingTasks.remove(Integer.valueOf(task));
assert removedTask;
if (mPendingTasks.isEmpty()) {
// TODO(dgn): We only notify about the personal tiles because that's the only ones we
// wait for to be loaded. We also currently rely on the tile order in the returned
// array as the reported position in UMA, but this is not accurate and would be broken
// if we returned all the tiles regardless of sections.
List<Tile> personalTiles = mTileSections.get(TileSectionType.PERSONALIZED);
assert personalTiles != null;
mTileGroupDelegate.onLoadingComplete(personalTiles);
}
}
/**
* @return Whether the current load is being tracked. Unrequested task tracking updates should
* not be sent, as it would cause calling {@link Delegate#onLoadingComplete(List)} at the
* wrong moment.
*/
private boolean isLoadTracked() {
return mPendingTasks.contains(TileTask.FETCH_DATA)
|| mPendingTasks.contains(TileTask.SCHEDULE_ICON_FETCH);
}
@VisibleForTesting
boolean isTaskPending(@TileTask int task) {
return mPendingTasks.contains(task);
}
@VisibleForTesting
TileSetupDelegate getTileSetupDelegate() {
return mTileSetupDelegate;
}
@Nullable
public SiteSuggestion getHomepageTileData() {
for (Tile tile : mTileSections.get(TileSectionType.PERSONALIZED)) {
if (tile.getSource() == TileSource.HOMEPAGE) {
return tile.getData();
}
}
return null;
}
private static SparseArray<List<Tile>> createEmptyTileData() {
SparseArray<List<Tile>> newTileData = new SparseArray<>();
// TODO(dgn): How do we want to handle empty states and sections that have no tiles?
// Have an empty list for now that can be rendered as-is without causing issues or too much
// state checking. We will have to decide if we want empty lists or no section at all for
// the others.
newTileData.put(TileSectionType.PERSONALIZED, new ArrayList<Tile>());
return newTileData;
}
// TODO(dgn): I would like to move that to TileRenderer, but setting the data on the tile,
// notifying the observer and updating the tasks make it awkward.
private class LargeIconCallbackImpl implements LargeIconBridge.LargeIconCallback {
private final SiteSuggestion mSiteData;
private final boolean mTrackLoadTask;
private LargeIconCallbackImpl(SiteSuggestion suggestion, boolean trackLoadTask) {
mSiteData = suggestion;
mTrackLoadTask = trackLoadTask;
}
@Override
public void onLargeIconAvailable(@Nullable Bitmap icon, int fallbackColor,
boolean isFallbackColorDefault, @IconType int iconType) {
Tile tile = findTile(mSiteData);
if (tile != null) { // Do nothing if the tile was removed.
tile.setIconType(iconType);
if (icon == null) {
mTileRenderer.setTileIconFromColor(tile, fallbackColor, isFallbackColorDefault);
} else {
mTileRenderer.setTileIconFromBitmap(tile, icon);
}
mObserver.onTileIconChanged(tile);
}
// This call needs to be made after the tiles are completely initialised, for UMA.
if (mTrackLoadTask) removeTask(TileTask.FETCH_ICON);
}
}
/**
* Implements various listener and delegate interfaces to handle user interactions with tiles.
*/
public class TileInteractionDelegate
implements ContextMenuManager.Delegate, OnClickListener, OnCreateContextMenuListener {
private final SiteSuggestion mSuggestion;
private Runnable mOnClickRunnable;
public TileInteractionDelegate(SiteSuggestion suggestion) {
mSuggestion = suggestion;
}
@Override
public void onClick(View view) {
Tile tile = findTile(mSuggestion);
if (tile == null) return;
SuggestionsMetrics.recordTileTapped();
if (mOnClickRunnable != null) mOnClickRunnable.run();
mTileGroupDelegate.openMostVisitedItem(WindowOpenDisposition.CURRENT_TAB, tile);
}
@Override
public void openItem(int windowDisposition) {
Tile tile = findTile(mSuggestion);
if (tile == null) return;
mTileGroupDelegate.openMostVisitedItem(windowDisposition, tile);
}
@Override
public void removeItem() {
Tile tile = findTile(mSuggestion);
if (tile == null) return;
// Note: This does not track all the removals, but will track the most recent one. If
// that removal is committed, it's good enough for change detection.
mPendingRemovalUrl = mSuggestion.url;
mTileGroupDelegate.removeMostVisitedItem(tile, url -> mPendingInsertionUrl = url);
}
@Override
public String getUrl() {
return mSuggestion.url;
}
@Override
public boolean isItemSupported(@ContextMenuItemId int menuItemId) {
switch (menuItemId) {
// Personalized tiles are the only tiles that can be removed.
case ContextMenuItemId.REMOVE:
return mSuggestion.sectionType == TileSectionType.PERSONALIZED;
case ContextMenuItemId.LEARN_MORE:
return SuggestionsConfig.scrollToLoad();
default:
return true;
}
}
@Override
public void onContextMenuCreated() {}
@Override
public void onCreateContextMenu(
ContextMenu contextMenu, View view, ContextMenuInfo contextMenuInfo) {
mContextMenuManager.createContextMenu(contextMenu, view, this);
}
/**
* Set a runnable for click events on the tile. This is primarily used to track interaction
* with the tile used by feature engagement purposes.
* @param clickRunnable The {@link Runnable} to be executed when tile is clicked.
*/
public void setOnClickRunnable(Runnable clickRunnable) {
mOnClickRunnable = clickRunnable;
}
}
private class OfflineModelObserver extends SuggestionsOfflineModelObserver<Tile> {
public OfflineModelObserver(OfflinePageBridge bridge) {
super(bridge);
}
@Override
public void onSuggestionOfflineIdChanged(Tile tile, OfflinePageItem item) {
boolean oldOfflineAvailable = tile.isOfflineAvailable();
tile.setOfflinePageOfflineId(item == null ? null : item.getOfflineId());
// Only notify to update the view if there will be a visible change.
if (oldOfflineAvailable == tile.isOfflineAvailable()) return;
mObserver.onTileOfflineBadgeVisibilityChanged(tile);
}
@Override
public Iterable<Tile> getOfflinableSuggestions() {
List<Tile> tiles = new ArrayList<>();
for (int i = 0; i < mTileSections.size(); ++i) tiles.addAll(mTileSections.valueAt(i));
return tiles;
}
}
}