2 * Copyright (C) 2017 The Android Open Source Project
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
17 package android.widget;
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.TextClassificationResult;
30 import android.view.textclassifier.TextClassifier;
31 import android.view.textclassifier.TextSelection;
32 import android.widget.Editor.SelectionModifierCursorController;
34 import com.android.internal.util.Preconditions;
36 import java.util.function.Consumer;
37 import java.util.function.Supplier;
40 * Helper class for starting selection action mode
41 * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
44 final class SelectionActionModeHelper {
47 * Maximum time (in milliseconds) to wait for a result before timing out.
49 // TODO: Consider making this a ViewConfiguration.
50 private static final int TIMEOUT_DURATION = 200;
52 private final Editor mEditor;
53 private final TextClassificationHelper mTextClassificationHelper;
55 private TextClassificationResult mTextClassificationResult;
56 private AsyncTask mTextClassificationAsyncTask;
58 private final SelectionInfo mSelectionInfo = new SelectionInfo();
60 SelectionActionModeHelper(@NonNull Editor editor) {
61 mEditor = Preconditions.checkNotNull(editor);
62 final TextView textView = mEditor.getTextView();
63 mTextClassificationHelper = new TextClassificationHelper(
64 textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales());
67 public void startActionModeAsync() {
69 if (isNoOpTextClassifier() || !hasSelection()) {
70 // No need to make an async call for a no-op TextClassifier.
71 // Do not call the TextClassifier if there is no selection.
72 startActionMode(null);
74 resetTextClassificationHelper();
75 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
76 mEditor.getTextView(), TIMEOUT_DURATION,
77 mTextClassificationHelper::suggestSelection, this::startActionMode)
82 public void startActionMode() {
83 startActionMode(null);
86 public void invalidateActionModeAsync() {
88 if (isNoOpTextClassifier() || !hasSelection()) {
89 // No need to make an async call for a no-op TextClassifier.
90 // Do not call the TextClassifier if there is no selection.
91 invalidateActionMode(null);
93 resetTextClassificationHelper();
94 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
95 mEditor.getTextView(), TIMEOUT_DURATION,
96 mTextClassificationHelper::classifyText, this::invalidateActionMode)
101 public boolean resetOriginalSelection(int textIndex) {
102 if (mSelectionInfo.resetOriginalSelection(textIndex, mEditor.getTextView().getText())) {
103 invalidateActionModeAsync();
110 public TextClassificationResult getTextClassificationResult() {
111 return mTextClassificationResult;
114 public void onDestroyActionMode() {
115 mSelectionInfo.onSelectionDestroyed();
119 private void cancelAsyncTask() {
120 if (mTextClassificationAsyncTask != null) {
121 mTextClassificationAsyncTask.cancel(true);
122 mTextClassificationAsyncTask = null;
124 mTextClassificationResult = null;
127 private boolean isNoOpTextClassifier() {
128 return mEditor.getTextView().getTextClassifier() == TextClassifier.NO_OP;
131 private boolean hasSelection() {
132 final TextView textView = mEditor.getTextView();
133 return textView.getSelectionEnd() > textView.getSelectionStart();
136 private void startActionMode(@Nullable SelectionResult result) {
137 final TextView textView = mEditor.getTextView();
138 final CharSequence text = textView.getText();
139 mSelectionInfo.setOriginalSelection(
140 textView.getSelectionStart(), textView.getSelectionEnd());
141 if (result != null && text instanceof Spannable) {
142 Selection.setSelection((Spannable) text, result.mStart, result.mEnd);
143 mTextClassificationResult = result.mResult;
145 mTextClassificationResult = null;
147 if (mEditor.startSelectionActionModeInternal()) {
148 final SelectionModifierCursorController controller = mEditor.getSelectionController();
149 if (controller != null) {
152 if (result != null) {
153 mSelectionInfo.onSelectionStarted(result.mStart, result.mEnd);
156 mEditor.setRestartActionModeOnNextRefresh(false);
157 mTextClassificationAsyncTask = null;
160 private void invalidateActionMode(@Nullable SelectionResult result) {
161 mTextClassificationResult = result != null ? result.mResult : null;
162 final ActionMode actionMode = mEditor.getTextActionMode();
163 if (actionMode != null) {
164 actionMode.invalidate();
166 final TextView textView = mEditor.getTextView();
167 mSelectionInfo.onSelectionUpdated(textView.getSelectionStart(), textView.getSelectionEnd());
168 mTextClassificationAsyncTask = null;
171 private void resetTextClassificationHelper() {
172 final TextView textView = mEditor.getTextView();
173 mTextClassificationHelper.reset(textView.getTextClassifier(), textView.getText(),
174 textView.getSelectionStart(), textView.getSelectionEnd(),
175 textView.getTextLocales());
179 * Holds information about the selection and uses it to decide on whether or not to update
180 * the selection when resetOriginalSelection is called.
181 * The expected UX here is to allow the user to re-snap the selection back to the original word
182 * that was selected with one tap on that word.
184 private static final class SelectionInfo {
186 private int mOriginalStart;
187 private int mOriginalEnd;
188 private int mSelectionStart;
189 private int mSelectionEnd;
191 private boolean mResetOriginal;
193 public void setOriginalSelection(int selectionStart, int selectionEnd) {
194 mOriginalStart = selectionStart;
195 mOriginalEnd = selectionEnd;
196 mResetOriginal = false;
199 public void onSelectionStarted(int selectionStart, int selectionEnd) {
200 // Set the reset flag to true if the selection changed.
201 mSelectionStart = selectionStart;
202 mSelectionEnd = selectionEnd;
203 mResetOriginal = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd;
206 public void onSelectionUpdated(int selectionStart, int selectionEnd) {
207 // If the selection did not change, maintain the reset state. Otherwise, disable reset.
208 mResetOriginal &= selectionStart == mSelectionStart && selectionEnd == mSelectionEnd;
211 public void onSelectionDestroyed() {
212 mResetOriginal = false;
215 public boolean resetOriginalSelection(int textIndex, CharSequence text) {
217 && textIndex >= mOriginalStart && textIndex <= mOriginalEnd
218 && text instanceof Spannable) {
219 Selection.setSelection((Spannable) text, mOriginalStart, mOriginalEnd);
220 // Only allow a reset once.
221 mResetOriginal = false;
229 * AsyncTask for running a query on a background thread and returning the result on the
230 * UiThread. The AsyncTask times out after a specified time, returning a null result if the
231 * query has not yet returned.
233 private static final class TextClassificationAsyncTask
234 extends AsyncTask<Void, Void, SelectionResult> {
236 private final int mTimeOutDuration;
237 private final Supplier<SelectionResult> mSelectionResultSupplier;
238 private final Consumer<SelectionResult> mSelectionResultCallback;
239 private final TextView mTextView;
240 private final String mOriginalText;
243 * @param textView the TextView
244 * @param timeOut time in milliseconds to timeout the query if it has not completed
245 * @param selectionResultSupplier fetches the selection results. Runs on a background thread
246 * @param selectionResultCallback receives the selection results. Runs on the UiThread
248 TextClassificationAsyncTask(
249 @NonNull TextView textView, int timeOut,
250 @NonNull Supplier<SelectionResult> selectionResultSupplier,
251 @NonNull Consumer<SelectionResult> selectionResultCallback) {
252 mTextView = Preconditions.checkNotNull(textView);
253 mTimeOutDuration = timeOut;
254 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier);
255 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback);
256 // Make a copy of the original text.
257 mOriginalText = mTextView.getText().toString();
262 protected SelectionResult doInBackground(Void... params) {
263 final Runnable onTimeOut = this::onTimeOut;
264 mTextView.postDelayed(onTimeOut, mTimeOutDuration);
265 final SelectionResult result = mSelectionResultSupplier.get();
266 mTextView.removeCallbacks(onTimeOut);
272 protected void onPostExecute(SelectionResult result) {
273 result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null;
274 mSelectionResultCallback.accept(result);
277 private void onTimeOut() {
278 if (getStatus() == Status.RUNNING) {
286 * Helper class for querying the TextClassifier.
287 * It trims text so that only text necessary to provide context of the selected text is
288 * sent to the TextClassifier.
290 private static final class TextClassificationHelper {
292 private static final int TRIM_DELTA = 120; // characters
294 private TextClassifier mTextClassifier;
296 /** The original TextView text. **/
297 private String mText;
298 /** Start index relative to mText. */
299 private int mSelectionStart;
300 /** End index relative to mText. */
301 private int mSelectionEnd;
302 private LocaleList mLocales;
304 /** Trimmed text starting from mTrimStart in mText. */
305 private CharSequence mTrimmedText;
306 /** Index indicating the start of mTrimmedText in mText. */
307 private int mTrimStart;
308 /** Start index relative to mTrimmedText */
309 private int mRelativeStart;
310 /** End index relative to mTrimmedText */
311 private int mRelativeEnd;
313 TextClassificationHelper(TextClassifier textClassifier,
314 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
315 reset(textClassifier, text, selectionStart, selectionEnd, locales);
319 public void reset(TextClassifier textClassifier,
320 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
321 mTextClassifier = Preconditions.checkNotNull(textClassifier);
322 mText = Preconditions.checkNotNull(text).toString();
323 Preconditions.checkArgument(selectionEnd > selectionStart);
324 mSelectionStart = selectionStart;
325 mSelectionEnd = selectionEnd;
330 public SelectionResult classifyText() {
332 return new SelectionResult(
335 mTextClassifier.getTextClassificationResult(
336 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales));
340 public SelectionResult suggestSelection() {
342 final TextSelection sel = mTextClassifier.suggestSelection(
343 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales);
344 mSelectionStart = Math.max(0, sel.getSelectionStartIndex() + mTrimStart);
345 mSelectionEnd = Math.min(mText.length(), sel.getSelectionEndIndex() + mTrimStart);
346 return classifyText();
349 private void trimText() {
350 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA);
351 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA);
352 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd);
353 mRelativeStart = mSelectionStart - mTrimStart;
354 mRelativeEnd = mSelectionEnd - mTrimStart;
361 private static final class SelectionResult {
362 private final int mStart;
363 private final int mEnd;
364 private final TextClassificationResult mResult;
366 SelectionResult(int start, int end, TextClassificationResult result) {
369 mResult = Preconditions.checkNotNull(result);