blob: 2561ffe572ab1771c9433f847cec79840861cff7 [file] [log] [blame]
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -08001/*
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;
Petar Šegina91df3f92017-08-15 16:20:43 +010023import android.graphics.PointF;
Petar Šegina701ba332017-08-01 17:57:26 +010024import android.graphics.RectF;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080025import android.os.AsyncTask;
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +000026import android.os.LocaleList;
Petar Šegina701ba332017-08-01 17:57:26 +010027import android.text.Layout;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080028import android.text.Selection;
29import android.text.Spannable;
30import android.text.TextUtils;
31import android.view.ActionMode;
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +010032import android.view.textclassifier.TextClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080033import android.view.textclassifier.TextClassifier;
34import android.view.textclassifier.TextSelection;
35import android.widget.Editor.SelectionModifierCursorController;
36
Petar Šegina91df3f92017-08-15 16:20:43 +010037import com.android.internal.annotations.VisibleForTesting;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080038import com.android.internal.util.Preconditions;
39
Petar Šegina701ba332017-08-01 17:57:26 +010040import java.util.ArrayList;
41import java.util.List;
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +010042import java.util.Objects;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080043import java.util.function.Consumer;
44import java.util.function.Supplier;
45
46/**
47 * Helper class for starting selection action mode
48 * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
Petar Šegina91df3f92017-08-15 16:20:43 +010049 * @hide
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080050 */
51@UiThread
Petar Šegina91df3f92017-08-15 16:20:43 +010052@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080053final class SelectionActionModeHelper {
54
55 /**
56 * Maximum time (in milliseconds) to wait for a result before timing out.
57 */
58 // TODO: Consider making this a ViewConfiguration.
59 private static final int TIMEOUT_DURATION = 200;
60
Petar Šeginae2f82632017-08-14 17:09:01 +010061 private static final boolean SMART_SELECT_ANIMATION_ENABLED = true;
Petar Šegina701ba332017-08-01 17:57:26 +010062
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080063 private final Editor mEditor;
64 private final TextClassificationHelper mTextClassificationHelper;
65
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +010066 private TextClassification mTextClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080067 private AsyncTask mTextClassificationAsyncTask;
68
Abodunrinwa Toki1d775572017-05-08 16:03:01 +010069 private final SelectionTracker mSelectionTracker;
Petar Šegina701ba332017-08-01 17:57:26 +010070 private final SmartSelectSprite mSmartSelectSprite;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +000071
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080072 SelectionActionModeHelper(@NonNull Editor editor) {
73 mEditor = Preconditions.checkNotNull(editor);
74 final TextView textView = mEditor.getTextView();
75 mTextClassificationHelper = new TextClassificationHelper(
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +000076 textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales());
Abodunrinwa Toki1d775572017-05-08 16:03:01 +010077 mSelectionTracker = new SelectionTracker(textView.getTextClassifier());
Petar Šegina701ba332017-08-01 17:57:26 +010078
79 if (SMART_SELECT_ANIMATION_ENABLED) {
80 mSmartSelectSprite = new SmartSelectSprite(textView);
81 } else {
82 mSmartSelectSprite = null;
83 }
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080084 }
85
Abodunrinwa Toki66c16272017-05-03 20:22:55 +010086 public void startActionModeAsync(boolean adjustSelection) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080087 cancelAsyncTask();
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +010088 if (skipTextClassification()) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080089 startActionMode(null);
90 } else {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +010091 resetTextClassificationHelper(true /* resetSelectionTag */);
Makoto Onuki1488a3a2017-05-24 12:25:46 -070092 final TextView tv = mEditor.getTextView();
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080093 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
Makoto Onuki1488a3a2017-05-24 12:25:46 -070094 tv,
Abodunrinwa Toki66c16272017-05-03 20:22:55 +010095 TIMEOUT_DURATION,
96 adjustSelection
97 ? mTextClassificationHelper::suggestSelection
98 : mTextClassificationHelper::classifyText,
Petar Šegina701ba332017-08-01 17:57:26 +010099 mSmartSelectSprite != null
100 ? this::startActionModeWithSmartSelectAnimation
101 : this::startActionMode)
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800102 .execute();
103 }
104 }
105
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800106 public void invalidateActionModeAsync() {
107 cancelAsyncTask();
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100108 if (skipTextClassification()) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800109 invalidateActionMode(null);
110 } else {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100111 resetTextClassificationHelper(false /* resetSelectionTag */);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800112 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
113 mEditor.getTextView(), TIMEOUT_DURATION,
114 mTextClassificationHelper::classifyText, this::invalidateActionMode)
115 .execute();
116 }
117 }
118
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100119 public void onSelectionAction() {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100120 mSelectionTracker.onSelectionAction(mTextClassificationHelper.getSelectionTag());
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100121 }
122
Abodunrinwa Toki8dd3a742017-05-02 18:44:19 +0100123 public boolean resetSelection(int textIndex) {
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100124 if (mSelectionTracker.resetSelection(
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100125 textIndex, mEditor, mTextClassificationHelper.getSelectionTag())) {
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000126 invalidateActionModeAsync();
127 return true;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800128 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000129 return false;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800130 }
131
132 @Nullable
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100133 public TextClassification getTextClassification() {
134 return mTextClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800135 }
136
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000137 public void onDestroyActionMode() {
Petar Šegina701ba332017-08-01 17:57:26 +0100138 cancelSmartSelectAnimation();
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100139 mSelectionTracker.onSelectionDestroyed();
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000140 cancelAsyncTask();
141 }
142
143 private void cancelAsyncTask() {
144 if (mTextClassificationAsyncTask != null) {
145 mTextClassificationAsyncTask.cancel(true);
146 mTextClassificationAsyncTask = null;
147 }
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100148 mTextClassification = null;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000149 }
150
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100151 private boolean skipTextClassification() {
Abodunrinwa Toki792d8202017-03-06 23:51:11 +0000152 final TextView textView = mEditor.getTextView();
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100153 // No need to make an async call for a no-op TextClassifier.
154 final boolean noOpTextClassifier = textView.getTextClassifier() == TextClassifier.NO_OP;
155 // Do not call the TextClassifier if there is no selection.
156 final boolean noSelection = textView.getSelectionEnd() == textView.getSelectionStart();
157 // Do not call the TextClassifier if this is a password field.
158 final boolean password = textView.hasPasswordTransformationMethod()
159 || TextView.isPasswordInputType(textView.getInputType());
160 return noOpTextClassifier || noSelection || password;
Abodunrinwa Toki792d8202017-03-06 23:51:11 +0000161 }
162
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800163 private void startActionMode(@Nullable SelectionResult result) {
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000164 final TextView textView = mEditor.getTextView();
165 final CharSequence text = textView.getText();
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100166 mSelectionTracker.setOriginalSelection(
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000167 textView.getSelectionStart(), textView.getSelectionEnd());
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800168 if (result != null && text instanceof Spannable) {
169 Selection.setSelection((Spannable) text, result.mStart, result.mEnd);
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100170 mTextClassification = result.mClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800171 } else {
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100172 mTextClassification = null;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800173 }
174 if (mEditor.startSelectionActionModeInternal()) {
175 final SelectionModifierCursorController controller = mEditor.getSelectionController();
176 if (controller != null) {
177 controller.show();
178 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000179 if (result != null) {
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100180 mSelectionTracker.onSelectionStarted(
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100181 result.mStart, result.mEnd, mTextClassificationHelper.getSelectionTag());
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000182 }
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800183 }
184 mEditor.setRestartActionModeOnNextRefresh(false);
185 mTextClassificationAsyncTask = null;
186 }
187
Petar Šegina701ba332017-08-01 17:57:26 +0100188 private void startActionModeWithSmartSelectAnimation(@Nullable SelectionResult result) {
189 final TextView textView = mEditor.getTextView();
190 final Layout layout = textView.getLayout();
191
192 final Runnable onAnimationEndCallback = () -> startActionMode(result);
193 // TODO do not trigger the animation if the change included only non-printable characters
194 final boolean didSelectionChange =
Petar Šeginac6381f72017-08-22 16:15:05 +0100195 result != null && (textView.getSelectionStart() != result.mStart
196 || textView.getSelectionEnd() != result.mEnd);
Petar Šegina701ba332017-08-01 17:57:26 +0100197
198 if (!didSelectionChange) {
199 onAnimationEndCallback.run();
200 return;
201 }
202
203 final List<RectF> selectionRectangles =
204 convertSelectionToRectangles(layout, result.mStart, result.mEnd);
205
206 /*
Petar Šegina855396d2017-08-14 17:01:45 +0100207 * Do not run the Smart Select animation when there are multiple lines involved, as this
208 * behavior is currently broken.
209 *
210 * TODO fix Smart Select Animation when the selection spans multiple lines
211 */
212 if (selectionRectangles.size() != 1) {
213 onAnimationEndCallback.run();
214 return;
215 }
216
217 /*
Petar Šegina701ba332017-08-01 17:57:26 +0100218 * TODO Figure out a more robust approach for this
219 * We have to translate all the generated rectangles by the top-left padding of the
220 * TextView because the padding influences the rendering of the ViewOverlay, but is not
221 * taken into account when generating the selection path rectangles.
222 */
223 for (RectF rectangle : selectionRectangles) {
224 rectangle.left += textView.getPaddingLeft();
225 rectangle.right += textView.getPaddingLeft();
226 rectangle.top += textView.getPaddingTop();
227 rectangle.bottom += textView.getPaddingTop();
228 }
229
Petar Šegina91df3f92017-08-15 16:20:43 +0100230 final PointF touchPoint = new PointF(
231 mEditor.getLastUpPositionX(),
232 mEditor.getLastUpPositionY());
Petar Šegina701ba332017-08-01 17:57:26 +0100233
Petar Šegina91df3f92017-08-15 16:20:43 +0100234 final PointF animationStartPoint =
235 movePointInsideNearestRectangle(touchPoint, selectionRectangles);
Petar Šegina701ba332017-08-01 17:57:26 +0100236
237 mSmartSelectSprite.startAnimation(
Petar Šegina91df3f92017-08-15 16:20:43 +0100238 animationStartPoint,
Petar Šegina701ba332017-08-01 17:57:26 +0100239 selectionRectangles,
240 onAnimationEndCallback);
241 }
242
243 private List<RectF> convertSelectionToRectangles(final Layout layout, final int start,
244 final int end) {
245 final List<RectF> result = new ArrayList<>();
246 // TODO filter out invalid rectangles
247 // getSelection might give us overlapping and zero-dimension rectangles which will interfere
248 // with the Smart Select animation
249 layout.getSelection(start, end, (left, top, right, bottom) ->
250 result.add(new RectF(left, top, right, bottom)));
251 return result;
252 }
253
Petar Šegina91df3f92017-08-15 16:20:43 +0100254 /** @hide */
255 @VisibleForTesting
256 public static PointF movePointInsideNearestRectangle(final PointF point,
257 final List<RectF> rectangles) {
258 float bestX = -1;
259 float bestY = -1;
260 double bestDistance = Double.MAX_VALUE;
261
262 for (final RectF rectangle : rectangles) {
263 final float candidateY = rectangle.centerY();
264 final float candidateX;
265
266 if (point.x > rectangle.right) {
267 candidateX = rectangle.right;
268 } else if (point.x < rectangle.left) {
269 candidateX = rectangle.left;
270 } else {
271 candidateX = point.x;
272 }
273
274 final double candidateDistance = Math.pow(point.x - candidateX, 2)
275 + Math.pow(point.y - candidateY, 2);
276
277 if (candidateDistance < bestDistance) {
278 bestX = candidateX;
279 bestY = candidateY;
280 bestDistance = candidateDistance;
281 }
282 }
283
284 return new PointF(bestX, bestY);
285 }
286
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800287 private void invalidateActionMode(@Nullable SelectionResult result) {
Petar Šegina701ba332017-08-01 17:57:26 +0100288 cancelSmartSelectAnimation();
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100289 mTextClassification = result != null ? result.mClassification : null;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800290 final ActionMode actionMode = mEditor.getTextActionMode();
291 if (actionMode != null) {
292 actionMode.invalidate();
293 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000294 final TextView textView = mEditor.getTextView();
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100295 mSelectionTracker.onSelectionUpdated(
296 textView.getSelectionStart(), textView.getSelectionEnd(),
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100297 mTextClassificationHelper.getSelectionTag());
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800298 mTextClassificationAsyncTask = null;
299 }
300
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100301 private void resetTextClassificationHelper(boolean resetSelectionTag) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800302 final TextView textView = mEditor.getTextView();
303 mTextClassificationHelper.reset(textView.getTextClassifier(), textView.getText(),
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +0000304 textView.getSelectionStart(), textView.getSelectionEnd(),
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100305 resetSelectionTag, textView.getTextLocales());
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800306 }
307
Petar Šegina701ba332017-08-01 17:57:26 +0100308 private void cancelSmartSelectAnimation() {
309 if (mSmartSelectSprite != null) {
310 mSmartSelectSprite.cancelAnimation();
311 }
312 }
313
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800314 /**
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100315 * Tracks and logs smart selection changes.
316 * It is important to trigger this object's methods at the appropriate event so that it tracks
317 * smart selection events appropriately.
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000318 */
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100319 private static final class SelectionTracker {
320
321 // Log event: Smart selection happened.
322 private static final String LOG_EVENT_MULTI_SELECTION =
323 "textClassifier_multiSelection";
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100324 private static final String LOG_EVENT_SINGLE_SELECTION =
325 "textClassifier_singleSelection";
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100326
327 // Log event: Smart selection acted upon.
328 private static final String LOG_EVENT_MULTI_SELECTION_ACTION =
329 "textClassifier_multiSelection_action";
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100330 private static final String LOG_EVENT_SINGLE_SELECTION_ACTION =
331 "textClassifier_singleSelection_action";
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100332
333 // Log event: Smart selection was reset to original selection.
334 private static final String LOG_EVENT_MULTI_SELECTION_RESET =
335 "textClassifier_multiSelection_reset";
336
337 // Log event: Smart selection was user modified.
338 private static final String LOG_EVENT_MULTI_SELECTION_MODIFIED =
339 "textClassifier_multiSelection_modified";
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100340 private static final String LOG_EVENT_SINGLE_SELECTION_MODIFIED =
341 "textClassifier_singleSelection_modified";
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100342
343 private final TextClassifier mClassifier;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000344
345 private int mOriginalStart;
346 private int mOriginalEnd;
347 private int mSelectionStart;
348 private int mSelectionEnd;
349
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100350 private boolean mMultiSelection;
351 private boolean mClassifierSelection;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000352
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100353 SelectionTracker(TextClassifier classifier) {
354 mClassifier = classifier;
355 }
356
357 /**
358 * Called to initialize the original selection before smart selection is triggered.
359 */
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000360 public void setOriginalSelection(int selectionStart, int selectionEnd) {
361 mOriginalStart = selectionStart;
362 mOriginalEnd = selectionEnd;
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100363 resetSelectionFlags();
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000364 }
365
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100366 /**
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100367 * Called when selection action mode is started and the results come from a classifier.
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100368 * If the selection indices are different from the original selection indices, we have a
369 * smart selection.
370 */
371 public void onSelectionStarted(int selectionStart, int selectionEnd, String logTag) {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100372 mClassifierSelection = !logTag.isEmpty();
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000373 mSelectionStart = selectionStart;
374 mSelectionEnd = selectionEnd;
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100375 // If the started selection is different from the original selection, we have a
376 // smart selection.
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100377 mMultiSelection =
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100378 mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd;
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100379 if (mMultiSelection) {
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100380 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION);
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100381 } else if (mClassifierSelection) {
382 mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100383 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000384 }
385
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100386 /**
387 * Called when selection bounds change.
388 */
389 public void onSelectionUpdated(int selectionStart, int selectionEnd, String logTag) {
390 final boolean selectionChanged =
391 selectionStart != mSelectionStart || selectionEnd != mSelectionEnd;
392 if (selectionChanged) {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100393 if (mMultiSelection) {
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100394 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_MODIFIED);
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100395 } else if (mClassifierSelection) {
396 mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_MODIFIED);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100397 }
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100398 resetSelectionFlags();
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100399 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000400 }
401
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100402 /**
403 * Called when the selection action mode is destroyed.
404 */
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000405 public void onSelectionDestroyed() {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100406 resetSelectionFlags();
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000407 }
408
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100409 /**
410 * Logs if the action was taken on a smart selection.
411 */
412 public void onSelectionAction(String logTag) {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100413 if (mMultiSelection) {
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100414 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_ACTION);
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100415 } else if (mClassifierSelection) {
416 mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_ACTION);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100417 }
418 }
419
420 /**
421 * Returns true if the current smart selection should be reset to normal selection based on
422 * information that has been recorded about the original selection and the smart selection.
423 * The expected UX here is to allow the user to select a word inside of the smart selection
424 * on a single tap.
425 */
426 public boolean resetSelection(int textIndex, Editor editor, String logTag) {
Abodunrinwa Toki8dd3a742017-05-02 18:44:19 +0100427 final CharSequence text = editor.getTextView().getText();
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100428 if (mMultiSelection
Abodunrinwa Toki8dd3a742017-05-02 18:44:19 +0100429 && textIndex >= mSelectionStart && textIndex <= mSelectionEnd
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000430 && text instanceof Spannable) {
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000431 // Only allow a reset once.
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100432 resetSelectionFlags();
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100433 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_RESET);
Abodunrinwa Toki8dd3a742017-05-02 18:44:19 +0100434 return editor.selectCurrentWord();
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000435 }
436 return false;
437 }
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100438
439 private void resetSelectionFlags() {
440 mMultiSelection = false;
441 mClassifierSelection = false;
442 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000443 }
444
445 /**
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800446 * AsyncTask for running a query on a background thread and returning the result on the
447 * UiThread. The AsyncTask times out after a specified time, returning a null result if the
448 * query has not yet returned.
449 */
450 private static final class TextClassificationAsyncTask
451 extends AsyncTask<Void, Void, SelectionResult> {
452
453 private final int mTimeOutDuration;
454 private final Supplier<SelectionResult> mSelectionResultSupplier;
455 private final Consumer<SelectionResult> mSelectionResultCallback;
456 private final TextView mTextView;
457 private final String mOriginalText;
458
459 /**
460 * @param textView the TextView
461 * @param timeOut time in milliseconds to timeout the query if it has not completed
462 * @param selectionResultSupplier fetches the selection results. Runs on a background thread
463 * @param selectionResultCallback receives the selection results. Runs on the UiThread
464 */
465 TextClassificationAsyncTask(
466 @NonNull TextView textView, int timeOut,
467 @NonNull Supplier<SelectionResult> selectionResultSupplier,
468 @NonNull Consumer<SelectionResult> selectionResultCallback) {
Makoto Onuki1488a3a2017-05-24 12:25:46 -0700469 super(textView != null ? textView.getHandler() : null);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800470 mTextView = Preconditions.checkNotNull(textView);
471 mTimeOutDuration = timeOut;
472 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier);
473 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback);
474 // Make a copy of the original text.
475 mOriginalText = mTextView.getText().toString();
476 }
477
478 @Override
479 @WorkerThread
480 protected SelectionResult doInBackground(Void... params) {
481 final Runnable onTimeOut = this::onTimeOut;
482 mTextView.postDelayed(onTimeOut, mTimeOutDuration);
483 final SelectionResult result = mSelectionResultSupplier.get();
484 mTextView.removeCallbacks(onTimeOut);
485 return result;
486 }
487
488 @Override
489 @UiThread
490 protected void onPostExecute(SelectionResult result) {
491 result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null;
492 mSelectionResultCallback.accept(result);
493 }
494
495 private void onTimeOut() {
496 if (getStatus() == Status.RUNNING) {
497 onPostExecute(null);
498 }
499 cancel(true);
500 }
501 }
502
503 /**
504 * Helper class for querying the TextClassifier.
505 * It trims text so that only text necessary to provide context of the selected text is
506 * sent to the TextClassifier.
507 */
508 private static final class TextClassificationHelper {
509
Abodunrinwa Tokid2d13992017-03-24 21:43:13 +0000510 private static final int TRIM_DELTA = 120; // characters
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800511
512 private TextClassifier mTextClassifier;
513
514 /** The original TextView text. **/
515 private String mText;
516 /** Start index relative to mText. */
517 private int mSelectionStart;
518 /** End index relative to mText. */
519 private int mSelectionEnd;
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +0000520 private LocaleList mLocales;
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100521 /** A tag for the classifier that returned the latest smart selection. */
522 private String mSelectionTag = "";
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800523
524 /** Trimmed text starting from mTrimStart in mText. */
525 private CharSequence mTrimmedText;
526 /** Index indicating the start of mTrimmedText in mText. */
527 private int mTrimStart;
528 /** Start index relative to mTrimmedText */
529 private int mRelativeStart;
530 /** End index relative to mTrimmedText */
531 private int mRelativeEnd;
532
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100533 /** Information about the last classified text to avoid re-running a query. */
534 private CharSequence mLastClassificationText;
535 private int mLastClassificationSelectionStart;
536 private int mLastClassificationSelectionEnd;
537 private LocaleList mLastClassificationLocales;
538 private SelectionResult mLastClassificationResult;
539
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800540 TextClassificationHelper(TextClassifier textClassifier,
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +0000541 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100542 reset(textClassifier, text, selectionStart, selectionEnd, true, locales);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800543 }
544
545 @UiThread
546 public void reset(TextClassifier textClassifier,
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100547 CharSequence text, int selectionStart, int selectionEnd,
548 boolean resetSelectionTag, LocaleList locales) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800549 mTextClassifier = Preconditions.checkNotNull(textClassifier);
550 mText = Preconditions.checkNotNull(text).toString();
Abodunrinwa Toki08925e62017-05-12 13:48:50 +0100551 mLastClassificationText = null; // invalidate.
Abodunrinwa Toki792d8202017-03-06 23:51:11 +0000552 Preconditions.checkArgument(selectionEnd > selectionStart);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800553 mSelectionStart = selectionStart;
554 mSelectionEnd = selectionEnd;
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +0000555 mLocales = locales;
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100556 if (resetSelectionTag) {
557 mSelectionTag = "";
558 }
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800559 }
560
561 @WorkerThread
562 public SelectionResult classifyText() {
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100563 if (!Objects.equals(mText, mLastClassificationText)
564 || mSelectionStart != mLastClassificationSelectionStart
565 || mSelectionEnd != mLastClassificationSelectionEnd
566 || !Objects.equals(mLocales, mLastClassificationLocales)) {
567
568 mLastClassificationText = mText;
569 mLastClassificationSelectionStart = mSelectionStart;
570 mLastClassificationSelectionEnd = mSelectionEnd;
571 mLastClassificationLocales = mLocales;
572
573 trimText();
574 mLastClassificationResult = new SelectionResult(
575 mSelectionStart,
576 mSelectionEnd,
577 mTextClassifier.classifyText(
578 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales));
579
580 }
581 return mLastClassificationResult;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800582 }
583
584 @WorkerThread
585 public SelectionResult suggestSelection() {
586 trimText();
587 final TextSelection sel = mTextClassifier.suggestSelection(
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +0000588 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800589 mSelectionStart = Math.max(0, sel.getSelectionStartIndex() + mTrimStart);
590 mSelectionEnd = Math.min(mText.length(), sel.getSelectionEndIndex() + mTrimStart);
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100591 mSelectionTag = sel.getSourceClassifier();
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800592 return classifyText();
593 }
594
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100595 String getSelectionTag() {
596 return mSelectionTag;
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100597 }
598
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800599 private void trimText() {
600 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA);
601 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA);
602 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd);
603 mRelativeStart = mSelectionStart - mTrimStart;
604 mRelativeEnd = mSelectionEnd - mTrimStart;
605 }
606 }
607
608 /**
609 * Selection result.
610 */
611 private static final class SelectionResult {
612 private final int mStart;
613 private final int mEnd;
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100614 private final TextClassification mClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800615
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100616 SelectionResult(int start, int end, TextClassification classification) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800617 mStart = start;
618 mEnd = end;
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100619 mClassification = Preconditions.checkNotNull(classification);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800620 }
621 }
622}