blob: 824c72a3820c33d35d3f03b10b0d01cf36687009 [file] [log] [blame]
// Copyright 2018 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.download.home.list;
import android.content.Intent;
import android.os.Handler;
import android.support.v4.util.Pair;
import androidx.annotation.Nullable;
import org.chromium.base.Callback;
import org.chromium.base.CollectionUtil;
import org.chromium.chrome.browser.GlobalDiscardableReferencePool;
import org.chromium.chrome.browser.download.home.DownloadManagerUiConfig;
import org.chromium.chrome.browser.download.home.JustNowProvider;
import org.chromium.chrome.browser.download.home.OfflineItemSource;
import org.chromium.chrome.browser.download.home.filter.DeleteUndoOfflineItemFilter;
import org.chromium.chrome.browser.download.home.filter.Filters.FilterType;
import org.chromium.chrome.browser.download.home.filter.InvalidStateOfflineItemFilter;
import org.chromium.chrome.browser.download.home.filter.OffTheRecordOfflineItemFilter;
import org.chromium.chrome.browser.download.home.filter.OfflineItemFilter;
import org.chromium.chrome.browser.download.home.filter.OfflineItemFilterObserver;
import org.chromium.chrome.browser.download.home.filter.OfflineItemFilterSource;
import org.chromium.chrome.browser.download.home.filter.SearchOfflineItemFilter;
import org.chromium.chrome.browser.download.home.filter.TypeOfflineItemFilter;
import org.chromium.chrome.browser.download.home.glue.OfflineContentProviderGlue;
import org.chromium.chrome.browser.download.home.glue.ThumbnailRequestGlue;
import org.chromium.chrome.browser.download.home.list.DateOrderedListCoordinator.DateOrderedListObserver;
import org.chromium.chrome.browser.download.home.list.DateOrderedListCoordinator.DeleteController;
import org.chromium.chrome.browser.download.home.metrics.OfflineItemStartupLogger;
import org.chromium.chrome.browser.download.home.metrics.UmaUtils;
import org.chromium.chrome.browser.download.home.metrics.UmaUtils.ViewAction;
import org.chromium.chrome.browser.widget.ThumbnailProvider;
import org.chromium.chrome.browser.widget.ThumbnailProvider.ThumbnailRequest;
import org.chromium.chrome.browser.widget.ThumbnailProviderImpl;
import org.chromium.chrome.browser.widget.selection.SelectionDelegate;
import org.chromium.components.offline_items_collection.OfflineContentProvider;
import org.chromium.components.offline_items_collection.OfflineItem;
import org.chromium.components.offline_items_collection.OfflineItemShareInfo;
import org.chromium.components.offline_items_collection.OfflineItemState;
import org.chromium.components.offline_items_collection.VisualsCallback;
import java.io.Closeable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* A Mediator responsible for converting an OfflineContentProvider to a list of items in downloads
* home. This includes support for filtering, deleting, etc..
*/
class DateOrderedListMediator {
/** Helper interface for handling share requests by the UI. */
@FunctionalInterface
public interface ShareController {
/**
* Will be called whenever {@link OfflineItem}s are being requested to be shared by the UI.
* @param intent The {@link Intent} representing the share action to broadcast to Android.
*/
void share(Intent intent);
}
/**
* Helper interface for handling rename requests by the UI, allows implementers of the
* RenameController to finish the asynchronous rename operation.
*/
@FunctionalInterface
public interface RenameCallback {
/**
* Calling this will asynchronously attempt to commit a new name.
* @param newName String representing the new name user designated to rename the item.
* @param callback A callback that will pass to the backend to determine the validation
* result.
*/
void tryToRename(String newName, Callback</*RenameResult*/ Integer> callback);
}
/** Helper interface for handling rename requests by the UI. */
@FunctionalInterface
public interface RenameController {
/**
* Will be called whenever {@link OfflineItem}s are being requested to be renamed by the UI.
* @param name representing new name user designated to rename the item.
*/
void rename(String name, RenameCallback result);
}
private final Handler mHandler = new Handler();
private final OfflineContentProviderGlue mProvider;
private final ShareController mShareController;
private final ListItemModel mModel;
private final DeleteController mDeleteController;
private final RenameController mRenameController;
private final OfflineItemSource mSource;
private final DateOrderedListMutator mListMutator;
private final ThumbnailProvider mThumbnailProvider;
private final MediatorSelectionObserver mSelectionObserver;
private final SelectionDelegate<ListItem> mSelectionDelegate;
private final DownloadManagerUiConfig mUiConfig;
private final OffTheRecordOfflineItemFilter mOffTheRecordFilter;
private final InvalidStateOfflineItemFilter mInvalidStateFilter;
private final DeleteUndoOfflineItemFilter mDeleteUndoFilter;
private final TypeOfflineItemFilter mTypeFilter;
private final SearchOfflineItemFilter mSearchFilter;
/**
* A selection observer that correctly updates the selection state for each item in the list.
*/
private class MediatorSelectionObserver
implements SelectionDelegate.SelectionObserver<ListItem> {
private final SelectionDelegate<ListItem> mSelectionDelegate;
public MediatorSelectionObserver(SelectionDelegate<ListItem> delegate) {
mSelectionDelegate = delegate;
mSelectionDelegate.addObserver(this);
}
@Override
public void onSelectionStateChange(List<ListItem> selectedItems) {
for (int i = 0; i < mModel.size(); i++) {
ListItem item = mModel.get(i);
boolean selected = mSelectionDelegate.isItemSelected(item);
item.showSelectedAnimation = selected && !item.selected;
item.selected = selected;
mModel.update(i, item);
}
mModel.dispatchLastEvent();
mModel.getProperties().set(
ListProperties.SELECTION_MODE_ACTIVE, mSelectionDelegate.isSelectionEnabled());
}
}
/**
* Creates an instance of a DateOrderedListMediator that will push {@code provider} into
* {@code model}.
* @param provider The {@link OfflineContentProvider} to visually represent.
* @param deleteController A class to manage whether or not items can be deleted.
* @param shareController A class responsible for sharing downloaded item {@link
* Intent}s.
* @param selectionDelegate A class responsible for handling list item selection.
* @param config A {@link DownloadManagerUiConfig} to provide UI config params.
* @param dateOrderedListObserver An observer of the list and recycler view.
* @param model The {@link ListItemModel} to push {@code provider} into.
*/
public DateOrderedListMediator(OfflineContentProvider provider, ShareController shareController,
DeleteController deleteController, RenameController renameController,
SelectionDelegate<ListItem> selectionDelegate, DownloadManagerUiConfig config,
DateOrderedListObserver dateOrderedListObserver, ListItemModel model) {
// Build a chain from the data source to the model. The chain will look like:
// [OfflineContentProvider] ->
// [OfflineItemSource] ->
// [OffTheRecordOfflineItemFilter] ->
// [InvalidStateOfflineItemFilter] ->
// [DeleteUndoOfflineItemFilter] ->
// [SearchOfflineItemFitler] ->
// [TypeOfflineItemFilter] ->
// [DateOrderedListMutator] ->
// [ListItemModel]
mProvider = new OfflineContentProviderGlue(provider, config);
mShareController = shareController;
mModel = model;
mDeleteController = deleteController;
mRenameController = renameController;
mSelectionDelegate = selectionDelegate;
mUiConfig = config;
mSource = new OfflineItemSource(mProvider);
mOffTheRecordFilter = new OffTheRecordOfflineItemFilter(config.isOffTheRecord, mSource);
mInvalidStateFilter = new InvalidStateOfflineItemFilter(mOffTheRecordFilter);
mDeleteUndoFilter = new DeleteUndoOfflineItemFilter(mInvalidStateFilter);
mSearchFilter = new SearchOfflineItemFilter(mDeleteUndoFilter);
mTypeFilter = new TypeOfflineItemFilter(mSearchFilter);
mListMutator = new DateOrderedListMutator(
mTypeFilter, mModel, config, new JustNowProvider(config));
new OfflineItemStartupLogger(config, mInvalidStateFilter);
mSearchFilter.addObserver(new EmptyStateObserver(mSearchFilter, dateOrderedListObserver));
mThumbnailProvider =
new ThumbnailProviderImpl(GlobalDiscardableReferencePool.getReferencePool(),
config.inMemoryThumbnailCacheSizeBytes,
ThumbnailProviderImpl.ClientType.DOWNLOAD_HOME);
mSelectionObserver = new MediatorSelectionObserver(selectionDelegate);
mModel.getProperties().set(ListProperties.ENABLE_ITEM_ANIMATIONS, true);
mModel.getProperties().set(ListProperties.CALLBACK_OPEN, this ::onOpenItem);
mModel.getProperties().set(ListProperties.CALLBACK_PAUSE, this ::onPauseItem);
mModel.getProperties().set(ListProperties.CALLBACK_RESUME, this ::onResumeItem);
mModel.getProperties().set(ListProperties.CALLBACK_CANCEL, this ::onCancelItem);
mModel.getProperties().set(ListProperties.CALLBACK_SHARE, this ::onShareItem);
mModel.getProperties().set(ListProperties.CALLBACK_REMOVE, this ::onDeleteItem);
mModel.getProperties().set(ListProperties.PROVIDER_VISUALS, this ::getVisuals);
mModel.getProperties().set(ListProperties.CALLBACK_SELECTION, this ::onSelection);
mModel.getProperties().set(ListProperties.CALLBACK_RENAME,
mUiConfig.isRenameEnabled ? this::onRenameItem : null);
}
/** Tears down this mediator. */
public void destroy() {
mSource.destroy();
mProvider.destroy();
mThumbnailProvider.destroy();
}
/**
* To be called when this mediator should filter its content based on {@code filter}.
* @see TypeOfflineItemFilter#onFilterSelected(int)
*/
public void onFilterTypeSelected(@FilterType int filter) {
mListMutator.onFilterTypeSelected(filter);
try (AnimationDisableClosable closeable = new AnimationDisableClosable()) {
mTypeFilter.onFilterSelected(filter);
}
}
/**
* To be called when this mediator should filter its content based on {@code filter}.
* @see SearchOfflineItemFilter#onQueryChanged(String)
*/
public void onFilterStringChanged(String filter) {
try (AnimationDisableClosable closeable = new AnimationDisableClosable()) {
mSearchFilter.onQueryChanged(filter);
}
}
/**
* Called to delete the list of currently selected items.
* @return The number of items that were deleted.
*/
public int deleteSelectedItems() {
deleteItemsInternal(ListUtils.toOfflineItems(mSelectionDelegate.getSelectedItems()));
int itemCount = mSelectionDelegate.getSelectedItems().size();
mSelectionDelegate.clearSelection();
return itemCount;
}
/**
* Called to share the list of currently selected items.
* @return The number of items that were shared.
*/
public int shareSelectedItems() {
shareItemsInternal(ListUtils.toOfflineItems(mSelectionDelegate.getSelectedItems()));
int itemCount = mSelectionDelegate.getSelectedItems().size();
mSelectionDelegate.clearSelection();
return itemCount;
}
/** Called to handle a back press event. */
public boolean handleBackPressed() {
if (mSelectionDelegate.isSelectionEnabled()) {
mSelectionDelegate.clearSelection();
return true;
}
return false;
}
/**
* @return The {@link OfflineItemFilterSource} that should be used to determine which filter
* options are available.
*/
public OfflineItemFilterSource getFilterSource() {
return mSearchFilter;
}
/**
* @return The {@link OfflineItemFilterSource} that should be used to determine whether there
* are no items and empty view should be shown.
*/
public OfflineItemFilterSource getEmptySource() {
return mTypeFilter;
}
private void onSelection(@Nullable ListItem item) {
mSelectionDelegate.toggleSelectionForItem(item);
}
private void onOpenItem(OfflineItem item) {
UmaUtils.recordItemAction(ViewAction.OPEN);
mProvider.openItem(item);
}
private void onPauseItem(OfflineItem item) {
UmaUtils.recordItemAction(ViewAction.PAUSE);
mProvider.pauseDownload(item);
}
private void onResumeItem(OfflineItem item) {
UmaUtils.recordItemAction(ViewAction.RESUME);
mProvider.resumeDownload(item, true /* hasUserGesture */);
}
private void onCancelItem(OfflineItem item) {
UmaUtils.recordItemAction(ViewAction.CANCEL);
mProvider.cancelDownload(item);
}
private void onDeleteItem(OfflineItem item) {
UmaUtils.recordItemAction(ViewAction.MENU_DELETE);
deleteItemsInternal(CollectionUtil.newArrayList(item));
}
private void onShareItem(OfflineItem item) {
UmaUtils.recordItemAction(ViewAction.MENU_SHARE);
shareItemsInternal(CollectionUtil.newHashSet(item));
}
private void onRenameItem(OfflineItem item) {
UmaUtils.recordItemAction(ViewAction.MENU_RENAME);
mRenameController.rename(item.title, (newName, renameCallback) -> {
mProvider.renameItem(item, newName, renameCallback);
});
}
/**
* Deletes a given list of items. If the items are not completed yet, they would be cancelled.
* @param items The list of items to delete.
*/
private void deleteItemsInternal(List<OfflineItem> items) {
// Calculate the real offline items we are going to remove here.
final Collection<OfflineItem> itemsToDelete =
ItemUtils.findItemsWithSameFilePath(items, mSource.getItems());
mDeleteUndoFilter.addPendingDeletions(itemsToDelete);
mDeleteController.canDelete(items, delete -> {
if (delete) {
for (OfflineItem item : itemsToDelete) {
if (item.state != OfflineItemState.COMPLETE) {
mProvider.cancelDownload(item);
} else {
mProvider.removeItem(item);
}
// Remove and have a single decision path for cleaning up thumbnails when the
// glue layer is no longer needed.
mProvider.removeVisualsForItem(mThumbnailProvider, item.id);
}
} else {
mDeleteUndoFilter.removePendingDeletions(itemsToDelete);
}
});
}
private void shareItemsInternal(Collection<OfflineItem> items) {
UmaUtils.recordItemsShared(items);
final Collection<Pair<OfflineItem, OfflineItemShareInfo>> shareInfo = new ArrayList<>();
for (OfflineItem item : items) {
mProvider.getShareInfoForItem(item, (id, info) -> {
shareInfo.add(Pair.create(item, info));
// When we've gotten callbacks for all items, create and share the intent.
if (shareInfo.size() == items.size()) {
Intent intent = ShareUtils.createIntent(shareInfo);
if (intent != null) mShareController.share(intent);
}
});
}
}
private Runnable getVisuals(
OfflineItem item, int iconWidthPx, int iconHeightPx, VisualsCallback callback) {
if (!UiUtils.canHaveThumbnails(item) || iconWidthPx == 0 || iconHeightPx == 0) {
mHandler.post(() -> callback.onVisualsAvailable(item.id, null));
return () -> {};
}
ThumbnailRequest request = new ThumbnailRequestGlue(mProvider, item, iconWidthPx,
iconHeightPx, mUiConfig.maxThumbnailScaleFactor, callback);
mThumbnailProvider.getThumbnail(request);
return () -> mThumbnailProvider.cancelRetrieval(request);
}
/** Helper class to disable animations for certain list changes. */
private class AnimationDisableClosable implements Closeable {
AnimationDisableClosable() {
mModel.getProperties().set(ListProperties.ENABLE_ITEM_ANIMATIONS, false);
}
// Closeable implementation.
@Override
public void close() {
mHandler.post(() -> {
mModel.getProperties().set(ListProperties.ENABLE_ITEM_ANIMATIONS, true);
});
}
}
/**
* A helper class to observe the list content and notify the given observer when the list state
* changes between empty and non-empty.
*/
private static class EmptyStateObserver implements OfflineItemFilterObserver {
private Boolean mIsEmpty;
private final DateOrderedListObserver mDateOrderedListObserver;
private final OfflineItemFilter mOfflineItemFilter;
public EmptyStateObserver(OfflineItemFilter offlineItemFilter,
DateOrderedListObserver dateOrderedListObserver) {
mOfflineItemFilter = offlineItemFilter;
mDateOrderedListObserver = dateOrderedListObserver;
new Handler().post(() -> calculateEmptyState());
}
@Override
public void onItemsAvailable() {
calculateEmptyState();
}
@Override
public void onItemsAdded(Collection<OfflineItem> items) {
calculateEmptyState();
}
@Override
public void onItemsRemoved(Collection<OfflineItem> items) {
calculateEmptyState();
}
@Override
public void onItemUpdated(OfflineItem oldItem, OfflineItem item) {
calculateEmptyState();
}
private void calculateEmptyState() {
Boolean isEmpty = mOfflineItemFilter.getItems().isEmpty();
if (isEmpty.equals(mIsEmpty)) return;
mIsEmpty = isEmpty;
mDateOrderedListObserver.onEmptyStateChanged(mIsEmpty);
}
}
}