| /* |
| * Copyright (C) 2017 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.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.annotation.UiThread; |
| import android.annotation.WorkerThread; |
| import android.graphics.PointF; |
| import android.graphics.RectF; |
| import android.os.AsyncTask; |
| import android.os.LocaleList; |
| import android.text.Layout; |
| import android.text.Selection; |
| import android.text.Spannable; |
| import android.text.TextUtils; |
| import android.view.ActionMode; |
| import android.view.textclassifier.TextClassification; |
| import android.view.textclassifier.TextClassifier; |
| import android.view.textclassifier.TextSelection; |
| import android.widget.Editor.SelectionModifierCursorController; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.util.Preconditions; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Objects; |
| import java.util.function.Consumer; |
| import java.util.function.Supplier; |
| |
| /** |
| * Helper class for starting selection action mode |
| * (synchronously without the TextClassifier, asynchronously with the TextClassifier). |
| * @hide |
| */ |
| @UiThread |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) |
| final class SelectionActionModeHelper { |
| |
| /** |
| * Maximum time (in milliseconds) to wait for a result before timing out. |
| */ |
| // TODO: Consider making this a ViewConfiguration. |
| private static final int TIMEOUT_DURATION = 200; |
| |
| private static final boolean SMART_SELECT_ANIMATION_ENABLED = true; |
| |
| private final Editor mEditor; |
| private final TextClassificationHelper mTextClassificationHelper; |
| |
| private TextClassification mTextClassification; |
| private AsyncTask mTextClassificationAsyncTask; |
| |
| private final SelectionTracker mSelectionTracker; |
| private final SmartSelectSprite mSmartSelectSprite; |
| |
| SelectionActionModeHelper(@NonNull Editor editor) { |
| mEditor = Preconditions.checkNotNull(editor); |
| final TextView textView = mEditor.getTextView(); |
| mTextClassificationHelper = new TextClassificationHelper( |
| textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales()); |
| mSelectionTracker = new SelectionTracker(textView.getTextClassifier()); |
| |
| if (SMART_SELECT_ANIMATION_ENABLED) { |
| mSmartSelectSprite = new SmartSelectSprite(textView); |
| } else { |
| mSmartSelectSprite = null; |
| } |
| } |
| |
| public void startActionModeAsync(boolean adjustSelection) { |
| cancelAsyncTask(); |
| if (skipTextClassification()) { |
| startActionMode(null); |
| } else { |
| resetTextClassificationHelper(true /* resetSelectionTag */); |
| final TextView tv = mEditor.getTextView(); |
| mTextClassificationAsyncTask = new TextClassificationAsyncTask( |
| tv, |
| TIMEOUT_DURATION, |
| adjustSelection |
| ? mTextClassificationHelper::suggestSelection |
| : mTextClassificationHelper::classifyText, |
| mSmartSelectSprite != null |
| ? this::startActionModeWithSmartSelectAnimation |
| : this::startActionMode) |
| .execute(); |
| } |
| } |
| |
| public void invalidateActionModeAsync() { |
| cancelAsyncTask(); |
| if (skipTextClassification()) { |
| invalidateActionMode(null); |
| } else { |
| resetTextClassificationHelper(false /* resetSelectionTag */); |
| mTextClassificationAsyncTask = new TextClassificationAsyncTask( |
| mEditor.getTextView(), TIMEOUT_DURATION, |
| mTextClassificationHelper::classifyText, this::invalidateActionMode) |
| .execute(); |
| } |
| } |
| |
| public void onSelectionAction() { |
| mSelectionTracker.onSelectionAction(mTextClassificationHelper.getSelectionTag()); |
| } |
| |
| public boolean resetSelection(int textIndex) { |
| if (mSelectionTracker.resetSelection( |
| textIndex, mEditor, mTextClassificationHelper.getSelectionTag())) { |
| invalidateActionModeAsync(); |
| return true; |
| } |
| return false; |
| } |
| |
| @Nullable |
| public TextClassification getTextClassification() { |
| return mTextClassification; |
| } |
| |
| public void onDestroyActionMode() { |
| cancelSmartSelectAnimation(); |
| mSelectionTracker.onSelectionDestroyed(); |
| cancelAsyncTask(); |
| } |
| |
| private void cancelAsyncTask() { |
| if (mTextClassificationAsyncTask != null) { |
| mTextClassificationAsyncTask.cancel(true); |
| mTextClassificationAsyncTask = null; |
| } |
| mTextClassification = null; |
| } |
| |
| private boolean skipTextClassification() { |
| final TextView textView = mEditor.getTextView(); |
| // No need to make an async call for a no-op TextClassifier. |
| final boolean noOpTextClassifier = textView.getTextClassifier() == TextClassifier.NO_OP; |
| // Do not call the TextClassifier if there is no selection. |
| final boolean noSelection = textView.getSelectionEnd() == textView.getSelectionStart(); |
| // Do not call the TextClassifier if this is a password field. |
| final boolean password = textView.hasPasswordTransformationMethod() |
| || TextView.isPasswordInputType(textView.getInputType()); |
| return noOpTextClassifier || noSelection || password; |
| } |
| |
| private void startActionMode(@Nullable SelectionResult result) { |
| final TextView textView = mEditor.getTextView(); |
| final CharSequence text = textView.getText(); |
| mSelectionTracker.setOriginalSelection( |
| textView.getSelectionStart(), textView.getSelectionEnd()); |
| if (result != null && text instanceof Spannable) { |
| Selection.setSelection((Spannable) text, result.mStart, result.mEnd); |
| mTextClassification = result.mClassification; |
| } else { |
| mTextClassification = null; |
| } |
| if (mEditor.startSelectionActionModeInternal()) { |
| final SelectionModifierCursorController controller = mEditor.getSelectionController(); |
| if (controller != null) { |
| controller.show(); |
| } |
| if (result != null) { |
| mSelectionTracker.onSelectionStarted( |
| result.mStart, result.mEnd, mTextClassificationHelper.getSelectionTag()); |
| } |
| } |
| mEditor.setRestartActionModeOnNextRefresh(false); |
| mTextClassificationAsyncTask = null; |
| } |
| |
| private void startActionModeWithSmartSelectAnimation(@Nullable SelectionResult result) { |
| final TextView textView = mEditor.getTextView(); |
| final Layout layout = textView.getLayout(); |
| |
| final Runnable onAnimationEndCallback = () -> startActionMode(result); |
| // TODO do not trigger the animation if the change included only non-printable characters |
| final boolean didSelectionChange = |
| result != null && (textView.getSelectionStart() != result.mStart |
| || textView.getSelectionEnd() != result.mEnd); |
| |
| if (!didSelectionChange) { |
| onAnimationEndCallback.run(); |
| return; |
| } |
| |
| final List<RectF> selectionRectangles = |
| convertSelectionToRectangles(layout, result.mStart, result.mEnd); |
| |
| /* |
| * Do not run the Smart Select animation when there are multiple lines involved, as this |
| * behavior is currently broken. |
| * |
| * TODO fix Smart Select Animation when the selection spans multiple lines |
| */ |
| if (selectionRectangles.size() != 1) { |
| onAnimationEndCallback.run(); |
| return; |
| } |
| |
| /* |
| * TODO Figure out a more robust approach for this |
| * We have to translate all the generated rectangles by the top-left padding of the |
| * TextView because the padding influences the rendering of the ViewOverlay, but is not |
| * taken into account when generating the selection path rectangles. |
| */ |
| for (RectF rectangle : selectionRectangles) { |
| rectangle.left += textView.getPaddingLeft(); |
| rectangle.right += textView.getPaddingLeft(); |
| rectangle.top += textView.getPaddingTop(); |
| rectangle.bottom += textView.getPaddingTop(); |
| } |
| |
| final PointF touchPoint = new PointF( |
| mEditor.getLastUpPositionX(), |
| mEditor.getLastUpPositionY()); |
| |
| final PointF animationStartPoint = |
| movePointInsideNearestRectangle(touchPoint, selectionRectangles); |
| |
| mSmartSelectSprite.startAnimation( |
| animationStartPoint, |
| selectionRectangles, |
| onAnimationEndCallback); |
| } |
| |
| private List<RectF> convertSelectionToRectangles(final Layout layout, final int start, |
| final int end) { |
| final List<RectF> result = new ArrayList<>(); |
| // TODO filter out invalid rectangles |
| // getSelection might give us overlapping and zero-dimension rectangles which will interfere |
| // with the Smart Select animation |
| layout.getSelection(start, end, (left, top, right, bottom) -> |
| result.add(new RectF(left, top, right, bottom))); |
| return result; |
| } |
| |
| /** @hide */ |
| @VisibleForTesting |
| public static PointF movePointInsideNearestRectangle(final PointF point, |
| final List<RectF> rectangles) { |
| float bestX = -1; |
| float bestY = -1; |
| double bestDistance = Double.MAX_VALUE; |
| |
| for (final RectF rectangle : rectangles) { |
| final float candidateY = rectangle.centerY(); |
| final float candidateX; |
| |
| if (point.x > rectangle.right) { |
| candidateX = rectangle.right; |
| } else if (point.x < rectangle.left) { |
| candidateX = rectangle.left; |
| } else { |
| candidateX = point.x; |
| } |
| |
| final double candidateDistance = Math.pow(point.x - candidateX, 2) |
| + Math.pow(point.y - candidateY, 2); |
| |
| if (candidateDistance < bestDistance) { |
| bestX = candidateX; |
| bestY = candidateY; |
| bestDistance = candidateDistance; |
| } |
| } |
| |
| return new PointF(bestX, bestY); |
| } |
| |
| private void invalidateActionMode(@Nullable SelectionResult result) { |
| cancelSmartSelectAnimation(); |
| mTextClassification = result != null ? result.mClassification : null; |
| final ActionMode actionMode = mEditor.getTextActionMode(); |
| if (actionMode != null) { |
| actionMode.invalidate(); |
| } |
| final TextView textView = mEditor.getTextView(); |
| mSelectionTracker.onSelectionUpdated( |
| textView.getSelectionStart(), textView.getSelectionEnd(), |
| mTextClassificationHelper.getSelectionTag()); |
| mTextClassificationAsyncTask = null; |
| } |
| |
| private void resetTextClassificationHelper(boolean resetSelectionTag) { |
| final TextView textView = mEditor.getTextView(); |
| mTextClassificationHelper.reset(textView.getTextClassifier(), textView.getText(), |
| textView.getSelectionStart(), textView.getSelectionEnd(), |
| resetSelectionTag, textView.getTextLocales()); |
| } |
| |
| private void cancelSmartSelectAnimation() { |
| if (mSmartSelectSprite != null) { |
| mSmartSelectSprite.cancelAnimation(); |
| } |
| } |
| |
| /** |
| * Tracks and logs smart selection changes. |
| * It is important to trigger this object's methods at the appropriate event so that it tracks |
| * smart selection events appropriately. |
| */ |
| private static final class SelectionTracker { |
| |
| // Log event: Smart selection happened. |
| private static final String LOG_EVENT_MULTI_SELECTION = |
| "textClassifier_multiSelection"; |
| private static final String LOG_EVENT_SINGLE_SELECTION = |
| "textClassifier_singleSelection"; |
| |
| // Log event: Smart selection acted upon. |
| private static final String LOG_EVENT_MULTI_SELECTION_ACTION = |
| "textClassifier_multiSelection_action"; |
| private static final String LOG_EVENT_SINGLE_SELECTION_ACTION = |
| "textClassifier_singleSelection_action"; |
| |
| // Log event: Smart selection was reset to original selection. |
| private static final String LOG_EVENT_MULTI_SELECTION_RESET = |
| "textClassifier_multiSelection_reset"; |
| |
| // Log event: Smart selection was user modified. |
| private static final String LOG_EVENT_MULTI_SELECTION_MODIFIED = |
| "textClassifier_multiSelection_modified"; |
| private static final String LOG_EVENT_SINGLE_SELECTION_MODIFIED = |
| "textClassifier_singleSelection_modified"; |
| |
| private final TextClassifier mClassifier; |
| |
| private int mOriginalStart; |
| private int mOriginalEnd; |
| private int mSelectionStart; |
| private int mSelectionEnd; |
| |
| private boolean mMultiSelection; |
| private boolean mClassifierSelection; |
| |
| SelectionTracker(TextClassifier classifier) { |
| mClassifier = classifier; |
| } |
| |
| /** |
| * Called to initialize the original selection before smart selection is triggered. |
| */ |
| public void setOriginalSelection(int selectionStart, int selectionEnd) { |
| mOriginalStart = selectionStart; |
| mOriginalEnd = selectionEnd; |
| resetSelectionFlags(); |
| } |
| |
| /** |
| * Called when selection action mode is started and the results come from a classifier. |
| * If the selection indices are different from the original selection indices, we have a |
| * smart selection. |
| */ |
| public void onSelectionStarted(int selectionStart, int selectionEnd, String logTag) { |
| mClassifierSelection = !logTag.isEmpty(); |
| mSelectionStart = selectionStart; |
| mSelectionEnd = selectionEnd; |
| // If the started selection is different from the original selection, we have a |
| // smart selection. |
| mMultiSelection = |
| mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; |
| if (mMultiSelection) { |
| mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION); |
| } else if (mClassifierSelection) { |
| mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION); |
| } |
| } |
| |
| /** |
| * Called when selection bounds change. |
| */ |
| public void onSelectionUpdated(int selectionStart, int selectionEnd, String logTag) { |
| final boolean selectionChanged = |
| selectionStart != mSelectionStart || selectionEnd != mSelectionEnd; |
| if (selectionChanged) { |
| if (mMultiSelection) { |
| mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_MODIFIED); |
| } else if (mClassifierSelection) { |
| mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_MODIFIED); |
| } |
| resetSelectionFlags(); |
| } |
| } |
| |
| /** |
| * Called when the selection action mode is destroyed. |
| */ |
| public void onSelectionDestroyed() { |
| resetSelectionFlags(); |
| } |
| |
| /** |
| * Logs if the action was taken on a smart selection. |
| */ |
| public void onSelectionAction(String logTag) { |
| if (mMultiSelection) { |
| mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_ACTION); |
| } else if (mClassifierSelection) { |
| mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_ACTION); |
| } |
| } |
| |
| /** |
| * Returns true if the current smart selection should be reset to normal selection based on |
| * information that has been recorded about the original selection and the smart selection. |
| * The expected UX here is to allow the user to select a word inside of the smart selection |
| * on a single tap. |
| */ |
| public boolean resetSelection(int textIndex, Editor editor, String logTag) { |
| final CharSequence text = editor.getTextView().getText(); |
| if (mMultiSelection |
| && textIndex >= mSelectionStart && textIndex <= mSelectionEnd |
| && text instanceof Spannable) { |
| // Only allow a reset once. |
| resetSelectionFlags(); |
| mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_RESET); |
| return editor.selectCurrentWord(); |
| } |
| return false; |
| } |
| |
| private void resetSelectionFlags() { |
| mMultiSelection = false; |
| mClassifierSelection = false; |
| } |
| } |
| |
| /** |
| * AsyncTask for running a query on a background thread and returning the result on the |
| * UiThread. The AsyncTask times out after a specified time, returning a null result if the |
| * query has not yet returned. |
| */ |
| private static final class TextClassificationAsyncTask |
| extends AsyncTask<Void, Void, SelectionResult> { |
| |
| private final int mTimeOutDuration; |
| private final Supplier<SelectionResult> mSelectionResultSupplier; |
| private final Consumer<SelectionResult> mSelectionResultCallback; |
| private final TextView mTextView; |
| private final String mOriginalText; |
| |
| /** |
| * @param textView the TextView |
| * @param timeOut time in milliseconds to timeout the query if it has not completed |
| * @param selectionResultSupplier fetches the selection results. Runs on a background thread |
| * @param selectionResultCallback receives the selection results. Runs on the UiThread |
| */ |
| TextClassificationAsyncTask( |
| @NonNull TextView textView, int timeOut, |
| @NonNull Supplier<SelectionResult> selectionResultSupplier, |
| @NonNull Consumer<SelectionResult> selectionResultCallback) { |
| super(textView != null ? textView.getHandler() : null); |
| mTextView = Preconditions.checkNotNull(textView); |
| mTimeOutDuration = timeOut; |
| mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier); |
| mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback); |
| // Make a copy of the original text. |
| mOriginalText = mTextView.getText().toString(); |
| } |
| |
| @Override |
| @WorkerThread |
| protected SelectionResult doInBackground(Void... params) { |
| final Runnable onTimeOut = this::onTimeOut; |
| mTextView.postDelayed(onTimeOut, mTimeOutDuration); |
| final SelectionResult result = mSelectionResultSupplier.get(); |
| mTextView.removeCallbacks(onTimeOut); |
| return result; |
| } |
| |
| @Override |
| @UiThread |
| protected void onPostExecute(SelectionResult result) { |
| result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null; |
| mSelectionResultCallback.accept(result); |
| } |
| |
| private void onTimeOut() { |
| if (getStatus() == Status.RUNNING) { |
| onPostExecute(null); |
| } |
| cancel(true); |
| } |
| } |
| |
| /** |
| * Helper class for querying the TextClassifier. |
| * It trims text so that only text necessary to provide context of the selected text is |
| * sent to the TextClassifier. |
| */ |
| private static final class TextClassificationHelper { |
| |
| private static final int TRIM_DELTA = 120; // characters |
| |
| private TextClassifier mTextClassifier; |
| |
| /** The original TextView text. **/ |
| private String mText; |
| /** Start index relative to mText. */ |
| private int mSelectionStart; |
| /** End index relative to mText. */ |
| private int mSelectionEnd; |
| private LocaleList mLocales; |
| /** A tag for the classifier that returned the latest smart selection. */ |
| private String mSelectionTag = ""; |
| |
| /** Trimmed text starting from mTrimStart in mText. */ |
| private CharSequence mTrimmedText; |
| /** Index indicating the start of mTrimmedText in mText. */ |
| private int mTrimStart; |
| /** Start index relative to mTrimmedText */ |
| private int mRelativeStart; |
| /** End index relative to mTrimmedText */ |
| private int mRelativeEnd; |
| |
| /** Information about the last classified text to avoid re-running a query. */ |
| private CharSequence mLastClassificationText; |
| private int mLastClassificationSelectionStart; |
| private int mLastClassificationSelectionEnd; |
| private LocaleList mLastClassificationLocales; |
| private SelectionResult mLastClassificationResult; |
| |
| TextClassificationHelper(TextClassifier textClassifier, |
| CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { |
| reset(textClassifier, text, selectionStart, selectionEnd, true, locales); |
| } |
| |
| @UiThread |
| public void reset(TextClassifier textClassifier, |
| CharSequence text, int selectionStart, int selectionEnd, |
| boolean resetSelectionTag, LocaleList locales) { |
| mTextClassifier = Preconditions.checkNotNull(textClassifier); |
| mText = Preconditions.checkNotNull(text).toString(); |
| mLastClassificationText = null; // invalidate. |
| Preconditions.checkArgument(selectionEnd > selectionStart); |
| mSelectionStart = selectionStart; |
| mSelectionEnd = selectionEnd; |
| mLocales = locales; |
| if (resetSelectionTag) { |
| mSelectionTag = ""; |
| } |
| } |
| |
| @WorkerThread |
| public SelectionResult classifyText() { |
| if (!Objects.equals(mText, mLastClassificationText) |
| || mSelectionStart != mLastClassificationSelectionStart |
| || mSelectionEnd != mLastClassificationSelectionEnd |
| || !Objects.equals(mLocales, mLastClassificationLocales)) { |
| |
| mLastClassificationText = mText; |
| mLastClassificationSelectionStart = mSelectionStart; |
| mLastClassificationSelectionEnd = mSelectionEnd; |
| mLastClassificationLocales = mLocales; |
| |
| trimText(); |
| mLastClassificationResult = new SelectionResult( |
| mSelectionStart, |
| mSelectionEnd, |
| mTextClassifier.classifyText( |
| mTrimmedText, mRelativeStart, mRelativeEnd, mLocales)); |
| |
| } |
| return mLastClassificationResult; |
| } |
| |
| @WorkerThread |
| public SelectionResult suggestSelection() { |
| trimText(); |
| final TextSelection sel = mTextClassifier.suggestSelection( |
| mTrimmedText, mRelativeStart, mRelativeEnd, mLocales); |
| mSelectionStart = Math.max(0, sel.getSelectionStartIndex() + mTrimStart); |
| mSelectionEnd = Math.min(mText.length(), sel.getSelectionEndIndex() + mTrimStart); |
| mSelectionTag = sel.getSourceClassifier(); |
| return classifyText(); |
| } |
| |
| String getSelectionTag() { |
| return mSelectionTag; |
| } |
| |
| private void trimText() { |
| mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); |
| final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); |
| mTrimmedText = mText.subSequence(mTrimStart, referenceEnd); |
| mRelativeStart = mSelectionStart - mTrimStart; |
| mRelativeEnd = mSelectionEnd - mTrimStart; |
| } |
| } |
| |
| /** |
| * Selection result. |
| */ |
| private static final class SelectionResult { |
| private final int mStart; |
| private final int mEnd; |
| private final TextClassification mClassification; |
| |
| SelectionResult(int start, int end, TextClassification classification) { |
| mStart = start; |
| mEnd = end; |
| mClassification = Preconditions.checkNotNull(classification); |
| } |
| } |
| } |