• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.widget;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.annotation.UiThread;
22 import android.annotation.WorkerThread;
23 import android.os.AsyncTask;
24 import android.os.LocaleList;
25 import android.text.Selection;
26 import android.text.Spannable;
27 import android.text.TextUtils;
28 import android.view.ActionMode;
29 import android.view.textclassifier.TextClassification;
30 import android.view.textclassifier.TextClassifier;
31 import android.view.textclassifier.TextSelection;
32 import android.widget.Editor.SelectionModifierCursorController;
33 
34 import com.android.internal.util.Preconditions;
35 
36 import java.util.Objects;
37 import java.util.function.Consumer;
38 import 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
45 final 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 
SelectionActionModeHelper(@onNull Editor editor)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 
startActionModeAsync(boolean adjustSelection)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 
invalidateActionModeAsync()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 
onSelectionAction()104     public void onSelectionAction() {
105         mSelectionTracker.onSelectionAction(mTextClassificationHelper.getSelectionTag());
106     }
107 
resetSelection(int textIndex)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
getTextClassification()118     public TextClassification getTextClassification() {
119         return mTextClassification;
120     }
121 
onDestroyActionMode()122     public void onDestroyActionMode() {
123         mSelectionTracker.onSelectionDestroyed();
124         cancelAsyncTask();
125     }
126 
cancelAsyncTask()127     private void cancelAsyncTask() {
128         if (mTextClassificationAsyncTask != null) {
129             mTextClassificationAsyncTask.cancel(true);
130             mTextClassificationAsyncTask = null;
131         }
132         mTextClassification = null;
133     }
134 
isNoOpTextClassifier()135     private boolean isNoOpTextClassifier() {
136         return mEditor.getTextView().getTextClassifier() == TextClassifier.NO_OP;
137     }
138 
hasSelection()139     private boolean hasSelection() {
140         final TextView textView = mEditor.getTextView();
141         return textView.getSelectionEnd() > textView.getSelectionStart();
142     }
143 
startActionMode(@ullable SelectionResult result)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 
invalidateActionMode(@ullable SelectionResult result)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 
resetTextClassificationHelper(boolean resetSelectionTag)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 
SelectionTracker(TextClassifier classifier)228         SelectionTracker(TextClassifier classifier) {
229             mClassifier = classifier;
230         }
231 
232         /**
233          * Called to initialize the original selection before smart selection is triggered.
234          */
setOriginalSelection(int selectionStart, int selectionEnd)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          */
onSelectionStarted(int selectionStart, int selectionEnd, String logTag)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          */
onSelectionUpdated(int selectionStart, int selectionEnd, String logTag)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          */
onSelectionDestroyed()280         public void onSelectionDestroyed() {
281             resetSelectionFlags();
282         }
283 
284         /**
285          * Logs if the action was taken on a smart selection.
286          */
onSelectionAction(String logTag)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          */
resetSelection(int textIndex, Editor editor, String logTag)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 
resetSelectionFlags()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          */
TextClassificationAsyncTask( @onNull TextView textView, int timeOut, @NonNull Supplier<SelectionResult> selectionResultSupplier, @NonNull Consumer<SelectionResult> selectionResultCallback)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
doInBackground(Void... params)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
onPostExecute(SelectionResult result)365         protected void onPostExecute(SelectionResult result) {
366             result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null;
367             mSelectionResultCallback.accept(result);
368         }
369 
onTimeOut()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 
TextClassificationHelper(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)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
reset(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, boolean resetSelectionTag, LocaleList locales)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
classifyText()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
suggestSelection()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 
getSelectionTag()470         String getSelectionTag() {
471             return mSelectionTag;
472         }
473 
trimText()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 
SelectionResult(int start, int end, TextClassification classification)491         SelectionResult(int start, int end, TextClassification classification) {
492             mStart = start;
493             mEnd = end;
494             mClassification = Preconditions.checkNotNull(classification);
495         }
496     }
497 }
498