OSDN Git Service

003db061c1404431297ee5bae30fa1e527ec0c0e
[android-x86/frameworks-base.git] / core / java / android / widget / SelectionActionModeHelper.java
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.TextClassificationResult;
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.function.Consumer;
37 import java.util.function.Supplier;
38
39 /**
40  * Helper class for starting selection action mode
41  * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
42  */
43 @UiThread
44 final class SelectionActionModeHelper {
45
46     /**
47      * Maximum time (in milliseconds) to wait for a result before timing out.
48      */
49     // TODO: Consider making this a ViewConfiguration.
50     private static final int TIMEOUT_DURATION = 200;
51
52     private final Editor mEditor;
53     private final TextClassificationHelper mTextClassificationHelper;
54
55     private TextClassificationResult mTextClassificationResult;
56     private AsyncTask mTextClassificationAsyncTask;
57
58     private final SelectionInfo mSelectionInfo = new SelectionInfo();
59
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());
65     }
66
67     public void startActionModeAsync() {
68         cancelAsyncTask();
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);
73         } else {
74             resetTextClassificationHelper();
75             mTextClassificationAsyncTask = new TextClassificationAsyncTask(
76                     mEditor.getTextView(), TIMEOUT_DURATION,
77                     mTextClassificationHelper::suggestSelection, this::startActionMode)
78                     .execute();
79         }
80     }
81
82     public void startActionMode() {
83         startActionMode(null);
84     }
85
86     public void invalidateActionModeAsync() {
87         cancelAsyncTask();
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);
92         } else {
93             resetTextClassificationHelper();
94             mTextClassificationAsyncTask = new TextClassificationAsyncTask(
95                     mEditor.getTextView(), TIMEOUT_DURATION,
96                     mTextClassificationHelper::classifyText, this::invalidateActionMode)
97                     .execute();
98         }
99     }
100
101     public boolean resetOriginalSelection(int textIndex) {
102         if (mSelectionInfo.resetOriginalSelection(textIndex, mEditor.getTextView().getText())) {
103             invalidateActionModeAsync();
104             return true;
105         }
106         return false;
107     }
108
109     @Nullable
110     public TextClassificationResult getTextClassificationResult() {
111         return mTextClassificationResult;
112     }
113
114     public void onDestroyActionMode() {
115         mSelectionInfo.onSelectionDestroyed();
116         cancelAsyncTask();
117     }
118
119     private void cancelAsyncTask() {
120         if (mTextClassificationAsyncTask != null) {
121             mTextClassificationAsyncTask.cancel(true);
122             mTextClassificationAsyncTask = null;
123         }
124         mTextClassificationResult = null;
125     }
126
127     private boolean isNoOpTextClassifier() {
128         return mEditor.getTextView().getTextClassifier() == TextClassifier.NO_OP;
129     }
130
131     private boolean hasSelection() {
132         final TextView textView = mEditor.getTextView();
133         return textView.getSelectionEnd() > textView.getSelectionStart();
134     }
135
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;
144         } else {
145             mTextClassificationResult = null;
146         }
147         if (mEditor.startSelectionActionModeInternal()) {
148             final SelectionModifierCursorController controller = mEditor.getSelectionController();
149             if (controller != null) {
150                 controller.show();
151             }
152             if (result != null) {
153                 mSelectionInfo.onSelectionStarted(result.mStart, result.mEnd);
154             }
155         }
156         mEditor.setRestartActionModeOnNextRefresh(false);
157         mTextClassificationAsyncTask = null;
158     }
159
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();
165         }
166         final TextView textView = mEditor.getTextView();
167         mSelectionInfo.onSelectionUpdated(textView.getSelectionStart(), textView.getSelectionEnd());
168         mTextClassificationAsyncTask = null;
169     }
170
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());
176     }
177
178     /**
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.
183      */
184     private static final class SelectionInfo {
185
186         private int mOriginalStart;
187         private int mOriginalEnd;
188         private int mSelectionStart;
189         private int mSelectionEnd;
190
191         private boolean mResetOriginal;
192
193         public void setOriginalSelection(int selectionStart, int selectionEnd) {
194             mOriginalStart = selectionStart;
195             mOriginalEnd = selectionEnd;
196             mResetOriginal = false;
197         }
198
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;
204         }
205
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;
209         }
210
211         public void onSelectionDestroyed() {
212             mResetOriginal = false;
213         }
214
215         public boolean resetOriginalSelection(int textIndex, CharSequence text) {
216             if (mResetOriginal
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;
222                 return true;
223             }
224             return false;
225         }
226     }
227
228     /**
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.
232      */
233     private static final class TextClassificationAsyncTask
234             extends AsyncTask<Void, Void, SelectionResult> {
235
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;
241
242         /**
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
247          */
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();
258         }
259
260         @Override
261         @WorkerThread
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);
267             return result;
268         }
269
270         @Override
271         @UiThread
272         protected void onPostExecute(SelectionResult result) {
273             result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null;
274             mSelectionResultCallback.accept(result);
275         }
276
277         private void onTimeOut() {
278             if (getStatus() == Status.RUNNING) {
279                 onPostExecute(null);
280             }
281             cancel(true);
282         }
283     }
284
285     /**
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.
289      */
290     private static final class TextClassificationHelper {
291
292         private static final int TRIM_DELTA = 120;  // characters
293
294         private TextClassifier mTextClassifier;
295
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;
303
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;
312
313         TextClassificationHelper(TextClassifier textClassifier,
314                 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
315             reset(textClassifier, text, selectionStart, selectionEnd, locales);
316         }
317
318         @UiThread
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;
326             mLocales = locales;
327         }
328
329         @WorkerThread
330         public SelectionResult classifyText() {
331             trimText();
332             return new SelectionResult(
333                     mSelectionStart,
334                     mSelectionEnd,
335                     mTextClassifier.getTextClassificationResult(
336                             mTrimmedText, mRelativeStart, mRelativeEnd, mLocales));
337         }
338
339         @WorkerThread
340         public SelectionResult suggestSelection() {
341             trimText();
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();
347         }
348
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;
355         }
356     }
357
358     /**
359      * Selection result.
360      */
361     private static final class SelectionResult {
362         private final int mStart;
363         private final int mEnd;
364         private final TextClassificationResult mResult;
365
366         SelectionResult(int start, int end, TextClassificationResult result) {
367             mStart = start;
368             mEnd = end;
369             mResult = Preconditions.checkNotNull(result);
370         }
371     }
372 }