• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.widget;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.annotation.UiThread;
22 import android.annotation.WorkerThread;
23 import android.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.ViewConfiguration;
40 import android.view.textclassifier.ExtrasUtils;
41 import android.view.textclassifier.SelectionEvent;
42 import android.view.textclassifier.SelectionEvent.InvocationMethod;
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 = Objects.requireNonNull(editor);
91         mTextView = mEditor.getTextView();
92         mTextClassificationHelper = new TextClassificationHelper(
93                 mTextView.getContext(),
94                 mTextView::getTextClassificationSession,
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         final SelectionModifierCursorController controller = mEditor.getSelectionController();
301         if (controller != null
302                 && (mTextView.isTextSelectable() || mTextView.isTextEditable())) {
303             controller.show();
304         }
305         if (mEditor.startActionModeInternal(actionMode)) {
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         if (!didSelectionChange) {
343             onAnimationEndCallback.run();
344             return;
345         }
346 
347         final List<SmartSelectSprite.RectangleWithTextSelectionLayout> selectionRectangles =
348                 convertSelectionToRectangles(layout, result.mStart, result.mEnd);
349 
350         final PointF touchPoint = new PointF(
351                 mEditor.getLastUpPositionX(),
352                 mEditor.getLastUpPositionY());
353 
354         final PointF animationStartPoint =
355                 movePointInsideNearestRectangle(touchPoint, selectionRectangles,
356                         SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle);
357 
358         mSmartSelectSprite.startAnimation(
359                 animationStartPoint,
360                 selectionRectangles,
361                 onAnimationEndCallback);
362     }
363 
convertSelectionToRectangles( final Layout layout, final int start, final int end)364     private List<SmartSelectSprite.RectangleWithTextSelectionLayout> convertSelectionToRectangles(
365             final Layout layout, final int start, final int end) {
366         final List<SmartSelectSprite.RectangleWithTextSelectionLayout> result = new ArrayList<>();
367 
368         final Layout.SelectionRectangleConsumer consumer =
369                 (left, top, right, bottom, textSelectionLayout) -> mergeRectangleIntoList(
370                         result,
371                         new RectF(left, top, right, bottom),
372                         SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle,
373                         r -> new SmartSelectSprite.RectangleWithTextSelectionLayout(r,
374                                 textSelectionLayout)
375                 );
376 
377         layout.getSelection(start, end, consumer);
378 
379         result.sort(Comparator.comparing(
380                 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle,
381                 SmartSelectSprite.RECTANGLE_COMPARATOR));
382 
383         return result;
384     }
385 
386     // TODO: Move public pure functions out of this class and make it package-private.
387     /**
388      * Merges a {@link RectF} into an existing list of any objects which contain a rectangle.
389      * While merging, this method makes sure that:
390      *
391      * <ol>
392      * <li>No rectangle is redundant (contained within a bigger rectangle)</li>
393      * <li>Rectangles of the same height and vertical position that intersect get merged</li>
394      * </ol>
395      *
396      * @param list      the list of rectangles (or other rectangle containers) to merge the new
397      *                  rectangle into
398      * @param candidate the {@link RectF} to merge into the list
399      * @param extractor a function that can extract a {@link RectF} from an element of the given
400      *                  list
401      * @param packer    a function that can wrap the resulting {@link RectF} into an element that
402      *                  the list contains
403      * @hide
404      */
405     @VisibleForTesting
mergeRectangleIntoList(final List<T> list, final RectF candidate, final Function<T, RectF> extractor, final Function<RectF, T> packer)406     public static <T> void mergeRectangleIntoList(final List<T> list,
407             final RectF candidate, final Function<T, RectF> extractor,
408             final Function<RectF, T> packer) {
409         if (candidate.isEmpty()) {
410             return;
411         }
412 
413         final int elementCount = list.size();
414         for (int index = 0; index < elementCount; ++index) {
415             final RectF existingRectangle = extractor.apply(list.get(index));
416             if (existingRectangle.contains(candidate)) {
417                 return;
418             }
419             if (candidate.contains(existingRectangle)) {
420                 existingRectangle.setEmpty();
421                 continue;
422             }
423 
424             final boolean rectanglesContinueEachOther = candidate.left == existingRectangle.right
425                     || candidate.right == existingRectangle.left;
426             final boolean canMerge = candidate.top == existingRectangle.top
427                     && candidate.bottom == existingRectangle.bottom
428                     && (RectF.intersects(candidate, existingRectangle)
429                     || rectanglesContinueEachOther);
430 
431             if (canMerge) {
432                 candidate.union(existingRectangle);
433                 existingRectangle.setEmpty();
434             }
435         }
436 
437         for (int index = elementCount - 1; index >= 0; --index) {
438             final RectF rectangle = extractor.apply(list.get(index));
439             if (rectangle.isEmpty()) {
440                 list.remove(index);
441             }
442         }
443 
444         list.add(packer.apply(candidate));
445     }
446 
447 
448     /** @hide */
449     @VisibleForTesting
movePointInsideNearestRectangle(final PointF point, final List<T> list, final Function<T, RectF> extractor)450     public static <T> PointF movePointInsideNearestRectangle(final PointF point,
451             final List<T> list, final Function<T, RectF> extractor) {
452         float bestX = -1;
453         float bestY = -1;
454         double bestDistance = Double.MAX_VALUE;
455 
456         final int elementCount = list.size();
457         for (int index = 0; index < elementCount; ++index) {
458             final RectF rectangle = extractor.apply(list.get(index));
459             final float candidateY = rectangle.centerY();
460             final float candidateX;
461 
462             if (point.x > rectangle.right) {
463                 candidateX = rectangle.right;
464             } else if (point.x < rectangle.left) {
465                 candidateX = rectangle.left;
466             } else {
467                 candidateX = point.x;
468             }
469 
470             final double candidateDistance = Math.pow(point.x - candidateX, 2)
471                     + Math.pow(point.y - candidateY, 2);
472 
473             if (candidateDistance < bestDistance) {
474                 bestX = candidateX;
475                 bestY = candidateY;
476                 bestDistance = candidateDistance;
477             }
478         }
479 
480         return new PointF(bestX, bestY);
481     }
482 
invalidateActionMode(@ullable SelectionResult result)483     private void invalidateActionMode(@Nullable SelectionResult result) {
484         cancelSmartSelectAnimation();
485         mTextClassification = result != null ? result.mClassification : null;
486         final ActionMode actionMode = mEditor.getTextActionMode();
487         if (actionMode != null) {
488             actionMode.invalidate();
489         }
490         final int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView);
491         mSelectionTracker.onSelectionUpdated(
492                 sortedSelectionIndices[0], sortedSelectionIndices[1], mTextClassification);
493         mTextClassificationAsyncTask = null;
494     }
495 
resetTextClassificationHelper(int selectionStart, int selectionEnd)496     private void resetTextClassificationHelper(int selectionStart, int selectionEnd) {
497         if (selectionStart < 0 || selectionEnd < 0) {
498             // Use selection indices
499             int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView);
500             selectionStart = sortedSelectionIndices[0];
501             selectionEnd = sortedSelectionIndices[1];
502         }
503         mTextClassificationHelper.init(
504                 mTextView::getTextClassificationSession,
505                 getText(mTextView),
506                 selectionStart, selectionEnd,
507                 mTextView.getTextLocales());
508     }
509 
resetTextClassificationHelper()510     private void resetTextClassificationHelper() {
511         resetTextClassificationHelper(-1, -1);
512     }
513 
cancelSmartSelectAnimation()514     private void cancelSmartSelectAnimation() {
515         if (mSmartSelectSprite != null) {
516             mSmartSelectSprite.cancelAnimation();
517         }
518     }
519 
520     /**
521      * Tracks and logs smart selection changes.
522      * It is important to trigger this object's methods at the appropriate event so that it tracks
523      * smart selection events appropriately.
524      */
525     private static final class SelectionTracker {
526 
527         private final TextView mTextView;
528         private SelectionMetricsLogger mLogger;
529 
530         private int mOriginalStart;
531         private int mOriginalEnd;
532         private int mSelectionStart;
533         private int mSelectionEnd;
534         private boolean mAllowReset;
535         private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable();
536 
SelectionTracker(TextView textView)537         SelectionTracker(TextView textView) {
538             mTextView = Objects.requireNonNull(textView);
539             mLogger = new SelectionMetricsLogger(textView);
540         }
541 
542         /**
543          * Called when the original selection happens, before smart selection is triggered.
544          */
onOriginalSelection( CharSequence text, int selectionStart, int selectionEnd, boolean isLink)545         public void onOriginalSelection(
546                 CharSequence text, int selectionStart, int selectionEnd, boolean isLink) {
547             // If we abandoned a selection and created a new one very shortly after, we may still
548             // have a pending request to log ABANDON, which we flush here.
549             mDelayedLogAbandon.flush();
550 
551             mOriginalStart = mSelectionStart = selectionStart;
552             mOriginalEnd = mSelectionEnd = selectionEnd;
553             mAllowReset = false;
554             maybeInvalidateLogger();
555             mLogger.logSelectionStarted(
556                     mTextView.getTextClassificationSession(),
557                     mTextView.getTextClassificationContext(),
558                     text,
559                     selectionStart,
560                     isLink ? SelectionEvent.INVOCATION_LINK : SelectionEvent.INVOCATION_MANUAL);
561         }
562 
563         /**
564          * Called when selection action mode is started and the results come from a classifier.
565          */
onSmartSelection(SelectionResult result)566         public void onSmartSelection(SelectionResult result) {
567             onClassifiedSelection(result);
568             mTextView.notifyContentCaptureTextChanged();
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                 mTextView.notifyContentCaptureTextChanged();
600                 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null);
601             }
602         }
603 
604         /**
605          * Called when the selection action mode is destroyed.
606          */
onSelectionDestroyed()607         public void onSelectionDestroyed() {
608             mAllowReset = false;
609             mTextView.notifyContentCaptureTextChanged();
610             // Wait a few ms to see if the selection was destroyed because of a text change event.
611             mDelayedLogAbandon.schedule(100 /* ms */);
612         }
613 
614         /**
615          * Called when an action is taken on a smart selection.
616          */
onSelectionAction( int selectionStart, int selectionEnd, @SelectionEvent.ActionType int action, @Nullable String actionLabel, @Nullable TextClassification classification)617         public void onSelectionAction(
618                 int selectionStart, int selectionEnd,
619                 @SelectionEvent.ActionType int action,
620                 @Nullable String actionLabel,
621                 @Nullable TextClassification classification) {
622             if (isSelectionStarted()) {
623                 mAllowReset = false;
624                 mLogger.logSelectionAction(
625                         selectionStart, selectionEnd, action, actionLabel, classification);
626             }
627         }
628 
629         /**
630          * Returns true if the current smart selection should be reset to normal selection based on
631          * information that has been recorded about the original selection and the smart selection.
632          * The expected UX here is to allow the user to select a word inside of the smart selection
633          * on a single tap.
634          */
resetSelection(int textIndex, Editor editor)635         public boolean resetSelection(int textIndex, Editor editor) {
636             final TextView textView = editor.getTextView();
637             if (isSelectionStarted()
638                     && mAllowReset
639                     && textIndex >= mSelectionStart && textIndex <= mSelectionEnd
640                     && getText(textView) instanceof Spannable) {
641                 mAllowReset = false;
642                 boolean selected = editor.selectCurrentWord();
643                 if (selected) {
644                     final int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(textView);
645                     mSelectionStart = sortedSelectionIndices[0];
646                     mSelectionEnd = sortedSelectionIndices[1];
647                     mLogger.logSelectionAction(
648                             sortedSelectionIndices[0], sortedSelectionIndices[1],
649                             SelectionEvent.ACTION_RESET,
650                             /* actionLabel= */ null, /* classification= */ null);
651                 }
652                 return selected;
653             }
654             return false;
655         }
656 
onTextChanged(int start, int end, TextClassification classification)657         public void onTextChanged(int start, int end, TextClassification classification) {
658             if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) {
659                 onSelectionAction(
660                         start, end, SelectionEvent.ACTION_OVERTYPE,
661                         /* actionLabel= */ null, classification);
662             }
663         }
664 
maybeInvalidateLogger()665         private void maybeInvalidateLogger() {
666             if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) {
667                 mLogger = new SelectionMetricsLogger(mTextView);
668             }
669         }
670 
isSelectionStarted()671         private boolean isSelectionStarted() {
672             return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd;
673         }
674 
675         /** A helper for keeping track of pending abandon logging requests. */
676         private final class LogAbandonRunnable implements Runnable {
677             private boolean mIsPending;
678 
679             /** Schedules an abandon to be logged with the given delay. Flush if necessary. */
schedule(int delayMillis)680             void schedule(int delayMillis) {
681                 if (mIsPending) {
682                     Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request");
683                     flush();
684                 }
685                 mIsPending = true;
686                 mTextView.postDelayed(this, delayMillis);
687             }
688 
689             /** If there is a pending log request, execute it now. */
flush()690             void flush() {
691                 mTextView.removeCallbacks(this);
692                 run();
693             }
694 
695             @Override
run()696             public void run() {
697                 if (mIsPending) {
698                     mLogger.logSelectionAction(
699                             mSelectionStart, mSelectionEnd,
700                             SelectionEvent.ACTION_ABANDON,
701                             /* actionLabel= */ null, /* classification= */ null);
702                     mSelectionStart = mSelectionEnd = -1;
703                     mLogger.endTextClassificationSession();
704                     mIsPending = false;
705                 }
706             }
707         }
708     }
709 
710     // TODO: Write tests
711     /**
712      * Metrics logging helper.
713      *
714      * This logger logs selection by word indices. The initial (start) single word selection is
715      * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the
716      * initial single word selection.
717      * e.g. New York city, NY. Suppose the initial selection is "York" in
718      * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2).
719      * "New York" is at [-1, 1).
720      * Part selection of a word e.g. "or" is counted as selecting the
721      * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g.
722      * "," is at [2, 3). Whitespaces are ignored.
723      *
724      * NOTE that the definition of a word is defined by the TextClassifier's Logger's token
725      * iterator.
726      */
727     private static final class SelectionMetricsLogger {
728 
729         private static final String LOG_TAG = "SelectionMetricsLogger";
730         private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+");
731 
732         private final boolean mEditTextLogger;
733         private final BreakIterator mTokenIterator;
734 
735         @Nullable private TextClassifier mClassificationSession;
736         @Nullable private TextClassificationContext mClassificationContext;
737 
738         @Nullable private TextClassifierEvent mTranslateViewEvent;
739         @Nullable private TextClassifierEvent mTranslateClickEvent;
740 
741         private int mStartIndex;
742         private String mText;
743 
SelectionMetricsLogger(TextView textView)744         SelectionMetricsLogger(TextView textView) {
745             Objects.requireNonNull(textView);
746             mEditTextLogger = textView.isTextEditable();
747             mTokenIterator = BreakIterator.getWordInstance(textView.getTextLocale());
748         }
749 
logSelectionStarted( TextClassifier classificationSession, TextClassificationContext classificationContext, CharSequence text, int index, @InvocationMethod int invocationMethod)750         public void logSelectionStarted(
751                 TextClassifier classificationSession,
752                 TextClassificationContext classificationContext,
753                 CharSequence text, int index,
754                 @InvocationMethod int invocationMethod) {
755             try {
756                 Objects.requireNonNull(text);
757                 Preconditions.checkArgumentInRange(index, 0, text.length(), "index");
758                 if (mText == null || !mText.contentEquals(text)) {
759                     mText = text.toString();
760                 }
761                 mTokenIterator.setText(mText);
762                 mStartIndex = index;
763                 mClassificationSession = classificationSession;
764                 mClassificationContext = classificationContext;
765                 if (hasActiveClassificationSession()) {
766                     mClassificationSession.onSelectionEvent(
767                             SelectionEvent.createSelectionStartedEvent(invocationMethod, 0));
768                 }
769             } catch (Exception e) {
770                 // Avoid crashes due to logging.
771                 Log.e(LOG_TAG, "" + e.getMessage(), e);
772             }
773         }
774 
logSelectionModified(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)775         public void logSelectionModified(int start, int end,
776                 @Nullable TextClassification classification, @Nullable TextSelection selection) {
777             try {
778                 if (hasActiveClassificationSession()) {
779                     Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
780                     Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
781                     int[] wordIndices = getWordDelta(start, end);
782                     if (selection != null) {
783                         mClassificationSession.onSelectionEvent(
784                                 SelectionEvent.createSelectionModifiedEvent(
785                                         wordIndices[0], wordIndices[1], selection));
786                     } else if (classification != null) {
787                         mClassificationSession.onSelectionEvent(
788                                 SelectionEvent.createSelectionModifiedEvent(
789                                         wordIndices[0], wordIndices[1], classification));
790                     } else {
791                         mClassificationSession.onSelectionEvent(
792                                 SelectionEvent.createSelectionModifiedEvent(
793                                         wordIndices[0], wordIndices[1]));
794                     }
795                     maybeGenerateTranslateViewEvent(classification);
796                 }
797             } catch (Exception e) {
798                 // Avoid crashes due to logging.
799                 Log.e(LOG_TAG, "" + e.getMessage(), e);
800             }
801         }
802 
logSelectionAction( int start, int end, @SelectionEvent.ActionType int action, @Nullable String actionLabel, @Nullable TextClassification classification)803         public void logSelectionAction(
804                 int start, int end,
805                 @SelectionEvent.ActionType int action,
806                 @Nullable String actionLabel,
807                 @Nullable TextClassification classification) {
808             try {
809                 if (hasActiveClassificationSession()) {
810                     Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
811                     Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
812                     int[] wordIndices = getWordDelta(start, end);
813                     if (classification != null) {
814                         mClassificationSession.onSelectionEvent(
815                                 SelectionEvent.createSelectionActionEvent(
816                                         wordIndices[0], wordIndices[1], action,
817                                         classification));
818                     } else {
819                         mClassificationSession.onSelectionEvent(
820                                 SelectionEvent.createSelectionActionEvent(
821                                         wordIndices[0], wordIndices[1], action));
822                     }
823 
824                     maybeGenerateTranslateClickEvent(classification, actionLabel);
825 
826                     if (SelectionEvent.isTerminal(action)) {
827                         endTextClassificationSession();
828                     }
829                 }
830             } catch (Exception e) {
831                 // Avoid crashes due to logging.
832                 Log.e(LOG_TAG, "" + e.getMessage(), e);
833             }
834         }
835 
isEditTextLogger()836         public boolean isEditTextLogger() {
837             return mEditTextLogger;
838         }
839 
endTextClassificationSession()840         public void endTextClassificationSession() {
841             if (hasActiveClassificationSession()) {
842                 maybeReportTranslateEvents();
843                 mClassificationSession.destroy();
844             }
845         }
846 
hasActiveClassificationSession()847         private boolean hasActiveClassificationSession() {
848             return mClassificationSession != null && !mClassificationSession.isDestroyed();
849         }
850 
getWordDelta(int start, int end)851         private int[] getWordDelta(int start, int end) {
852             int[] wordIndices = new int[2];
853 
854             if (start == mStartIndex) {
855                 wordIndices[0] = 0;
856             } else if (start < mStartIndex) {
857                 wordIndices[0] = -countWordsForward(start);
858             } else {  // start > mStartIndex
859                 wordIndices[0] = countWordsBackward(start);
860 
861                 // For the selection start index, avoid counting a partial word backwards.
862                 if (!mTokenIterator.isBoundary(start)
863                         && !isWhitespace(
864                         mTokenIterator.preceding(start),
865                         mTokenIterator.following(start))) {
866                     // We counted a partial word. Remove it.
867                     wordIndices[0]--;
868                 }
869             }
870 
871             if (end == mStartIndex) {
872                 wordIndices[1] = 0;
873             } else if (end < mStartIndex) {
874                 wordIndices[1] = -countWordsForward(end);
875             } else {  // end > mStartIndex
876                 wordIndices[1] = countWordsBackward(end);
877             }
878 
879             return wordIndices;
880         }
881 
countWordsBackward(int from)882         private int countWordsBackward(int from) {
883             Preconditions.checkArgument(from >= mStartIndex);
884             int wordCount = 0;
885             int offset = from;
886             while (offset > mStartIndex) {
887                 int start = mTokenIterator.preceding(offset);
888                 if (!isWhitespace(start, offset)) {
889                     wordCount++;
890                 }
891                 offset = start;
892             }
893             return wordCount;
894         }
895 
countWordsForward(int from)896         private int countWordsForward(int from) {
897             Preconditions.checkArgument(from <= mStartIndex);
898             int wordCount = 0;
899             int offset = from;
900             while (offset < mStartIndex) {
901                 int end = mTokenIterator.following(offset);
902                 if (!isWhitespace(offset, end)) {
903                     wordCount++;
904                 }
905                 offset = end;
906             }
907             return wordCount;
908         }
909 
isWhitespace(int start, int end)910         private boolean isWhitespace(int start, int end) {
911             return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches();
912         }
913 
maybeGenerateTranslateViewEvent(@ullable TextClassification classification)914         private void maybeGenerateTranslateViewEvent(@Nullable TextClassification classification) {
915             if (classification != null) {
916                 final TextClassifierEvent event = generateTranslateEvent(
917                         TextClassifierEvent.TYPE_ACTIONS_SHOWN,
918                         classification, mClassificationContext, /* actionLabel= */null);
919                 mTranslateViewEvent = (event != null) ? event : mTranslateViewEvent;
920             }
921         }
922 
maybeGenerateTranslateClickEvent( @ullable TextClassification classification, String actionLabel)923         private void maybeGenerateTranslateClickEvent(
924                 @Nullable TextClassification classification, String actionLabel) {
925             if (classification != null) {
926                 mTranslateClickEvent = generateTranslateEvent(
927                         TextClassifierEvent.TYPE_SMART_ACTION,
928                         classification, mClassificationContext, actionLabel);
929             }
930         }
931 
maybeReportTranslateEvents()932         private void maybeReportTranslateEvents() {
933             // Translate view and click events should only be logged once per selection session.
934             if (mTranslateViewEvent != null) {
935                 mClassificationSession.onTextClassifierEvent(mTranslateViewEvent);
936                 mTranslateViewEvent = null;
937             }
938             if (mTranslateClickEvent != null) {
939                 mClassificationSession.onTextClassifierEvent(mTranslateClickEvent);
940                 mTranslateClickEvent = null;
941             }
942         }
943 
944         @Nullable
generateTranslateEvent( int eventType, TextClassification classification, TextClassificationContext classificationContext, @Nullable String actionLabel)945         private static TextClassifierEvent generateTranslateEvent(
946                 int eventType, TextClassification classification,
947                 TextClassificationContext classificationContext, @Nullable String actionLabel) {
948 
949             // The platform attempts to log "views" and "clicks" of the "Translate" action.
950             // Views are logged if a user is presented with the translate action during a selection
951             // session.
952             // Clicks are logged if the user clicks on the translate action.
953             // The index of the translate action is also logged to indicate whether it might have
954             // been in the main panel or overflow panel of the selection toolbar.
955             // NOTE that the "views" metric may be flawed if a TextView removes the translate menu
956             // item via a custom action mode callback or does not show a selection menu item.
957 
958             final RemoteAction translateAction = ExtrasUtils.findTranslateAction(classification);
959             if (translateAction == null) {
960                 // No translate action present. Nothing to log. Exit.
961                 return null;
962             }
963 
964             if (eventType == TextClassifierEvent.TYPE_SMART_ACTION
965                     && !translateAction.getTitle().toString().equals(actionLabel)) {
966                 // Clicked action is not a translate action. Nothing to log. Exit.
967                 // Note that we don't expect an actionLabel for "view" events.
968                 return null;
969             }
970 
971             final Bundle foreignLanguageExtra = ExtrasUtils.getForeignLanguageExtra(classification);
972             final String language = ExtrasUtils.getEntityType(foreignLanguageExtra);
973             final float score = ExtrasUtils.getScore(foreignLanguageExtra);
974             final String model = ExtrasUtils.getModelName(foreignLanguageExtra);
975             return new TextClassifierEvent.LanguageDetectionEvent.Builder(eventType)
976                     .setEventContext(classificationContext)
977                     .setResultId(classification.getId())
978                     // b/158481016: Disable language logging.
979                     //.setEntityTypes(language)
980                     .setScores(score)
981                     .setActionIndices(classification.getActions().indexOf(translateAction))
982                     .setModelName(model)
983                     .build();
984         }
985     }
986 
987     /**
988      * AsyncTask for running a query on a background thread and returning the result on the
989      * UiThread. The AsyncTask times out after a specified time, returning a null result if the
990      * query has not yet returned.
991      */
992     private static final class TextClassificationAsyncTask
993             extends AsyncTask<Void, Void, SelectionResult> {
994 
995         private final int mTimeOutDuration;
996         private final Supplier<SelectionResult> mSelectionResultSupplier;
997         private final Consumer<SelectionResult> mSelectionResultCallback;
998         private final Supplier<SelectionResult> mTimeOutResultSupplier;
999         private final TextView mTextView;
1000         private final String mOriginalText;
1001 
1002         /**
1003          * @param textView the TextView
1004          * @param timeOut time in milliseconds to timeout the query if it has not completed
1005          * @param selectionResultSupplier fetches the selection results. Runs on a background thread
1006          * @param selectionResultCallback receives the selection results. Runs on the UiThread
1007          * @param timeOutResultSupplier default result if the task times out
1008          */
TextClassificationAsyncTask( @onNull TextView textView, int timeOut, @NonNull Supplier<SelectionResult> selectionResultSupplier, @NonNull Consumer<SelectionResult> selectionResultCallback, @NonNull Supplier<SelectionResult> timeOutResultSupplier)1009         TextClassificationAsyncTask(
1010                 @NonNull TextView textView, int timeOut,
1011                 @NonNull Supplier<SelectionResult> selectionResultSupplier,
1012                 @NonNull Consumer<SelectionResult> selectionResultCallback,
1013                 @NonNull Supplier<SelectionResult> timeOutResultSupplier) {
1014             super(textView != null ? textView.getHandler() : null);
1015             mTextView = Objects.requireNonNull(textView);
1016             mTimeOutDuration = timeOut;
1017             mSelectionResultSupplier = Objects.requireNonNull(selectionResultSupplier);
1018             mSelectionResultCallback = Objects.requireNonNull(selectionResultCallback);
1019             mTimeOutResultSupplier = Objects.requireNonNull(timeOutResultSupplier);
1020             // Make a copy of the original text.
1021             mOriginalText = getText(mTextView).toString();
1022         }
1023 
1024         @Override
1025         @WorkerThread
doInBackground(Void... params)1026         protected SelectionResult doInBackground(Void... params) {
1027             final Runnable onTimeOut = this::onTimeOut;
1028             mTextView.postDelayed(onTimeOut, mTimeOutDuration);
1029             SelectionResult result = null;
1030             try {
1031                 result = mSelectionResultSupplier.get();
1032             } catch (IllegalStateException e) {
1033                 // TODO(b/174300371): Only swallows the exception if the TCSession is destroyed
1034                 Log.w(LOG_TAG, "TextClassificationAsyncTask failed.", e);
1035             }
1036             mTextView.removeCallbacks(onTimeOut);
1037             return result;
1038         }
1039 
1040         @Override
1041         @UiThread
onPostExecute(SelectionResult result)1042         protected void onPostExecute(SelectionResult result) {
1043             result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null;
1044             mSelectionResultCallback.accept(result);
1045         }
1046 
onTimeOut()1047         private void onTimeOut() {
1048             Log.d(LOG_TAG, "Timeout in TextClassificationAsyncTask");
1049             if (getStatus() == Status.RUNNING) {
1050                 onPostExecute(mTimeOutResultSupplier.get());
1051             }
1052             cancel(true);
1053         }
1054     }
1055 
1056     /**
1057      * Helper class for querying the TextClassifier.
1058      * It trims text so that only text necessary to provide context of the selected text is
1059      * sent to the TextClassifier.
1060      */
1061     private static final class TextClassificationHelper {
1062 
1063         // The fixed upper bound of context size.
1064         private static final int TRIM_DELTA_UPPER_BOUND = 240;
1065 
1066         private final Context mContext;
1067         private Supplier<TextClassifier> mTextClassifier;
1068         private final ViewConfiguration mViewConfiguration;
1069 
1070         /** The original TextView text. **/
1071         private String mText;
1072         /** Start index relative to mText. */
1073         private int mSelectionStart;
1074         /** End index relative to mText. */
1075         private int mSelectionEnd;
1076 
1077         @Nullable
1078         private LocaleList mDefaultLocales;
1079 
1080         /** Trimmed text starting from mTrimStart in mText. */
1081         private CharSequence mTrimmedText;
1082         /** Index indicating the start of mTrimmedText in mText. */
1083         private int mTrimStart;
1084         /** Start index relative to mTrimmedText */
1085         private int mRelativeStart;
1086         /** End index relative to mTrimmedText */
1087         private int mRelativeEnd;
1088 
1089         /** Information about the last classified text to avoid re-running a query. */
1090         private CharSequence mLastClassificationText;
1091         private int mLastClassificationSelectionStart;
1092         private int mLastClassificationSelectionEnd;
1093         private LocaleList mLastClassificationLocales;
1094         private SelectionResult mLastClassificationResult;
1095 
1096         /** Whether the TextClassifier has been initialized. */
1097         private boolean mInitialized;
1098 
TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)1099         TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier,
1100                 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
1101             init(textClassifier, text, selectionStart, selectionEnd, locales);
1102             mContext = Objects.requireNonNull(context);
1103             mViewConfiguration = ViewConfiguration.get(mContext);
1104         }
1105 
1106         @UiThread
init(Supplier<TextClassifier> textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)1107         public void init(Supplier<TextClassifier> textClassifier, CharSequence text,
1108                 int selectionStart, int selectionEnd, LocaleList locales) {
1109             mTextClassifier = Objects.requireNonNull(textClassifier);
1110             mText = Objects.requireNonNull(text).toString();
1111             mLastClassificationText = null; // invalidate.
1112             Preconditions.checkArgument(selectionEnd > selectionStart);
1113             mSelectionStart = selectionStart;
1114             mSelectionEnd = selectionEnd;
1115             mDefaultLocales = locales;
1116         }
1117 
1118         @WorkerThread
classifyText()1119         public SelectionResult classifyText() {
1120             mInitialized = true;
1121             return performClassification(null /* selection */);
1122         }
1123 
1124         @WorkerThread
suggestSelection()1125         public SelectionResult suggestSelection() {
1126             mInitialized = true;
1127             trimText();
1128             final TextSelection selection;
1129             if (mContext.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.P) {
1130                 final TextSelection.Request request = new TextSelection.Request.Builder(
1131                         mTrimmedText, mRelativeStart, mRelativeEnd)
1132                         .setDefaultLocales(mDefaultLocales)
1133                         .setDarkLaunchAllowed(true)
1134                         .setIncludeTextClassification(true)
1135                         .build();
1136                 selection = mTextClassifier.get().suggestSelection(request);
1137             } else {
1138                 // Use old APIs.
1139                 selection = mTextClassifier.get().suggestSelection(
1140                         mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales);
1141             }
1142             // Do not classify new selection boundaries if TextClassifier should be dark launched.
1143             if (!isDarkLaunchEnabled()) {
1144                 mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart);
1145                 mSelectionEnd = Math.min(
1146                         mText.length(), selection.getSelectionEndIndex() + mTrimStart);
1147             }
1148             return performClassification(selection);
1149         }
1150 
getOriginalSelection()1151         public SelectionResult getOriginalSelection() {
1152             return new SelectionResult(mSelectionStart, mSelectionEnd, null, null);
1153         }
1154 
1155         /**
1156          * Maximum time (in milliseconds) to wait for a textclassifier result before timing out.
1157          */
getTimeoutDuration()1158         public int getTimeoutDuration() {
1159             if (mInitialized) {
1160                 return mViewConfiguration.getSmartSelectionInitializedTimeout();
1161             } else {
1162                 // Return a slightly larger number than usual when the TextClassifier is first
1163                 // initialized. Initialization would usually take longer than subsequent calls to
1164                 // the TextClassifier. The impact of this on the UI is that we do not show the
1165                 // selection handles or toolbar until after this timeout.
1166                 return mViewConfiguration.getSmartSelectionInitializingTimeout();
1167             }
1168         }
1169 
isDarkLaunchEnabled()1170         private boolean isDarkLaunchEnabled() {
1171             return TextClassificationManager.getSettings(mContext).isModelDarkLaunchEnabled();
1172         }
1173 
performClassification(@ullable TextSelection selection)1174         private SelectionResult performClassification(@Nullable TextSelection selection) {
1175             if (!Objects.equals(mText, mLastClassificationText)
1176                     || mSelectionStart != mLastClassificationSelectionStart
1177                     || mSelectionEnd != mLastClassificationSelectionEnd
1178                     || !Objects.equals(mDefaultLocales, mLastClassificationLocales)) {
1179 
1180                 mLastClassificationText = mText;
1181                 mLastClassificationSelectionStart = mSelectionStart;
1182                 mLastClassificationSelectionEnd = mSelectionEnd;
1183                 mLastClassificationLocales = mDefaultLocales;
1184 
1185                 trimText();
1186                 final TextClassification classification;
1187                 if (Linkify.containsUnsupportedCharacters(mText)) {
1188                     // Do not show smart actions for text containing unsupported characters.
1189                     android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, "");
1190                     classification = TextClassification.EMPTY;
1191                 } else if (selection != null && selection.getTextClassification() != null) {
1192                     classification = selection.getTextClassification();
1193                 } else if (mContext.getApplicationInfo().targetSdkVersion
1194                         >= Build.VERSION_CODES.P) {
1195                     final TextClassification.Request request =
1196                             new TextClassification.Request.Builder(
1197                                     mTrimmedText, mRelativeStart, mRelativeEnd)
1198                                     .setDefaultLocales(mDefaultLocales)
1199                                     .build();
1200                     classification = mTextClassifier.get().classifyText(request);
1201                 } else {
1202                     // Use old APIs.
1203                     classification = mTextClassifier.get().classifyText(
1204                             mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales);
1205                 }
1206                 mLastClassificationResult = new SelectionResult(
1207                         mSelectionStart, mSelectionEnd, classification, selection);
1208 
1209             }
1210             return mLastClassificationResult;
1211         }
1212 
trimText()1213         private void trimText() {
1214             final int trimDelta = Math.min(
1215                     TextClassificationManager.getSettings(mContext).getSmartSelectionTrimDelta(),
1216                     TRIM_DELTA_UPPER_BOUND);
1217             mTrimStart = Math.max(0, mSelectionStart - trimDelta);
1218             final int referenceEnd = Math.min(mText.length(), mSelectionEnd + trimDelta);
1219             mTrimmedText = mText.subSequence(mTrimStart, referenceEnd);
1220             mRelativeStart = mSelectionStart - mTrimStart;
1221             mRelativeEnd = mSelectionEnd - mTrimStart;
1222         }
1223     }
1224 
1225     /**
1226      * Selection result.
1227      */
1228     private static final class SelectionResult {
1229         private final int mStart;
1230         private final int mEnd;
1231         @Nullable private final TextClassification mClassification;
1232         @Nullable private final TextSelection mSelection;
1233 
SelectionResult(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)1234         SelectionResult(int start, int end,
1235                 @Nullable TextClassification classification, @Nullable TextSelection selection) {
1236             int[] sortedIndices = sortSelectionIndices(start, end);
1237             mStart = sortedIndices[0];
1238             mEnd = sortedIndices[1];
1239             mClassification = classification;
1240             mSelection = selection;
1241         }
1242     }
1243 
1244     @SelectionEvent.ActionType
getActionType(int menuItemId)1245     private static int getActionType(int menuItemId) {
1246         switch (menuItemId) {
1247             case TextView.ID_SELECT_ALL:
1248                 return SelectionEvent.ACTION_SELECT_ALL;
1249             case TextView.ID_CUT:
1250                 return SelectionEvent.ACTION_CUT;
1251             case TextView.ID_COPY:
1252                 return SelectionEvent.ACTION_COPY;
1253             case TextView.ID_PASTE:  // fall through
1254             case TextView.ID_PASTE_AS_PLAIN_TEXT:
1255                 return SelectionEvent.ACTION_PASTE;
1256             case TextView.ID_SHARE:
1257                 return SelectionEvent.ACTION_SHARE;
1258             case TextView.ID_ASSIST:
1259                 return SelectionEvent.ACTION_SMART_SHARE;
1260             default:
1261                 return SelectionEvent.ACTION_OTHER;
1262         }
1263     }
1264 
getText(TextView textView)1265     private static CharSequence getText(TextView textView) {
1266         // Extracts the textView's text.
1267         // TODO: Investigate why/when TextView.getText() is null.
1268         final CharSequence text = textView.getText();
1269         if (text != null) {
1270             return text;
1271         }
1272         return "";
1273     }
1274 }
1275