2 * Copyright (C) 2017 The Android Open Source Project
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
8 * http://www.apache.org/licenses/LICENSE-2.0
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
17 package android.widget;
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.annotation.UiThread;
22 import android.annotation.WorkerThread;
23 import android.os.AsyncTask;
24 import android.os.LocaleList;
25 import android.text.Selection;
26 import android.text.Spannable;
27 import android.text.TextUtils;
28 import android.text.util.Linkify;
29 import android.util.Log;
30 import android.view.ActionMode;
31 import android.view.textclassifier.TextClassification;
32 import android.view.textclassifier.TextClassifier;
33 import android.view.textclassifier.TextSelection;
34 import android.view.textclassifier.logging.SmartSelectionEventTracker;
35 import android.view.textclassifier.logging.SmartSelectionEventTracker.SelectionEvent;
36 import android.widget.Editor.SelectionModifierCursorController;
38 import com.android.internal.util.Preconditions;
40 import java.text.BreakIterator;
41 import java.util.Objects;
42 import java.util.function.Consumer;
43 import java.util.function.Supplier;
44 import java.util.regex.Pattern;
47 * Helper class for starting selection action mode
48 * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
51 final class SelectionActionModeHelper {
53 private static final String LOG_TAG = "SelectActionModeHelper";
55 private final Editor mEditor;
56 private final TextView mTextView;
57 private final TextClassificationHelper mTextClassificationHelper;
59 private TextClassification mTextClassification;
60 private AsyncTask mTextClassificationAsyncTask;
62 private final SelectionTracker mSelectionTracker;
64 SelectionActionModeHelper(@NonNull Editor editor) {
65 mEditor = Preconditions.checkNotNull(editor);
66 mTextView = mEditor.getTextView();
67 mTextClassificationHelper = new TextClassificationHelper(
68 mTextView.getTextClassifier(),
70 0, 1, mTextView.getTextLocales());
71 mSelectionTracker = new SelectionTracker(mTextView);
74 public void startActionModeAsync(boolean adjustSelection) {
75 // Check if the smart selection should run for editable text.
76 adjustSelection &= !mTextView.isTextEditable()
77 || mTextView.getTextClassifier().getSettings()
78 .isSuggestSelectionEnabledForEditableText();
80 mSelectionTracker.onOriginalSelection(
82 mTextView.getSelectionStart(),
83 mTextView.getSelectionEnd());
85 if (skipTextClassification()) {
86 startActionMode(null);
88 resetTextClassificationHelper();
89 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
91 mTextClassificationHelper.getTimeoutDuration(),
93 ? mTextClassificationHelper::suggestSelection
94 : mTextClassificationHelper::classifyText,
95 this::startActionMode)
100 public void invalidateActionModeAsync() {
102 if (skipTextClassification()) {
103 invalidateActionMode(null);
105 resetTextClassificationHelper();
106 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
108 mTextClassificationHelper.getTimeoutDuration(),
109 mTextClassificationHelper::classifyText,
110 this::invalidateActionMode)
115 public void onSelectionAction(int menuItemId) {
116 mSelectionTracker.onSelectionAction(
117 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
118 getActionType(menuItemId), mTextClassification);
121 public void onSelectionDrag() {
122 mSelectionTracker.onSelectionAction(
123 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
124 SelectionEvent.ActionType.DRAG, mTextClassification);
127 public void onTextChanged(int start, int end) {
128 mSelectionTracker.onTextChanged(start, end, mTextClassification);
131 public boolean resetSelection(int textIndex) {
132 if (mSelectionTracker.resetSelection(textIndex, mEditor)) {
133 invalidateActionModeAsync();
140 public TextClassification getTextClassification() {
141 return mTextClassification;
144 public void onDestroyActionMode() {
145 mSelectionTracker.onSelectionDestroyed();
149 private void cancelAsyncTask() {
150 if (mTextClassificationAsyncTask != null) {
151 mTextClassificationAsyncTask.cancel(true);
152 mTextClassificationAsyncTask = null;
154 mTextClassification = null;
157 private boolean skipTextClassification() {
158 // No need to make an async call for a no-op TextClassifier.
159 final boolean noOpTextClassifier = mTextView.getTextClassifier() == TextClassifier.NO_OP;
160 // Do not call the TextClassifier if there is no selection.
161 final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart();
162 // Do not call the TextClassifier if this is a password field.
163 final boolean password = mTextView.hasPasswordTransformationMethod()
164 || TextView.isPasswordInputType(mTextView.getInputType());
165 return noOpTextClassifier || noSelection || password;
168 private void startActionMode(@Nullable SelectionResult result) {
169 final CharSequence text = getText(mTextView);
170 if (result != null && text instanceof Spannable) {
171 // Do not change the selection if TextClassifier should be dark launched.
172 if (!mTextView.getTextClassifier().getSettings().isDarkLaunch()) {
173 Selection.setSelection((Spannable) text, result.mStart, result.mEnd);
175 mTextClassification = result.mClassification;
177 mTextClassification = null;
179 if (mEditor.startSelectionActionModeInternal()) {
180 final SelectionModifierCursorController controller = mEditor.getSelectionController();
181 if (controller != null) {
184 if (result != null) {
185 mSelectionTracker.onSmartSelection(result);
188 mEditor.setRestartActionModeOnNextRefresh(false);
189 mTextClassificationAsyncTask = null;
192 private void invalidateActionMode(@Nullable SelectionResult result) {
193 mTextClassification = result != null ? result.mClassification : null;
194 final ActionMode actionMode = mEditor.getTextActionMode();
195 if (actionMode != null) {
196 actionMode.invalidate();
198 mSelectionTracker.onSelectionUpdated(
199 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), mTextClassification);
200 mTextClassificationAsyncTask = null;
203 private void resetTextClassificationHelper() {
204 mTextClassificationHelper.init(
205 mTextView.getTextClassifier(),
207 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
208 mTextView.getTextLocales());
212 * Tracks and logs smart selection changes.
213 * It is important to trigger this object's methods at the appropriate event so that it tracks
214 * smart selection events appropriately.
216 private static final class SelectionTracker {
218 private final TextView mTextView;
219 private SelectionMetricsLogger mLogger;
221 private int mOriginalStart;
222 private int mOriginalEnd;
223 private int mSelectionStart;
224 private int mSelectionEnd;
225 private boolean mAllowReset;
226 private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable();
228 SelectionTracker(TextView textView) {
229 mTextView = Preconditions.checkNotNull(textView);
230 mLogger = new SelectionMetricsLogger(textView);
234 * Called when the original selection happens, before smart selection is triggered.
236 public void onOriginalSelection(CharSequence text, int selectionStart, int selectionEnd) {
237 // If we abandoned a selection and created a new one very shortly after, we may still
238 // have a pending request to log ABANDON, which we flush here.
239 mDelayedLogAbandon.flush();
241 mOriginalStart = mSelectionStart = selectionStart;
242 mOriginalEnd = mSelectionEnd = selectionEnd;
244 maybeInvalidateLogger();
245 mLogger.logSelectionStarted(text, selectionStart);
249 * Called when selection action mode is started and the results come from a classifier.
251 public void onSmartSelection(SelectionResult result) {
252 if (isSelectionStarted()) {
253 mSelectionStart = result.mStart;
254 mSelectionEnd = result.mEnd;
255 mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd;
256 mLogger.logSelectionModified(
257 result.mStart, result.mEnd, result.mClassification, result.mSelection);
262 * Called when selection bounds change.
264 public void onSelectionUpdated(
265 int selectionStart, int selectionEnd,
266 @Nullable TextClassification classification) {
267 if (isSelectionStarted()) {
268 mSelectionStart = selectionStart;
269 mSelectionEnd = selectionEnd;
271 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null);
276 * Called when the selection action mode is destroyed.
278 public void onSelectionDestroyed() {
280 // Wait a few ms to see if the selection was destroyed because of a text change event.
281 mDelayedLogAbandon.schedule(100 /* ms */);
285 * Called when an action is taken on a smart selection.
287 public void onSelectionAction(
288 int selectionStart, int selectionEnd,
289 @SelectionEvent.ActionType int action,
290 @Nullable TextClassification classification) {
291 if (isSelectionStarted()) {
293 mLogger.logSelectionAction(selectionStart, selectionEnd, action, classification);
298 * Returns true if the current smart selection should be reset to normal selection based on
299 * information that has been recorded about the original selection and the smart selection.
300 * The expected UX here is to allow the user to select a word inside of the smart selection
303 public boolean resetSelection(int textIndex, Editor editor) {
304 final TextView textView = editor.getTextView();
305 if (isSelectionStarted()
307 && textIndex >= mSelectionStart && textIndex <= mSelectionEnd
308 && getText(textView) instanceof Spannable) {
310 boolean selected = editor.selectCurrentWord();
312 mSelectionStart = editor.getTextView().getSelectionStart();
313 mSelectionEnd = editor.getTextView().getSelectionEnd();
314 mLogger.logSelectionAction(
315 textView.getSelectionStart(), textView.getSelectionEnd(),
316 SelectionEvent.ActionType.RESET, null /* classification */);
323 public void onTextChanged(int start, int end, TextClassification classification) {
324 if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) {
325 onSelectionAction(start, end, SelectionEvent.ActionType.OVERTYPE, classification);
329 private void maybeInvalidateLogger() {
330 if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) {
331 mLogger = new SelectionMetricsLogger(mTextView);
335 private boolean isSelectionStarted() {
336 return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd;
339 /** A helper for keeping track of pending abandon logging requests. */
340 private final class LogAbandonRunnable implements Runnable {
341 private boolean mIsPending;
343 /** Schedules an abandon to be logged with the given delay. Flush if necessary. */
344 void schedule(int delayMillis) {
346 Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request");
350 mTextView.postDelayed(this, delayMillis);
353 /** If there is a pending log request, execute it now. */
355 mTextView.removeCallbacks(this);
362 mLogger.logSelectionAction(
363 mSelectionStart, mSelectionEnd,
364 SelectionEvent.ActionType.ABANDON, null /* classification */);
365 mSelectionStart = mSelectionEnd = -1;
374 * Metrics logging helper.
376 * This logger logs selection by word indices. The initial (start) single word selection is
377 * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the
378 * initial single word selection.
379 * e.g. New York city, NY. Suppose the initial selection is "York" in
380 * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2).
381 * "New York" is at [-1, 1).
382 * Part selection of a word e.g. "or" is counted as selecting the
383 * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g.
384 * "," is at [2, 3). Whitespaces are ignored.
386 private static final class SelectionMetricsLogger {
388 private static final String LOG_TAG = "SelectionMetricsLogger";
389 private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+");
391 private final SmartSelectionEventTracker mDelegate;
392 private final boolean mEditTextLogger;
393 private final BreakIterator mWordIterator;
394 private int mStartIndex;
395 private String mText;
397 SelectionMetricsLogger(TextView textView) {
398 Preconditions.checkNotNull(textView);
399 final @SmartSelectionEventTracker.WidgetType int widgetType = textView.isTextEditable()
400 ? SmartSelectionEventTracker.WidgetType.EDITTEXT
401 : SmartSelectionEventTracker.WidgetType.TEXTVIEW;
402 mDelegate = new SmartSelectionEventTracker(textView.getContext(), widgetType);
403 mEditTextLogger = textView.isTextEditable();
404 mWordIterator = BreakIterator.getWordInstance(textView.getTextLocale());
407 public void logSelectionStarted(CharSequence text, int index) {
409 Preconditions.checkNotNull(text);
410 Preconditions.checkArgumentInRange(index, 0, text.length(), "index");
411 if (mText == null || !mText.contentEquals(text)) {
412 mText = text.toString();
414 mWordIterator.setText(mText);
416 mDelegate.logEvent(SelectionEvent.selectionStarted(0));
417 } catch (Exception e) {
418 // Avoid crashes due to logging.
419 Log.d(LOG_TAG, e.getMessage());
423 public void logSelectionModified(int start, int end,
424 @Nullable TextClassification classification, @Nullable TextSelection selection) {
426 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
427 Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
428 int[] wordIndices = getWordDelta(start, end);
429 if (selection != null) {
430 mDelegate.logEvent(SelectionEvent.selectionModified(
431 wordIndices[0], wordIndices[1], selection));
432 } else if (classification != null) {
433 mDelegate.logEvent(SelectionEvent.selectionModified(
434 wordIndices[0], wordIndices[1], classification));
436 mDelegate.logEvent(SelectionEvent.selectionModified(
437 wordIndices[0], wordIndices[1]));
439 } catch (Exception e) {
440 // Avoid crashes due to logging.
441 Log.d(LOG_TAG, e.getMessage());
445 public void logSelectionAction(
447 @SelectionEvent.ActionType int action,
448 @Nullable TextClassification classification) {
450 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
451 Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
452 int[] wordIndices = getWordDelta(start, end);
453 if (classification != null) {
454 mDelegate.logEvent(SelectionEvent.selectionAction(
455 wordIndices[0], wordIndices[1], action, classification));
457 mDelegate.logEvent(SelectionEvent.selectionAction(
458 wordIndices[0], wordIndices[1], action));
460 } catch (Exception e) {
461 // Avoid crashes due to logging.
462 Log.d(LOG_TAG, e.getMessage());
466 public boolean isEditTextLogger() {
467 return mEditTextLogger;
470 private int[] getWordDelta(int start, int end) {
471 int[] wordIndices = new int[2];
473 if (start == mStartIndex) {
475 } else if (start < mStartIndex) {
476 wordIndices[0] = -countWordsForward(start);
477 } else { // start > mStartIndex
478 wordIndices[0] = countWordsBackward(start);
480 // For the selection start index, avoid counting a partial word backwards.
481 if (!mWordIterator.isBoundary(start)
483 mWordIterator.preceding(start),
484 mWordIterator.following(start))) {
485 // We counted a partial word. Remove it.
490 if (end == mStartIndex) {
492 } else if (end < mStartIndex) {
493 wordIndices[1] = -countWordsForward(end);
494 } else { // end > mStartIndex
495 wordIndices[1] = countWordsBackward(end);
501 private int countWordsBackward(int from) {
502 Preconditions.checkArgument(from >= mStartIndex);
505 while (offset > mStartIndex) {
506 int start = mWordIterator.preceding(offset);
507 if (!isWhitespace(start, offset)) {
515 private int countWordsForward(int from) {
516 Preconditions.checkArgument(from <= mStartIndex);
519 while (offset < mStartIndex) {
520 int end = mWordIterator.following(offset);
521 if (!isWhitespace(offset, end)) {
529 private boolean isWhitespace(int start, int end) {
530 return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches();
535 * AsyncTask for running a query on a background thread and returning the result on the
536 * UiThread. The AsyncTask times out after a specified time, returning a null result if the
537 * query has not yet returned.
539 private static final class TextClassificationAsyncTask
540 extends AsyncTask<Void, Void, SelectionResult> {
542 private final long mTimeOutDuration;
543 private final Supplier<SelectionResult> mSelectionResultSupplier;
544 private final Consumer<SelectionResult> mSelectionResultCallback;
545 private final TextView mTextView;
546 private final String mOriginalText;
549 * @param textView the TextView
550 * @param timeOut time in milliseconds to timeout the query if it has not completed
551 * @param selectionResultSupplier fetches the selection results. Runs on a background thread
552 * @param selectionResultCallback receives the selection results. Runs on the UiThread
554 TextClassificationAsyncTask(
555 @NonNull TextView textView, long timeOut,
556 @NonNull Supplier<SelectionResult> selectionResultSupplier,
557 @NonNull Consumer<SelectionResult> selectionResultCallback) {
558 super(textView != null ? textView.getHandler() : null);
559 mTextView = Preconditions.checkNotNull(textView);
560 mTimeOutDuration = timeOut;
561 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier);
562 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback);
563 // Make a copy of the original text.
564 mOriginalText = getText(mTextView).toString();
569 protected SelectionResult doInBackground(Void... params) {
570 final Runnable onTimeOut = this::onTimeOut;
571 mTextView.postDelayed(onTimeOut, mTimeOutDuration);
572 final SelectionResult result = mSelectionResultSupplier.get();
573 mTextView.removeCallbacks(onTimeOut);
579 protected void onPostExecute(SelectionResult result) {
580 result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null;
581 mSelectionResultCallback.accept(result);
584 private void onTimeOut() {
585 if (getStatus() == Status.RUNNING) {
593 * Helper class for querying the TextClassifier.
594 * It trims text so that only text necessary to provide context of the selected text is
595 * sent to the TextClassifier.
597 private static final class TextClassificationHelper {
599 private static final int TRIM_DELTA = 120; // characters
601 private TextClassifier mTextClassifier;
603 /** The original TextView text. **/
604 private String mText;
605 /** Start index relative to mText. */
606 private int mSelectionStart;
607 /** End index relative to mText. */
608 private int mSelectionEnd;
609 private LocaleList mLocales;
611 /** Trimmed text starting from mTrimStart in mText. */
612 private CharSequence mTrimmedText;
613 /** Index indicating the start of mTrimmedText in mText. */
614 private int mTrimStart;
615 /** Start index relative to mTrimmedText */
616 private int mRelativeStart;
617 /** End index relative to mTrimmedText */
618 private int mRelativeEnd;
620 /** Information about the last classified text to avoid re-running a query. */
621 private CharSequence mLastClassificationText;
622 private int mLastClassificationSelectionStart;
623 private int mLastClassificationSelectionEnd;
624 private LocaleList mLastClassificationLocales;
625 private SelectionResult mLastClassificationResult;
627 /** Whether the TextClassifier has been initialized. */
628 private boolean mHot;
630 TextClassificationHelper(TextClassifier textClassifier,
631 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
632 init(textClassifier, text, selectionStart, selectionEnd, locales);
636 public void init(TextClassifier textClassifier,
637 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
638 mTextClassifier = Preconditions.checkNotNull(textClassifier);
639 mText = Preconditions.checkNotNull(text).toString();
640 mLastClassificationText = null; // invalidate.
641 Preconditions.checkArgument(selectionEnd > selectionStart);
642 mSelectionStart = selectionStart;
643 mSelectionEnd = selectionEnd;
648 public SelectionResult classifyText() {
650 return performClassification(null /* selection */);
654 public SelectionResult suggestSelection() {
657 final TextSelection selection = mTextClassifier.suggestSelection(
658 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales);
659 // Do not classify new selection boundaries if TextClassifier should be dark launched.
660 if (!mTextClassifier.getSettings().isDarkLaunch()) {
661 mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart);
662 mSelectionEnd = Math.min(
663 mText.length(), selection.getSelectionEndIndex() + mTrimStart);
665 return performClassification(selection);
669 * Maximum time (in milliseconds) to wait for a textclassifier result before timing out.
671 // TODO: Consider making this a ViewConfiguration.
672 public long getTimeoutDuration() {
676 // Return a slightly larger number than usual when the TextClassifier is first
677 // initialized. Initialization would usually take longer than subsequent calls to
678 // the TextClassifier. The impact of this on the UI is that we do not show the
679 // selection handles or toolbar until after this timeout.
684 private SelectionResult performClassification(@Nullable TextSelection selection) {
685 if (!Objects.equals(mText, mLastClassificationText)
686 || mSelectionStart != mLastClassificationSelectionStart
687 || mSelectionEnd != mLastClassificationSelectionEnd
688 || !Objects.equals(mLocales, mLastClassificationLocales)) {
690 mLastClassificationText = mText;
691 mLastClassificationSelectionStart = mSelectionStart;
692 mLastClassificationSelectionEnd = mSelectionEnd;
693 mLastClassificationLocales = mLocales;
696 final TextClassification classification;
697 if (Linkify.containsUnsupportedCharacters(mText)) {
698 // Do not show smart actions for text containing unsupported characters.
699 android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, "");
700 classification = TextClassification.EMPTY;
702 classification = mTextClassifier.classifyText(
703 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales);
705 mLastClassificationResult = new SelectionResult(
712 return mLastClassificationResult;
715 private void trimText() {
716 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA);
717 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA);
718 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd);
719 mRelativeStart = mSelectionStart - mTrimStart;
720 mRelativeEnd = mSelectionEnd - mTrimStart;
727 private static final class SelectionResult {
728 private final int mStart;
729 private final int mEnd;
730 private final TextClassification mClassification;
731 @Nullable private final TextSelection mSelection;
733 SelectionResult(int start, int end,
734 TextClassification classification, @Nullable TextSelection selection) {
737 mClassification = Preconditions.checkNotNull(classification);
738 mSelection = selection;
742 @SelectionEvent.ActionType
743 private static int getActionType(int menuItemId) {
744 switch (menuItemId) {
745 case TextView.ID_SELECT_ALL:
746 return SelectionEvent.ActionType.SELECT_ALL;
747 case TextView.ID_CUT:
748 return SelectionEvent.ActionType.CUT;
749 case TextView.ID_COPY:
750 return SelectionEvent.ActionType.COPY;
751 case TextView.ID_PASTE: // fall through
752 case TextView.ID_PASTE_AS_PLAIN_TEXT:
753 return SelectionEvent.ActionType.PASTE;
754 case TextView.ID_SHARE:
755 return SelectionEvent.ActionType.SHARE;
756 case TextView.ID_ASSIST:
757 return SelectionEvent.ActionType.SMART_SHARE;
759 return SelectionEvent.ActionType.OTHER;
763 private static CharSequence getText(TextView textView) {
764 // Extracts the textView's text.
765 // TODO: Investigate why/when TextView.getText() is null.
766 final CharSequence text = textView.getText();