Show Floating Toolbar when tapping a selectable TextLink in TextView.
Test: bit FrameworksCoreTests:android.widget.TextViewActivityTest\#testToolbarAppearsAfterLinkClicked
Bug: b/67629726
Change-Id: Ied7a1903a308db37d0eb288c8e611da8229f381a
diff --git a/core/java/android/view/textclassifier/TextLinks.java b/core/java/android/view/textclassifier/TextLinks.java
index 0e039e3..4fe5662 100644
--- a/core/java/android/view/textclassifier/TextLinks.java
+++ b/core/java/android/view/textclassifier/TextLinks.java
@@ -22,6 +22,8 @@
import android.os.LocaleList;
import android.text.SpannableString;
import android.text.style.ClickableSpan;
+import android.view.View;
+import android.widget.TextView;
import com.android.internal.util.Preconditions;
@@ -189,9 +191,14 @@
* @hide
*/
public static final Function<TextLink, ClickableSpan> DEFAULT_SPAN_FACTORY =
- textLink -> {
- // TODO: Implement.
- throw new UnsupportedOperationException("Not yet implemented");
+ textLink -> new ClickableSpan() {
+ @Override
+ public void onClick(View widget) {
+ if (widget instanceof TextView) {
+ final TextView textView = (TextView) widget;
+ textView.requestActionMode(textLink);
+ }
+ }
};
/**
diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java
index a440398..05d18d1 100644
--- a/core/java/android/widget/Editor.java
+++ b/core/java/android/widget/Editor.java
@@ -107,6 +107,7 @@
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import android.view.textclassifier.TextClassification;
+import android.view.textclassifier.TextLinks;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.TextView.Drawables;
import android.widget.TextView.OnEditorActionListener;
@@ -174,6 +175,13 @@
int SELECTION_END = 2;
}
+ @IntDef({TextActionMode.SELECTION, TextActionMode.INSERTION, TextActionMode.TEXT_LINK})
+ @interface TextActionMode {
+ int SELECTION = 0;
+ int INSERTION = 1;
+ int TEXT_LINK = 2;
+ }
+
// Each Editor manages its own undo stack.
private final UndoManager mUndoManager = new UndoManager();
private UndoOwner mUndoOwner = mUndoManager.getOwner(UNDO_OWNER_TAG, this);
@@ -2053,7 +2061,7 @@
stopTextActionMode();
ActionMode.Callback actionModeCallback =
- new TextActionModeCallback(false /* hasSelection */);
+ new TextActionModeCallback(TextActionMode.INSERTION);
mTextActionMode = mTextView.startActionMode(
actionModeCallback, ActionMode.TYPE_FLOATING);
if (mTextActionMode != null && getInsertionController() != null) {
@@ -2079,7 +2087,23 @@
* Asynchronously starts a selection action mode using the TextClassifier.
*/
void startSelectionActionModeAsync(boolean adjustSelection) {
- getSelectionActionModeHelper().startActionModeAsync(adjustSelection);
+ getSelectionActionModeHelper().startSelectionActionModeAsync(adjustSelection);
+ }
+
+ void startLinkActionModeAsync(TextLinks.TextLink link) {
+ Preconditions.checkNotNull(link);
+ if (!(mTextView.getText() instanceof Spannable)) {
+ return;
+ }
+ Spannable text = (Spannable) mTextView.getText();
+ stopTextActionMode();
+ if (mTextView.isTextSelectable()) {
+ Selection.setSelection((Spannable) text, link.getStart(), link.getEnd());
+ } else {
+ //TODO: Nonselectable text
+ }
+
+ getSelectionActionModeHelper().startLinkActionModeAsync(link);
}
/**
@@ -2145,7 +2169,7 @@
return true;
}
- boolean startSelectionActionModeInternal() {
+ boolean startActionModeInternal(@TextActionMode int actionMode) {
if (extractedTextModeWillBeStarted()) {
return false;
}
@@ -2159,8 +2183,7 @@
return false;
}
- ActionMode.Callback actionModeCallback =
- new TextActionModeCallback(true /* hasSelection */);
+ ActionMode.Callback actionModeCallback = new TextActionModeCallback(actionMode);
mTextActionMode = mTextView.startActionMode(actionModeCallback, ActionMode.TYPE_FLOATING);
final boolean selectionStarted = mTextActionMode != null;
@@ -3828,8 +3851,9 @@
private final int mHandleHeight;
private final Map<MenuItem, OnClickListener> mAssistClickHandlers = new HashMap<>();
- public TextActionModeCallback(boolean hasSelection) {
- mHasSelection = hasSelection;
+ TextActionModeCallback(@TextActionMode int mode) {
+ mHasSelection = mode == TextActionMode.SELECTION
+ || (mTextIsSelectable && mode == TextActionMode.TEXT_LINK);
if (mHasSelection) {
SelectionModifierCursorController selectionController = getSelectionController();
if (selectionController.mStartHandle == null) {
diff --git a/core/java/android/widget/SelectionActionModeHelper.java b/core/java/android/widget/SelectionActionModeHelper.java
index d0ad27a..2c6466c 100644
--- a/core/java/android/widget/SelectionActionModeHelper.java
+++ b/core/java/android/widget/SelectionActionModeHelper.java
@@ -35,6 +35,7 @@
import android.view.ActionMode;
import android.view.textclassifier.TextClassification;
import android.view.textclassifier.TextClassifier;
+import android.view.textclassifier.TextLinks;
import android.view.textclassifier.TextSelection;
import android.view.textclassifier.logging.SmartSelectionEventTracker;
import android.view.textclassifier.logging.SmartSelectionEventTracker.SelectionEvent;
@@ -97,7 +98,10 @@
}
}
- public void startActionModeAsync(boolean adjustSelection) {
+ /**
+ * Starts Selection ActionMode.
+ */
+ public void startSelectionActionModeAsync(boolean adjustSelection) {
// Check if the smart selection should run for editable text.
adjustSelection &= !mTextView.isTextEditable()
|| mTextView.getTextClassifier().getSettings()
@@ -109,7 +113,7 @@
mTextView.getSelectionEnd());
cancelAsyncTask();
if (skipTextClassification()) {
- startActionMode(null);
+ startSelectionActionMode(null);
} else {
resetTextClassificationHelper();
mTextClassificationAsyncTask = new TextClassificationAsyncTask(
@@ -119,8 +123,27 @@
? mTextClassificationHelper::suggestSelection
: mTextClassificationHelper::classifyText,
mSmartSelectSprite != null
- ? this::startActionModeWithSmartSelectAnimation
- : this::startActionMode)
+ ? this::startSelectionActionModeWithSmartSelectAnimation
+ : this::startSelectionActionMode)
+ .execute();
+ }
+ }
+
+ /**
+ * Starts Link ActionMode.
+ */
+ public void startLinkActionModeAsync(TextLinks.TextLink textLink) {
+ //TODO: tracking/logging
+ cancelAsyncTask();
+ if (skipTextClassification()) {
+ startLinkActionMode(null);
+ } else {
+ resetTextClassificationHelper(textLink.getStart(), textLink.getEnd());
+ mTextClassificationAsyncTask = new TextClassificationAsyncTask(
+ mTextView,
+ mTextClassificationHelper.getTimeoutDuration(),
+ mTextClassificationHelper::classifyText,
+ this::startLinkActionMode)
.execute();
}
}
@@ -200,9 +223,19 @@
return noOpTextClassifier || noSelection || password;
}
- private void startActionMode(@Nullable SelectionResult result) {
+ private void startLinkActionMode(@Nullable SelectionResult result) {
+ startActionMode(Editor.TextActionMode.TEXT_LINK, result);
+ }
+
+ private void startSelectionActionMode(@Nullable SelectionResult result) {
+ startActionMode(Editor.TextActionMode.SELECTION, result);
+ }
+
+ private void startActionMode(
+ @Editor.TextActionMode int actionMode, @Nullable SelectionResult result) {
final CharSequence text = getText(mTextView);
- if (result != null && text instanceof Spannable) {
+ if (result != null && text instanceof Spannable
+ && (mTextView.isTextSelectable() || mTextView.isTextEditable())) {
// Do not change the selection if TextClassifier should be dark launched.
if (!mTextView.getTextClassifier().getSettings().isDarkLaunch()) {
Selection.setSelection((Spannable) text, result.mStart, result.mEnd);
@@ -211,12 +244,13 @@
} else {
mTextClassification = null;
}
- if (mEditor.startSelectionActionModeInternal()) {
+ if (mEditor.startActionModeInternal(actionMode)) {
final SelectionModifierCursorController controller = mEditor.getSelectionController();
- if (controller != null) {
+ if (controller != null
+ && (mTextView.isTextSelectable() || mTextView.isTextEditable())) {
controller.show();
}
- if (result != null) {
+ if (result != null && actionMode == Editor.TextActionMode.SELECTION) {
mSelectionTracker.onSmartSelection(result);
}
}
@@ -224,10 +258,11 @@
mTextClassificationAsyncTask = null;
}
- private void startActionModeWithSmartSelectAnimation(@Nullable SelectionResult result) {
+ private void startSelectionActionModeWithSmartSelectAnimation(
+ @Nullable SelectionResult result) {
final Layout layout = mTextView.getLayout();
- final Runnable onAnimationEndCallback = () -> startActionMode(result);
+ final Runnable onAnimationEndCallback = () -> startSelectionActionMode(result);
// TODO do not trigger the animation if the change included only non-printable characters
final boolean didSelectionChange =
result != null && (mTextView.getSelectionStart() != result.mStart
@@ -386,15 +421,24 @@
mTextClassificationAsyncTask = null;
}
- private void resetTextClassificationHelper() {
+ private void resetTextClassificationHelper(int selectionStart, int selectionEnd) {
+ if (selectionStart < 0 || selectionEnd < 0) {
+ // Use selection indices
+ selectionStart = mTextView.getSelectionStart();
+ selectionEnd = mTextView.getSelectionEnd();
+ }
mTextClassificationHelper.init(
mTextView.getContext(),
mTextView.getTextClassifier(),
getText(mTextView),
- mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
+ selectionStart, selectionEnd,
mTextView.getTextLocales());
}
+ private void resetTextClassificationHelper() {
+ resetTextClassificationHelper(-1, -1);
+ }
+
private void cancelSmartSelectAnimation() {
if (mSmartSelectSprite != null) {
mSmartSelectSprite.cancelAnimation();
diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java
index 903d3ca..9ac443b 100644
--- a/core/java/android/widget/TextView.java
+++ b/core/java/android/widget/TextView.java
@@ -160,6 +160,7 @@
import android.view.inputmethod.InputMethodManager;
import android.view.textclassifier.TextClassificationManager;
import android.view.textclassifier.TextClassifier;
+import android.view.textclassifier.TextLinks;
import android.view.textservice.SpellCheckerSubtype;
import android.view.textservice.TextServicesManager;
import android.widget.RemoteViews.RemoteView;
@@ -168,6 +169,7 @@
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.internal.util.FastMath;
+import com.android.internal.util.Preconditions;
import com.android.internal.widget.EditableInputConnection;
import libcore.util.EmptyArray;
@@ -11151,6 +11153,20 @@
}
/**
+ * Starts an ActionMode for the specified TextLink.
+ *
+ * @return Whether or not we're attempting to start the action mode.
+ * @hide
+ */
+ public boolean requestActionMode(@NonNull TextLinks.TextLink link) {
+ Preconditions.checkNotNull(link);
+ if (mEditor != null) {
+ mEditor.startLinkActionModeAsync(link);
+ return true;
+ }
+ return false;
+ }
+ /**
* @hide
*/
protected void stopTextActionMode() {
diff --git a/core/tests/coretests/src/android/widget/TextViewActivityTest.java b/core/tests/coretests/src/android/widget/TextViewActivityTest.java
index 0e460b9..1a654f4 100644
--- a/core/tests/coretests/src/android/widget/TextViewActivityTest.java
+++ b/core/tests/coretests/src/android/widget/TextViewActivityTest.java
@@ -27,8 +27,10 @@
import static android.support.test.espresso.matcher.ViewMatchers.withText;
import static android.widget.espresso.CustomViewActions.longPressAtRelativeCoordinates;
import static android.widget.espresso.DragHandleUtils.onHandleView;
-import static android.widget.espresso.FloatingToolbarEspressoUtils.assertFloatingToolbarContainsItem;
-import static android.widget.espresso.FloatingToolbarEspressoUtils.assertFloatingToolbarDoesNotContainItem;
+import static android.widget.espresso.FloatingToolbarEspressoUtils
+ .assertFloatingToolbarContainsItem;
+import static android.widget.espresso.FloatingToolbarEspressoUtils
+ .assertFloatingToolbarDoesNotContainItem;
import static android.widget.espresso.FloatingToolbarEspressoUtils.assertFloatingToolbarIsDisplayed;
import static android.widget.espresso.FloatingToolbarEspressoUtils.assertFloatingToolbarItemIndex;
import static android.widget.espresso.FloatingToolbarEspressoUtils.clickFloatingToolbarItem;
@@ -68,12 +70,15 @@
import android.text.InputType;
import android.text.Selection;
import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.method.LinkMovementMethod;
import android.view.ActionMode;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.view.textclassifier.TextClassificationManager;
import android.view.textclassifier.TextClassifier;
+import android.view.textclassifier.TextLinks;
import android.widget.espresso.CustomViewActions.RelativeCoordinatesProvider;
import com.android.frameworks.coretests.R;
@@ -305,6 +310,33 @@
}
@Test
+ public void testToolbarAppearsAfterLinkClicked() throws Throwable {
+ useSystemDefaultTextClassifier();
+ TextClassificationManager textClassificationManager =
+ mActivity.getSystemService(TextClassificationManager.class);
+ TextClassifier textClassifier = textClassificationManager.getTextClassifier();
+ final TextView textView = mActivity.findViewById(R.id.textview);
+ SpannableString content = new SpannableString("Call me at +19148277737");
+ TextLinks links = textClassifier.generateLinks(content);
+ links.apply(content, null);
+
+ mActivityRule.runOnUiThread(() -> {
+ textView.setText(content);
+ textView.setMovementMethod(LinkMovementMethod.getInstance());
+ });
+ mInstrumentation.waitForIdleSync();
+
+ // Wait for the UI thread to refresh
+ Thread.sleep(1000);
+
+ TextLinks.TextLink textLink = links.getLinks().iterator().next();
+ int position = (textLink.getStart() + textLink.getEnd()) / 2;
+ onView(withId(R.id.textview)).perform(clickOnTextAtIndex(position));
+ sleepForFloatingToolbarPopup();
+ assertFloatingToolbarIsDisplayed();
+ }
+
+ @Test
public void testToolbarAndInsertionHandle() {
final String text = "text";
onView(withId(R.id.textview)).perform(replaceText(text));