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