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