// Copyright 2022 The Chromium Authors
// 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.tasks.tab_management;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.chromium.base.ContextUtils;
import org.chromium.base.ObserverList;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tabmodel.TabModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_management.TabUiMetricsHelper.TabListEditorExitMetricGroups;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.widget.selectable_list.SelectionDelegate;
import org.chromium.ui.modelutil.PropertyModel;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
/** Defines the core action of a {@link TabListEditorMenuItem}. */
public abstract class TabListEditorAction {
@IntDef({ShowMode.MENU_ONLY, ShowMode.IF_ROOM, ShowMode.NUM_ENTRIES})
@Retention(RetentionPolicy.SOURCE)
public @interface ShowMode {
/** Never show an ActionView, only show a menu item. */
int MENU_ONLY = 0;
/**
* Only show an ActionView if there is room. Priority is based on ordering amongst other
* {@link MenuItem}s.
*/
int IF_ROOM = 1;
int NUM_ENTRIES = 2;
}
@IntDef({ButtonType.TEXT, ButtonType.ICON, ButtonType.ICON_AND_TEXT, ButtonType.NUM_ENTRIES})
@Retention(RetentionPolicy.SOURCE)
public @interface ButtonType {
/** Show text in the ActionView. */
int TEXT = 0;
/** Show an icon in the ActionView. If an action has no icon then nothing will be shown. */
int ICON = 1;
/**
* Shows an icon and text for the ActionView. If an action has no icon this is equivalent to
* {@code TEXT}.
*/
int ICON_AND_TEXT = 2;
int NUM_ENTRIES = 3;
}
@IntDef({IconPosition.START, IconPosition.END, IconPosition.NUM_ENTRIES})
@Retention(RetentionPolicy.SOURCE)
public @interface IconPosition {
/** Show icon at the start in the ActionView. */
int START = 0;
/** Show icon at the end in the ActionView. */
int END = 1;
int NUM_ENTRIES = 2;
}
/** Observer for watching if an action is being taken. */
public interface ActionObserver {
// TODO(ckitagawa): Determine if this can be removed or moved to post processing.
/**
* Called at the start of {@link TabListEditorAction#perform()} before an action
* is executed.
* @param tabs The list of tabs that will be acted on.
*/
void preProcessSelectedTabs(List<Tab> tabs);
}
/**
* Delegate for handling additional selection and control actions for the TabListEditor.
*/
public interface ActionDelegate {
/** Selects all tabs in the current selection editor. */
void selectAll();
/** Clears all selected tabs. */
void deselectAll();
/** Whether all the tabs in the editor are selected. */
boolean areAllTabsSelected();
/** Hides the selection editor. */
void hideByAction();
/**
* Sync position of the client {@link TabListCoordinator}'s RecyclerView with the editor's.
*/
void syncRecyclerViewPosition();
/** Retrieves the SnackbarManager for the selection editor. */
SnackbarManager getSnackbarManager();
/** Retrieves the BottomSheetController for the selection editor. */
BottomSheetController getBottomSheetController();
}
private ObserverList<ActionObserver> mObsevers = new ObserverList<>();
private PropertyModel mModel;
private Supplier<TabModelFilter> mCurrentTabModelFilterSupplier;
private ActionDelegate mActionDelegate;
private SelectionDelegate<Integer> mSelectionDelegate;
private Boolean mEditorSupportsActionOnRelatedTabs;
public TabListEditorAction(
int menuItemId,
@ShowMode int showMode,
@ButtonType int buttonType,
@IconPosition int iconPosition,
int titleResourceId,
@Nullable Integer contentDescriptionResourceId,
@Nullable Drawable icon) {
assert showMode >= ShowMode.MENU_ONLY && showMode < ShowMode.NUM_ENTRIES;
assert buttonType >= ButtonType.TEXT && buttonType < ButtonType.NUM_ENTRIES;
assert iconPosition >= IconPosition.START && iconPosition < IconPosition.NUM_ENTRIES;
final String expectedResourceourceTypeName = "plurals";
boolean titleIsPlural =
expectedResourceourceTypeName.equals(
ContextUtils.getApplicationContext()
.getResources()
.getResourceTypeName(titleResourceId));
mModel =
new PropertyModel.Builder(TabListEditorActionProperties.ACTION_KEYS)
.with(TabListEditorActionProperties.MENU_ITEM_ID, menuItemId)
.with(TabListEditorActionProperties.SHOW_MODE, showMode)
.with(TabListEditorActionProperties.BUTTON_TYPE, buttonType)
.with(TabListEditorActionProperties.ICON_POSITION, iconPosition)
.with(TabListEditorActionProperties.TITLE_RESOURCE_ID, titleResourceId)
.with(TabListEditorActionProperties.TITLE_IS_PLURAL, titleIsPlural)
.with(TabListEditorActionProperties.ENABLED, false)
.with(TabListEditorActionProperties.ITEM_COUNT, 0)
.with(
TabListEditorActionProperties.TEXT_TINT,
ColorStateList.valueOf(Color.TRANSPARENT))
.with(
TabListEditorActionProperties.ICON_TINT,
ColorStateList.valueOf(Color.TRANSPARENT))
.with(TabListEditorActionProperties.ON_CLICK_LISTENER, this::perform)
.with(TabListEditorActionProperties.SHOULD_DISMISS_MENU, true)
.with(
TabListEditorActionProperties.ON_SELECTION_STATE_CHANGE,
this::onSelectionStateChange)
.build();
if (contentDescriptionResourceId != null) {
mModel.set(
TabListEditorActionProperties.CONTENT_DESCRIPTION_RESOURCE_ID,
contentDescriptionResourceId);
assert expectedResourceourceTypeName.equals(
ContextUtils.getApplicationContext()
.getResources()
.getResourceTypeName(contentDescriptionResourceId))
: "Quantity strings (plurals) with one integer format argument is needed";
}
if (icon != null) {
mModel.set(TabListEditorActionProperties.ICON, icon);
}
}
/**
* @param observer an {@link ActionObserver} to observe when this action occurs.
*/
public void addActionObserver(ActionObserver observer) {
mObsevers.addObserver(observer);
}
/**
* @param observer an {@link ActionObserver} to remove.
*/
public void removeActionObserver(ActionObserver observer) {
mObsevers.removeObserver(observer);
}
/**
* Defaults to notifying observers of when an action is taken. Should be overridden to false if
* the action changes the selection state rather than taking an action.
* @return Whether to notify obsevers of the action.
*/
public boolean shouldNotifyObserversOfAction() {
return true;
}
/**
* @return Whether the TabListEditor supports applying the actions to related tabs.
*/
public boolean editorSupportsActionOnRelatedTabs() {
assert mEditorSupportsActionOnRelatedTabs != null;
return mEditorSupportsActionOnRelatedTabs;
}
/**
* Actions should override this to decide if an action should be enabled and
* to provide the enabled state and count to the PropertyModel.
* @param tabIds the list of selected tab ids.
* @return Whether the action should be enabled.
*/
public abstract void onSelectionStateChange(List<Integer> tabIds);
/**
* Processes the selected tabs from the selection list this includes related tabs if
* {@link #editorSupportsActionOnRelatedTabs()} is true.
* @param tabs a list of tabs from getTabsFromSelection().
* @return Whether an action was performed without an error.
*/
public abstract boolean performAction(List<Tab> tabs);
/**
* @return Whether to hide the editor after tabking the action.
*/
public abstract boolean shouldHideEditorAfterAction();
/**
* Processes the selected tabs from the selection list.
* @return whether an action was taken.
*/
public boolean perform() {
assert mActionDelegate != null;
assert mCurrentTabModelFilterSupplier != null;
assert mSelectionDelegate != null;
List<Tab> tabs = getTabsOrTabsAndRelatedTabsFromSelection();
if (shouldNotifyObserversOfAction()) {
for (ActionObserver obs : mObsevers) {
obs.preProcessSelectedTabs(tabs);
}
}
// When hiding by action it is expected that syncRecyclerViewPosition() is called before the
// action occurs. This is because an action may remove tabs so it needs to sync position
// before the removal of items occurs to ensure the positions match correctly for
// animations.
if (shouldHideEditorAfterAction()) {
mActionDelegate.syncRecyclerViewPosition();
}
if (!performAction(tabs)) {
return false;
}
if (shouldHideEditorAfterAction()) {
mActionDelegate.hideByAction();
TabUiMetricsHelper.recordSelectionEditorExitMetrics(
TabListEditorExitMetricGroups.CLOSED_AUTOMATICALLY,
tabs.get(0).getContext());
}
return true;
}
/**
* Called by {@link TabListEditorMediator} to supply additional dependencies.
*
* @param currentTabModelFilterSupplier that this action should act on.
* @param selectionDelegate to get selected tab IDs from.
* @param actionDelegate to control the TabListEditor.
* @param editorSupportsActionOnRelatedTabs whether the TabListEditor supports actions on
* related tabs.
*/
void configure(
@NonNull Supplier<TabModelFilter> currentTabModelFilterSupplier,
@NonNull SelectionDelegate<Integer> selectionDelegate,
@NonNull ActionDelegate actionDelegate,
boolean editorSupportsActionOnRelatedTabs) {
mCurrentTabModelFilterSupplier = currentTabModelFilterSupplier;
mSelectionDelegate = selectionDelegate;
mActionDelegate = actionDelegate;
mEditorSupportsActionOnRelatedTabs = editorSupportsActionOnRelatedTabs;
onSelectionStateChange(mSelectionDelegate.getSelectedItemsAsList());
}
PropertyModel getPropertyModel() {
return mModel;
}
protected @NonNull TabGroupModelFilter getTabGroupModelFilter() {
TabGroupModelFilter filter = (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
assert filter != null;
return filter;
}
protected @NonNull ActionDelegate getActionDelegate() {
assert mActionDelegate != null;
return mActionDelegate;
}
protected void setEnabledAndItemCount(boolean enabled, int itemCount) {
mModel.set(TabListEditorActionProperties.ENABLED, enabled);
mModel.set(TabListEditorActionProperties.ITEM_COUNT, itemCount);
}
private List<Tab> getTabsFromSelection() {
List<Tab> selectedTabs = new ArrayList<>();
for (int tabId : mSelectionDelegate.getSelectedItems()) {
Tab tab = getTabGroupModelFilter().getTabModel().getTabById(tabId);
if (tab == null) continue;
selectedTabs.add(tab);
}
return selectedTabs;
}
private List<Tab> getTabsAndRelatedTabsFromSelection() {
TabGroupModelFilter filter = (TabGroupModelFilter) mCurrentTabModelFilterSupplier.get();
List<Tab> tabs = new ArrayList<>();
for (int tabId : mSelectionDelegate.getSelectedItems()) {
tabs.addAll(filter.getRelatedTabList(tabId));
}
return tabs;
}
protected List<Tab> getTabsOrTabsAndRelatedTabsFromSelection() {
return editorSupportsActionOnRelatedTabs()
? getTabsAndRelatedTabsFromSelection()
: getTabsFromSelection();
}
public static int getTabCountIncludingRelatedTabs(
TabGroupModelFilter tabGroupModelFilter, List<Integer> tabIds) {
int tabCount = 0;
for (int tabId : tabIds) {
Tab tab = tabGroupModelFilter.getTabModel().getTabById(tabId);
// TODO(crbug.com/41495189): Find out how we can have a tab ID that is no longer
// in the tab model here.
if (tab == null) continue;
tabCount += tabGroupModelFilter.getRelatedTabCountForRootId(tab.getRootId());
}
return tabCount;
}
}