• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
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
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
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.
15  */
16 
17 package android.widget;
18 
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;
37 
38 import com.android.internal.util.Preconditions;
39 
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;
45 
46 /**
47  * Helper class for starting selection action mode
48  * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
49  */
50 @UiThread
51 final class SelectionActionModeHelper {
52 
53     private static final String LOG_TAG = "SelectActionModeHelper";
54 
55     private final Editor mEditor;
56     private final TextView mTextView;
57     private final TextClassificationHelper mTextClassificationHelper;
58 
59     private TextClassification mTextClassification;
60     private AsyncTask mTextClassificationAsyncTask;
61 
62     private final SelectionTracker mSelectionTracker;
63 
SelectionActionModeHelper(@onNull Editor editor)64     SelectionActionModeHelper(@NonNull Editor editor) {
65         mEditor = Preconditions.checkNotNull(editor);
66         mTextView = mEditor.getTextView();
67         mTextClassificationHelper = new TextClassificationHelper(
68                 mTextView.getTextClassifier(),
69                 getText(mTextView),
70                 0, 1, mTextView.getTextLocales());
71         mSelectionTracker = new SelectionTracker(mTextView);
72     }
73 
startActionModeAsync(boolean adjustSelection)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();
79 
80         mSelectionTracker.onOriginalSelection(
81                 getText(mTextView),
82                 mTextView.getSelectionStart(),
83                 mTextView.getSelectionEnd());
84         cancelAsyncTask();
85         if (skipTextClassification()) {
86             startActionMode(null);
87         } else {
88             resetTextClassificationHelper();
89             mTextClassificationAsyncTask = new TextClassificationAsyncTask(
90                     mTextView,
91                     mTextClassificationHelper.getTimeoutDuration(),
92                     adjustSelection
93                             ? mTextClassificationHelper::suggestSelection
94                             : mTextClassificationHelper::classifyText,
95                     this::startActionMode)
96                     .execute();
97         }
98     }
99 
invalidateActionModeAsync()100     public void invalidateActionModeAsync() {
101         cancelAsyncTask();
102         if (skipTextClassification()) {
103             invalidateActionMode(null);
104         } else {
105             resetTextClassificationHelper();
106             mTextClassificationAsyncTask = new TextClassificationAsyncTask(
107                     mTextView,
108                     mTextClassificationHelper.getTimeoutDuration(),
109                     mTextClassificationHelper::classifyText,
110                     this::invalidateActionMode)
111                     .execute();
112         }
113     }
114 
onSelectionAction(int menuItemId)115     public void onSelectionAction(int menuItemId) {
116         mSelectionTracker.onSelectionAction(
117                 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
118                 getActionType(menuItemId), mTextClassification);
119     }
120 
onSelectionDrag()121     public void onSelectionDrag() {
122         mSelectionTracker.onSelectionAction(
123                 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
124                 SelectionEvent.ActionType.DRAG, mTextClassification);
125     }
126 
onTextChanged(int start, int end)127     public void onTextChanged(int start, int end) {
128         mSelectionTracker.onTextChanged(start, end, mTextClassification);
129     }
130 
resetSelection(int textIndex)131     public boolean resetSelection(int textIndex) {
132         if (mSelectionTracker.resetSelection(textIndex, mEditor)) {
133             invalidateActionModeAsync();
134             return true;
135         }
136         return false;
137     }
138 
139     @Nullable
getTextClassification()140     public TextClassification getTextClassification() {
141         return mTextClassification;
142     }
143 
onDestroyActionMode()144     public void onDestroyActionMode() {
145         mSelectionTracker.onSelectionDestroyed();
146         cancelAsyncTask();
147     }
148 
cancelAsyncTask()149     private void cancelAsyncTask() {
150         if (mTextClassificationAsyncTask != null) {
151             mTextClassificationAsyncTask.cancel(true);
152             mTextClassificationAsyncTask = null;
153         }
154         mTextClassification = null;
155     }
156 
skipTextClassification()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;
166     }
167 
startActionMode(@ullable SelectionResult result)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);
174             }
175             mTextClassification = result.mClassification;
176         } else {
177             mTextClassification = null;
178         }
179         if (mEditor.startSelectionActionModeInternal()) {
180             final SelectionModifierCursorController controller = mEditor.getSelectionController();
181             if (controller != null) {
182                 controller.show();
183             }
184             if (result != null) {
185                 mSelectionTracker.onSmartSelection(result);
186             }
187         }
188         mEditor.setRestartActionModeOnNextRefresh(false);
189         mTextClassificationAsyncTask = null;
190     }
191 
invalidateActionMode(@ullable SelectionResult result)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();
197         }
198         mSelectionTracker.onSelectionUpdated(
199                 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), mTextClassification);
200         mTextClassificationAsyncTask = null;
201     }
202 
resetTextClassificationHelper()203     private void resetTextClassificationHelper() {
204         mTextClassificationHelper.init(
205                 mTextView.getTextClassifier(),
206                 getText(mTextView),
207                 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
208                 mTextView.getTextLocales());
209     }
210 
211     /**
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.
215      */
216     private static final class SelectionTracker {
217 
218         private final TextView mTextView;
219         private SelectionMetricsLogger mLogger;
220 
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();
227 
SelectionTracker(TextView textView)228         SelectionTracker(TextView textView) {
229             mTextView = Preconditions.checkNotNull(textView);
230             mLogger = new SelectionMetricsLogger(textView);
231         }
232 
233         /**
234          * Called when the original selection happens, before smart selection is triggered.
235          */
onOriginalSelection(CharSequence text, int selectionStart, int selectionEnd)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();
240 
241             mOriginalStart = mSelectionStart = selectionStart;
242             mOriginalEnd = mSelectionEnd = selectionEnd;
243             mAllowReset = false;
244             maybeInvalidateLogger();
245             mLogger.logSelectionStarted(text, selectionStart);
246         }
247 
248         /**
249          * Called when selection action mode is started and the results come from a classifier.
250          */
onSmartSelection(SelectionResult result)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);
258             }
259         }
260 
261         /**
262          * Called when selection bounds change.
263          */
onSelectionUpdated( int selectionStart, int selectionEnd, @Nullable TextClassification classification)264         public void onSelectionUpdated(
265                 int selectionStart, int selectionEnd,
266                 @Nullable TextClassification classification) {
267             if (isSelectionStarted()) {
268                 mSelectionStart = selectionStart;
269                 mSelectionEnd = selectionEnd;
270                 mAllowReset = false;
271                 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null);
272             }
273         }
274 
275         /**
276          * Called when the selection action mode is destroyed.
277          */
onSelectionDestroyed()278         public void onSelectionDestroyed() {
279             mAllowReset = false;
280             // Wait a few ms to see if the selection was destroyed because of a text change event.
281             mDelayedLogAbandon.schedule(100 /* ms */);
282         }
283 
284         /**
285          * Called when an action is taken on a smart selection.
286          */
onSelectionAction( int selectionStart, int selectionEnd, @SelectionEvent.ActionType int action, @Nullable TextClassification classification)287         public void onSelectionAction(
288                 int selectionStart, int selectionEnd,
289                 @SelectionEvent.ActionType int action,
290                 @Nullable TextClassification classification) {
291             if (isSelectionStarted()) {
292                 mAllowReset = false;
293                 mLogger.logSelectionAction(selectionStart, selectionEnd, action, classification);
294             }
295         }
296 
297         /**
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
301          * on a single tap.
302          */
resetSelection(int textIndex, Editor editor)303         public boolean resetSelection(int textIndex, Editor editor) {
304             final TextView textView = editor.getTextView();
305             if (isSelectionStarted()
306                     && mAllowReset
307                     && textIndex >= mSelectionStart && textIndex <= mSelectionEnd
308                     && getText(textView) instanceof Spannable) {
309                 mAllowReset = false;
310                 boolean selected = editor.selectCurrentWord();
311                 if (selected) {
312                     mSelectionStart = editor.getTextView().getSelectionStart();
313                     mSelectionEnd = editor.getTextView().getSelectionEnd();
314                     mLogger.logSelectionAction(
315                             textView.getSelectionStart(), textView.getSelectionEnd(),
316                             SelectionEvent.ActionType.RESET, null /* classification */);
317                 }
318                 return selected;
319             }
320             return false;
321         }
322 
onTextChanged(int start, int end, TextClassification 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);
326             }
327         }
328 
maybeInvalidateLogger()329         private void maybeInvalidateLogger() {
330             if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) {
331                 mLogger = new SelectionMetricsLogger(mTextView);
332             }
333         }
334 
isSelectionStarted()335         private boolean isSelectionStarted() {
336             return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd;
337         }
338 
339         /** A helper for keeping track of pending abandon logging requests. */
340         private final class LogAbandonRunnable implements Runnable {
341             private boolean mIsPending;
342 
343             /** Schedules an abandon to be logged with the given delay. Flush if necessary. */
schedule(int delayMillis)344             void schedule(int delayMillis) {
345                 if (mIsPending) {
346                     Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request");
347                     flush();
348                 }
349                 mIsPending = true;
350                 mTextView.postDelayed(this, delayMillis);
351             }
352 
353             /** If there is a pending log request, execute it now. */
flush()354             void flush() {
355                 mTextView.removeCallbacks(this);
356                 run();
357             }
358 
359             @Override
run()360             public void run() {
361                 if (mIsPending) {
362                     mLogger.logSelectionAction(
363                             mSelectionStart, mSelectionEnd,
364                             SelectionEvent.ActionType.ABANDON, null /* classification */);
365                     mSelectionStart = mSelectionEnd = -1;
366                     mIsPending = false;
367                 }
368             }
369         }
370     }
371 
372     // TODO: Write tests
373     /**
374      * Metrics logging helper.
375      *
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.
385      */
386     private static final class SelectionMetricsLogger {
387 
388         private static final String LOG_TAG = "SelectionMetricsLogger";
389         private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+");
390 
391         private final SmartSelectionEventTracker mDelegate;
392         private final boolean mEditTextLogger;
393         private final BreakIterator mWordIterator;
394         private int mStartIndex;
395         private String mText;
396 
SelectionMetricsLogger(TextView textView)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());
405         }
406 
logSelectionStarted(CharSequence text, int index)407         public void logSelectionStarted(CharSequence text, int index) {
408             try {
409                 Preconditions.checkNotNull(text);
410                 Preconditions.checkArgumentInRange(index, 0, text.length(), "index");
411                 if (mText == null || !mText.contentEquals(text)) {
412                     mText = text.toString();
413                 }
414                 mWordIterator.setText(mText);
415                 mStartIndex = index;
416                 mDelegate.logEvent(SelectionEvent.selectionStarted(0));
417             } catch (Exception e) {
418                 // Avoid crashes due to logging.
419                 Log.d(LOG_TAG, e.getMessage());
420             }
421         }
422 
logSelectionModified(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)423         public void logSelectionModified(int start, int end,
424                 @Nullable TextClassification classification, @Nullable TextSelection selection) {
425             try {
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));
435                 } else {
436                     mDelegate.logEvent(SelectionEvent.selectionModified(
437                             wordIndices[0], wordIndices[1]));
438                 }
439             } catch (Exception e) {
440                 // Avoid crashes due to logging.
441                 Log.d(LOG_TAG, e.getMessage());
442             }
443         }
444 
logSelectionAction( int start, int end, @SelectionEvent.ActionType int action, @Nullable TextClassification classification)445         public void logSelectionAction(
446                 int start, int end,
447                 @SelectionEvent.ActionType int action,
448                 @Nullable TextClassification classification) {
449             try {
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));
456                 } else {
457                     mDelegate.logEvent(SelectionEvent.selectionAction(
458                             wordIndices[0], wordIndices[1], action));
459                 }
460             } catch (Exception e) {
461                 // Avoid crashes due to logging.
462                 Log.d(LOG_TAG, e.getMessage());
463             }
464         }
465 
isEditTextLogger()466         public boolean isEditTextLogger() {
467             return mEditTextLogger;
468         }
469 
getWordDelta(int start, int end)470         private int[] getWordDelta(int start, int end) {
471             int[] wordIndices = new int[2];
472 
473             if (start == mStartIndex) {
474                 wordIndices[0] = 0;
475             } else if (start < mStartIndex) {
476                 wordIndices[0] = -countWordsForward(start);
477             } else {  // start > mStartIndex
478                 wordIndices[0] = countWordsBackward(start);
479 
480                 // For the selection start index, avoid counting a partial word backwards.
481                 if (!mWordIterator.isBoundary(start)
482                         && !isWhitespace(
483                                 mWordIterator.preceding(start),
484                                 mWordIterator.following(start))) {
485                     // We counted a partial word. Remove it.
486                     wordIndices[0]--;
487                 }
488             }
489 
490             if (end == mStartIndex) {
491                 wordIndices[1] = 0;
492             } else if (end < mStartIndex) {
493                 wordIndices[1] = -countWordsForward(end);
494             } else {  // end > mStartIndex
495                 wordIndices[1] = countWordsBackward(end);
496             }
497 
498             return wordIndices;
499         }
500 
countWordsBackward(int from)501         private int countWordsBackward(int from) {
502             Preconditions.checkArgument(from >= mStartIndex);
503             int wordCount = 0;
504             int offset = from;
505             while (offset > mStartIndex) {
506                 int start = mWordIterator.preceding(offset);
507                 if (!isWhitespace(start, offset)) {
508                     wordCount++;
509                 }
510                 offset = start;
511             }
512             return wordCount;
513         }
514 
countWordsForward(int from)515         private int countWordsForward(int from) {
516             Preconditions.checkArgument(from <= mStartIndex);
517             int wordCount = 0;
518             int offset = from;
519             while (offset < mStartIndex) {
520                 int end = mWordIterator.following(offset);
521                 if (!isWhitespace(offset, end)) {
522                     wordCount++;
523                 }
524                 offset = end;
525             }
526             return wordCount;
527         }
528 
isWhitespace(int start, int end)529         private boolean isWhitespace(int start, int end) {
530             return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches();
531         }
532     }
533 
534     /**
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.
538      */
539     private static final class TextClassificationAsyncTask
540             extends AsyncTask<Void, Void, SelectionResult> {
541 
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;
547 
548         /**
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
553          */
TextClassificationAsyncTask( @onNull TextView textView, long timeOut, @NonNull Supplier<SelectionResult> selectionResultSupplier, @NonNull Consumer<SelectionResult> selectionResultCallback)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();
565         }
566 
567         @Override
568         @WorkerThread
doInBackground(Void... params)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);
574             return result;
575         }
576 
577         @Override
578         @UiThread
onPostExecute(SelectionResult result)579         protected void onPostExecute(SelectionResult result) {
580             result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null;
581             mSelectionResultCallback.accept(result);
582         }
583 
onTimeOut()584         private void onTimeOut() {
585             if (getStatus() == Status.RUNNING) {
586                 onPostExecute(null);
587             }
588             cancel(true);
589         }
590     }
591 
592     /**
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.
596      */
597     private static final class TextClassificationHelper {
598 
599         private static final int TRIM_DELTA = 120;  // characters
600 
601         private TextClassifier mTextClassifier;
602 
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;
610 
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;
619 
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;
626 
627         /** Whether the TextClassifier has been initialized. */
628         private boolean mHot;
629 
TextClassificationHelper(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)630         TextClassificationHelper(TextClassifier textClassifier,
631                 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
632             init(textClassifier, text, selectionStart, selectionEnd, locales);
633         }
634 
635         @UiThread
init(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList 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;
644             mLocales = locales;
645         }
646 
647         @WorkerThread
classifyText()648         public SelectionResult classifyText() {
649             mHot = true;
650             return performClassification(null /* selection */);
651         }
652 
653         @WorkerThread
suggestSelection()654         public SelectionResult suggestSelection() {
655             mHot = true;
656             trimText();
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);
664             }
665             return performClassification(selection);
666         }
667 
668         /**
669          * Maximum time (in milliseconds) to wait for a textclassifier result before timing out.
670          */
671         // TODO: Consider making this a ViewConfiguration.
getTimeoutDuration()672         public long getTimeoutDuration() {
673             if (mHot) {
674                 return 200;
675             } else {
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.
680                 return 500;
681             }
682         }
683 
performClassification(@ullable TextSelection selection)684         private SelectionResult performClassification(@Nullable TextSelection selection) {
685             if (!Objects.equals(mText, mLastClassificationText)
686                     || mSelectionStart != mLastClassificationSelectionStart
687                     || mSelectionEnd != mLastClassificationSelectionEnd
688                     || !Objects.equals(mLocales, mLastClassificationLocales)) {
689 
690                 mLastClassificationText = mText;
691                 mLastClassificationSelectionStart = mSelectionStart;
692                 mLastClassificationSelectionEnd = mSelectionEnd;
693                 mLastClassificationLocales = mLocales;
694 
695                 trimText();
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;
701                 } else {
702                     classification = mTextClassifier.classifyText(
703                             mTrimmedText, mRelativeStart, mRelativeEnd, mLocales);
704                 }
705                 mLastClassificationResult = new SelectionResult(
706                         mSelectionStart,
707                         mSelectionEnd,
708                         classification,
709                         selection);
710 
711             }
712             return mLastClassificationResult;
713         }
714 
trimText()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;
721         }
722     }
723 
724     /**
725      * Selection result.
726      */
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;
732 
SelectionResult(int start, int end, TextClassification classification, @Nullable TextSelection selection)733         SelectionResult(int start, int end,
734                 TextClassification classification, @Nullable TextSelection selection) {
735             mStart = start;
736             mEnd = end;
737             mClassification = Preconditions.checkNotNull(classification);
738             mSelection = selection;
739         }
740     }
741 
742     @SelectionEvent.ActionType
getActionType(int menuItemId)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;
758             default:
759                 return SelectionEvent.ActionType.OTHER;
760         }
761     }
762 
getText(TextView textView)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();
767         if (text != null) {
768             return text;
769         }
770         return "";
771     }
772 }
773