// Copyright 2023 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.content.browser.selection;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.app.RemoteAction;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.textclassifier.TextClassification;
import androidx.annotation.IdRes;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.PackageManagerUtils;
import org.chromium.base.StrictModeContext;
import org.chromium.content.R;
import org.chromium.content_public.browser.ContentFeatureMap;
import org.chromium.content_public.browser.SelectionClient;
import org.chromium.content_public.browser.SelectionClient.Result;
import org.chromium.content_public.browser.SelectionMenuGroup;
import org.chromium.content_public.browser.SelectionMenuItem;
import org.chromium.content_public.browser.selection.SelectionActionMenuDelegate;
import org.chromium.content_public.common.ContentFeatures;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
/**
* Utility class around menu items for the text selection action menu.
* This was created (as opposed to using a menu.xml) because we have multiple ways of rendering the
* menu that cannot necessarily leverage the {@link android.view.Menu} & {@link MenuItem} APIs.
*/
public class SelectActionMenuHelper {
private static final String TAG = "SelectActionMenu"; // 20 char limit.
@Retention(RetentionPolicy.SOURCE)
@IntDef({
GroupItemOrder.ASSIST_ITEMS,
GroupItemOrder.DEFAULT_ITEMS,
GroupItemOrder.SECONDARY_ASSIST_ITEMS,
GroupItemOrder.TEXT_PROCESSING_ITEMS
})
public @interface GroupItemOrder {
int ASSIST_ITEMS = 1;
int DEFAULT_ITEMS = 2;
int SECONDARY_ASSIST_ITEMS = 3;
int TEXT_PROCESSING_ITEMS = 4;
}
@Retention(RetentionPolicy.SOURCE)
@IntDef({
DefaultItemOrder.CUT,
DefaultItemOrder.COPY,
DefaultItemOrder.PASTE,
DefaultItemOrder.PASTE_AS_PLAIN_TEXT,
DefaultItemOrder.SHARE,
DefaultItemOrder.SELECT_ALL,
DefaultItemOrder.WEB_SEARCH
})
public @interface DefaultItemOrder {
int CUT = 1;
int COPY = 2;
int PASTE = 3;
int PASTE_AS_PLAIN_TEXT = 4;
int SHARE = 5;
int SELECT_ALL = 6;
int WEB_SEARCH = 7;
}
@Retention(RetentionPolicy.SOURCE)
@IntDef({
ItemKeyShortcuts.CUT,
ItemKeyShortcuts.COPY,
ItemKeyShortcuts.PASTE,
ItemKeyShortcuts.SELECT_ALL
})
public @interface ItemKeyShortcuts {
char CUT = 'x';
char COPY = 'c';
char PASTE = 'v';
char SELECT_ALL = 'a';
}
/** Delegate for the select action menu. */
public interface SelectActionMenuDelegate {
boolean canCut();
boolean canCopy();
boolean canPaste();
boolean canShare();
boolean canSelectAll();
boolean canWebSearch();
boolean canPasteAsPlainText();
}
/** For the text processing menu items. */
public interface TextProcessingIntentHandler {
void handleIntent(Intent textProcessingIntent);
}
// Do not instantiate.
private SelectActionMenuHelper() {}
/** Removes all the menu item groups potentially added using {@link #getMenuItems}. */
public static void removeAllAddedGroupsFromMenu(Menu menu) {
// Only remove action mode items we added. See more http://crbug.com/709878.
menu.removeGroup(R.id.select_action_menu_default_items);
menu.removeGroup(R.id.select_action_menu_assist_items);
menu.removeGroup(R.id.select_action_menu_text_processing_items);
menu.removeGroup(android.R.id.textAssist);
}
/**
* Returns all items for the text selection menu when there is text selected.
*
* @param context the context used by the menu.
* @param classificationResult the text classification result.
* @param isSelectionReadOnly true if the selection is non-editable.
* @param textProcessingIntentHandler the intent handler for text processing actions.
*/
public static SortedSet<SelectionMenuGroup> getMenuItems(
SelectActionMenuDelegate delegate,
Context context,
@Nullable SelectionClient.Result classificationResult,
boolean isSelectionPassword,
boolean isSelectionReadOnly,
String selectedText,
@Nullable TextProcessingIntentHandler textProcessingIntentHandler,
@Nullable SelectionActionMenuDelegate selectionActionMenuDelegate) {
SortedSet<SelectionMenuGroup> itemGroups = new TreeSet<>();
itemGroups.add(
getDefaultItems(
context,
delegate,
selectionActionMenuDelegate,
isSelectionPassword,
selectedText));
SelectionMenuGroup primaryAssistItems =
getPrimaryAssistItems(context, selectedText, classificationResult);
if (primaryAssistItems != null) itemGroups.add(primaryAssistItems);
SelectionMenuGroup secondaryAssistItems =
getSecondaryAssistItems(
selectionActionMenuDelegate, classificationResult, selectedText);
if (secondaryAssistItems != null) itemGroups.add(secondaryAssistItems);
itemGroups.add(
getTextProcessingItems(
context,
isSelectionPassword,
isSelectionReadOnly,
textProcessingIntentHandler,
selectionActionMenuDelegate));
return itemGroups;
}
@Nullable
@RequiresApi(Build.VERSION_CODES.O)
private static SelectionMenuGroup getPrimaryAssistItems(
Context context,
String selectedText,
@Nullable SelectionClient.Result classificationResult) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || selectedText.isEmpty()) {
return null;
}
if (classificationResult == null || !classificationResult.hasNamedAction()) {
return null;
}
SelectionMenuGroup primaryAssistGroup =
new SelectionMenuGroup(
R.id.select_action_menu_assist_items, GroupItemOrder.ASSIST_ITEMS);
View.OnClickListener clickListener = null;
if (classificationResult.onClickListener != null) {
clickListener = classificationResult.onClickListener;
} else if (classificationResult.intent != null) {
clickListener = v -> context.startActivity(classificationResult.intent);
}
primaryAssistGroup.addItem(
new SelectionMenuItem.Builder(classificationResult.label)
.setId(android.R.id.textAssist)
.setIcon(getPrimaryActionIconForClassificationResult(classificationResult))
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM)
.setClickListener(clickListener)
.build());
return primaryAssistGroup;
}
@VisibleForTesting
static SelectionMenuGroup getDefaultItems(
@Nullable Context context,
SelectActionMenuDelegate delegate,
@Nullable SelectionActionMenuDelegate selectionActionMenuDelegate,
boolean isSelectionPassword,
String selectedText) {
SelectionMenuGroup defaultGroup =
new SelectionMenuGroup(
R.id.select_action_menu_default_items, GroupItemOrder.DEFAULT_ITEMS);
List<SelectionMenuItem.Builder> menuItemBuilders = new ArrayList<>();
menuItemBuilders.add(cut(delegate.canCut()));
menuItemBuilders.add(copy(delegate.canCopy()));
menuItemBuilders.add(paste(delegate.canPaste()));
menuItemBuilders.add(share(context, delegate.canShare()));
menuItemBuilders.add(selectAll(delegate.canSelectAll()));
menuItemBuilders.add(webSearch(context, delegate.canWebSearch()));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
menuItemBuilders.add(pasteAsPlainText(context, delegate.canPasteAsPlainText()));
}
if (ContentFeatureMap.isEnabled(ContentFeatures.SELECTION_MENU_ITEM_MODIFICATION)
&& selectionActionMenuDelegate != null) {
selectionActionMenuDelegate.modifyDefaultMenuItems(
menuItemBuilders, isSelectionPassword, selectedText);
}
for (SelectionMenuItem.Builder builder : menuItemBuilders) {
defaultGroup.addItem(builder.build());
}
return defaultGroup;
}
@Nullable
private static SelectionMenuGroup getSecondaryAssistItems(
@Nullable SelectionActionMenuDelegate selectionActionMenuDelegate,
@Nullable Result classificationResult,
String selectedText) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null;
// We have to use android.R.id.textAssist as group id to make framework show icons for
// menu items if there is selected text.
@IdRes int groupId = selectedText.isEmpty() ? Menu.NONE : android.R.id.textAssist;
SelectionMenuGroup secondaryAssistItems =
new SelectionMenuGroup(groupId, GroupItemOrder.SECONDARY_ASSIST_ITEMS);
if (selectedText.isEmpty() && selectionActionMenuDelegate != null) {
List<SelectionMenuItem> additionalMenuItems =
selectionActionMenuDelegate.getAdditionalNonSelectionItems();
if (!additionalMenuItems.isEmpty()) {
secondaryAssistItems.addItems(additionalMenuItems);
return secondaryAssistItems;
}
}
if (classificationResult == null) {
return null;
}
TextClassification classification = classificationResult.textClassification;
if (classification == null) {
return null;
}
List<RemoteAction> actions = classification.getActions();
if (actions == null) {
return null;
}
final int count = actions.size();
if (count < 2) {
// More than one item is needed as the first item is reserved for the
// primary assist item.
return null;
}
List<Drawable> icons = classificationResult.additionalIcons;
assert icons == null || icons.size() == count
: "icons list should be either null or have the same length with actions.";
// First action is reserved for primary action so start at index 1.
final int startIndex = 1;
for (int i = startIndex; i < count; i++) {
RemoteAction action = actions.get(i);
final View.OnClickListener listener = getActionClickListener(action);
if (listener == null) continue;
SelectionMenuItem item =
new SelectionMenuItem.Builder(action.getTitle())
.setId(Menu.NONE)
.setIcon(icons == null ? null : icons.get(i))
.setOrderInCategory(i - startIndex)
.setContentDescription(action.getContentDescription())
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM)
.setClickListener(listener)
.build();
secondaryAssistItems.addItem(item);
}
return secondaryAssistItems;
}
@VisibleForTesting
/* package */ static SelectionMenuGroup getTextProcessingItems(
Context context,
boolean isSelectionPassword,
boolean isSelectionReadOnly,
@Nullable TextProcessingIntentHandler intentHandler,
@Nullable SelectionActionMenuDelegate selectionActionMenuDelegate) {
SelectionMenuGroup textProcessingItems =
new SelectionMenuGroup(
R.id.select_action_menu_text_processing_items,
GroupItemOrder.TEXT_PROCESSING_ITEMS);
if (isSelectionPassword || intentHandler == null) {
addAdditionalTextProcessingItems(textProcessingItems, selectionActionMenuDelegate);
return textProcessingItems;
}
List<ResolveInfo> supportedActivities =
PackageManagerUtils.queryIntentActivities(createProcessTextIntent(), 0);
if (ContentFeatureMap.isEnabled(ContentFeatures.SELECTION_MENU_ITEM_MODIFICATION) &&
selectionActionMenuDelegate != null) {
supportedActivities =
selectionActionMenuDelegate.filterTextProcessingActivities(supportedActivities);
}
if (supportedActivities.isEmpty()) {
addAdditionalTextProcessingItems(textProcessingItems, selectionActionMenuDelegate);
return textProcessingItems;
}
final PackageManager packageManager = context.getPackageManager();
for (int i = 0; i < supportedActivities.size(); i++) {
ResolveInfo resolveInfo = supportedActivities.get(i);
if (resolveInfo.activityInfo == null || !resolveInfo.activityInfo.exported) continue;
CharSequence title = resolveInfo.loadLabel(packageManager);
Drawable icon;
try (StrictModeContext ignored = StrictModeContext.allowDiskWrites();
StrictModeContext ignored2 = StrictModeContext.allowUnbufferedIo()) {
icon = resolveInfo.loadIcon(packageManager);
}
Intent intent = createProcessTextIntentForResolveInfo(resolveInfo, isSelectionReadOnly);
View.OnClickListener listener = v -> intentHandler.handleIntent(intent);
textProcessingItems.addItem(
new SelectionMenuItem.Builder(title)
.setId(Menu.NONE)
.setIcon(icon)
.setOrderInCategory(i)
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_IF_ROOM)
.setClickListener(listener)
.setIntent(intent)
.build());
}
addAdditionalTextProcessingItems(textProcessingItems, selectionActionMenuDelegate);
return textProcessingItems;
}
private static void addAdditionalTextProcessingItems(
SelectionMenuGroup textProcessingItems,
SelectionActionMenuDelegate selectionActionMenuDelegate) {
if (ContentFeatureMap.isEnabled(ContentFeatures.SELECTION_MENU_ITEM_MODIFICATION)
&& selectionActionMenuDelegate != null) {
textProcessingItems.addItems(
selectionActionMenuDelegate.getAdditionalTextProcessingItems());
}
}
private static Intent createProcessTextIntentForResolveInfo(
ResolveInfo info, boolean isSelectionReadOnly) {
return createProcessTextIntent()
.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, isSelectionReadOnly)
.setClassName(info.activityInfo.packageName, info.activityInfo.name);
}
private static Intent createProcessTextIntent() {
return new Intent().setAction(Intent.ACTION_PROCESS_TEXT).setType("text/plain");
}
@Nullable
private static Drawable getPrimaryActionIconForClassificationResult(
SelectionClient.Result classificationResult) {
final List<Drawable> additionalIcons = classificationResult.additionalIcons;
Drawable icon;
if (additionalIcons != null && !additionalIcons.isEmpty()) {
// The primary action is always first so check index 0.
icon = additionalIcons.get(0);
} else {
icon = classificationResult.icon;
}
return icon;
}
@Nullable
@RequiresApi(Build.VERSION_CODES.O)
private static View.OnClickListener getActionClickListener(RemoteAction action) {
if (TextUtils.isEmpty(action.getTitle()) || action.getActionIntent() == null) {
return null;
}
return v -> {
try {
ActivityOptions options = ActivityOptions.makeBasic();
ApiCompatibilityUtils.setActivityOptionsBackgroundActivityStartMode(options);
action.getActionIntent()
.send(
ContextUtils.getApplicationContext(),
0,
null,
null,
null,
null,
options.toBundle());
} catch (PendingIntent.CanceledException e) {
Log.e(TAG, "Error creating OnClickListener from PendingIntent", e);
}
};
}
private static SelectionMenuItem.Builder cut(boolean isEnabled) {
return new SelectionMenuItem.Builder(android.R.string.cut)
.setId(R.id.select_action_menu_cut)
.setIconAttr(android.R.attr.actionModeCutDrawable)
.setAlphabeticShortcut(ItemKeyShortcuts.CUT)
.setOrderInCategory(DefaultItemOrder.CUT)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT)
.setIsEnabled(isEnabled)
.setIsIconTintable(true);
}
private static SelectionMenuItem.Builder copy(boolean isEnabled) {
return new SelectionMenuItem.Builder(android.R.string.copy)
.setId(R.id.select_action_menu_copy)
.setIconAttr(android.R.attr.actionModeCopyDrawable)
.setAlphabeticShortcut(ItemKeyShortcuts.COPY)
.setOrderInCategory(DefaultItemOrder.COPY)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT)
.setIsEnabled(isEnabled)
.setIsIconTintable(true);
}
private static SelectionMenuItem.Builder paste(boolean isEnabled) {
return new SelectionMenuItem.Builder(android.R.string.paste)
.setId(R.id.select_action_menu_paste)
.setIconAttr(android.R.attr.actionModePasteDrawable)
.setAlphabeticShortcut(ItemKeyShortcuts.PASTE)
.setOrderInCategory(DefaultItemOrder.PASTE)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT)
.setIsEnabled(isEnabled)
.setIsIconTintable(true);
}
private static SelectionMenuItem.Builder share(@Nullable Context context, boolean isEnabled) {
if (context == null) {
context = ContextUtils.getApplicationContext();
}
return new SelectionMenuItem.Builder(context.getString(R.string.actionbar_share))
.setId(R.id.select_action_menu_share)
.setIconAttr(android.R.attr.actionModeShareDrawable)
.setOrderInCategory(DefaultItemOrder.SHARE)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT)
.setIsEnabled(isEnabled)
.setIsIconTintable(true);
}
private static SelectionMenuItem.Builder selectAll(boolean isEnabled) {
return new SelectionMenuItem.Builder(android.R.string.selectAll)
.setId(R.id.select_action_menu_select_all)
.setIconAttr(android.R.attr.actionModeSelectAllDrawable)
.setAlphabeticShortcut(ItemKeyShortcuts.SELECT_ALL)
.setOrderInCategory(DefaultItemOrder.SELECT_ALL)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT)
.setIsEnabled(isEnabled)
.setIsIconTintable(true);
}
@RequiresApi(Build.VERSION_CODES.O)
private static SelectionMenuItem.Builder pasteAsPlainText(
@Nullable Context context, boolean isEnabled) {
SelectionMenuItem.Builder builder =
new SelectionMenuItem.Builder(android.R.string.paste_as_plain_text)
.setId(R.id.select_action_menu_paste_as_plain_text)
.setOrderInCategory(DefaultItemOrder.PASTE_AS_PLAIN_TEXT)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT)
.setIsEnabled(isEnabled);
if (context != null) {
builder.setIcon(ContextCompat.getDrawable(context, R.drawable.ic_paste_as_plain_text))
.setIsIconTintable(true);
}
return builder;
}
private static SelectionMenuItem.Builder webSearch(
@Nullable Context context, boolean isEnabled) {
if (context == null) {
context = ContextUtils.getApplicationContext();
}
return new SelectionMenuItem.Builder(context.getString(R.string.actionbar_web_search))
.setId(R.id.select_action_menu_web_search)
.setIconAttr(android.R.attr.actionModeWebSearchDrawable)
.setOrderInCategory(DefaultItemOrder.WEB_SEARCH)
.setShowAsActionFlags(
MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT)
.setIsEnabled(isEnabled)
.setIsIconTintable(true);
}
}