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