Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 26b8722d authored by Richard Ledley's avatar Richard Ledley
Browse files

Show Floating Toolbar when tapping a selectable TextLink in TextView.

Test: bit FrameworksCoreTests:android.widget.TextViewActivityTest\#testToolbarAppearsAfterLinkClicked
Bug: b/67629726
Change-Id: Ied7a1903a308db37d0eb288c8e611da8229f381a
parent 171fec8c
Loading
Loading
Loading
Loading
+10 −3
Original line number Diff line number Diff line
@@ -22,6 +22,8 @@ import android.annotation.Nullable;
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 @@ public final class TextLinks {
     * @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);
                    }
                }
            };

    /**
+31 −7
Original line number Diff line number Diff line
@@ -107,6 +107,7 @@ import android.view.inputmethod.ExtractedTextRequest;
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 @@ public class Editor {
        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 @@ public class Editor {
        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 @@ public class Editor {
     * 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 @@ public class Editor {
        return true;
    }

    boolean startSelectionActionModeInternal() {
    boolean startActionModeInternal(@TextActionMode int actionMode) {
        if (extractedTextModeWillBeStarted()) {
            return false;
        }
@@ -2159,8 +2183,7 @@ public class Editor {
            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 @@ public class Editor {
        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) {
+57 −13
Original line number Diff line number Diff line
@@ -35,6 +35,7 @@ import android.util.Log;
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 final class SelectionActionModeHelper {
        }
    }

    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 @@ public final class SelectionActionModeHelper {
                mTextView.getSelectionEnd());
        cancelAsyncTask();
        if (skipTextClassification()) {
            startActionMode(null);
            startSelectionActionMode(null);
        } else {
            resetTextClassificationHelper();
            mTextClassificationAsyncTask = new TextClassificationAsyncTask(
@@ -119,8 +123,27 @@ public final class SelectionActionModeHelper {
                            ? 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 @@ public final class SelectionActionModeHelper {
        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 @@ public final class SelectionActionModeHelper {
        } 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 @@ public final class SelectionActionModeHelper {
        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 @@ public final class SelectionActionModeHelper {
        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();
+16 −0
Original line number Diff line number Diff line
@@ -160,6 +160,7 @@ import android.view.inputmethod.InputConnection;
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.annotations.VisibleForTesting;
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;
@@ -11150,6 +11152,20 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
        return mTextClassifier;
    }

    /**
     * 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
     */
+34 −2
Original line number Diff line number Diff line
@@ -27,8 +27,10 @@ import static android.support.test.espresso.matcher.ViewMatchers.withId;
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.test.suitebuilder.annotation.Suppress;
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;
@@ -304,6 +309,33 @@ public class TextViewActivityTest {
        assertFloatingToolbarIsDisplayed();
    }

    @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";