SelectionActionModeHelper.java revision 6e8e27bf9ad118038282f29abc75b89e8f37bb86
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
17package android.widget;
18
19import android.annotation.NonNull;
20import android.annotation.Nullable;
21import android.annotation.UiThread;
22import android.annotation.WorkerThread;
23import android.content.Context;
24import android.graphics.Canvas;
25import android.graphics.PointF;
26import android.graphics.RectF;
27import android.os.AsyncTask;
28import android.os.Build;
29import android.os.LocaleList;
30import android.text.Layout;
31import android.text.Selection;
32import android.text.Spannable;
33import android.text.TextUtils;
34import android.util.Log;
35import android.view.ActionMode;
36import android.view.textclassifier.SelectionEvent;
37import android.view.textclassifier.SelectionEvent.InvocationMethod;
38import android.view.textclassifier.SelectionSessionLogger;
39import android.view.textclassifier.TextClassification;
40import android.view.textclassifier.TextClassificationConstants;
41import android.view.textclassifier.TextClassificationManager;
42import android.view.textclassifier.TextClassifier;
43import android.view.textclassifier.TextSelection;
44import android.widget.Editor.SelectionModifierCursorController;
45
46import com.android.internal.annotations.VisibleForTesting;
47import com.android.internal.util.Preconditions;
48
49import java.text.BreakIterator;
50import java.util.ArrayList;
51import java.util.Comparator;
52import java.util.List;
53import java.util.Objects;
54import java.util.function.Consumer;
55import java.util.function.Function;
56import java.util.function.Supplier;
57import 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)
66public 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