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.os.AsyncTask;
24import android.os.LocaleList;
25import android.text.Selection;
26import android.text.Spannable;
27import android.text.TextUtils;
28import android.view.ActionMode;
29import android.view.textclassifier.TextClassification;
30import android.view.textclassifier.TextClassifier;
31import android.view.textclassifier.TextSelection;
32import android.widget.Editor.SelectionModifierCursorController;
33
34import com.android.internal.util.Preconditions;
35
36import java.util.Objects;
37import java.util.function.Consumer;
38import java.util.function.Supplier;
39
40/**
41 * Helper class for starting selection action mode
42 * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
43 */
44@UiThread
45final class SelectionActionModeHelper {
46
47    /**
48     * Maximum time (in milliseconds) to wait for a result before timing out.
49     */
50    // TODO: Consider making this a ViewConfiguration.
51    private static final int TIMEOUT_DURATION = 200;
52
53    private final Editor mEditor;
54    private final TextClassificationHelper mTextClassificationHelper;
55
56    private TextClassification mTextClassification;
57    private AsyncTask mTextClassificationAsyncTask;
58
59    private final SelectionTracker mSelectionTracker;
60
61    SelectionActionModeHelper(@NonNull Editor editor) {
62        mEditor = Preconditions.checkNotNull(editor);
63        final TextView textView = mEditor.getTextView();
64        mTextClassificationHelper = new TextClassificationHelper(
65                textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales());
66        mSelectionTracker = new SelectionTracker(textView.getTextClassifier());
67    }
68
69    public void startActionModeAsync(boolean adjustSelection) {
70        cancelAsyncTask();
71        if (isNoOpTextClassifier() || !hasSelection()) {
72            // No need to make an async call for a no-op TextClassifier.
73            // Do not call the TextClassifier if there is no selection.
74            startActionMode(null);
75        } else {
76            resetTextClassificationHelper(true /* resetSelectionTag */);
77            final TextView tv = mEditor.getTextView();
78            mTextClassificationAsyncTask = new TextClassificationAsyncTask(
79                    tv,
80                    TIMEOUT_DURATION,
81                    adjustSelection
82                            ? mTextClassificationHelper::suggestSelection
83                            : mTextClassificationHelper::classifyText,
84                    this::startActionMode)
85                    .execute();
86        }
87    }
88
89    public void invalidateActionModeAsync() {
90        cancelAsyncTask();
91        if (isNoOpTextClassifier() || !hasSelection()) {
92            // No need to make an async call for a no-op TextClassifier.
93            // Do not call the TextClassifier if there is no selection.
94            invalidateActionMode(null);
95        } else {
96            resetTextClassificationHelper(false /* resetSelectionTag */);
97            mTextClassificationAsyncTask = new TextClassificationAsyncTask(
98                    mEditor.getTextView(), TIMEOUT_DURATION,
99                    mTextClassificationHelper::classifyText, this::invalidateActionMode)
100                    .execute();
101        }
102    }
103
104    public void onSelectionAction() {
105        mSelectionTracker.onSelectionAction(mTextClassificationHelper.getSelectionTag());
106    }
107
108    public boolean resetSelection(int textIndex) {
109        if (mSelectionTracker.resetSelection(
110                textIndex, mEditor, mTextClassificationHelper.getSelectionTag())) {
111            invalidateActionModeAsync();
112            return true;
113        }
114        return false;
115    }
116
117    @Nullable
118    public TextClassification getTextClassification() {
119        return mTextClassification;
120    }
121
122    public void onDestroyActionMode() {
123        mSelectionTracker.onSelectionDestroyed();
124        cancelAsyncTask();
125    }
126
127    private void cancelAsyncTask() {
128        if (mTextClassificationAsyncTask != null) {
129            mTextClassificationAsyncTask.cancel(true);
130            mTextClassificationAsyncTask = null;
131        }
132        mTextClassification = null;
133    }
134
135    private boolean isNoOpTextClassifier() {
136        return mEditor.getTextView().getTextClassifier() == TextClassifier.NO_OP;
137    }
138
139    private boolean hasSelection() {
140        final TextView textView = mEditor.getTextView();
141        return textView.getSelectionEnd() > textView.getSelectionStart();
142    }
143
144    private void startActionMode(@Nullable SelectionResult result) {
145        final TextView textView = mEditor.getTextView();
146        final CharSequence text = textView.getText();
147        mSelectionTracker.setOriginalSelection(
148                textView.getSelectionStart(), textView.getSelectionEnd());
149        if (result != null && text instanceof Spannable) {
150            Selection.setSelection((Spannable) text, result.mStart, result.mEnd);
151            mTextClassification = result.mClassification;
152        } else {
153            mTextClassification = null;
154        }
155        if (mEditor.startSelectionActionModeInternal()) {
156            final SelectionModifierCursorController controller = mEditor.getSelectionController();
157            if (controller != null) {
158                controller.show();
159            }
160            if (result != null) {
161                mSelectionTracker.onSelectionStarted(
162                        result.mStart, result.mEnd, mTextClassificationHelper.getSelectionTag());
163            }
164        }
165        mEditor.setRestartActionModeOnNextRefresh(false);
166        mTextClassificationAsyncTask = null;
167    }
168
169    private void invalidateActionMode(@Nullable SelectionResult result) {
170        mTextClassification = result != null ? result.mClassification : null;
171        final ActionMode actionMode = mEditor.getTextActionMode();
172        if (actionMode != null) {
173            actionMode.invalidate();
174        }
175        final TextView textView = mEditor.getTextView();
176        mSelectionTracker.onSelectionUpdated(
177                textView.getSelectionStart(), textView.getSelectionEnd(),
178                mTextClassificationHelper.getSelectionTag());
179        mTextClassificationAsyncTask = null;
180    }
181
182    private void resetTextClassificationHelper(boolean resetSelectionTag) {
183        final TextView textView = mEditor.getTextView();
184        mTextClassificationHelper.reset(textView.getTextClassifier(), textView.getText(),
185                textView.getSelectionStart(), textView.getSelectionEnd(),
186                resetSelectionTag, textView.getTextLocales());
187    }
188
189    /**
190     * Tracks and logs smart selection changes.
191     * It is important to trigger this object's methods at the appropriate event so that it tracks
192     * smart selection events appropriately.
193     */
194    private static final class SelectionTracker {
195
196        // Log event: Smart selection happened.
197        private static final String LOG_EVENT_MULTI_SELECTION =
198                "textClassifier_multiSelection";
199        private static final String LOG_EVENT_SINGLE_SELECTION =
200                "textClassifier_singleSelection";
201
202        // Log event: Smart selection acted upon.
203        private static final String LOG_EVENT_MULTI_SELECTION_ACTION =
204                "textClassifier_multiSelection_action";
205        private static final String LOG_EVENT_SINGLE_SELECTION_ACTION =
206                "textClassifier_singleSelection_action";
207
208        // Log event: Smart selection was reset to original selection.
209        private static final String LOG_EVENT_MULTI_SELECTION_RESET =
210                "textClassifier_multiSelection_reset";
211
212        // Log event: Smart selection was user modified.
213        private static final String LOG_EVENT_MULTI_SELECTION_MODIFIED =
214                "textClassifier_multiSelection_modified";
215        private static final String LOG_EVENT_SINGLE_SELECTION_MODIFIED =
216                "textClassifier_singleSelection_modified";
217
218        private final TextClassifier mClassifier;
219
220        private int mOriginalStart;
221        private int mOriginalEnd;
222        private int mSelectionStart;
223        private int mSelectionEnd;
224
225        private boolean mMultiSelection;
226        private boolean mClassifierSelection;
227
228        SelectionTracker(TextClassifier classifier) {
229            mClassifier = classifier;
230        }
231
232        /**
233         * Called to initialize the original selection before smart selection is triggered.
234         */
235        public void setOriginalSelection(int selectionStart, int selectionEnd) {
236            mOriginalStart = selectionStart;
237            mOriginalEnd = selectionEnd;
238            resetSelectionFlags();
239        }
240
241        /**
242         * Called when selection action mode is started and the results come from a classifier.
243         * If the selection indices are different from the original selection indices, we have a
244         * smart selection.
245         */
246        public void onSelectionStarted(int selectionStart, int selectionEnd, String logTag) {
247            mClassifierSelection = !logTag.isEmpty();
248            mSelectionStart = selectionStart;
249            mSelectionEnd = selectionEnd;
250            // If the started selection is different from the original selection, we have a
251            // smart selection.
252            mMultiSelection =
253                    mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd;
254            if (mMultiSelection) {
255                mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION);
256            } else if (mClassifierSelection) {
257                mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION);
258            }
259        }
260
261        /**
262         * Called when selection bounds change.
263         */
264        public void onSelectionUpdated(int selectionStart, int selectionEnd, String logTag) {
265            final boolean selectionChanged =
266                    selectionStart != mSelectionStart || selectionEnd != mSelectionEnd;
267            if (selectionChanged) {
268                if (mMultiSelection) {
269                    mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_MODIFIED);
270                } else if (mClassifierSelection) {
271                    mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_MODIFIED);
272                }
273                resetSelectionFlags();
274            }
275        }
276
277        /**
278         * Called when the selection action mode is destroyed.
279         */
280        public void onSelectionDestroyed() {
281            resetSelectionFlags();
282        }
283
284        /**
285         * Logs if the action was taken on a smart selection.
286         */
287        public void onSelectionAction(String logTag) {
288            if (mMultiSelection) {
289                mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_ACTION);
290            } else if (mClassifierSelection) {
291                mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_ACTION);
292            }
293        }
294
295        /**
296         * Returns true if the current smart selection should be reset to normal selection based on
297         * information that has been recorded about the original selection and the smart selection.
298         * The expected UX here is to allow the user to select a word inside of the smart selection
299         * on a single tap.
300         */
301        public boolean resetSelection(int textIndex, Editor editor, String logTag) {
302            final CharSequence text = editor.getTextView().getText();
303            if (mMultiSelection
304                    && textIndex >= mSelectionStart && textIndex <= mSelectionEnd
305                    && text instanceof Spannable) {
306                // Only allow a reset once.
307                resetSelectionFlags();
308                mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_RESET);
309                return editor.selectCurrentWord();
310            }
311            return false;
312        }
313
314        private void resetSelectionFlags() {
315            mMultiSelection = false;
316            mClassifierSelection = false;
317        }
318    }
319
320    /**
321     * AsyncTask for running a query on a background thread and returning the result on the
322     * UiThread. The AsyncTask times out after a specified time, returning a null result if the
323     * query has not yet returned.
324     */
325    private static final class TextClassificationAsyncTask
326            extends AsyncTask<Void, Void, SelectionResult> {
327
328        private final int mTimeOutDuration;
329        private final Supplier<SelectionResult> mSelectionResultSupplier;
330        private final Consumer<SelectionResult> mSelectionResultCallback;
331        private final TextView mTextView;
332        private final String mOriginalText;
333
334        /**
335         * @param textView the TextView
336         * @param timeOut time in milliseconds to timeout the query if it has not completed
337         * @param selectionResultSupplier fetches the selection results. Runs on a background thread
338         * @param selectionResultCallback receives the selection results. Runs on the UiThread
339         */
340        TextClassificationAsyncTask(
341                @NonNull TextView textView, int timeOut,
342                @NonNull Supplier<SelectionResult> selectionResultSupplier,
343                @NonNull Consumer<SelectionResult> selectionResultCallback) {
344            super(textView != null ? textView.getHandler() : null);
345            mTextView = Preconditions.checkNotNull(textView);
346            mTimeOutDuration = timeOut;
347            mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier);
348            mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback);
349            // Make a copy of the original text.
350            mOriginalText = mTextView.getText().toString();
351        }
352
353        @Override
354        @WorkerThread
355        protected SelectionResult doInBackground(Void... params) {
356            final Runnable onTimeOut = this::onTimeOut;
357            mTextView.postDelayed(onTimeOut, mTimeOutDuration);
358            final SelectionResult result = mSelectionResultSupplier.get();
359            mTextView.removeCallbacks(onTimeOut);
360            return result;
361        }
362
363        @Override
364        @UiThread
365        protected void onPostExecute(SelectionResult result) {
366            result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null;
367            mSelectionResultCallback.accept(result);
368        }
369
370        private void onTimeOut() {
371            if (getStatus() == Status.RUNNING) {
372                onPostExecute(null);
373            }
374            cancel(true);
375        }
376    }
377
378    /**
379     * Helper class for querying the TextClassifier.
380     * It trims text so that only text necessary to provide context of the selected text is
381     * sent to the TextClassifier.
382     */
383    private static final class TextClassificationHelper {
384
385        private static final int TRIM_DELTA = 120;  // characters
386
387        private TextClassifier mTextClassifier;
388
389        /** The original TextView text. **/
390        private String mText;
391        /** Start index relative to mText. */
392        private int mSelectionStart;
393        /** End index relative to mText. */
394        private int mSelectionEnd;
395        private LocaleList mLocales;
396        /** A tag for the classifier that returned the latest smart selection. */
397        private String mSelectionTag = "";
398
399        /** Trimmed text starting from mTrimStart in mText. */
400        private CharSequence mTrimmedText;
401        /** Index indicating the start of mTrimmedText in mText. */
402        private int mTrimStart;
403        /** Start index relative to mTrimmedText */
404        private int mRelativeStart;
405        /** End index relative to mTrimmedText */
406        private int mRelativeEnd;
407
408        /** Information about the last classified text to avoid re-running a query. */
409        private CharSequence mLastClassificationText;
410        private int mLastClassificationSelectionStart;
411        private int mLastClassificationSelectionEnd;
412        private LocaleList mLastClassificationLocales;
413        private SelectionResult mLastClassificationResult;
414
415        TextClassificationHelper(TextClassifier textClassifier,
416                CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
417            reset(textClassifier, text, selectionStart, selectionEnd, true, locales);
418        }
419
420        @UiThread
421        public void reset(TextClassifier textClassifier,
422                CharSequence text, int selectionStart, int selectionEnd,
423                boolean resetSelectionTag, LocaleList locales) {
424            mTextClassifier = Preconditions.checkNotNull(textClassifier);
425            mText = Preconditions.checkNotNull(text).toString();
426            mLastClassificationText = null; // invalidate.
427            Preconditions.checkArgument(selectionEnd > selectionStart);
428            mSelectionStart = selectionStart;
429            mSelectionEnd = selectionEnd;
430            mLocales = locales;
431            if (resetSelectionTag) {
432                mSelectionTag = "";
433            }
434        }
435
436        @WorkerThread
437        public SelectionResult classifyText() {
438            if (!Objects.equals(mText, mLastClassificationText)
439                    || mSelectionStart != mLastClassificationSelectionStart
440                    || mSelectionEnd != mLastClassificationSelectionEnd
441                    || !Objects.equals(mLocales, mLastClassificationLocales)) {
442
443                mLastClassificationText = mText;
444                mLastClassificationSelectionStart = mSelectionStart;
445                mLastClassificationSelectionEnd = mSelectionEnd;
446                mLastClassificationLocales = mLocales;
447
448                trimText();
449                mLastClassificationResult = new SelectionResult(
450                        mSelectionStart,
451                        mSelectionEnd,
452                        mTextClassifier.classifyText(
453                                mTrimmedText, mRelativeStart, mRelativeEnd, mLocales));
454
455            }
456            return mLastClassificationResult;
457        }
458
459        @WorkerThread
460        public SelectionResult suggestSelection() {
461            trimText();
462            final TextSelection sel = mTextClassifier.suggestSelection(
463                    mTrimmedText, mRelativeStart, mRelativeEnd, mLocales);
464            mSelectionStart = Math.max(0, sel.getSelectionStartIndex() + mTrimStart);
465            mSelectionEnd = Math.min(mText.length(), sel.getSelectionEndIndex() + mTrimStart);
466            mSelectionTag = sel.getSourceClassifier();
467            return classifyText();
468        }
469
470        String getSelectionTag() {
471            return mSelectionTag;
472        }
473
474        private void trimText() {
475            mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA);
476            final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA);
477            mTrimmedText = mText.subSequence(mTrimStart, referenceEnd);
478            mRelativeStart = mSelectionStart - mTrimStart;
479            mRelativeEnd = mSelectionEnd - mTrimStart;
480        }
481    }
482
483    /**
484     * Selection result.
485     */
486    private static final class SelectionResult {
487        private final int mStart;
488        private final int mEnd;
489        private final TextClassification mClassification;
490
491        SelectionResult(int start, int end, TextClassification classification) {
492            mStart = start;
493            mEnd = end;
494            mClassification = Preconditions.checkNotNull(classification);
495        }
496    }
497}
498