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.app.RemoteAction;
24 import android.content.Context;
25 import android.graphics.Canvas;
26 import android.graphics.PointF;
27 import android.graphics.RectF;
28 import android.os.AsyncTask;
29 import android.os.Build;
30 import android.os.Bundle;
31 import android.os.LocaleList;
32 import android.text.Layout;
33 import android.text.Selection;
34 import android.text.Spannable;
35 import android.text.TextUtils;
36 import android.text.util.Linkify;
37 import android.util.Log;
38 import android.view.ActionMode;
39 import android.view.textclassifier.ExtrasUtils;
40 import android.view.textclassifier.SelectionEvent;
41 import android.view.textclassifier.SelectionEvent.InvocationMethod;
42 import android.view.textclassifier.TextClassification;
43 import android.view.textclassifier.TextClassificationConstants;
44 import android.view.textclassifier.TextClassificationContext;
45 import android.view.textclassifier.TextClassificationManager;
46 import android.view.textclassifier.TextClassifier;
47 import android.view.textclassifier.TextClassifierEvent;
48 import android.view.textclassifier.TextSelection;
49 import android.widget.Editor.SelectionModifierCursorController;
50 
51 import com.android.internal.annotations.VisibleForTesting;
52 import com.android.internal.util.Preconditions;
53 
54 import java.text.BreakIterator;
55 import java.util.ArrayList;
56 import java.util.Comparator;
57 import java.util.List;
58 import java.util.Objects;
59 import java.util.function.Consumer;
60 import java.util.function.Function;
61 import java.util.function.Supplier;
62 import java.util.regex.Pattern;
63 
64 /**
65  * Helper class for starting selection action mode
66  * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
67  * @hide
68  */
69 @UiThread
70 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
71 public final class SelectionActionModeHelper {
72 
73     private static final String LOG_TAG = "SelectActionModeHelper";
74 
75     private final Editor mEditor;
76     private final TextView mTextView;
77     private final TextClassificationHelper mTextClassificationHelper;
78 
79     @Nullable private TextClassification mTextClassification;
80     private AsyncTask mTextClassificationAsyncTask;
81 
82     private final SelectionTracker mSelectionTracker;
83 
84     // TODO remove nullable marker once the switch gating the feature gets removed
85     @Nullable
86     private final SmartSelectSprite mSmartSelectSprite;
87 
SelectionActionModeHelper(@onNull Editor editor)88     SelectionActionModeHelper(@NonNull Editor editor) {
89         mEditor = Objects.requireNonNull(editor);
90         mTextView = mEditor.getTextView();
91         mTextClassificationHelper = new TextClassificationHelper(
92                 mTextView.getContext(),
93                 mTextView::getTextClassificationSession,
94                 getText(mTextView),
95                 0, 1, mTextView.getTextLocales());
96         mSelectionTracker = new SelectionTracker(mTextView);
97 
98         if (getTextClassificationSettings().isSmartSelectionAnimationEnabled()) {
99             mSmartSelectSprite = new SmartSelectSprite(mTextView.getContext(),
100                     editor.getTextView().mHighlightColor, mTextView::invalidate);
101         } else {
102             mSmartSelectSprite = null;
103         }
104     }
105 
106     /**
107      * Swap the selection index if the start index is greater than end index.
108      *
109      * @return the swap result, index 0 is the start index and index 1 is the end index.
110      */
sortSelctionIndices(int selectionStart, int selectionEnd)111     private static int[] sortSelctionIndices(int selectionStart, int selectionEnd) {
112         if (selectionStart < selectionEnd) {
113             return new int[]{selectionStart, selectionEnd};
114         }
115         return new int[]{selectionEnd, selectionStart};
116     }
117 
118     /**
119      * The {@link TextView} selection start and end index may not be sorted, this method will swap
120      * the {@link TextView} selection index if the start index is greater than end index.
121      *
122      * @param textView the selected TextView.
123      * @return the swap result, index 0 is the start index and index 1 is the end index.
124      */
sortSelctionIndicesFromTextView(TextView textView)125     private static int[] sortSelctionIndicesFromTextView(TextView textView) {
126         int selectionStart = textView.getSelectionStart();
127         int selectionEnd = textView.getSelectionEnd();
128 
129         return sortSelctionIndices(selectionStart, selectionEnd);
130     }
131 
132     /**
133      * Starts Selection ActionMode.
134      */
startSelectionActionModeAsync(boolean adjustSelection)135     public void startSelectionActionModeAsync(boolean adjustSelection) {
136         // Check if the smart selection should run for editable text.
137         adjustSelection &= getTextClassificationSettings().isSmartSelectionEnabled();
138         int[] sortedSelectionIndices = sortSelctionIndicesFromTextView(mTextView);
139 
140         mSelectionTracker.onOriginalSelection(
141                 getText(mTextView),
142                 sortedSelectionIndices[0],
143                 sortedSelectionIndices[1],
144                 false /*isLink*/);
145         cancelAsyncTask();
146         if (skipTextClassification()) {
147             startSelectionActionMode(null);
148         } else {
149             resetTextClassificationHelper();
150             mTextClassificationAsyncTask = new TextClassificationAsyncTask(
151                     mTextView,
152                     mTextClassificationHelper.getTimeoutDuration(),
153                     adjustSelection
154                             ? mTextClassificationHelper::suggestSelection
155                             : mTextClassificationHelper::classifyText,
156                     mSmartSelectSprite != null
157                             ? this::startSelectionActionModeWithSmartSelectAnimation
158                             : this::startSelectionActionMode,
159                     mTextClassificationHelper::getOriginalSelection)
160                     .execute();
161         }
162     }
163 
164     /**
165      * Starts Link ActionMode.
166      */
startLinkActionModeAsync(int start, int end)167     public void startLinkActionModeAsync(int start, int end) {
168         int[] indexResult = sortSelctionIndices(start, end);
169         mSelectionTracker.onOriginalSelection(getText(mTextView), indexResult[0], indexResult[1],
170                 true /*isLink*/);
171         cancelAsyncTask();
172         if (skipTextClassification()) {
173             startLinkActionMode(null);
174         } else {
175             resetTextClassificationHelper(indexResult[0], indexResult[1]);
176             mTextClassificationAsyncTask = new TextClassificationAsyncTask(
177                     mTextView,
178                     mTextClassificationHelper.getTimeoutDuration(),
179                     mTextClassificationHelper::classifyText,
180                     this::startLinkActionMode,
181                     mTextClassificationHelper::getOriginalSelection)
182                     .execute();
183         }
184     }
185 
invalidateActionModeAsync()186     public void invalidateActionModeAsync() {
187         cancelAsyncTask();
188         if (skipTextClassification()) {
189             invalidateActionMode(null);
190         } else {
191             resetTextClassificationHelper();
192             mTextClassificationAsyncTask = new TextClassificationAsyncTask(
193                     mTextView,
194                     mTextClassificationHelper.getTimeoutDuration(),
195                     mTextClassificationHelper::classifyText,
196                     this::invalidateActionMode,
197                     mTextClassificationHelper::getOriginalSelection)
198                     .execute();
199         }
200     }
201 
202     /** Reports a selection action event. */
onSelectionAction(int menuItemId, @Nullable String actionLabel)203     public void onSelectionAction(int menuItemId, @Nullable String actionLabel) {
204         int[] sortedSelectionIndices = sortSelctionIndicesFromTextView(mTextView);
205         mSelectionTracker.onSelectionAction(
206                 sortedSelectionIndices[0], sortedSelectionIndices[1],
207                 getActionType(menuItemId), actionLabel, mTextClassification);
208     }
209 
onSelectionDrag()210     public void onSelectionDrag() {
211         int[] sortedSelectionIndices = sortSelctionIndicesFromTextView(mTextView);
212         mSelectionTracker.onSelectionAction(
213                 sortedSelectionIndices[0], sortedSelectionIndices[1],
214                 SelectionEvent.ACTION_DRAG, /* actionLabel= */ null, mTextClassification);
215     }
216 
onTextChanged(int start, int end)217     public void onTextChanged(int start, int end) {
218         int[] sortedSelectionIndices = sortSelctionIndices(start, end);
219         mSelectionTracker.onTextChanged(sortedSelectionIndices[0], sortedSelectionIndices[1],
220                 mTextClassification);
221     }
222 
resetSelection(int textIndex)223     public boolean resetSelection(int textIndex) {
224         if (mSelectionTracker.resetSelection(textIndex, mEditor)) {
225             invalidateActionModeAsync();
226             return true;
227         }
228         return false;
229     }
230 
231     @Nullable
getTextClassification()232     public TextClassification getTextClassification() {
233         return mTextClassification;
234     }
235 
onDestroyActionMode()236     public void onDestroyActionMode() {
237         cancelSmartSelectAnimation();
238         mSelectionTracker.onSelectionDestroyed();
239         cancelAsyncTask();
240     }
241 
onDraw(final Canvas canvas)242     public void onDraw(final Canvas canvas) {
243         if (isDrawingHighlight() && mSmartSelectSprite != null) {
244             mSmartSelectSprite.draw(canvas);
245         }
246     }
247 
isDrawingHighlight()248     public boolean isDrawingHighlight() {
249         return mSmartSelectSprite != null && mSmartSelectSprite.isAnimationActive();
250     }
251 
getTextClassificationSettings()252     private TextClassificationConstants getTextClassificationSettings() {
253         return TextClassificationManager.getSettings(mTextView.getContext());
254     }
255 
cancelAsyncTask()256     private void cancelAsyncTask() {
257         if (mTextClassificationAsyncTask != null) {
258             mTextClassificationAsyncTask.cancel(true);
259             mTextClassificationAsyncTask = null;
260         }
261         mTextClassification = null;
262     }
263 
skipTextClassification()264     private boolean skipTextClassification() {
265         // No need to make an async call for a no-op TextClassifier.
266         final boolean noOpTextClassifier = mTextView.usesNoOpTextClassifier();
267         // Do not call the TextClassifier if there is no selection.
268         final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart();
269         // Do not call the TextClassifier if this is a password field.
270         final boolean password = mTextView.hasPasswordTransformationMethod()
271                 || TextView.isPasswordInputType(mTextView.getInputType());
272         return noOpTextClassifier || noSelection || password;
273     }
274 
startLinkActionMode(@ullable SelectionResult result)275     private void startLinkActionMode(@Nullable SelectionResult result) {
276         startActionMode(Editor.TextActionMode.TEXT_LINK, result);
277     }
278 
startSelectionActionMode(@ullable SelectionResult result)279     private void startSelectionActionMode(@Nullable SelectionResult result) {
280         startActionMode(Editor.TextActionMode.SELECTION, result);
281     }
282 
startActionMode( @ditor.TextActionMode int actionMode, @Nullable SelectionResult result)283     private void startActionMode(
284             @Editor.TextActionMode int actionMode, @Nullable SelectionResult result) {
285         final CharSequence text = getText(mTextView);
286         if (result != null && text instanceof Spannable
287                 && (mTextView.isTextSelectable() || mTextView.isTextEditable())) {
288             // Do not change the selection if TextClassifier should be dark launched.
289             if (!getTextClassificationSettings().isModelDarkLaunchEnabled()) {
290                 Selection.setSelection((Spannable) text, result.mStart, result.mEnd);
291                 mTextView.invalidate();
292             }
293             mTextClassification = result.mClassification;
294         } else if (result != null && actionMode == Editor.TextActionMode.TEXT_LINK) {
295             mTextClassification = result.mClassification;
296         } else {
297             mTextClassification = null;
298         }
299         if (mEditor.startActionModeInternal(actionMode)) {
300             final SelectionModifierCursorController controller = mEditor.getSelectionController();
301             if (controller != null
302                     && (mTextView.isTextSelectable() || mTextView.isTextEditable())) {
303                 controller.show();
304             }
305             if (result != null) {
306                 switch (actionMode) {
307                     case Editor.TextActionMode.SELECTION:
308                         mSelectionTracker.onSmartSelection(result);
309                         break;
310                     case Editor.TextActionMode.TEXT_LINK:
311                         mSelectionTracker.onLinkSelected(result);
312                         break;
313                     default:
314                         break;
315                 }
316             }
317         }
318         mEditor.setRestartActionModeOnNextRefresh(false);
319         mTextClassificationAsyncTask = null;
320     }
321 
startSelectionActionModeWithSmartSelectAnimation( @ullable SelectionResult result)322     private void startSelectionActionModeWithSmartSelectAnimation(
323             @Nullable SelectionResult result) {
324         final Layout layout = mTextView.getLayout();
325 
326         final Runnable onAnimationEndCallback = () -> {
327             final SelectionResult startSelectionResult;
328             if (result != null && result.mStart >= 0 && result.mEnd <= getText(mTextView).length()
329                     && result.mStart <= result.mEnd) {
330                 startSelectionResult = result;
331             } else {
332                 startSelectionResult = null;
333             }
334             startSelectionActionMode(startSelectionResult);
335         };
336         // TODO do not trigger the animation if the change included only non-printable characters
337         int[] sortedSelectionIndices = sortSelctionIndicesFromTextView(mTextView);
338         final boolean didSelectionChange =
339                 result != null && (sortedSelectionIndices[0] != result.mStart
340                         || sortedSelectionIndices[1] != result.mEnd);
341         if (!didSelectionChange) {
342             onAnimationEndCallback.run();
343             return;
344         }
345 
346         final List<SmartSelectSprite.RectangleWithTextSelectionLayout> selectionRectangles =
347                 convertSelectionToRectangles(layout, result.mStart, result.mEnd);
348 
349         final PointF touchPoint = new PointF(
350                 mEditor.getLastUpPositionX(),
351                 mEditor.getLastUpPositionY());
352 
353         final PointF animationStartPoint =
354                 movePointInsideNearestRectangle(touchPoint, selectionRectangles,
355                         SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle);
356 
357         mSmartSelectSprite.startAnimation(
358                 animationStartPoint,
359                 selectionRectangles,
360                 onAnimationEndCallback);
361     }
362 
convertSelectionToRectangles( final Layout layout, final int start, final int end)363     private List<SmartSelectSprite.RectangleWithTextSelectionLayout> convertSelectionToRectangles(
364             final Layout layout, final int start, final int end) {
365         final List<SmartSelectSprite.RectangleWithTextSelectionLayout> result = new ArrayList<>();
366 
367         final Layout.SelectionRectangleConsumer consumer =
368                 (left, top, right, bottom, textSelectionLayout) -> mergeRectangleIntoList(
369                         result,
370                         new RectF(left, top, right, bottom),
371                         SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle,
372                         r -> new SmartSelectSprite.RectangleWithTextSelectionLayout(r,
373                                 textSelectionLayout)
374                 );
375 
376         layout.getSelection(start, end, consumer);
377 
378         result.sort(Comparator.comparing(
379                 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle,
380                 SmartSelectSprite.RECTANGLE_COMPARATOR));
381 
382         return result;
383     }
384 
385     // TODO: Move public pure functions out of this class and make it package-private.
386     /**
387      * Merges a {@link RectF} into an existing list of any objects which contain a rectangle.
388      * While merging, this method makes sure that:
389      *
390      * <ol>
391      * <li>No rectangle is redundant (contained within a bigger rectangle)</li>
392      * <li>Rectangles of the same height and vertical position that intersect get merged</li>
393      * </ol>
394      *
395      * @param list      the list of rectangles (or other rectangle containers) to merge the new
396      *                  rectangle into
397      * @param candidate the {@link RectF} to merge into the list
398      * @param extractor a function that can extract a {@link RectF} from an element of the given
399      *                  list
400      * @param packer    a function that can wrap the resulting {@link RectF} into an element that
401      *                  the list contains
402      * @hide
403      */
404     @VisibleForTesting
mergeRectangleIntoList(final List<T> list, final RectF candidate, final Function<T, RectF> extractor, final Function<RectF, T> packer)405     public static <T> void mergeRectangleIntoList(final List<T> list,
406             final RectF candidate, final Function<T, RectF> extractor,
407             final Function<RectF, T> packer) {
408         if (candidate.isEmpty()) {
409             return;
410         }
411 
412         final int elementCount = list.size();
413         for (int index = 0; index < elementCount; ++index) {
414             final RectF existingRectangle = extractor.apply(list.get(index));
415             if (existingRectangle.contains(candidate)) {
416                 return;
417             }
418             if (candidate.contains(existingRectangle)) {
419                 existingRectangle.setEmpty();
420                 continue;
421             }
422 
423             final boolean rectanglesContinueEachOther = candidate.left == existingRectangle.right
424                     || candidate.right == existingRectangle.left;
425             final boolean canMerge = candidate.top == existingRectangle.top
426                     && candidate.bottom == existingRectangle.bottom
427                     && (RectF.intersects(candidate, existingRectangle)
428                     || rectanglesContinueEachOther);
429 
430             if (canMerge) {
431                 candidate.union(existingRectangle);
432                 existingRectangle.setEmpty();
433             }
434         }
435 
436         for (int index = elementCount - 1; index >= 0; --index) {
437             final RectF rectangle = extractor.apply(list.get(index));
438             if (rectangle.isEmpty()) {
439                 list.remove(index);
440             }
441         }
442 
443         list.add(packer.apply(candidate));
444     }
445 
446 
447     /** @hide */
448     @VisibleForTesting
movePointInsideNearestRectangle(final PointF point, final List<T> list, final Function<T, RectF> extractor)449     public static <T> PointF movePointInsideNearestRectangle(final PointF point,
450             final List<T> list, final Function<T, RectF> extractor) {
451         float bestX = -1;
452         float bestY = -1;
453         double bestDistance = Double.MAX_VALUE;
454 
455         final int elementCount = list.size();
456         for (int index = 0; index < elementCount; ++index) {
457             final RectF rectangle = extractor.apply(list.get(index));
458             final float candidateY = rectangle.centerY();
459             final float candidateX;
460 
461             if (point.x > rectangle.right) {
462                 candidateX = rectangle.right;
463             } else if (point.x < rectangle.left) {
464                 candidateX = rectangle.left;
465             } else {
466                 candidateX = point.x;
467             }
468 
469             final double candidateDistance = Math.pow(point.x - candidateX, 2)
470                     + Math.pow(point.y - candidateY, 2);
471 
472             if (candidateDistance < bestDistance) {
473                 bestX = candidateX;
474                 bestY = candidateY;
475                 bestDistance = candidateDistance;
476             }
477         }
478 
479         return new PointF(bestX, bestY);
480     }
481 
invalidateActionMode(@ullable SelectionResult result)482     private void invalidateActionMode(@Nullable SelectionResult result) {
483         cancelSmartSelectAnimation();
484         mTextClassification = result != null ? result.mClassification : null;
485         final ActionMode actionMode = mEditor.getTextActionMode();
486         if (actionMode != null) {
487             actionMode.invalidate();
488         }
489         final int[] sortedSelectionIndices = sortSelctionIndicesFromTextView(mTextView);
490         mSelectionTracker.onSelectionUpdated(
491                 sortedSelectionIndices[0], sortedSelectionIndices[1], mTextClassification);
492         mTextClassificationAsyncTask = null;
493     }
494 
resetTextClassificationHelper(int selectionStart, int selectionEnd)495     private void resetTextClassificationHelper(int selectionStart, int selectionEnd) {
496         if (selectionStart < 0 || selectionEnd < 0) {
497             // Use selection indices
498             int[] sortedSelectionIndices = sortSelctionIndicesFromTextView(mTextView);
499             selectionStart = sortedSelectionIndices[0];
500             selectionEnd = sortedSelectionIndices[1];
501         }
502         mTextClassificationHelper.init(
503                 mTextView::getTextClassificationSession,
504                 getText(mTextView),
505                 selectionStart, selectionEnd,
506                 mTextView.getTextLocales());
507     }
508 
resetTextClassificationHelper()509     private void resetTextClassificationHelper() {
510         resetTextClassificationHelper(-1, -1);
511     }
512 
cancelSmartSelectAnimation()513     private void cancelSmartSelectAnimation() {
514         if (mSmartSelectSprite != null) {
515             mSmartSelectSprite.cancelAnimation();
516         }
517     }
518 
519     /**
520      * Tracks and logs smart selection changes.
521      * It is important to trigger this object's methods at the appropriate event so that it tracks
522      * smart selection events appropriately.
523      */
524     private static final class SelectionTracker {
525 
526         private final TextView mTextView;
527         private SelectionMetricsLogger mLogger;
528 
529         private int mOriginalStart;
530         private int mOriginalEnd;
531         private int mSelectionStart;
532         private int mSelectionEnd;
533         private boolean mAllowReset;
534         private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable();
535 
SelectionTracker(TextView textView)536         SelectionTracker(TextView textView) {
537             mTextView = Objects.requireNonNull(textView);
538             mLogger = new SelectionMetricsLogger(textView);
539         }
540 
541         /**
542          * Called when the original selection happens, before smart selection is triggered.
543          */
onOriginalSelection( CharSequence text, int selectionStart, int selectionEnd, boolean isLink)544         public void onOriginalSelection(
545                 CharSequence text, int selectionStart, int selectionEnd, boolean isLink) {
546             // If we abandoned a selection and created a new one very shortly after, we may still
547             // have a pending request to log ABANDON, which we flush here.
548             mDelayedLogAbandon.flush();
549 
550             mOriginalStart = mSelectionStart = selectionStart;
551             mOriginalEnd = mSelectionEnd = selectionEnd;
552             mAllowReset = false;
553             maybeInvalidateLogger();
554             mLogger.logSelectionStarted(
555                     mTextView.getTextClassificationSession(),
556                     mTextView.getTextClassificationContext(),
557                     text,
558                     selectionStart,
559                     isLink ? SelectionEvent.INVOCATION_LINK : SelectionEvent.INVOCATION_MANUAL);
560         }
561 
562         /**
563          * Called when selection action mode is started and the results come from a classifier.
564          */
onSmartSelection(SelectionResult result)565         public void onSmartSelection(SelectionResult result) {
566             onClassifiedSelection(result);
567             mLogger.logSelectionModified(
568                     result.mStart, result.mEnd, result.mClassification, result.mSelection);
569         }
570 
571         /**
572          * Called when link action mode is started and the classification comes from a classifier.
573          */
onLinkSelected(SelectionResult result)574         public void onLinkSelected(SelectionResult result) {
575             onClassifiedSelection(result);
576             // TODO: log (b/70246800)
577         }
578 
onClassifiedSelection(SelectionResult result)579         private void onClassifiedSelection(SelectionResult result) {
580             if (isSelectionStarted()) {
581                 mSelectionStart = result.mStart;
582                 mSelectionEnd = result.mEnd;
583                 mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd;
584             }
585         }
586 
587         /**
588          * Called when selection bounds change.
589          */
onSelectionUpdated( int selectionStart, int selectionEnd, @Nullable TextClassification classification)590         public void onSelectionUpdated(
591                 int selectionStart, int selectionEnd,
592                 @Nullable TextClassification classification) {
593             if (isSelectionStarted()) {
594                 mSelectionStart = selectionStart;
595                 mSelectionEnd = selectionEnd;
596                 mAllowReset = false;
597                 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null);
598             }
599         }
600 
601         /**
602          * Called when the selection action mode is destroyed.
603          */
onSelectionDestroyed()604         public void onSelectionDestroyed() {
605             mAllowReset = false;
606             // Wait a few ms to see if the selection was destroyed because of a text change event.
607             mDelayedLogAbandon.schedule(100 /* ms */);
608         }
609 
610         /**
611          * Called when an action is taken on a smart selection.
612          */
onSelectionAction( int selectionStart, int selectionEnd, @SelectionEvent.ActionType int action, @Nullable String actionLabel, @Nullable TextClassification classification)613         public void onSelectionAction(
614                 int selectionStart, int selectionEnd,
615                 @SelectionEvent.ActionType int action,
616                 @Nullable String actionLabel,
617                 @Nullable TextClassification classification) {
618             if (isSelectionStarted()) {
619                 mAllowReset = false;
620                 mLogger.logSelectionAction(
621                         selectionStart, selectionEnd, action, actionLabel, classification);
622             }
623         }
624 
625         /**
626          * Returns true if the current smart selection should be reset to normal selection based on
627          * information that has been recorded about the original selection and the smart selection.
628          * The expected UX here is to allow the user to select a word inside of the smart selection
629          * on a single tap.
630          */
resetSelection(int textIndex, Editor editor)631         public boolean resetSelection(int textIndex, Editor editor) {
632             final TextView textView = editor.getTextView();
633             if (isSelectionStarted()
634                     && mAllowReset
635                     && textIndex >= mSelectionStart && textIndex <= mSelectionEnd
636                     && getText(textView) instanceof Spannable) {
637                 mAllowReset = false;
638                 boolean selected = editor.selectCurrentWord();
639                 if (selected) {
640                     final int[] sortedSelectionIndices = sortSelctionIndicesFromTextView(textView);
641                     mSelectionStart = sortedSelectionIndices[0];
642                     mSelectionEnd = sortedSelectionIndices[1];
643                     mLogger.logSelectionAction(
644                             sortedSelectionIndices[0], sortedSelectionIndices[1],
645                             SelectionEvent.ACTION_RESET,
646                             /* actionLabel= */ null, /* classification= */ null);
647                 }
648                 return selected;
649             }
650             return false;
651         }
652 
onTextChanged(int start, int end, TextClassification classification)653         public void onTextChanged(int start, int end, TextClassification classification) {
654             if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) {
655                 onSelectionAction(
656                         start, end, SelectionEvent.ACTION_OVERTYPE,
657                         /* actionLabel= */ null, classification);
658             }
659         }
660 
maybeInvalidateLogger()661         private void maybeInvalidateLogger() {
662             if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) {
663                 mLogger = new SelectionMetricsLogger(mTextView);
664             }
665         }
666 
isSelectionStarted()667         private boolean isSelectionStarted() {
668             return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd;
669         }
670 
671         /** A helper for keeping track of pending abandon logging requests. */
672         private final class LogAbandonRunnable implements Runnable {
673             private boolean mIsPending;
674 
675             /** Schedules an abandon to be logged with the given delay. Flush if necessary. */
schedule(int delayMillis)676             void schedule(int delayMillis) {
677                 if (mIsPending) {
678                     Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request");
679                     flush();
680                 }
681                 mIsPending = true;
682                 mTextView.postDelayed(this, delayMillis);
683             }
684 
685             /** If there is a pending log request, execute it now. */
flush()686             void flush() {
687                 mTextView.removeCallbacks(this);
688                 run();
689             }
690 
691             @Override
run()692             public void run() {
693                 if (mIsPending) {
694                     mLogger.logSelectionAction(
695                             mSelectionStart, mSelectionEnd,
696                             SelectionEvent.ACTION_ABANDON,
697                             /* actionLabel= */ null, /* classification= */ null);
698                     mSelectionStart = mSelectionEnd = -1;
699                     mLogger.endTextClassificationSession();
700                     mIsPending = false;
701                 }
702             }
703         }
704     }
705 
706     // TODO: Write tests
707     /**
708      * Metrics logging helper.
709      *
710      * This logger logs selection by word indices. The initial (start) single word selection is
711      * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the
712      * initial single word selection.
713      * e.g. New York city, NY. Suppose the initial selection is "York" in
714      * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2).
715      * "New York" is at [-1, 1).
716      * Part selection of a word e.g. "or" is counted as selecting the
717      * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g.
718      * "," is at [2, 3). Whitespaces are ignored.
719      *
720      * NOTE that the definition of a word is defined by the TextClassifier's Logger's token
721      * iterator.
722      */
723     private static final class SelectionMetricsLogger {
724 
725         private static final String LOG_TAG = "SelectionMetricsLogger";
726         private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+");
727 
728         private final boolean mEditTextLogger;
729         private final BreakIterator mTokenIterator;
730 
731         @Nullable private TextClassifier mClassificationSession;
732         @Nullable private TextClassificationContext mClassificationContext;
733 
734         @Nullable private TextClassifierEvent mTranslateViewEvent;
735         @Nullable private TextClassifierEvent mTranslateClickEvent;
736 
737         private int mStartIndex;
738         private String mText;
739 
SelectionMetricsLogger(TextView textView)740         SelectionMetricsLogger(TextView textView) {
741             Objects.requireNonNull(textView);
742             mEditTextLogger = textView.isTextEditable();
743             mTokenIterator = BreakIterator.getWordInstance(textView.getTextLocale());
744         }
745 
logSelectionStarted( TextClassifier classificationSession, TextClassificationContext classificationContext, CharSequence text, int index, @InvocationMethod int invocationMethod)746         public void logSelectionStarted(
747                 TextClassifier classificationSession,
748                 TextClassificationContext classificationContext,
749                 CharSequence text, int index,
750                 @InvocationMethod int invocationMethod) {
751             try {
752                 Objects.requireNonNull(text);
753                 Preconditions.checkArgumentInRange(index, 0, text.length(), "index");
754                 if (mText == null || !mText.contentEquals(text)) {
755                     mText = text.toString();
756                 }
757                 mTokenIterator.setText(mText);
758                 mStartIndex = index;
759                 mClassificationSession = classificationSession;
760                 mClassificationContext = classificationContext;
761                 if (hasActiveClassificationSession()) {
762                     mClassificationSession.onSelectionEvent(
763                             SelectionEvent.createSelectionStartedEvent(invocationMethod, 0));
764                 }
765             } catch (Exception e) {
766                 // Avoid crashes due to logging.
767                 Log.e(LOG_TAG, "" + e.getMessage(), e);
768             }
769         }
770 
logSelectionModified(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)771         public void logSelectionModified(int start, int end,
772                 @Nullable TextClassification classification, @Nullable TextSelection selection) {
773             try {
774                 if (hasActiveClassificationSession()) {
775                     Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
776                     Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
777                     int[] wordIndices = getWordDelta(start, end);
778                     if (selection != null) {
779                         mClassificationSession.onSelectionEvent(
780                                 SelectionEvent.createSelectionModifiedEvent(
781                                         wordIndices[0], wordIndices[1], selection));
782                     } else if (classification != null) {
783                         mClassificationSession.onSelectionEvent(
784                                 SelectionEvent.createSelectionModifiedEvent(
785                                         wordIndices[0], wordIndices[1], classification));
786                     } else {
787                         mClassificationSession.onSelectionEvent(
788                                 SelectionEvent.createSelectionModifiedEvent(
789                                         wordIndices[0], wordIndices[1]));
790                     }
791                     maybeGenerateTranslateViewEvent(classification);
792                 }
793             } catch (Exception e) {
794                 // Avoid crashes due to logging.
795                 Log.e(LOG_TAG, "" + e.getMessage(), e);
796             }
797         }
798 
logSelectionAction( int start, int end, @SelectionEvent.ActionType int action, @Nullable String actionLabel, @Nullable TextClassification classification)799         public void logSelectionAction(
800                 int start, int end,
801                 @SelectionEvent.ActionType int action,
802                 @Nullable String actionLabel,
803                 @Nullable TextClassification classification) {
804             try {
805                 if (hasActiveClassificationSession()) {
806                     Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
807                     Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
808                     int[] wordIndices = getWordDelta(start, end);
809                     if (classification != null) {
810                         mClassificationSession.onSelectionEvent(
811                                 SelectionEvent.createSelectionActionEvent(
812                                         wordIndices[0], wordIndices[1], action,
813                                         classification));
814                     } else {
815                         mClassificationSession.onSelectionEvent(
816                                 SelectionEvent.createSelectionActionEvent(
817                                         wordIndices[0], wordIndices[1], action));
818                     }
819 
820                     maybeGenerateTranslateClickEvent(classification, actionLabel);
821 
822                     if (SelectionEvent.isTerminal(action)) {
823                         endTextClassificationSession();
824                     }
825                 }
826             } catch (Exception e) {
827                 // Avoid crashes due to logging.
828                 Log.e(LOG_TAG, "" + e.getMessage(), e);
829             }
830         }
831 
isEditTextLogger()832         public boolean isEditTextLogger() {
833             return mEditTextLogger;
834         }
835 
endTextClassificationSession()836         public void endTextClassificationSession() {
837             if (hasActiveClassificationSession()) {
838                 maybeReportTranslateEvents();
839                 mClassificationSession.destroy();
840             }
841         }
842 
hasActiveClassificationSession()843         private boolean hasActiveClassificationSession() {
844             return mClassificationSession != null && !mClassificationSession.isDestroyed();
845         }
846 
getWordDelta(int start, int end)847         private int[] getWordDelta(int start, int end) {
848             int[] wordIndices = new int[2];
849 
850             if (start == mStartIndex) {
851                 wordIndices[0] = 0;
852             } else if (start < mStartIndex) {
853                 wordIndices[0] = -countWordsForward(start);
854             } else {  // start > mStartIndex
855                 wordIndices[0] = countWordsBackward(start);
856 
857                 // For the selection start index, avoid counting a partial word backwards.
858                 if (!mTokenIterator.isBoundary(start)
859                         && !isWhitespace(
860                         mTokenIterator.preceding(start),
861                         mTokenIterator.following(start))) {
862                     // We counted a partial word. Remove it.
863                     wordIndices[0]--;
864                 }
865             }
866 
867             if (end == mStartIndex) {
868                 wordIndices[1] = 0;
869             } else if (end < mStartIndex) {
870                 wordIndices[1] = -countWordsForward(end);
871             } else {  // end > mStartIndex
872                 wordIndices[1] = countWordsBackward(end);
873             }
874 
875             return wordIndices;
876         }
877 
countWordsBackward(int from)878         private int countWordsBackward(int from) {
879             Preconditions.checkArgument(from >= mStartIndex);
880             int wordCount = 0;
881             int offset = from;
882             while (offset > mStartIndex) {
883                 int start = mTokenIterator.preceding(offset);
884                 if (!isWhitespace(start, offset)) {
885                     wordCount++;
886                 }
887                 offset = start;
888             }
889             return wordCount;
890         }
891 
countWordsForward(int from)892         private int countWordsForward(int from) {
893             Preconditions.checkArgument(from <= mStartIndex);
894             int wordCount = 0;
895             int offset = from;
896             while (offset < mStartIndex) {
897                 int end = mTokenIterator.following(offset);
898                 if (!isWhitespace(offset, end)) {
899                     wordCount++;
900                 }
901                 offset = end;
902             }
903             return wordCount;
904         }
905 
isWhitespace(int start, int end)906         private boolean isWhitespace(int start, int end) {
907             return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches();
908         }
909 
maybeGenerateTranslateViewEvent(@ullable TextClassification classification)910         private void maybeGenerateTranslateViewEvent(@Nullable TextClassification classification) {
911             if (classification != null) {
912                 final TextClassifierEvent event = generateTranslateEvent(
913                         TextClassifierEvent.TYPE_ACTIONS_SHOWN,
914                         classification, mClassificationContext, /* actionLabel= */null);
915                 mTranslateViewEvent = (event != null) ? event : mTranslateViewEvent;
916             }
917         }
918 
maybeGenerateTranslateClickEvent( @ullable TextClassification classification, String actionLabel)919         private void maybeGenerateTranslateClickEvent(
920                 @Nullable TextClassification classification, String actionLabel) {
921             if (classification != null) {
922                 mTranslateClickEvent = generateTranslateEvent(
923                         TextClassifierEvent.TYPE_SMART_ACTION,
924                         classification, mClassificationContext, actionLabel);
925             }
926         }
927 
maybeReportTranslateEvents()928         private void maybeReportTranslateEvents() {
929             // Translate view and click events should only be logged once per selection session.
930             if (mTranslateViewEvent != null) {
931                 mClassificationSession.onTextClassifierEvent(mTranslateViewEvent);
932                 mTranslateViewEvent = null;
933             }
934             if (mTranslateClickEvent != null) {
935                 mClassificationSession.onTextClassifierEvent(mTranslateClickEvent);
936                 mTranslateClickEvent = null;
937             }
938         }
939 
940         @Nullable
generateTranslateEvent( int eventType, TextClassification classification, TextClassificationContext classificationContext, @Nullable String actionLabel)941         private static TextClassifierEvent generateTranslateEvent(
942                 int eventType, TextClassification classification,
943                 TextClassificationContext classificationContext, @Nullable String actionLabel) {
944 
945             // The platform attempts to log "views" and "clicks" of the "Translate" action.
946             // Views are logged if a user is presented with the translate action during a selection
947             // session.
948             // Clicks are logged if the user clicks on the translate action.
949             // The index of the translate action is also logged to indicate whether it might have
950             // been in the main panel or overflow panel of the selection toolbar.
951             // NOTE that the "views" metric may be flawed if a TextView removes the translate menu
952             // item via a custom action mode callback or does not show a selection menu item.
953 
954             final RemoteAction translateAction = ExtrasUtils.findTranslateAction(classification);
955             if (translateAction == null) {
956                 // No translate action present. Nothing to log. Exit.
957                 return null;
958             }
959 
960             if (eventType == TextClassifierEvent.TYPE_SMART_ACTION
961                     && !translateAction.getTitle().toString().equals(actionLabel)) {
962                 // Clicked action is not a translate action. Nothing to log. Exit.
963                 // Note that we don't expect an actionLabel for "view" events.
964                 return null;
965             }
966 
967             final Bundle foreignLanguageExtra = ExtrasUtils.getForeignLanguageExtra(classification);
968             final String language = ExtrasUtils.getEntityType(foreignLanguageExtra);
969             final float score = ExtrasUtils.getScore(foreignLanguageExtra);
970             final String model = ExtrasUtils.getModelName(foreignLanguageExtra);
971             return new TextClassifierEvent.LanguageDetectionEvent.Builder(eventType)
972                     .setEventContext(classificationContext)
973                     .setResultId(classification.getId())
974                     // b/158481016: Disable language logging.
975                     //.setEntityTypes(language)
976                     .setScores(score)
977                     .setActionIndices(classification.getActions().indexOf(translateAction))
978                     .setModelName(model)
979                     .build();
980         }
981     }
982 
983     /**
984      * AsyncTask for running a query on a background thread and returning the result on the
985      * UiThread. The AsyncTask times out after a specified time, returning a null result if the
986      * query has not yet returned.
987      */
988     private static final class TextClassificationAsyncTask
989             extends AsyncTask<Void, Void, SelectionResult> {
990 
991         private final int mTimeOutDuration;
992         private final Supplier<SelectionResult> mSelectionResultSupplier;
993         private final Consumer<SelectionResult> mSelectionResultCallback;
994         private final Supplier<SelectionResult> mTimeOutResultSupplier;
995         private final TextView mTextView;
996         private final String mOriginalText;
997 
998         /**
999          * @param textView the TextView
1000          * @param timeOut time in milliseconds to timeout the query if it has not completed
1001          * @param selectionResultSupplier fetches the selection results. Runs on a background thread
1002          * @param selectionResultCallback receives the selection results. Runs on the UiThread
1003          * @param timeOutResultSupplier default result if the task times out
1004          */
TextClassificationAsyncTask( @onNull TextView textView, int timeOut, @NonNull Supplier<SelectionResult> selectionResultSupplier, @NonNull Consumer<SelectionResult> selectionResultCallback, @NonNull Supplier<SelectionResult> timeOutResultSupplier)1005         TextClassificationAsyncTask(
1006                 @NonNull TextView textView, int timeOut,
1007                 @NonNull Supplier<SelectionResult> selectionResultSupplier,
1008                 @NonNull Consumer<SelectionResult> selectionResultCallback,
1009                 @NonNull Supplier<SelectionResult> timeOutResultSupplier) {
1010             super(textView != null ? textView.getHandler() : null);
1011             mTextView = Objects.requireNonNull(textView);
1012             mTimeOutDuration = timeOut;
1013             mSelectionResultSupplier = Objects.requireNonNull(selectionResultSupplier);
1014             mSelectionResultCallback = Objects.requireNonNull(selectionResultCallback);
1015             mTimeOutResultSupplier = Objects.requireNonNull(timeOutResultSupplier);
1016             // Make a copy of the original text.
1017             mOriginalText = getText(mTextView).toString();
1018         }
1019 
1020         @Override
1021         @WorkerThread
doInBackground(Void... params)1022         protected SelectionResult doInBackground(Void... params) {
1023             final Runnable onTimeOut = this::onTimeOut;
1024             mTextView.postDelayed(onTimeOut, mTimeOutDuration);
1025             final SelectionResult result = mSelectionResultSupplier.get();
1026             mTextView.removeCallbacks(onTimeOut);
1027             return result;
1028         }
1029 
1030         @Override
1031         @UiThread
onPostExecute(SelectionResult result)1032         protected void onPostExecute(SelectionResult result) {
1033             result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null;
1034             mSelectionResultCallback.accept(result);
1035         }
1036 
onTimeOut()1037         private void onTimeOut() {
1038             Log.d(LOG_TAG, "Timeout in TextClassificationAsyncTask");
1039             if (getStatus() == Status.RUNNING) {
1040                 onPostExecute(mTimeOutResultSupplier.get());
1041             }
1042             cancel(true);
1043         }
1044     }
1045 
1046     /**
1047      * Helper class for querying the TextClassifier.
1048      * It trims text so that only text necessary to provide context of the selected text is
1049      * sent to the TextClassifier.
1050      */
1051     private static final class TextClassificationHelper {
1052 
1053         private static final int TRIM_DELTA = 120;  // characters
1054 
1055         private final Context mContext;
1056         private Supplier<TextClassifier> mTextClassifier;
1057 
1058         /** The original TextView text. **/
1059         private String mText;
1060         /** Start index relative to mText. */
1061         private int mSelectionStart;
1062         /** End index relative to mText. */
1063         private int mSelectionEnd;
1064 
1065         @Nullable
1066         private LocaleList mDefaultLocales;
1067 
1068         /** Trimmed text starting from mTrimStart in mText. */
1069         private CharSequence mTrimmedText;
1070         /** Index indicating the start of mTrimmedText in mText. */
1071         private int mTrimStart;
1072         /** Start index relative to mTrimmedText */
1073         private int mRelativeStart;
1074         /** End index relative to mTrimmedText */
1075         private int mRelativeEnd;
1076 
1077         /** Information about the last classified text to avoid re-running a query. */
1078         private CharSequence mLastClassificationText;
1079         private int mLastClassificationSelectionStart;
1080         private int mLastClassificationSelectionEnd;
1081         private LocaleList mLastClassificationLocales;
1082         private SelectionResult mLastClassificationResult;
1083 
1084         /** Whether the TextClassifier has been initialized. */
1085         private boolean mHot;
1086 
TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)1087         TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier,
1088                 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
1089             init(textClassifier, text, selectionStart, selectionEnd, locales);
1090             mContext = Objects.requireNonNull(context);
1091         }
1092 
1093         @UiThread
init(Supplier<TextClassifier> textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)1094         public void init(Supplier<TextClassifier> textClassifier, CharSequence text,
1095                 int selectionStart, int selectionEnd, LocaleList locales) {
1096             mTextClassifier = Objects.requireNonNull(textClassifier);
1097             mText = Objects.requireNonNull(text).toString();
1098             mLastClassificationText = null; // invalidate.
1099             Preconditions.checkArgument(selectionEnd > selectionStart);
1100             mSelectionStart = selectionStart;
1101             mSelectionEnd = selectionEnd;
1102             mDefaultLocales = locales;
1103         }
1104 
1105         @WorkerThread
classifyText()1106         public SelectionResult classifyText() {
1107             mHot = true;
1108             return performClassification(null /* selection */);
1109         }
1110 
1111         @WorkerThread
suggestSelection()1112         public SelectionResult suggestSelection() {
1113             mHot = true;
1114             trimText();
1115             final TextSelection selection;
1116             if (mContext.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.P) {
1117                 final TextSelection.Request request = new TextSelection.Request.Builder(
1118                         mTrimmedText, mRelativeStart, mRelativeEnd)
1119                         .setDefaultLocales(mDefaultLocales)
1120                         .setDarkLaunchAllowed(true)
1121                         .build();
1122                 selection = mTextClassifier.get().suggestSelection(request);
1123             } else {
1124                 // Use old APIs.
1125                 selection = mTextClassifier.get().suggestSelection(
1126                         mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales);
1127             }
1128             // Do not classify new selection boundaries if TextClassifier should be dark launched.
1129             if (!isDarkLaunchEnabled()) {
1130                 mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart);
1131                 mSelectionEnd = Math.min(
1132                         mText.length(), selection.getSelectionEndIndex() + mTrimStart);
1133             }
1134             return performClassification(selection);
1135         }
1136 
getOriginalSelection()1137         public SelectionResult getOriginalSelection() {
1138             return new SelectionResult(mSelectionStart, mSelectionEnd, null, null);
1139         }
1140 
1141         /**
1142          * Maximum time (in milliseconds) to wait for a textclassifier result before timing out.
1143          */
1144         // TODO: Consider making this a ViewConfiguration.
getTimeoutDuration()1145         public int getTimeoutDuration() {
1146             if (mHot) {
1147                 return 200;
1148             } else {
1149                 // Return a slightly larger number than usual when the TextClassifier is first
1150                 // initialized. Initialization would usually take longer than subsequent calls to
1151                 // the TextClassifier. The impact of this on the UI is that we do not show the
1152                 // selection handles or toolbar until after this timeout.
1153                 return 500;
1154             }
1155         }
1156 
isDarkLaunchEnabled()1157         private boolean isDarkLaunchEnabled() {
1158             return TextClassificationManager.getSettings(mContext).isModelDarkLaunchEnabled();
1159         }
1160 
performClassification(@ullable TextSelection selection)1161         private SelectionResult performClassification(@Nullable TextSelection selection) {
1162             if (!Objects.equals(mText, mLastClassificationText)
1163                     || mSelectionStart != mLastClassificationSelectionStart
1164                     || mSelectionEnd != mLastClassificationSelectionEnd
1165                     || !Objects.equals(mDefaultLocales, mLastClassificationLocales)) {
1166 
1167                 mLastClassificationText = mText;
1168                 mLastClassificationSelectionStart = mSelectionStart;
1169                 mLastClassificationSelectionEnd = mSelectionEnd;
1170                 mLastClassificationLocales = mDefaultLocales;
1171 
1172                 trimText();
1173                 final TextClassification classification;
1174                 if (Linkify.containsUnsupportedCharacters(mText)) {
1175                     // Do not show smart actions for text containing unsupported characters.
1176                     android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, "");
1177                     classification = TextClassification.EMPTY;
1178                 } else if (mContext.getApplicationInfo().targetSdkVersion
1179                         >= Build.VERSION_CODES.P) {
1180                     final TextClassification.Request request =
1181                             new TextClassification.Request.Builder(
1182                                     mTrimmedText, mRelativeStart, mRelativeEnd)
1183                                     .setDefaultLocales(mDefaultLocales)
1184                                     .build();
1185                     classification = mTextClassifier.get().classifyText(request);
1186                 } else {
1187                     // Use old APIs.
1188                     classification = mTextClassifier.get().classifyText(
1189                             mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales);
1190                 }
1191                 mLastClassificationResult = new SelectionResult(
1192                         mSelectionStart, mSelectionEnd, classification, selection);
1193 
1194             }
1195             return mLastClassificationResult;
1196         }
1197 
trimText()1198         private void trimText() {
1199             mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA);
1200             final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA);
1201             mTrimmedText = mText.subSequence(mTrimStart, referenceEnd);
1202             mRelativeStart = mSelectionStart - mTrimStart;
1203             mRelativeEnd = mSelectionEnd - mTrimStart;
1204         }
1205     }
1206 
1207     /**
1208      * Selection result.
1209      */
1210     private static final class SelectionResult {
1211         private final int mStart;
1212         private final int mEnd;
1213         @Nullable private final TextClassification mClassification;
1214         @Nullable private final TextSelection mSelection;
1215 
SelectionResult(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)1216         SelectionResult(int start, int end,
1217                 @Nullable TextClassification classification, @Nullable TextSelection selection) {
1218             int[] sortedIndices = sortSelctionIndices(start, end);
1219             mStart = sortedIndices[0];
1220             mEnd = sortedIndices[1];
1221             mClassification = classification;
1222             mSelection = selection;
1223         }
1224     }
1225 
1226     @SelectionEvent.ActionType
getActionType(int menuItemId)1227     private static int getActionType(int menuItemId) {
1228         switch (menuItemId) {
1229             case TextView.ID_SELECT_ALL:
1230                 return SelectionEvent.ACTION_SELECT_ALL;
1231             case TextView.ID_CUT:
1232                 return SelectionEvent.ACTION_CUT;
1233             case TextView.ID_COPY:
1234                 return SelectionEvent.ACTION_COPY;
1235             case TextView.ID_PASTE:  // fall through
1236             case TextView.ID_PASTE_AS_PLAIN_TEXT:
1237                 return SelectionEvent.ACTION_PASTE;
1238             case TextView.ID_SHARE:
1239                 return SelectionEvent.ACTION_SHARE;
1240             case TextView.ID_ASSIST:
1241                 return SelectionEvent.ACTION_SMART_SHARE;
1242             default:
1243                 return SelectionEvent.ACTION_OTHER;
1244         }
1245     }
1246 
getText(TextView textView)1247     private static CharSequence getText(TextView textView) {
1248         // Extracts the textView's text.
1249         // TODO: Investigate why/when TextView.getText() is null.
1250         final CharSequence text = textView.getText();
1251         if (text != null) {
1252             return text;
1253         }
1254         return "";
1255     }
1256 }
1257