blob: d0ad27af0a92617a2b6f79af02b9e088988f461a [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;
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +010023import android.content.Context;
Petar Šegina5ab7bb22017-09-05 20:48:42 +010024import android.graphics.Canvas;
Petar Šegina91df3f92017-08-15 16:20:43 +010025import android.graphics.PointF;
Petar Šegina701ba332017-08-01 17:57:26 +010026import android.graphics.RectF;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080027import android.os.AsyncTask;
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +010028import android.os.Build;
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +000029import android.os.LocaleList;
Petar Šegina701ba332017-08-01 17:57:26 +010030import android.text.Layout;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080031import android.text.Selection;
32import android.text.Spannable;
33import android.text.TextUtils;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +010034import android.util.Log;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080035import android.view.ActionMode;
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +010036import android.view.textclassifier.TextClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080037import android.view.textclassifier.TextClassifier;
38import android.view.textclassifier.TextSelection;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +010039import android.view.textclassifier.logging.SmartSelectionEventTracker;
40import android.view.textclassifier.logging.SmartSelectionEventTracker.SelectionEvent;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080041import android.widget.Editor.SelectionModifierCursorController;
42
Petar Šegina91df3f92017-08-15 16:20:43 +010043import com.android.internal.annotations.VisibleForTesting;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080044import com.android.internal.util.Preconditions;
45
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +010046import java.text.BreakIterator;
Petar Šegina701ba332017-08-01 17:57:26 +010047import java.util.ArrayList;
Petar Šegina7c8196f2017-09-11 18:03:14 +010048import java.util.Comparator;
Petar Šegina701ba332017-08-01 17:57:26 +010049import java.util.List;
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +010050import java.util.Objects;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080051import java.util.function.Consumer;
Petar Šegina7c8196f2017-09-11 18:03:14 +010052import java.util.function.Function;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080053import java.util.function.Supplier;
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +010054import java.util.regex.Pattern;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080055
56/**
57 * Helper class for starting selection action mode
58 * (synchronously without the TextClassifier, asynchronously with the TextClassifier).
Petar Šegina91df3f92017-08-15 16:20:43 +010059 * @hide
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080060 */
61@UiThread
Petar Šegina91df3f92017-08-15 16:20:43 +010062@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
Petar Šeginaba1b8562017-08-31 18:09:16 +010063public final class SelectionActionModeHelper {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080064
Jan Althausb3513a12017-09-22 18:26:06 +020065 private static final String LOG_TAG = "SelectActionModeHelper";
66
Petar Šeginae2f82632017-08-14 17:09:01 +010067 private static final boolean SMART_SELECT_ANIMATION_ENABLED = true;
Petar Šegina701ba332017-08-01 17:57:26 +010068
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080069 private final Editor mEditor;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +010070 private final TextView mTextView;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080071 private final TextClassificationHelper mTextClassificationHelper;
72
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +010073 private TextClassification mTextClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080074 private AsyncTask mTextClassificationAsyncTask;
75
Abodunrinwa Toki1d775572017-05-08 16:03:01 +010076 private final SelectionTracker mSelectionTracker;
Petar Šegina5ab7bb22017-09-05 20:48:42 +010077
78 // TODO remove nullable marker once the switch gating the feature gets removed
79 @Nullable
Petar Šegina701ba332017-08-01 17:57:26 +010080 private final SmartSelectSprite mSmartSelectSprite;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +000081
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080082 SelectionActionModeHelper(@NonNull Editor editor) {
83 mEditor = Preconditions.checkNotNull(editor);
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +010084 mTextView = mEditor.getTextView();
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080085 mTextClassificationHelper = new TextClassificationHelper(
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +010086 mTextView.getContext(),
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +010087 mTextView.getTextClassifier(),
88 getText(mTextView),
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +010089 0, 1, mTextView.getTextLocales());
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +010090 mSelectionTracker = new SelectionTracker(mTextView);
Petar Šegina701ba332017-08-01 17:57:26 +010091
92 if (SMART_SELECT_ANIMATION_ENABLED) {
Petar Šegina5ab7bb22017-09-05 20:48:42 +010093 mSmartSelectSprite = new SmartSelectSprite(mTextView.getContext(),
94 mTextView::invalidate);
Petar Šegina701ba332017-08-01 17:57:26 +010095 } else {
96 mSmartSelectSprite = null;
97 }
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -080098 }
99
Abodunrinwa Toki66c16272017-05-03 20:22:55 +0100100 public void startActionModeAsync(boolean adjustSelection) {
Abodunrinwa Toki0e6b43e2017-09-19 23:18:40 +0100101 // Check if the smart selection should run for editable text.
102 adjustSelection &= !mTextView.isTextEditable()
103 || mTextView.getTextClassifier().getSettings()
104 .isSuggestSelectionEnabledForEditableText();
105
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100106 mSelectionTracker.onOriginalSelection(
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +0100107 getText(mTextView),
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100108 mTextView.getSelectionStart(),
Abodunrinwa Toki0e6b43e2017-09-19 23:18:40 +0100109 mTextView.getSelectionEnd());
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800110 cancelAsyncTask();
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100111 if (skipTextClassification()) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800112 startActionMode(null);
113 } else {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100114 resetTextClassificationHelper();
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800115 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100116 mTextView,
Abodunrinwa Toki3521f3f2017-09-27 02:38:55 +0100117 mTextClassificationHelper.getTimeoutDuration(),
Abodunrinwa Toki66c16272017-05-03 20:22:55 +0100118 adjustSelection
119 ? mTextClassificationHelper::suggestSelection
120 : mTextClassificationHelper::classifyText,
Petar Šegina701ba332017-08-01 17:57:26 +0100121 mSmartSelectSprite != null
122 ? this::startActionModeWithSmartSelectAnimation
123 : this::startActionMode)
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800124 .execute();
125 }
126 }
127
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800128 public void invalidateActionModeAsync() {
129 cancelAsyncTask();
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100130 if (skipTextClassification()) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800131 invalidateActionMode(null);
132 } else {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100133 resetTextClassificationHelper();
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800134 mTextClassificationAsyncTask = new TextClassificationAsyncTask(
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100135 mTextView,
Abodunrinwa Toki3521f3f2017-09-27 02:38:55 +0100136 mTextClassificationHelper.getTimeoutDuration(),
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100137 mTextClassificationHelper::classifyText,
138 this::invalidateActionMode)
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800139 .execute();
140 }
141 }
142
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100143 public void onSelectionAction(int menuItemId) {
144 mSelectionTracker.onSelectionAction(
145 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
146 getActionType(menuItemId), mTextClassification);
147 }
148
149 public void onSelectionDrag() {
150 mSelectionTracker.onSelectionAction(
151 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
152 SelectionEvent.ActionType.DRAG, mTextClassification);
153 }
154
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100155 public void onTextChanged(int start, int end) {
156 mSelectionTracker.onTextChanged(start, end, mTextClassification);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100157 }
158
Abodunrinwa Toki8dd3a742017-05-02 18:44:19 +0100159 public boolean resetSelection(int textIndex) {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100160 if (mSelectionTracker.resetSelection(textIndex, mEditor)) {
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000161 invalidateActionModeAsync();
162 return true;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800163 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000164 return false;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800165 }
166
167 @Nullable
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100168 public TextClassification getTextClassification() {
169 return mTextClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800170 }
171
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000172 public void onDestroyActionMode() {
Petar Šegina701ba332017-08-01 17:57:26 +0100173 cancelSmartSelectAnimation();
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100174 mSelectionTracker.onSelectionDestroyed();
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000175 cancelAsyncTask();
176 }
177
Petar Šegina5ab7bb22017-09-05 20:48:42 +0100178 public void onDraw(final Canvas canvas) {
179 if (mSmartSelectSprite != null) {
180 mSmartSelectSprite.draw(canvas);
181 }
182 }
183
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000184 private void cancelAsyncTask() {
185 if (mTextClassificationAsyncTask != null) {
186 mTextClassificationAsyncTask.cancel(true);
187 mTextClassificationAsyncTask = null;
188 }
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100189 mTextClassification = null;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000190 }
191
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100192 private boolean skipTextClassification() {
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100193 // No need to make an async call for a no-op TextClassifier.
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100194 final boolean noOpTextClassifier = mTextView.getTextClassifier() == TextClassifier.NO_OP;
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100195 // Do not call the TextClassifier if there is no selection.
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100196 final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart();
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100197 // Do not call the TextClassifier if this is a password field.
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100198 final boolean password = mTextView.hasPasswordTransformationMethod()
199 || TextView.isPasswordInputType(mTextView.getInputType());
Abodunrinwa Toki76b51dc2017-07-13 23:37:11 +0100200 return noOpTextClassifier || noSelection || password;
Abodunrinwa Toki792d8202017-03-06 23:51:11 +0000201 }
202
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800203 private void startActionMode(@Nullable SelectionResult result) {
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +0100204 final CharSequence text = getText(mTextView);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800205 if (result != null && text instanceof Spannable) {
Abodunrinwa Toki0e6b43e2017-09-19 23:18:40 +0100206 // Do not change the selection if TextClassifier should be dark launched.
207 if (!mTextView.getTextClassifier().getSettings().isDarkLaunch()) {
208 Selection.setSelection((Spannable) text, result.mStart, result.mEnd);
209 }
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100210 mTextClassification = result.mClassification;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800211 } else {
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100212 mTextClassification = null;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800213 }
214 if (mEditor.startSelectionActionModeInternal()) {
215 final SelectionModifierCursorController controller = mEditor.getSelectionController();
216 if (controller != null) {
217 controller.show();
218 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000219 if (result != null) {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100220 mSelectionTracker.onSmartSelection(result);
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000221 }
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800222 }
223 mEditor.setRestartActionModeOnNextRefresh(false);
224 mTextClassificationAsyncTask = null;
225 }
226
Petar Šegina701ba332017-08-01 17:57:26 +0100227 private void startActionModeWithSmartSelectAnimation(@Nullable SelectionResult result) {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100228 final Layout layout = mTextView.getLayout();
Petar Šegina701ba332017-08-01 17:57:26 +0100229
230 final Runnable onAnimationEndCallback = () -> startActionMode(result);
231 // TODO do not trigger the animation if the change included only non-printable characters
232 final boolean didSelectionChange =
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100233 result != null && (mTextView.getSelectionStart() != result.mStart
234 || mTextView.getSelectionEnd() != result.mEnd);
Petar Šegina701ba332017-08-01 17:57:26 +0100235
236 if (!didSelectionChange) {
237 onAnimationEndCallback.run();
238 return;
239 }
240
Petar Šegina7c8196f2017-09-11 18:03:14 +0100241 final List<SmartSelectSprite.RectangleWithTextSelectionLayout> selectionRectangles =
Petar Šegina701ba332017-08-01 17:57:26 +0100242 convertSelectionToRectangles(layout, result.mStart, result.mEnd);
243
Petar Šegina91df3f92017-08-15 16:20:43 +0100244 final PointF touchPoint = new PointF(
245 mEditor.getLastUpPositionX(),
246 mEditor.getLastUpPositionY());
Petar Šegina701ba332017-08-01 17:57:26 +0100247
Petar Šegina91df3f92017-08-15 16:20:43 +0100248 final PointF animationStartPoint =
Petar Šegina7c8196f2017-09-11 18:03:14 +0100249 movePointInsideNearestRectangle(touchPoint, selectionRectangles,
250 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle);
Petar Šegina701ba332017-08-01 17:57:26 +0100251
252 mSmartSelectSprite.startAnimation(
Petar Šegina91df3f92017-08-15 16:20:43 +0100253 animationStartPoint,
Petar Šegina701ba332017-08-01 17:57:26 +0100254 selectionRectangles,
255 onAnimationEndCallback);
256 }
257
Petar Šegina7c8196f2017-09-11 18:03:14 +0100258 private List<SmartSelectSprite.RectangleWithTextSelectionLayout> convertSelectionToRectangles(
259 final Layout layout, final int start, final int end) {
260 final List<SmartSelectSprite.RectangleWithTextSelectionLayout> result = new ArrayList<>();
Petar Šegina72729252017-08-31 15:25:06 +0100261
Petar Šegina7c8196f2017-09-11 18:03:14 +0100262 final Layout.SelectionRectangleConsumer consumer =
263 (left, top, right, bottom, textSelectionLayout) -> mergeRectangleIntoList(
264 result,
265 new RectF(left, top, right, bottom),
266 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle,
267 r -> new SmartSelectSprite.RectangleWithTextSelectionLayout(r,
268 textSelectionLayout)
269 );
270
271 layout.getSelection(start, end, consumer);
272
273 result.sort(Comparator.comparing(
274 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle,
275 SmartSelectSprite.RECTANGLE_COMPARATOR));
276
Petar Šegina701ba332017-08-01 17:57:26 +0100277 return result;
278 }
279
Abodunrinwa Toki3521f3f2017-09-27 02:38:55 +0100280 // TODO: Move public pure functions out of this class and make it package-private.
Petar Šeginaba1b8562017-08-31 18:09:16 +0100281 /**
Petar Šegina7c8196f2017-09-11 18:03:14 +0100282 * Merges a {@link RectF} into an existing list of any objects which contain a rectangle.
283 * While merging, this method makes sure that:
Petar Šeginaba1b8562017-08-31 18:09:16 +0100284 *
285 * <ol>
286 * <li>No rectangle is redundant (contained within a bigger rectangle)</li>
287 * <li>Rectangles of the same height and vertical position that intersect get merged</li>
288 * </ol>
289 *
Petar Šegina7c8196f2017-09-11 18:03:14 +0100290 * @param list the list of rectangles (or other rectangle containers) to merge the new
291 * rectangle into
Petar Šeginaba1b8562017-08-31 18:09:16 +0100292 * @param candidate the {@link RectF} to merge into the list
Petar Šegina7c8196f2017-09-11 18:03:14 +0100293 * @param extractor a function that can extract a {@link RectF} from an element of the given
294 * list
295 * @param packer a function that can wrap the resulting {@link RectF} into an element that
296 * the list contains
Petar Šeginaba1b8562017-08-31 18:09:16 +0100297 * @hide
298 */
299 @VisibleForTesting
Petar Šegina7c8196f2017-09-11 18:03:14 +0100300 public static <T> void mergeRectangleIntoList(final List<T> list,
301 final RectF candidate, final Function<T, RectF> extractor,
302 final Function<RectF, T> packer) {
Petar Šeginaba1b8562017-08-31 18:09:16 +0100303 if (candidate.isEmpty()) {
304 return;
305 }
306
307 final int elementCount = list.size();
308 for (int index = 0; index < elementCount; ++index) {
Petar Šegina7c8196f2017-09-11 18:03:14 +0100309 final RectF existingRectangle = extractor.apply(list.get(index));
Petar Šeginaba1b8562017-08-31 18:09:16 +0100310 if (existingRectangle.contains(candidate)) {
311 return;
312 }
313 if (candidate.contains(existingRectangle)) {
314 existingRectangle.setEmpty();
315 continue;
316 }
317
318 final boolean rectanglesContinueEachOther = candidate.left == existingRectangle.right
319 || candidate.right == existingRectangle.left;
320 final boolean canMerge = candidate.top == existingRectangle.top
321 && candidate.bottom == existingRectangle.bottom
322 && (RectF.intersects(candidate, existingRectangle)
323 || rectanglesContinueEachOther);
324
325 if (canMerge) {
326 candidate.union(existingRectangle);
327 existingRectangle.setEmpty();
328 }
329 }
330
331 for (int index = elementCount - 1; index >= 0; --index) {
Petar Šegina7c8196f2017-09-11 18:03:14 +0100332 final RectF rectangle = extractor.apply(list.get(index));
333 if (rectangle.isEmpty()) {
Petar Šeginaba1b8562017-08-31 18:09:16 +0100334 list.remove(index);
335 }
336 }
337
Petar Šegina7c8196f2017-09-11 18:03:14 +0100338 list.add(packer.apply(candidate));
Petar Šeginaba1b8562017-08-31 18:09:16 +0100339 }
340
341
Petar Šegina91df3f92017-08-15 16:20:43 +0100342 /** @hide */
343 @VisibleForTesting
Petar Šegina7c8196f2017-09-11 18:03:14 +0100344 public static <T> PointF movePointInsideNearestRectangle(final PointF point,
345 final List<T> list, final Function<T, RectF> extractor) {
Petar Šegina91df3f92017-08-15 16:20:43 +0100346 float bestX = -1;
347 float bestY = -1;
348 double bestDistance = Double.MAX_VALUE;
349
Petar Šegina7c8196f2017-09-11 18:03:14 +0100350 final int elementCount = list.size();
Petar Šeginaba1b8562017-08-31 18:09:16 +0100351 for (int index = 0; index < elementCount; ++index) {
Petar Šegina7c8196f2017-09-11 18:03:14 +0100352 final RectF rectangle = extractor.apply(list.get(index));
Petar Šegina91df3f92017-08-15 16:20:43 +0100353 final float candidateY = rectangle.centerY();
354 final float candidateX;
355
356 if (point.x > rectangle.right) {
357 candidateX = rectangle.right;
358 } else if (point.x < rectangle.left) {
359 candidateX = rectangle.left;
360 } else {
361 candidateX = point.x;
362 }
363
364 final double candidateDistance = Math.pow(point.x - candidateX, 2)
365 + Math.pow(point.y - candidateY, 2);
366
367 if (candidateDistance < bestDistance) {
368 bestX = candidateX;
369 bestY = candidateY;
370 bestDistance = candidateDistance;
371 }
372 }
373
374 return new PointF(bestX, bestY);
375 }
376
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800377 private void invalidateActionMode(@Nullable SelectionResult result) {
Petar Šegina701ba332017-08-01 17:57:26 +0100378 cancelSmartSelectAnimation();
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100379 mTextClassification = result != null ? result.mClassification : null;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800380 final ActionMode actionMode = mEditor.getTextActionMode();
381 if (actionMode != null) {
382 actionMode.invalidate();
383 }
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100384 mSelectionTracker.onSelectionUpdated(
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100385 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), mTextClassification);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800386 mTextClassificationAsyncTask = null;
387 }
388
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100389 private void resetTextClassificationHelper() {
Abodunrinwa Toki0e6b43e2017-09-19 23:18:40 +0100390 mTextClassificationHelper.init(
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100391 mTextView.getContext(),
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +0100392 mTextView.getTextClassifier(),
393 getText(mTextView),
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100394 mTextView.getSelectionStart(), mTextView.getSelectionEnd(),
395 mTextView.getTextLocales());
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800396 }
397
Petar Šegina701ba332017-08-01 17:57:26 +0100398 private void cancelSmartSelectAnimation() {
399 if (mSmartSelectSprite != null) {
400 mSmartSelectSprite.cancelAnimation();
401 }
402 }
403
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800404 /**
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100405 * Tracks and logs smart selection changes.
406 * It is important to trigger this object's methods at the appropriate event so that it tracks
407 * smart selection events appropriately.
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000408 */
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100409 private static final class SelectionTracker {
410
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100411 private final TextView mTextView;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100412 private SelectionMetricsLogger mLogger;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000413
414 private int mOriginalStart;
415 private int mOriginalEnd;
416 private int mSelectionStart;
417 private int mSelectionEnd;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100418 private boolean mAllowReset;
Jan Althausb3513a12017-09-22 18:26:06 +0200419 private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable();
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000420
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100421 SelectionTracker(TextView textView) {
422 mTextView = Preconditions.checkNotNull(textView);
423 mLogger = new SelectionMetricsLogger(textView);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100424 }
425
426 /**
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100427 * Called when the original selection happens, before smart selection is triggered.
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100428 */
Abodunrinwa Toki0e6b43e2017-09-19 23:18:40 +0100429 public void onOriginalSelection(CharSequence text, int selectionStart, int selectionEnd) {
Jan Althausb3513a12017-09-22 18:26:06 +0200430 // If we abandoned a selection and created a new one very shortly after, we may still
431 // have a pending request to log ABANDON, which we flush here.
432 mDelayedLogAbandon.flush();
433
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100434 mOriginalStart = mSelectionStart = selectionStart;
435 mOriginalEnd = mSelectionEnd = selectionEnd;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100436 mAllowReset = false;
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100437 maybeInvalidateLogger();
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100438 mLogger.logSelectionStarted(text, selectionStart);
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000439 }
440
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100441 /**
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100442 * Called when selection action mode is started and the results come from a classifier.
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100443 */
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100444 public void onSmartSelection(SelectionResult result) {
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100445 if (isSelectionStarted()) {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100446 mSelectionStart = result.mStart;
447 mSelectionEnd = result.mEnd;
448 mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd;
449 mLogger.logSelectionModified(
450 result.mStart, result.mEnd, result.mClassification, result.mSelection);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100451 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000452 }
453
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100454 /**
455 * Called when selection bounds change.
456 */
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100457 public void onSelectionUpdated(
458 int selectionStart, int selectionEnd,
459 @Nullable TextClassification classification) {
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100460 if (isSelectionStarted()) {
461 mSelectionStart = selectionStart;
462 mSelectionEnd = selectionEnd;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100463 mAllowReset = false;
464 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100465 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000466 }
467
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100468 /**
469 * Called when the selection action mode is destroyed.
470 */
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000471 public void onSelectionDestroyed() {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100472 mAllowReset = false;
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100473 // Wait a few ms to see if the selection was destroyed because of a text change event.
Jan Althausb3513a12017-09-22 18:26:06 +0200474 mDelayedLogAbandon.schedule(100 /* ms */);
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000475 }
476
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100477 /**
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100478 * Called when an action is taken on a smart selection.
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100479 */
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100480 public void onSelectionAction(
481 int selectionStart, int selectionEnd,
482 @SelectionEvent.ActionType int action,
483 @Nullable TextClassification classification) {
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100484 if (isSelectionStarted()) {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100485 mAllowReset = false;
486 mLogger.logSelectionAction(selectionStart, selectionEnd, action, classification);
Abodunrinwa Toki1d775572017-05-08 16:03:01 +0100487 }
488 }
489
490 /**
491 * Returns true if the current smart selection should be reset to normal selection based on
492 * information that has been recorded about the original selection and the smart selection.
493 * The expected UX here is to allow the user to select a word inside of the smart selection
494 * on a single tap.
495 */
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100496 public boolean resetSelection(int textIndex, Editor editor) {
497 final TextView textView = editor.getTextView();
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100498 if (isSelectionStarted()
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100499 && mAllowReset
Abodunrinwa Toki8dd3a742017-05-02 18:44:19 +0100500 && textIndex >= mSelectionStart && textIndex <= mSelectionEnd
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +0100501 && getText(textView) instanceof Spannable) {
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100502 mAllowReset = false;
503 boolean selected = editor.selectCurrentWord();
504 if (selected) {
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100505 mSelectionStart = editor.getTextView().getSelectionStart();
506 mSelectionEnd = editor.getTextView().getSelectionEnd();
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100507 mLogger.logSelectionAction(
508 textView.getSelectionStart(), textView.getSelectionEnd(),
509 SelectionEvent.ActionType.RESET, null /* classification */);
510 }
511 return selected;
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000512 }
513 return false;
514 }
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100515
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100516 public void onTextChanged(int start, int end, TextClassification classification) {
517 if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) {
518 onSelectionAction(start, end, SelectionEvent.ActionType.OVERTYPE, classification);
519 }
520 }
521
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100522 private void maybeInvalidateLogger() {
523 if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) {
524 mLogger = new SelectionMetricsLogger(mTextView);
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100525 }
526 }
Abodunrinwa Toki78940eb2017-09-12 02:58:49 +0100527
528 private boolean isSelectionStarted() {
529 return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd;
530 }
Jan Althausb3513a12017-09-22 18:26:06 +0200531
532 /** A helper for keeping track of pending abandon logging requests. */
533 private final class LogAbandonRunnable implements Runnable {
534 private boolean mIsPending;
535
536 /** Schedules an abandon to be logged with the given delay. Flush if necessary. */
537 void schedule(int delayMillis) {
538 if (mIsPending) {
539 Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request");
540 flush();
541 }
542 mIsPending = true;
543 mTextView.postDelayed(this, delayMillis);
544 }
545
546 /** If there is a pending log request, execute it now. */
547 void flush() {
548 mTextView.removeCallbacks(this);
549 run();
550 }
551
552 @Override
553 public void run() {
554 if (mIsPending) {
555 mLogger.logSelectionAction(
556 mSelectionStart, mSelectionEnd,
557 SelectionEvent.ActionType.ABANDON, null /* classification */);
558 mSelectionStart = mSelectionEnd = -1;
559 mIsPending = false;
560 }
561 }
562 }
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100563 }
564
565 // TODO: Write tests
566 /**
567 * Metrics logging helper.
568 *
569 * This logger logs selection by word indices. The initial (start) single word selection is
570 * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the
571 * initial single word selection.
572 * e.g. New York city, NY. Suppose the initial selection is "York" in
573 * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2).
574 * "New York" is at [-1, 1).
575 * Part selection of a word e.g. "or" is counted as selecting the
576 * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g.
577 * "," is at [2, 3). Whitespaces are ignored.
578 */
579 private static final class SelectionMetricsLogger {
580
581 private static final String LOG_TAG = "SelectionMetricsLogger";
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100582 private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+");
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100583
584 private final SmartSelectionEventTracker mDelegate;
585 private final boolean mEditTextLogger;
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100586 private final BreakIterator mWordIterator;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100587 private int mStartIndex;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100588 private String mText;
589
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100590 SelectionMetricsLogger(TextView textView) {
591 Preconditions.checkNotNull(textView);
592 final @SmartSelectionEventTracker.WidgetType int widgetType = textView.isTextEditable()
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100593 ? SmartSelectionEventTracker.WidgetType.EDITTEXT
Jan Althausb3c6ece2017-11-14 15:40:16 +0100594 : (textView.isTextSelectable()
595 ? SmartSelectionEventTracker.WidgetType.TEXTVIEW
596 : SmartSelectionEventTracker.WidgetType.UNSELECTABLE_TEXTVIEW);
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100597 mDelegate = new SmartSelectionEventTracker(textView.getContext(), widgetType);
598 mEditTextLogger = textView.isTextEditable();
599 mWordIterator = BreakIterator.getWordInstance(textView.getTextLocale());
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100600 }
601
602 public void logSelectionStarted(CharSequence text, int index) {
603 try {
604 Preconditions.checkNotNull(text);
605 Preconditions.checkArgumentInRange(index, 0, text.length(), "index");
606 if (mText == null || !mText.contentEquals(text)) {
607 mText = text.toString();
608 }
609 mWordIterator.setText(mText);
610 mStartIndex = index;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100611 mDelegate.logEvent(SelectionEvent.selectionStarted(0));
612 } catch (Exception e) {
613 // Avoid crashes due to logging.
614 Log.d(LOG_TAG, e.getMessage());
615 }
616 }
617
618 public void logSelectionModified(int start, int end,
619 @Nullable TextClassification classification, @Nullable TextSelection selection) {
620 try {
621 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
622 Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
623 int[] wordIndices = getWordDelta(start, end);
624 if (selection != null) {
625 mDelegate.logEvent(SelectionEvent.selectionModified(
626 wordIndices[0], wordIndices[1], selection));
627 } else if (classification != null) {
628 mDelegate.logEvent(SelectionEvent.selectionModified(
629 wordIndices[0], wordIndices[1], classification));
630 } else {
631 mDelegate.logEvent(SelectionEvent.selectionModified(
632 wordIndices[0], wordIndices[1]));
633 }
634 } catch (Exception e) {
635 // Avoid crashes due to logging.
636 Log.d(LOG_TAG, e.getMessage());
637 }
638 }
639
640 public void logSelectionAction(
641 int start, int end,
642 @SelectionEvent.ActionType int action,
643 @Nullable TextClassification classification) {
644 try {
645 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start");
646 Preconditions.checkArgumentInRange(end, start, mText.length(), "end");
647 int[] wordIndices = getWordDelta(start, end);
648 if (classification != null) {
649 mDelegate.logEvent(SelectionEvent.selectionAction(
650 wordIndices[0], wordIndices[1], action, classification));
651 } else {
652 mDelegate.logEvent(SelectionEvent.selectionAction(
653 wordIndices[0], wordIndices[1], action));
654 }
655 } catch (Exception e) {
656 // Avoid crashes due to logging.
657 Log.d(LOG_TAG, e.getMessage());
658 }
659 }
660
661 public boolean isEditTextLogger() {
662 return mEditTextLogger;
663 }
664
665 private int[] getWordDelta(int start, int end) {
666 int[] wordIndices = new int[2];
667
668 if (start == mStartIndex) {
669 wordIndices[0] = 0;
670 } else if (start < mStartIndex) {
671 wordIndices[0] = -countWordsForward(start);
672 } else { // start > mStartIndex
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100673 wordIndices[0] = countWordsBackward(start);
674
675 // For the selection start index, avoid counting a partial word backwards.
676 if (!mWordIterator.isBoundary(start)
677 && !isWhitespace(
678 mWordIterator.preceding(start),
679 mWordIterator.following(start))) {
680 // We counted a partial word. Remove it.
681 wordIndices[0]--;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100682 }
683 }
684
685 if (end == mStartIndex) {
686 wordIndices[1] = 0;
687 } else if (end < mStartIndex) {
688 wordIndices[1] = -countWordsForward(end);
689 } else { // end > mStartIndex
690 wordIndices[1] = countWordsBackward(end);
691 }
692
693 return wordIndices;
694 }
695
696 private int countWordsBackward(int from) {
697 Preconditions.checkArgument(from >= mStartIndex);
698 int wordCount = 0;
699 int offset = from;
700 while (offset > mStartIndex) {
701 int start = mWordIterator.preceding(offset);
702 if (!isWhitespace(start, offset)) {
703 wordCount++;
704 }
705 offset = start;
706 }
707 return wordCount;
708 }
709
710 private int countWordsForward(int from) {
711 Preconditions.checkArgument(from <= mStartIndex);
712 int wordCount = 0;
713 int offset = from;
714 while (offset < mStartIndex) {
715 int end = mWordIterator.following(offset);
716 if (!isWhitespace(offset, end)) {
717 wordCount++;
718 }
719 offset = end;
720 }
721 return wordCount;
722 }
723
724 private boolean isWhitespace(int start, int end) {
Abodunrinwa Tokid62a86e2017-09-11 18:19:53 +0100725 return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches();
Abodunrinwa Tokie78ac522017-05-23 14:51:22 +0100726 }
Abodunrinwa Toki89ba5fb2017-02-23 02:52:05 +0000727 }
728
729 /**
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800730 * AsyncTask for running a query on a background thread and returning the result on the
731 * UiThread. The AsyncTask times out after a specified time, returning a null result if the
732 * query has not yet returned.
733 */
734 private static final class TextClassificationAsyncTask
735 extends AsyncTask<Void, Void, SelectionResult> {
736
737 private final int mTimeOutDuration;
738 private final Supplier<SelectionResult> mSelectionResultSupplier;
739 private final Consumer<SelectionResult> mSelectionResultCallback;
740 private final TextView mTextView;
741 private final String mOriginalText;
742
743 /**
744 * @param textView the TextView
745 * @param timeOut time in milliseconds to timeout the query if it has not completed
746 * @param selectionResultSupplier fetches the selection results. Runs on a background thread
747 * @param selectionResultCallback receives the selection results. Runs on the UiThread
748 */
749 TextClassificationAsyncTask(
750 @NonNull TextView textView, int timeOut,
751 @NonNull Supplier<SelectionResult> selectionResultSupplier,
752 @NonNull Consumer<SelectionResult> selectionResultCallback) {
Makoto Onuki1488a3a2017-05-24 12:25:46 -0700753 super(textView != null ? textView.getHandler() : null);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800754 mTextView = Preconditions.checkNotNull(textView);
755 mTimeOutDuration = timeOut;
756 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier);
757 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback);
758 // Make a copy of the original text.
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +0100759 mOriginalText = getText(mTextView).toString();
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800760 }
761
762 @Override
763 @WorkerThread
764 protected SelectionResult doInBackground(Void... params) {
765 final Runnable onTimeOut = this::onTimeOut;
766 mTextView.postDelayed(onTimeOut, mTimeOutDuration);
767 final SelectionResult result = mSelectionResultSupplier.get();
768 mTextView.removeCallbacks(onTimeOut);
769 return result;
770 }
771
772 @Override
773 @UiThread
774 protected void onPostExecute(SelectionResult result) {
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +0100775 result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800776 mSelectionResultCallback.accept(result);
777 }
778
779 private void onTimeOut() {
780 if (getStatus() == Status.RUNNING) {
781 onPostExecute(null);
782 }
783 cancel(true);
784 }
785 }
786
787 /**
788 * Helper class for querying the TextClassifier.
789 * It trims text so that only text necessary to provide context of the selected text is
790 * sent to the TextClassifier.
791 */
792 private static final class TextClassificationHelper {
793
Abodunrinwa Tokid2d13992017-03-24 21:43:13 +0000794 private static final int TRIM_DELTA = 120; // characters
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800795
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100796 private Context mContext;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800797 private TextClassifier mTextClassifier;
798
799 /** The original TextView text. **/
800 private String mText;
801 /** Start index relative to mText. */
802 private int mSelectionStart;
803 /** End index relative to mText. */
804 private int mSelectionEnd;
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100805
806 private final TextSelection.Options mSelectionOptions = new TextSelection.Options();
807 private final TextClassification.Options mClassificationOptions =
808 new TextClassification.Options();
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800809
810 /** Trimmed text starting from mTrimStart in mText. */
811 private CharSequence mTrimmedText;
812 /** Index indicating the start of mTrimmedText in mText. */
813 private int mTrimStart;
814 /** Start index relative to mTrimmedText */
815 private int mRelativeStart;
816 /** End index relative to mTrimmedText */
817 private int mRelativeEnd;
818
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100819 /** Information about the last classified text to avoid re-running a query. */
820 private CharSequence mLastClassificationText;
821 private int mLastClassificationSelectionStart;
822 private int mLastClassificationSelectionEnd;
823 private LocaleList mLastClassificationLocales;
824 private SelectionResult mLastClassificationResult;
825
Abodunrinwa Toki3521f3f2017-09-27 02:38:55 +0100826 /** Whether the TextClassifier has been initialized. */
827 private boolean mHot;
828
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100829 TextClassificationHelper(Context context, TextClassifier textClassifier,
Abodunrinwa Toki4cfda0b2017-02-28 18:56:47 +0000830 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100831 init(context, textClassifier, text, selectionStart, selectionEnd, locales);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800832 }
833
834 @UiThread
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100835 public void init(Context context, TextClassifier textClassifier,
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100836 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) {
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100837 mContext = Preconditions.checkNotNull(context);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800838 mTextClassifier = Preconditions.checkNotNull(textClassifier);
839 mText = Preconditions.checkNotNull(text).toString();
Abodunrinwa Toki08925e62017-05-12 13:48:50 +0100840 mLastClassificationText = null; // invalidate.
Abodunrinwa Toki792d8202017-03-06 23:51:11 +0000841 Preconditions.checkArgument(selectionEnd > selectionStart);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800842 mSelectionStart = selectionStart;
843 mSelectionEnd = selectionEnd;
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100844 mClassificationOptions.setDefaultLocales(locales);
845 mSelectionOptions.setDefaultLocales(locales)
846 .setDarkLaunchAllowed(true);
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800847 }
848
849 @WorkerThread
850 public SelectionResult classifyText() {
Abodunrinwa Toki3521f3f2017-09-27 02:38:55 +0100851 mHot = true;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100852 return performClassification(null /* selection */);
853 }
854
855 @WorkerThread
856 public SelectionResult suggestSelection() {
Abodunrinwa Toki3521f3f2017-09-27 02:38:55 +0100857 mHot = true;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100858 trimText();
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100859 final TextSelection selection;
860 if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.O_MR1) {
861 selection = mTextClassifier.suggestSelection(
862 mTrimmedText, mRelativeStart, mRelativeEnd, mSelectionOptions);
863 } else {
864 // Use old APIs.
865 selection = mTextClassifier.suggestSelection(
866 mTrimmedText, mRelativeStart, mRelativeEnd,
867 mSelectionOptions.getDefaultLocales());
868 }
Abodunrinwa Toki0e6b43e2017-09-19 23:18:40 +0100869 // Do not classify new selection boundaries if TextClassifier should be dark launched.
870 if (!mTextClassifier.getSettings().isDarkLaunch()) {
871 mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart);
872 mSelectionEnd = Math.min(
873 mText.length(), selection.getSelectionEndIndex() + mTrimStart);
874 }
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100875 return performClassification(selection);
876 }
877
Abodunrinwa Toki3521f3f2017-09-27 02:38:55 +0100878 /**
879 * Maximum time (in milliseconds) to wait for a textclassifier result before timing out.
880 */
881 // TODO: Consider making this a ViewConfiguration.
882 public int getTimeoutDuration() {
883 if (mHot) {
884 return 200;
885 } else {
886 // Return a slightly larger number than usual when the TextClassifier is first
887 // initialized. Initialization would usually take longer than subsequent calls to
888 // the TextClassifier. The impact of this on the UI is that we do not show the
889 // selection handles or toolbar until after this timeout.
890 return 500;
891 }
892 }
893
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100894 private SelectionResult performClassification(@Nullable TextSelection selection) {
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100895 if (!Objects.equals(mText, mLastClassificationText)
896 || mSelectionStart != mLastClassificationSelectionStart
897 || mSelectionEnd != mLastClassificationSelectionEnd
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100898 || !Objects.equals(
899 mClassificationOptions.getDefaultLocales(),
900 mLastClassificationLocales)) {
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100901
902 mLastClassificationText = mText;
903 mLastClassificationSelectionStart = mSelectionStart;
904 mLastClassificationSelectionEnd = mSelectionEnd;
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100905 mLastClassificationLocales = mClassificationOptions.getDefaultLocales();
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100906
907 trimText();
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100908 final TextClassification classification;
909 if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.O_MR1) {
910 classification = mTextClassifier.classifyText(
911 mTrimmedText, mRelativeStart, mRelativeEnd, mClassificationOptions);
912 } else {
913 // Use old APIs.
914 classification = mTextClassifier.classifyText(
915 mTrimmedText, mRelativeStart, mRelativeEnd,
916 mClassificationOptions.getDefaultLocales());
917 }
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100918 mLastClassificationResult = new SelectionResult(
Abodunrinwa Toki2b6020f2017-10-28 02:28:45 +0100919 mSelectionStart, mSelectionEnd, classification, selection);
Abodunrinwa Tokifdf57ba2017-05-02 20:32:23 +0100920
921 }
922 return mLastClassificationResult;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800923 }
924
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800925 private void trimText() {
926 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA);
927 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA);
928 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd);
929 mRelativeStart = mSelectionStart - mTrimStart;
930 mRelativeEnd = mSelectionEnd - mTrimStart;
931 }
932 }
933
934 /**
935 * Selection result.
936 */
937 private static final class SelectionResult {
938 private final int mStart;
939 private final int mEnd;
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100940 private final TextClassification mClassification;
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100941 @Nullable private final TextSelection mSelection;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800942
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100943 SelectionResult(int start, int end,
944 TextClassification classification, @Nullable TextSelection selection) {
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800945 mStart = start;
946 mEnd = end;
Abodunrinwa Tokie0b57892017-04-28 19:59:57 +0100947 mClassification = Preconditions.checkNotNull(classification);
Abodunrinwa Toki57ec6ea2017-09-04 20:32:31 +0100948 mSelection = selection;
949 }
950 }
951
952 @SelectionEvent.ActionType
953 private static int getActionType(int menuItemId) {
954 switch (menuItemId) {
955 case TextView.ID_SELECT_ALL:
956 return SelectionEvent.ActionType.SELECT_ALL;
957 case TextView.ID_CUT:
958 return SelectionEvent.ActionType.CUT;
959 case TextView.ID_COPY:
960 return SelectionEvent.ActionType.COPY;
961 case TextView.ID_PASTE: // fall through
962 case TextView.ID_PASTE_AS_PLAIN_TEXT:
963 return SelectionEvent.ActionType.PASTE;
964 case TextView.ID_SHARE:
965 return SelectionEvent.ActionType.SHARE;
966 case TextView.ID_ASSIST:
967 return SelectionEvent.ActionType.SMART_SHARE;
968 default:
969 return SelectionEvent.ActionType.OTHER;
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800970 }
971 }
Abodunrinwa Toki7c1e4252017-09-26 20:09:06 +0100972
973 private static CharSequence getText(TextView textView) {
974 // Extracts the textView's text.
975 // TODO: Investigate why/when TextView.getText() is null.
976 final CharSequence text = textView.getText();
977 if (text != null) {
978 return text;
979 }
980 return "";
981 }
Abodunrinwa Toki8710ea12017-01-24 10:34:13 -0800982}