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