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.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 
     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      */
    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      */
    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 
    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. */
    175     public void onSelectionAction(int menuItemId, @Nullable String actionLabel) {
    176         mSelectionTracker.onSelectionAction(
    177                 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
    178                 getActionType(menuItemId), actionLabel, mTextClassification);
    179     }
    180 
    181     public void onSelectionDrag() {
    182         mSelectionTracker.onSelectionAction(
    183                 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
    184                 SelectionEvent.ACTION_DRAG, /* actionLabel= */ null, mTextClassification);
    185     }
    186 
    187     public void onTextChanged(int start, int end) {
    188         mSelectionTracker.onTextChanged(start, end, mTextClassification);
    189     }
    190 
    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
    200     public TextClassification getTextClassification() {
    201         return mTextClassification;
    202     }
    203 
    204     public void onDestroyActionMode() {
    205         cancelSmartSelectAnimation();
    206         mSelectionTracker.onSelectionDestroyed();
    207         cancelAsyncTask();
    208     }
    209 
    210     public void onDraw(final Canvas canvas) {
    211         if (isDrawingHighlight() && mSmartSelectSprite != null) {
    212             mSmartSelectSprite.draw(canvas);
    213         }
    214     }
    215 
    216     public boolean isDrawingHighlight() {
    217         return mSmartSelectSprite != null && mSmartSelectSprite.isAnimationActive();
    218     }
    219 
    220     private TextClassificationConstants getTextClassificationSettings() {
    221         return TextClassificationManager.getSettings(mTextView.getContext());
    222     }
    223 
    224     private void cancelAsyncTask() {
    225         if (mTextClassificationAsyncTask != null) {
    226             mTextClassificationAsyncTask.cancel(true);
    227             mTextClassificationAsyncTask = null;
    228         }
    229         mTextClassification = null;
    230     }
    231 
    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 
    243     private void startLinkActionMode(@Nullable SelectionResult result) {
    244         startActionMode(Editor.TextActionMode.TEXT_LINK, result);
    245     }
    246 
    247     private void startSelectionActionMode(@Nullable SelectionResult result) {
    248         startActionMode(Editor.TextActionMode.SELECTION, result);
    249     }
    250 
    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 
    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 
    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
    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
    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 
    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 
    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 
    475     private void resetTextClassificationHelper() {
    476         resetTextClassificationHelper(-1, -1);
    477     }
    478 
    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 
    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          */
    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          */
    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          */
    540         public void onLinkSelected(SelectionResult result) {
    541             onClassifiedSelection(result);
    542             // TODO: log (b/70246800)
    543         }
    544 
    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          */
    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          */
    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          */
    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          */
    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 
    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 
    626         private void maybeInvalidateLogger() {
    627             if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) {
    628                 mLogger = new SelectionMetricsLogger(mTextView);
    629             }
    630         }
    631 
    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. */
    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. */
    651             void flush() {
    652                 mTextView.removeCallbacks(this);
    653                 run();
    654             }
    655 
    656             @Override
    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 
    705         SelectionMetricsLogger(TextView textView) {
    706             Preconditions.checkNotNull(textView);
    707             mEditTextLogger = textView.isTextEditable();
    708             mTokenIterator = SelectionSessionLogger.getTokenIterator(textView.getTextLocale());
    709         }
    710 
    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 
    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 
    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 
    797         public boolean isEditTextLogger() {
    798             return mEditTextLogger;
    799         }
    800 
    801         public void endTextClassificationSession() {
    802             if (hasActiveClassificationSession()) {
    803                 maybeReportTranslateEvents();
    804                 mClassificationSession.destroy();
    805             }
    806         }
    807 
    808         private boolean hasActiveClassificationSession() {
    809             return mClassificationSession != null && !mClassificationSession.isDestroyed();
    810         }
    811 
    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 
    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 
    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 
    871         private boolean isWhitespace(int start, int end) {
    872             return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches();
    873         }
    874 
    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 
    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 
    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
    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          */
    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
    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
    996         protected void onPostExecute(SelectionResult result) {
    997             result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null;
    998             mSelectionResultCallback.accept(result);
    999         }
   1000 
   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 
   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
   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
   1069         public SelectionResult classifyText() {
   1070             mHot = true;
   1071             return performClassification(null /* selection */);
   1072         }
   1073 
   1074         @WorkerThread
   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 
   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.
   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 
   1120         private boolean isDarkLaunchEnabled() {
   1121             return TextClassificationManager.getSettings(mContext).isModelDarkLaunchEnabled();
   1122         }
   1123 
   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 
   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 
   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
   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 
   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