Set smart selection menu into context menu item

This CL extract the common logic between ActionMode and ContextMenu and
put it to AssistantCallbackHelper.

Bug: 267984410
Test: atest TextViewContextMenuTest SelectionActionModeHelperTest
Change-Id: I82a1d4873cd34a250c920e91a88382b509a46adc
This commit is contained in:
Seigo Nonaka
2023-02-02 14:19:37 +09:00
parent 7fcfe4d7db
commit b57c8180da
6 changed files with 437 additions and 129 deletions

View File

@@ -3090,7 +3090,33 @@ public class Editor {
mContextMenuAnchorY = y;
}
void onCreateContextMenu(ContextMenu menu) {
private void setAssistContextMenuItems(Menu menu) {
final TextClassification textClassification =
getSelectionActionModeHelper().getTextClassification();
if (textClassification == null) {
return;
}
final AssistantCallbackHelper helper =
new AssistantCallbackHelper(getSelectionActionModeHelper());
helper.updateAssistMenuItems(menu, (MenuItem item) -> {
getSelectionActionModeHelper()
.onSelectionAction(item.getItemId(), item.getTitle().toString());
if (mProcessTextIntentActionsHandler.performMenuItemAction(item)) {
return true;
}
if (item.getGroupId() == TextView.ID_ASSIST && helper.onAssistMenuItemClicked(item)) {
return true;
}
return mTextView.onTextContextMenuItem(item.getItemId());
});
}
/**
* Called when the context menu is created.
*/
public void onCreateContextMenu(ContextMenu menu) {
if (mIsBeingLongClicked || Float.isNaN(mContextMenuAnchorX)
|| Float.isNaN(mContextMenuAnchorY)) {
return;
@@ -3153,6 +3179,8 @@ public class Editor {
menu.setOptionalIconsVisible(true);
menu.setGroupDividerEnabled(true);
setAssistContextMenuItems(menu);
final int keyboard = mTextView.getResources().getConfiguration().keyboard;
menu.setQwertyMode(keyboard == Configuration.KEYBOARD_QWERTY);
} else {
@@ -4308,6 +4336,165 @@ public class Editor {
}
}
/**
* Helper class for UI component (e.g. ActionMode and ContextMenu) with TextClassification.
* @hide
*/
@VisibleForTesting
public class AssistantCallbackHelper {
private final Map<MenuItem, OnClickListener> mAssistClickHandlers = new HashMap<>();
@Nullable private TextClassification mPrevTextClassification;
@NonNull private final SelectionActionModeHelper mHelper;
public AssistantCallbackHelper(SelectionActionModeHelper helper) {
mHelper = helper;
}
/**
* Clears callback handlers.
*/
public void clearCallbackHandlers() {
mAssistClickHandlers.clear();
}
/**
* Get on click listener assisiated with the MenuItem.
*/
public OnClickListener getOnClickListener(MenuItem key) {
return mAssistClickHandlers.get(key);
}
/**
* Update menu items.
*
* Existing assist menu will be cleared and latest assist menu will be added.
*/
public void updateAssistMenuItems(Menu menu, MenuItem.OnMenuItemClickListener listener) {
final TextClassification textClassification = mHelper.getTextClassification();
if (mPrevTextClassification == textClassification) {
// Already handled.
return;
}
clearAssistMenuItems(menu);
if (textClassification == null) {
return;
}
if (!shouldEnableAssistMenuItems()) {
return;
}
if (!textClassification.getActions().isEmpty()) {
// Primary assist action (Always shown).
final MenuItem item = addAssistMenuItem(menu,
textClassification.getActions().get(0), TextView.ID_ASSIST,
ACTION_MODE_MENU_ITEM_ORDER_ASSIST, MenuItem.SHOW_AS_ACTION_ALWAYS,
listener);
item.setIntent(textClassification.getIntent());
} else if (hasLegacyAssistItem(textClassification)) {
// Legacy primary assist action (Always shown).
final MenuItem item = menu.add(TextView.ID_ASSIST, TextView.ID_ASSIST,
ACTION_MODE_MENU_ITEM_ORDER_ASSIST,
textClassification.getLabel())
.setIcon(textClassification.getIcon())
.setIntent(textClassification.getIntent());
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
mAssistClickHandlers.put(item, TextClassification.createIntentOnClickListener(
TextClassification.createPendingIntent(mTextView.getContext(),
textClassification.getIntent(),
createAssistMenuItemPendingIntentRequestCode())));
}
final int count = textClassification.getActions().size();
for (int i = 1; i < count; i++) {
// Secondary assist action (Never shown).
addAssistMenuItem(menu, textClassification.getActions().get(i), Menu.NONE,
ACTION_MODE_MENU_ITEM_ORDER_SECONDARY_ASSIST_ACTIONS_START + i - 1,
MenuItem.SHOW_AS_ACTION_NEVER, listener);
}
mPrevTextClassification = textClassification;
}
private MenuItem addAssistMenuItem(Menu menu, RemoteAction action, int itemId, int order,
int showAsAction, MenuItem.OnMenuItemClickListener listener) {
final MenuItem item = menu.add(TextView.ID_ASSIST, itemId, order, action.getTitle())
.setContentDescription(action.getContentDescription());
if (action.shouldShowIcon()) {
item.setIcon(action.getIcon().loadDrawable(mTextView.getContext()));
}
item.setShowAsAction(showAsAction);
mAssistClickHandlers.put(item,
TextClassification.createIntentOnClickListener(action.getActionIntent()));
mA11ySmartActions.addAction(action);
if (listener != null) {
item.setOnMenuItemClickListener(listener);
}
return item;
}
private void clearAssistMenuItems(Menu menu) {
int i = 0;
while (i < menu.size()) {
final MenuItem menuItem = menu.getItem(i);
if (menuItem.getGroupId() == TextView.ID_ASSIST) {
menu.removeItem(menuItem.getItemId());
continue;
}
i++;
}
mA11ySmartActions.reset();
}
private boolean hasLegacyAssistItem(TextClassification classification) {
// Check whether we have the UI data and action.
return (classification.getIcon() != null || !TextUtils.isEmpty(
classification.getLabel())) && (classification.getIntent() != null
|| classification.getOnClickListener() != null);
}
private boolean shouldEnableAssistMenuItems() {
return mTextView.isDeviceProvisioned()
&& TextClassificationManager.getSettings(mTextView.getContext())
.isSmartTextShareEnabled();
}
private int createAssistMenuItemPendingIntentRequestCode() {
return mTextView.hasSelection()
? mTextView.getText().subSequence(
mTextView.getSelectionStart(), mTextView.getSelectionEnd())
.hashCode()
: 0;
}
/**
* Called when the assist menu on ActionMode or ContextMenu is called.
*/
public boolean onAssistMenuItemClicked(MenuItem assistMenuItem) {
Preconditions.checkArgument(assistMenuItem.getGroupId() == TextView.ID_ASSIST);
final TextClassification textClassification =
getSelectionActionModeHelper().getTextClassification();
if (!shouldEnableAssistMenuItems() || textClassification == null) {
// No textClassification result to handle the click. Eat the click.
return true;
}
OnClickListener onClickListener = getOnClickListener(assistMenuItem);
if (onClickListener == null) {
final Intent intent = assistMenuItem.getIntent();
if (intent != null) {
onClickListener = TextClassification.createIntentOnClickListener(
TextClassification.createPendingIntent(
mTextView.getContext(), intent,
createAssistMenuItemPendingIntentRequestCode()));
}
}
if (onClickListener != null) {
onClickListener.onClick(mTextView);
stopTextActionMode();
}
// We tried our best.
return true;
}
}
/**
* An ActionMode Callback class that is used to provide actions while in text insertion or
* selection mode.
@@ -4320,9 +4507,8 @@ public class Editor {
private final RectF mSelectionBounds = new RectF();
private final boolean mHasSelection;
private final int mHandleHeight;
private final Map<MenuItem, OnClickListener> mAssistClickHandlers = new HashMap<>();
@Nullable
private TextClassification mPrevTextClassification;
private final AssistantCallbackHelper mHelper = new AssistantCallbackHelper(
getSelectionActionModeHelper());
TextActionModeCallback(@TextActionMode int mode) {
mHasSelection = mode == TextActionMode.SELECTION
@@ -4351,7 +4537,7 @@ public class Editor {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mAssistClickHandlers.clear();
mHelper.clearCallbackHandlers();
mode.setTitle(null);
mode.setSubtitle(null);
@@ -4432,14 +4618,14 @@ public class Editor {
updateSelectAllItem(menu);
updateReplaceItem(menu);
updateAssistMenuItems(menu);
mHelper.updateAssistMenuItems(menu, null);
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
updateSelectAllItem(menu);
updateReplaceItem(menu);
updateAssistMenuItems(menu);
mHelper.updateAssistMenuItems(menu, null);
Callback customCallback = getCustomCallback();
if (customCallback != null) {
@@ -4472,125 +4658,6 @@ public class Editor {
}
}
private void updateAssistMenuItems(Menu menu) {
final TextClassification textClassification =
getSelectionActionModeHelper().getTextClassification();
if (mPrevTextClassification == textClassification) {
// Already handled.
return;
}
clearAssistMenuItems(menu);
if (textClassification == null) {
return;
}
if (!shouldEnableAssistMenuItems()) {
return;
}
if (!textClassification.getActions().isEmpty()) {
// Primary assist action (Always shown).
final MenuItem item = addAssistMenuItem(menu,
textClassification.getActions().get(0), TextView.ID_ASSIST,
ACTION_MODE_MENU_ITEM_ORDER_ASSIST, MenuItem.SHOW_AS_ACTION_ALWAYS);
item.setIntent(textClassification.getIntent());
} else if (hasLegacyAssistItem(textClassification)) {
// Legacy primary assist action (Always shown).
final MenuItem item = menu.add(
TextView.ID_ASSIST, TextView.ID_ASSIST, ACTION_MODE_MENU_ITEM_ORDER_ASSIST,
textClassification.getLabel())
.setIcon(textClassification.getIcon())
.setIntent(textClassification.getIntent());
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
mAssistClickHandlers.put(item, TextClassification.createIntentOnClickListener(
TextClassification.createPendingIntent(mTextView.getContext(),
textClassification.getIntent(),
createAssistMenuItemPendingIntentRequestCode())));
}
final int count = textClassification.getActions().size();
for (int i = 1; i < count; i++) {
// Secondary assist action (Never shown).
addAssistMenuItem(menu, textClassification.getActions().get(i), Menu.NONE,
ACTION_MODE_MENU_ITEM_ORDER_SECONDARY_ASSIST_ACTIONS_START + i - 1,
MenuItem.SHOW_AS_ACTION_NEVER);
}
mPrevTextClassification = textClassification;
}
private MenuItem addAssistMenuItem(Menu menu, RemoteAction action, int itemId, int order,
int showAsAction) {
final MenuItem item = menu.add(TextView.ID_ASSIST, itemId, order, action.getTitle())
.setContentDescription(action.getContentDescription());
if (action.shouldShowIcon()) {
item.setIcon(action.getIcon().loadDrawable(mTextView.getContext()));
}
item.setShowAsAction(showAsAction);
mAssistClickHandlers.put(item,
TextClassification.createIntentOnClickListener(action.getActionIntent()));
mA11ySmartActions.addAction(action);
return item;
}
private void clearAssistMenuItems(Menu menu) {
int i = 0;
while (i < menu.size()) {
final MenuItem menuItem = menu.getItem(i);
if (menuItem.getGroupId() == TextView.ID_ASSIST) {
menu.removeItem(menuItem.getItemId());
continue;
}
i++;
}
mA11ySmartActions.reset();
}
private boolean hasLegacyAssistItem(TextClassification classification) {
// Check whether we have the UI data and and action.
return (classification.getIcon() != null || !TextUtils.isEmpty(
classification.getLabel())) && (classification.getIntent() != null
|| classification.getOnClickListener() != null);
}
private boolean onAssistMenuItemClicked(MenuItem assistMenuItem) {
Preconditions.checkArgument(assistMenuItem.getGroupId() == TextView.ID_ASSIST);
final TextClassification textClassification =
getSelectionActionModeHelper().getTextClassification();
if (!shouldEnableAssistMenuItems() || textClassification == null) {
// No textClassification result to handle the click. Eat the click.
return true;
}
OnClickListener onClickListener = mAssistClickHandlers.get(assistMenuItem);
if (onClickListener == null) {
final Intent intent = assistMenuItem.getIntent();
if (intent != null) {
onClickListener = TextClassification.createIntentOnClickListener(
TextClassification.createPendingIntent(
mTextView.getContext(), intent,
createAssistMenuItemPendingIntentRequestCode()));
}
}
if (onClickListener != null) {
onClickListener.onClick(mTextView);
stopTextActionMode();
}
// We tried our best.
return true;
}
private int createAssistMenuItemPendingIntentRequestCode() {
return mTextView.hasSelection()
? mTextView.getText().subSequence(
mTextView.getSelectionStart(), mTextView.getSelectionEnd())
.hashCode()
: 0;
}
private boolean shouldEnableAssistMenuItems() {
return mTextView.isDeviceProvisioned()
&& TextClassificationManager.getSettings(mTextView.getContext())
.isSmartTextShareEnabled();
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
getSelectionActionModeHelper()
@@ -4603,7 +4670,7 @@ public class Editor {
if (customCallback != null && customCallback.onActionItemClicked(mode, item)) {
return true;
}
if (item.getGroupId() == TextView.ID_ASSIST && onAssistMenuItemClicked(item)) {
if (item.getGroupId() == TextView.ID_ASSIST && mHelper.onAssistMenuItemClicked(item)) {
return true;
}
return mTextView.onTextContextMenuItem(item.getItemId());
@@ -4633,7 +4700,7 @@ public class Editor {
mSelectionModifierCursorController.hide();
}
mAssistClickHandlers.clear();
mHelper.clearCallbackHandlers();
mRequestingLinkActionMode = false;
}

View File

@@ -70,7 +70,7 @@ import java.util.regex.Pattern;
*/
@UiThread
@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
public final class SelectionActionModeHelper {
public class SelectionActionModeHelper {
private static final String LOG_TAG = "SelectActionModeHelper";

View File

@@ -1607,6 +1607,15 @@
</intent-filter>
</activity>
<activity android:name="android.widget.TextViewContextMenuActivity"
android:screenOrientation="locked"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.FRAMEWORK_INSTRUMENTATION_TEST" />
</intent-filter>
</activity>
<activity android:name="android.animation.AnimatorSetActivity"
android:screenOrientation="locked"
android:exported="true">

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
**
** Copyright 2023, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/editText"
/>
</LinearLayout>

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.widget;
import android.app.Activity;
import android.os.Bundle;
import com.android.frameworks.coretests.R;
public class TextViewContextMenuActivity extends Activity {
@Override
public void onCreate(Bundle savedBundleInstance) {
super.onCreate(savedBundleInstance);
setContentView(R.layout.textview_contextmenu);
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.widget;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyChar;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import android.app.Activity;
import android.app.PendingIntent;
import android.app.RemoteAction;
import android.content.Intent;
import android.graphics.drawable.Icon;
import android.view.ContextMenu;
import android.view.MenuItem;
import android.view.textclassifier.TextClassification;
import androidx.test.annotation.UiThreadTest;
import androidx.test.filters.MediumTest;
import androidx.test.rule.ActivityTestRule;
import androidx.test.runner.AndroidJUnit4;
import com.android.frameworks.coretests.R;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
/**
* TextViewTest tests {@link TextView}.
*/
@RunWith(AndroidJUnit4.class)
@MediumTest
public class TextViewContextMenuTest {
private static final String INTENT_ACTION_MOCK_ACTION_TEXT_CLASSIFICATION =
"android.text.coretest.textclassifiation";
private static final String ACTION_TITLE = "ACTION_TITLE";
private static final String ACTION_DESCRIPTION = "ACTION_DESCRIPTION";
@Rule
public final ActivityTestRule<TextViewContextMenuActivity> mActivityRule =
new ActivityTestRule<>(TextViewContextMenuActivity.class);
// Setup MenuItem mock with chaining.
private MenuItem newMockMenuItem() {
MenuItem mockItem = mock(MenuItem.class);
when(mockItem.setAlphabeticShortcut(anyChar())).thenReturn(mockItem);
when(mockItem.setAlphabeticShortcut(anyChar(), anyInt())).thenReturn(mockItem);
when(mockItem.setOnMenuItemClickListener(any())).thenReturn(mockItem);
when(mockItem.setEnabled(anyBoolean())).thenReturn(mockItem);
when(mockItem.setIcon(any())).thenReturn(mockItem);
when(mockItem.setIntent(any())).thenReturn(mockItem);
when(mockItem.setContentDescription(any())).thenReturn(mockItem);
return mockItem;
}
private RemoteAction createRemoteAction() {
Intent intent = new Intent(INTENT_ACTION_MOCK_ACTION_TEXT_CLASSIFICATION)
.setPackage(mActivity.getPackageName());
PendingIntent pIntent = PendingIntent.getBroadcast(mActivity, 0, intent,
PendingIntent.FLAG_MUTABLE);
return new RemoteAction(
Icon.createWithResource(mActivity, android.R.drawable.btn_star),
ACTION_TITLE, ACTION_DESCRIPTION, pIntent);
}
private Activity mActivity;
private SelectionActionModeHelper mMockHelper;
private Editor.AssistantCallbackHelper mCallbackHelper;
@Before
public void setUp() {
mActivity = mActivityRule.getActivity();
EditText et = mActivity.findViewById(R.id.editText);
mMockHelper = mock(SelectionActionModeHelper.class);
mCallbackHelper = et.getEditorForTesting().new AssistantCallbackHelper(mMockHelper);
}
@UiThreadTest
@Test
public void testNoMenuInteraction_noTextClassification() {
when(mMockHelper.getTextClassification()).thenReturn(null);
ContextMenu menu = mock(ContextMenu.class);
mCallbackHelper.updateAssistMenuItems(menu, null);
verifyNoMoreInteractions(menu);
}
@UiThreadTest
@Test
public void testAddMenuForTextClassification() {
// Setup
RemoteAction action = createRemoteAction();
TextClassification classification = new TextClassification.Builder()
.addAction(action).build();
when(mMockHelper.getTextClassification()).thenReturn(classification);
ContextMenu menu = mock(ContextMenu.class);
MenuItem mockMenuItem = newMockMenuItem();
when(menu.add(anyInt(), anyInt(), anyInt(), any())).thenReturn(mockMenuItem);
// Execute
mCallbackHelper.updateAssistMenuItems(menu, null);
// Verify
ArgumentCaptor<Integer> idCaptor = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptor<CharSequence> titleCaptor = ArgumentCaptor.forClass(CharSequence.class);
verify(menu, times(1)).add(anyInt(), idCaptor.capture(), anyInt(), titleCaptor.capture());
assertThat(idCaptor.getValue()).isEqualTo(TextView.ID_ASSIST);
assertThat(titleCaptor.getValue().toString()).isEqualTo(ACTION_TITLE);
verify(mockMenuItem, times(1)).setContentDescription(eq(ACTION_DESCRIPTION));
}
@UiThreadTest
@Test
public void testAddMenuForLegacyTextClassification() {
// Setup
Intent intent = new Intent(INTENT_ACTION_MOCK_ACTION_TEXT_CLASSIFICATION)
.setPackage(mActivity.getPackageName());
TextClassification classification = new TextClassification.Builder()
.setIcon(mActivity.getResources().getDrawable(android.R.drawable.star_on))
.setLabel(ACTION_TITLE)
.setIntent(intent)
.build();
when(mMockHelper.getTextClassification()).thenReturn(classification);
ContextMenu menu = mock(ContextMenu.class);
MenuItem mockMenuItem = newMockMenuItem();
when(menu.add(anyInt(), anyInt(), anyInt(), any())).thenReturn(mockMenuItem);
// Execute
mCallbackHelper.updateAssistMenuItems(menu, null);
// Verify
ArgumentCaptor<Integer> idCaptor = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptor<CharSequence> titleCaptor = ArgumentCaptor.forClass(CharSequence.class);
verify(menu, times(1)).add(anyInt(), idCaptor.capture(), anyInt(), titleCaptor.capture());
assertThat(idCaptor.getValue()).isEqualTo(TextView.ID_ASSIST);
assertThat(titleCaptor.getValue().toString()).isEqualTo(ACTION_TITLE);
}
}