SelectionActionModeHelper.java revision 5a03094ebc91df1c64a2232be648ac3ed26657ce
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 17package android.widget; 18 19import android.annotation.NonNull; 20import android.annotation.Nullable; 21import android.annotation.UiThread; 22import android.annotation.WorkerThread; 23import android.content.Context; 24import android.graphics.Canvas; 25import android.graphics.PointF; 26import android.graphics.RectF; 27import android.os.AsyncTask; 28import android.os.Build; 29import android.os.LocaleList; 30import android.text.Layout; 31import android.text.Selection; 32import android.text.Spannable; 33import android.text.TextUtils; 34import android.util.Log; 35import android.view.ActionMode; 36import android.view.textclassifier.SelectionEvent; 37import android.view.textclassifier.SelectionEvent.InvocationMethod; 38import android.view.textclassifier.SelectionSessionLogger; 39import android.view.textclassifier.TextClassification; 40import android.view.textclassifier.TextClassificationConstants; 41import android.view.textclassifier.TextClassificationManager; 42import android.view.textclassifier.TextClassifier; 43import android.view.textclassifier.TextSelection; 44import android.widget.Editor.SelectionModifierCursorController; 45 46import com.android.internal.annotations.VisibleForTesting; 47import com.android.internal.util.Preconditions; 48 49import java.text.BreakIterator; 50import java.util.ArrayList; 51import java.util.Comparator; 52import java.util.List; 53import java.util.Objects; 54import java.util.function.Consumer; 55import java.util.function.Function; 56import java.util.function.Supplier; 57import java.util.regex.Pattern; 58 59/** 60 * Helper class for starting selection action mode 61 * (synchronously without the TextClassifier, asynchronously with the TextClassifier). 62 * @hide 63 */ 64@UiThread 65@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) 66public final class SelectionActionModeHelper { 67 68 private static final String LOG_TAG = "SelectActionModeHelper"; 69 70 private final Editor mEditor; 71 private final TextView mTextView; 72 private final TextClassificationHelper mTextClassificationHelper; 73 private final TextClassificationConstants mTextClassificationSettings; 74 75 @Nullable private TextClassification mTextClassification; 76 private AsyncTask mTextClassificationAsyncTask; 77 78 private final SelectionTracker mSelectionTracker; 79 80 // TODO remove nullable marker once the switch gating the feature gets removed 81 @Nullable 82 private final SmartSelectSprite mSmartSelectSprite; 83 84 SelectionActionModeHelper(@NonNull Editor editor) { 85 mEditor = Preconditions.checkNotNull(editor); 86 mTextView = mEditor.getTextView(); 87 mTextClassificationSettings = TextClassificationManager.getSettings(mTextView.getContext()); 88 mTextClassificationHelper = new TextClassificationHelper( 89 mTextView.getContext(), 90 mTextView::getTextClassifier, 91 getText(mTextView), 92 0, 1, mTextView.getTextLocales()); 93 mSelectionTracker = new SelectionTracker(mTextView); 94 95 if (mTextClassificationSettings.isSmartSelectionAnimationEnabled()) { 96 mSmartSelectSprite = new SmartSelectSprite(mTextView.getContext(), 97 editor.getTextView().mHighlightColor, mTextView::invalidate); 98 } else { 99 mSmartSelectSprite = null; 100 } 101 } 102 103 /** 104 * Starts Selection ActionMode. 105 */ 106 public void startSelectionActionModeAsync(boolean adjustSelection) { 107 // Check if the smart selection should run for editable text. 108 adjustSelection &= mTextClassificationSettings.isSmartSelectionEnabled(); 109 110 mSelectionTracker.onOriginalSelection( 111 getText(mTextView), 112 mTextView.getSelectionStart(), 113 mTextView.getSelectionEnd(), 114 false /*isLink*/); 115 cancelAsyncTask(); 116 if (skipTextClassification()) { 117 startSelectionActionMode(null); 118 } else { 119 resetTextClassificationHelper(); 120 mTextClassificationAsyncTask = new TextClassificationAsyncTask( 121 mTextView, 122 mTextClassificationHelper.getTimeoutDuration(), 123 adjustSelection 124 ? mTextClassificationHelper::suggestSelection 125 : mTextClassificationHelper::classifyText, 126 mSmartSelectSprite != null 127 ? this::startSelectionActionModeWithSmartSelectAnimation 128 : this::startSelectionActionMode, 129 mTextClassificationHelper::getOriginalSelection) 130 .execute(); 131 } 132 } 133 134 /** 135 * Starts Link ActionMode. 136 */ 137 public void startLinkActionModeAsync(int start, int end) { 138 mSelectionTracker.onOriginalSelection(getText(mTextView), start, end, true /*isLink*/); 139 cancelAsyncTask(); 140 if (skipTextClassification()) { 141 startLinkActionMode(null); 142 } else { 143 resetTextClassificationHelper(start, end); 144 mTextClassificationAsyncTask = new TextClassificationAsyncTask( 145 mTextView, 146 mTextClassificationHelper.getTimeoutDuration(), 147 mTextClassificationHelper::classifyText, 148 this::startLinkActionMode, 149 mTextClassificationHelper::getOriginalSelection) 150 .execute(); 151 } 152 } 153 154 public void invalidateActionModeAsync() { 155 cancelAsyncTask(); 156 if (skipTextClassification()) { 157 invalidateActionMode(null); 158 } else { 159 resetTextClassificationHelper(); 160 mTextClassificationAsyncTask = new TextClassificationAsyncTask( 161 mTextView, 162 mTextClassificationHelper.getTimeoutDuration(), 163 mTextClassificationHelper::classifyText, 164 this::invalidateActionMode, 165 mTextClassificationHelper::getOriginalSelection) 166 .execute(); 167 } 168 } 169 170 public void onSelectionAction(int menuItemId) { 171 mSelectionTracker.onSelectionAction( 172 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), 173 getActionType(menuItemId), mTextClassification); 174 } 175 176 public void onSelectionDrag() { 177 mSelectionTracker.onSelectionAction( 178 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), 179 SelectionEvent.ACTION_DRAG, mTextClassification); 180 } 181 182 public void onTextChanged(int start, int end) { 183 mSelectionTracker.onTextChanged(start, end, mTextClassification); 184 } 185 186 public boolean resetSelection(int textIndex) { 187 if (mSelectionTracker.resetSelection(textIndex, mEditor)) { 188 invalidateActionModeAsync(); 189 return true; 190 } 191 return false; 192 } 193 194 @Nullable 195 public TextClassification getTextClassification() { 196 return mTextClassification; 197 } 198 199 public void onDestroyActionMode() { 200 cancelSmartSelectAnimation(); 201 mSelectionTracker.onSelectionDestroyed(); 202 cancelAsyncTask(); 203 } 204 205 public void onDraw(final Canvas canvas) { 206 if (isDrawingHighlight() && mSmartSelectSprite != null) { 207 mSmartSelectSprite.draw(canvas); 208 } 209 } 210 211 public boolean isDrawingHighlight() { 212 return mSmartSelectSprite != null && mSmartSelectSprite.isAnimationActive(); 213 } 214 215 private void cancelAsyncTask() { 216 if (mTextClassificationAsyncTask != null) { 217 mTextClassificationAsyncTask.cancel(true); 218 mTextClassificationAsyncTask = null; 219 } 220 mTextClassification = null; 221 } 222 223 private boolean skipTextClassification() { 224 // No need to make an async call for a no-op TextClassifier. 225 final boolean noOpTextClassifier = mTextView.usesNoOpTextClassifier(); 226 // Do not call the TextClassifier if there is no selection. 227 final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart(); 228 // Do not call the TextClassifier if this is a password field. 229 final boolean password = mTextView.hasPasswordTransformationMethod() 230 || TextView.isPasswordInputType(mTextView.getInputType()); 231 return noOpTextClassifier || noSelection || password; 232 } 233 234 private void startLinkActionMode(@Nullable SelectionResult result) { 235 startActionMode(Editor.TextActionMode.TEXT_LINK, result); 236 } 237 238 private void startSelectionActionMode(@Nullable SelectionResult result) { 239 startActionMode(Editor.TextActionMode.SELECTION, result); 240 } 241 242 private void startActionMode( 243 @Editor.TextActionMode int actionMode, @Nullable SelectionResult result) { 244 final CharSequence text = getText(mTextView); 245 if (result != null && text instanceof Spannable 246 && (mTextView.isTextSelectable() || mTextView.isTextEditable())) { 247 // Do not change the selection if TextClassifier should be dark launched. 248 if (!mTextClassificationSettings.isModelDarkLaunchEnabled()) { 249 Selection.setSelection((Spannable) text, result.mStart, result.mEnd); 250 mTextView.invalidate(); 251 } 252 mTextClassification = result.mClassification; 253 } else if (result != null && actionMode == Editor.TextActionMode.TEXT_LINK) { 254 mTextClassification = result.mClassification; 255 } else { 256 mTextClassification = null; 257 } 258 if (mEditor.startActionModeInternal(actionMode)) { 259 final SelectionModifierCursorController controller = mEditor.getSelectionController(); 260 if (controller != null 261 && (mTextView.isTextSelectable() || mTextView.isTextEditable())) { 262 controller.show(); 263 } 264 if (result != null) { 265 switch (actionMode) { 266 case Editor.TextActionMode.SELECTION: 267 mSelectionTracker.onSmartSelection(result); 268 break; 269 case Editor.TextActionMode.TEXT_LINK: 270 mSelectionTracker.onLinkSelected(result); 271 break; 272 default: 273 break; 274 } 275 } 276 } 277 mEditor.setRestartActionModeOnNextRefresh(false); 278 mTextClassificationAsyncTask = null; 279 } 280 281 private void startSelectionActionModeWithSmartSelectAnimation( 282 @Nullable SelectionResult result) { 283 final Layout layout = mTextView.getLayout(); 284 285 final Runnable onAnimationEndCallback = () -> startSelectionActionMode(result); 286 // TODO do not trigger the animation if the change included only non-printable characters 287 final boolean didSelectionChange = 288 result != null && (mTextView.getSelectionStart() != result.mStart 289 || mTextView.getSelectionEnd() != result.mEnd); 290 291 if (!didSelectionChange) { 292 onAnimationEndCallback.run(); 293 return; 294 } 295 296 final List<SmartSelectSprite.RectangleWithTextSelectionLayout> selectionRectangles = 297 convertSelectionToRectangles(layout, result.mStart, result.mEnd); 298 299 final PointF touchPoint = new PointF( 300 mEditor.getLastUpPositionX(), 301 mEditor.getLastUpPositionY()); 302 303 final PointF animationStartPoint = 304 movePointInsideNearestRectangle(touchPoint, selectionRectangles, 305 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle); 306 307 mSmartSelectSprite.startAnimation( 308 animationStartPoint, 309 selectionRectangles, 310 onAnimationEndCallback); 311 } 312 313 private List<SmartSelectSprite.RectangleWithTextSelectionLayout> convertSelectionToRectangles( 314 final Layout layout, final int start, final int end) { 315 final List<SmartSelectSprite.RectangleWithTextSelectionLayout> result = new ArrayList<>(); 316 317 final Layout.SelectionRectangleConsumer consumer = 318 (left, top, right, bottom, textSelectionLayout) -> mergeRectangleIntoList( 319 result, 320 new RectF(left, top, right, bottom), 321 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, 322 r -> new SmartSelectSprite.RectangleWithTextSelectionLayout(r, 323 textSelectionLayout) 324 ); 325 326 layout.getSelection(start, end, consumer); 327 328 result.sort(Comparator.comparing( 329 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, 330 SmartSelectSprite.RECTANGLE_COMPARATOR)); 331 332 return result; 333 } 334 335 // TODO: Move public pure functions out of this class and make it package-private. 336 /** 337 * Merges a {@link RectF} into an existing list of any objects which contain a rectangle. 338 * While merging, this method makes sure that: 339 * 340 * <ol> 341 * <li>No rectangle is redundant (contained within a bigger rectangle)</li> 342 * <li>Rectangles of the same height and vertical position that intersect get merged</li> 343 * </ol> 344 * 345 * @param list the list of rectangles (or other rectangle containers) to merge the new 346 * rectangle into 347 * @param candidate the {@link RectF} to merge into the list 348 * @param extractor a function that can extract a {@link RectF} from an element of the given 349 * list 350 * @param packer a function that can wrap the resulting {@link RectF} into an element that 351 * the list contains 352 * @hide 353 */ 354 @VisibleForTesting 355 public static <T> void mergeRectangleIntoList(final List<T> list, 356 final RectF candidate, final Function<T, RectF> extractor, 357 final Function<RectF, T> packer) { 358 if (candidate.isEmpty()) { 359 return; 360 } 361 362 final int elementCount = list.size(); 363 for (int index = 0; index < elementCount; ++index) { 364 final RectF existingRectangle = extractor.apply(list.get(index)); 365 if (existingRectangle.contains(candidate)) { 366 return; 367 } 368 if (candidate.contains(existingRectangle)) { 369 existingRectangle.setEmpty(); 370 continue; 371 } 372 373 final boolean rectanglesContinueEachOther = candidate.left == existingRectangle.right 374 || candidate.right == existingRectangle.left; 375 final boolean canMerge = candidate.top == existingRectangle.top 376 && candidate.bottom == existingRectangle.bottom 377 && (RectF.intersects(candidate, existingRectangle) 378 || rectanglesContinueEachOther); 379 380 if (canMerge) { 381 candidate.union(existingRectangle); 382 existingRectangle.setEmpty(); 383 } 384 } 385 386 for (int index = elementCount - 1; index >= 0; --index) { 387 final RectF rectangle = extractor.apply(list.get(index)); 388 if (rectangle.isEmpty()) { 389 list.remove(index); 390 } 391 } 392 393 list.add(packer.apply(candidate)); 394 } 395 396 397 /** @hide */ 398 @VisibleForTesting 399 public static <T> PointF movePointInsideNearestRectangle(final PointF point, 400 final List<T> list, final Function<T, RectF> extractor) { 401 float bestX = -1; 402 float bestY = -1; 403 double bestDistance = Double.MAX_VALUE; 404 405 final int elementCount = list.size(); 406 for (int index = 0; index < elementCount; ++index) { 407 final RectF rectangle = extractor.apply(list.get(index)); 408 final float candidateY = rectangle.centerY(); 409 final float candidateX; 410 411 if (point.x > rectangle.right) { 412 candidateX = rectangle.right; 413 } else if (point.x < rectangle.left) { 414 candidateX = rectangle.left; 415 } else { 416 candidateX = point.x; 417 } 418 419 final double candidateDistance = Math.pow(point.x - candidateX, 2) 420 + Math.pow(point.y - candidateY, 2); 421 422 if (candidateDistance < bestDistance) { 423 bestX = candidateX; 424 bestY = candidateY; 425 bestDistance = candidateDistance; 426 } 427 } 428 429 return new PointF(bestX, bestY); 430 } 431 432 private void invalidateActionMode(@Nullable SelectionResult result) { 433 cancelSmartSelectAnimation(); 434 mTextClassification = result != null ? result.mClassification : null; 435 final ActionMode actionMode = mEditor.getTextActionMode(); 436 if (actionMode != null) { 437 actionMode.invalidate(); 438 } 439 mSelectionTracker.onSelectionUpdated( 440 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), mTextClassification); 441 mTextClassificationAsyncTask = null; 442 } 443 444 private void resetTextClassificationHelper(int selectionStart, int selectionEnd) { 445 if (selectionStart < 0 || selectionEnd < 0) { 446 // Use selection indices 447 selectionStart = mTextView.getSelectionStart(); 448 selectionEnd = mTextView.getSelectionEnd(); 449 } 450 mTextClassificationHelper.init( 451 mTextView::getTextClassifier, 452 getText(mTextView), 453 selectionStart, selectionEnd, 454 mTextView.getTextLocales()); 455 } 456 457 private void resetTextClassificationHelper() { 458 resetTextClassificationHelper(-1, -1); 459 } 460 461 private void cancelSmartSelectAnimation() { 462 if (mSmartSelectSprite != null) { 463 mSmartSelectSprite.cancelAnimation(); 464 } 465 } 466 467 /** 468 * Tracks and logs smart selection changes. 469 * It is important to trigger this object's methods at the appropriate event so that it tracks 470 * smart selection events appropriately. 471 */ 472 private static final class SelectionTracker { 473 474 private final TextView mTextView; 475 private SelectionMetricsLogger mLogger; 476 477 private int mOriginalStart; 478 private int mOriginalEnd; 479 private int mSelectionStart; 480 private int mSelectionEnd; 481 private boolean mAllowReset; 482 private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable(); 483 484 SelectionTracker(TextView textView) { 485 mTextView = Preconditions.checkNotNull(textView); 486 mLogger = new SelectionMetricsLogger(textView); 487 } 488 489 /** 490 * Called when the original selection happens, before smart selection is triggered. 491 */ 492 public void onOriginalSelection( 493 CharSequence text, int selectionStart, int selectionEnd, boolean isLink) { 494 // If we abandoned a selection and created a new one very shortly after, we may still 495 // have a pending request to log ABANDON, which we flush here. 496 mDelayedLogAbandon.flush(); 497 498 mOriginalStart = mSelectionStart = selectionStart; 499 mOriginalEnd = mSelectionEnd = selectionEnd; 500 mAllowReset = false; 501 maybeInvalidateLogger(); 502 mLogger.logSelectionStarted(mTextView.getTextClassificationSession(), 503 text, selectionStart, 504 isLink ? SelectionEvent.INVOCATION_LINK : SelectionEvent.INVOCATION_MANUAL); 505 } 506 507 /** 508 * Called when selection action mode is started and the results come from a classifier. 509 */ 510 public void onSmartSelection(SelectionResult result) { 511 onClassifiedSelection(result); 512 mLogger.logSelectionModified( 513 result.mStart, result.mEnd, result.mClassification, result.mSelection); 514 } 515 516 /** 517 * Called when link action mode is started and the classification comes from a classifier. 518 */ 519 public void onLinkSelected(SelectionResult result) { 520 onClassifiedSelection(result); 521 // TODO: log (b/70246800) 522 } 523 524 private void onClassifiedSelection(SelectionResult result) { 525 if (isSelectionStarted()) { 526 mSelectionStart = result.mStart; 527 mSelectionEnd = result.mEnd; 528 mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; 529 } 530 } 531 532 /** 533 * Called when selection bounds change. 534 */ 535 public void onSelectionUpdated( 536 int selectionStart, int selectionEnd, 537 @Nullable TextClassification classification) { 538 if (isSelectionStarted()) { 539 mSelectionStart = selectionStart; 540 mSelectionEnd = selectionEnd; 541 mAllowReset = false; 542 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null); 543 } 544 } 545 546 /** 547 * Called when the selection action mode is destroyed. 548 */ 549 public void onSelectionDestroyed() { 550 mAllowReset = false; 551 // Wait a few ms to see if the selection was destroyed because of a text change event. 552 mDelayedLogAbandon.schedule(100 /* ms */); 553 } 554 555 /** 556 * Called when an action is taken on a smart selection. 557 */ 558 public void onSelectionAction( 559 int selectionStart, int selectionEnd, 560 @SelectionEvent.ActionType int action, 561 @Nullable TextClassification classification) { 562 if (isSelectionStarted()) { 563 mAllowReset = false; 564 mLogger.logSelectionAction(selectionStart, selectionEnd, action, classification); 565 } 566 } 567 568 /** 569 * Returns true if the current smart selection should be reset to normal selection based on 570 * information that has been recorded about the original selection and the smart selection. 571 * The expected UX here is to allow the user to select a word inside of the smart selection 572 * on a single tap. 573 */ 574 public boolean resetSelection(int textIndex, Editor editor) { 575 final TextView textView = editor.getTextView(); 576 if (isSelectionStarted() 577 && mAllowReset 578 && textIndex >= mSelectionStart && textIndex <= mSelectionEnd 579 && getText(textView) instanceof Spannable) { 580 mAllowReset = false; 581 boolean selected = editor.selectCurrentWord(); 582 if (selected) { 583 mSelectionStart = editor.getTextView().getSelectionStart(); 584 mSelectionEnd = editor.getTextView().getSelectionEnd(); 585 mLogger.logSelectionAction( 586 textView.getSelectionStart(), textView.getSelectionEnd(), 587 SelectionEvent.ACTION_RESET, null /* classification */); 588 } 589 return selected; 590 } 591 return false; 592 } 593 594 public void onTextChanged(int start, int end, TextClassification classification) { 595 if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) { 596 onSelectionAction(start, end, SelectionEvent.ACTION_OVERTYPE, classification); 597 } 598 } 599 600 private void maybeInvalidateLogger() { 601 if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) { 602 mLogger = new SelectionMetricsLogger(mTextView); 603 } 604 } 605 606 private boolean isSelectionStarted() { 607 return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd; 608 } 609 610 /** A helper for keeping track of pending abandon logging requests. */ 611 private final class LogAbandonRunnable implements Runnable { 612 private boolean mIsPending; 613 614 /** Schedules an abandon to be logged with the given delay. Flush if necessary. */ 615 void schedule(int delayMillis) { 616 if (mIsPending) { 617 Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request"); 618 flush(); 619 } 620 mIsPending = true; 621 mTextView.postDelayed(this, delayMillis); 622 } 623 624 /** If there is a pending log request, execute it now. */ 625 void flush() { 626 mTextView.removeCallbacks(this); 627 run(); 628 } 629 630 @Override 631 public void run() { 632 if (mIsPending) { 633 mLogger.logSelectionAction( 634 mSelectionStart, mSelectionEnd, 635 SelectionEvent.ACTION_ABANDON, null /* classification */); 636 mSelectionStart = mSelectionEnd = -1; 637 mTextView.getTextClassificationSession().destroy(); 638 mIsPending = false; 639 } 640 } 641 } 642 } 643 644 // TODO: Write tests 645 /** 646 * Metrics logging helper. 647 * 648 * This logger logs selection by word indices. The initial (start) single word selection is 649 * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the 650 * initial single word selection. 651 * e.g. New York city, NY. Suppose the initial selection is "York" in 652 * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2). 653 * "New York" is at [-1, 1). 654 * Part selection of a word e.g. "or" is counted as selecting the 655 * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g. 656 * "," is at [2, 3). Whitespaces are ignored. 657 * 658 * NOTE that the definition of a word is defined by the TextClassifier's Logger's token 659 * iterator. 660 */ 661 private static final class SelectionMetricsLogger { 662 663 private static final String LOG_TAG = "SelectionMetricsLogger"; 664 private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+"); 665 666 private final boolean mEditTextLogger; 667 private final BreakIterator mTokenIterator; 668 669 @Nullable private TextClassifier mClassificationSession; 670 private int mStartIndex; 671 private String mText; 672 673 SelectionMetricsLogger(TextView textView) { 674 Preconditions.checkNotNull(textView); 675 mEditTextLogger = textView.isTextEditable(); 676 mTokenIterator = SelectionSessionLogger.getTokenIterator(textView.getTextLocale()); 677 } 678 679 @TextClassifier.WidgetType 680 private static String getWidetType(TextView textView) { 681 if (textView.isTextEditable()) { 682 return TextClassifier.WIDGET_TYPE_EDITTEXT; 683 } 684 if (textView.isTextSelectable()) { 685 return TextClassifier.WIDGET_TYPE_TEXTVIEW; 686 } 687 return TextClassifier.WIDGET_TYPE_UNSELECTABLE_TEXTVIEW; 688 } 689 690 public void logSelectionStarted( 691 TextClassifier classificationSession, 692 CharSequence text, int index, 693 @InvocationMethod int invocationMethod) { 694 try { 695 Preconditions.checkNotNull(text); 696 Preconditions.checkArgumentInRange(index, 0, text.length(), "index"); 697 if (mText == null || !mText.contentEquals(text)) { 698 mText = text.toString(); 699 } 700 mTokenIterator.setText(mText); 701 mStartIndex = index; 702 mClassificationSession = classificationSession; 703 mClassificationSession.onSelectionEvent( 704 SelectionEvent.createSelectionStartedEvent(invocationMethod, 0)); 705 } catch (Exception e) { 706 // Avoid crashes due to logging. 707 Log.e(LOG_TAG, "" + e.getMessage(), e); 708 } 709 } 710 711 public void logSelectionModified(int start, int end, 712 @Nullable TextClassification classification, @Nullable TextSelection selection) { 713 try { 714 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); 715 Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); 716 int[] wordIndices = getWordDelta(start, end); 717 if (selection != null) { 718 if (mClassificationSession != null) { 719 mClassificationSession.onSelectionEvent( 720 SelectionEvent.createSelectionModifiedEvent( 721 wordIndices[0], wordIndices[1], selection)); 722 } 723 } else if (classification != null) { 724 if (mClassificationSession != null) { 725 mClassificationSession.onSelectionEvent( 726 SelectionEvent.createSelectionModifiedEvent( 727 wordIndices[0], wordIndices[1], classification)); 728 } 729 } else { 730 if (mClassificationSession != null) { 731 mClassificationSession.onSelectionEvent( 732 SelectionEvent.createSelectionModifiedEvent( 733 wordIndices[0], wordIndices[1])); 734 } 735 } 736 } catch (Exception e) { 737 // Avoid crashes due to logging. 738 Log.e(LOG_TAG, "" + e.getMessage(), e); 739 } 740 } 741 742 public void logSelectionAction( 743 int start, int end, 744 @SelectionEvent.ActionType int action, 745 @Nullable TextClassification classification) { 746 try { 747 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); 748 Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); 749 int[] wordIndices = getWordDelta(start, end); 750 if (classification != null) { 751 if (mClassificationSession != null) { 752 mClassificationSession.onSelectionEvent( 753 SelectionEvent.createSelectionActionEvent( 754 wordIndices[0], wordIndices[1], action, classification)); 755 } 756 } else { 757 if (mClassificationSession != null) { 758 mClassificationSession.onSelectionEvent( 759 SelectionEvent.createSelectionActionEvent( 760 wordIndices[0], wordIndices[1], action)); 761 } 762 } 763 } catch (Exception e) { 764 // Avoid crashes due to logging. 765 Log.e(LOG_TAG, "" + e.getMessage(), e); 766 } 767 } 768 769 public boolean isEditTextLogger() { 770 return mEditTextLogger; 771 } 772 773 private int[] getWordDelta(int start, int end) { 774 int[] wordIndices = new int[2]; 775 776 if (start == mStartIndex) { 777 wordIndices[0] = 0; 778 } else if (start < mStartIndex) { 779 wordIndices[0] = -countWordsForward(start); 780 } else { // start > mStartIndex 781 wordIndices[0] = countWordsBackward(start); 782 783 // For the selection start index, avoid counting a partial word backwards. 784 if (!mTokenIterator.isBoundary(start) 785 && !isWhitespace( 786 mTokenIterator.preceding(start), 787 mTokenIterator.following(start))) { 788 // We counted a partial word. Remove it. 789 wordIndices[0]--; 790 } 791 } 792 793 if (end == mStartIndex) { 794 wordIndices[1] = 0; 795 } else if (end < mStartIndex) { 796 wordIndices[1] = -countWordsForward(end); 797 } else { // end > mStartIndex 798 wordIndices[1] = countWordsBackward(end); 799 } 800 801 return wordIndices; 802 } 803 804 private int countWordsBackward(int from) { 805 Preconditions.checkArgument(from >= mStartIndex); 806 int wordCount = 0; 807 int offset = from; 808 while (offset > mStartIndex) { 809 int start = mTokenIterator.preceding(offset); 810 if (!isWhitespace(start, offset)) { 811 wordCount++; 812 } 813 offset = start; 814 } 815 return wordCount; 816 } 817 818 private int countWordsForward(int from) { 819 Preconditions.checkArgument(from <= mStartIndex); 820 int wordCount = 0; 821 int offset = from; 822 while (offset < mStartIndex) { 823 int end = mTokenIterator.following(offset); 824 if (!isWhitespace(offset, end)) { 825 wordCount++; 826 } 827 offset = end; 828 } 829 return wordCount; 830 } 831 832 private boolean isWhitespace(int start, int end) { 833 return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches(); 834 } 835 } 836 837 /** 838 * AsyncTask for running a query on a background thread and returning the result on the 839 * UiThread. The AsyncTask times out after a specified time, returning a null result if the 840 * query has not yet returned. 841 */ 842 private static final class TextClassificationAsyncTask 843 extends AsyncTask<Void, Void, SelectionResult> { 844 845 private final int mTimeOutDuration; 846 private final Supplier<SelectionResult> mSelectionResultSupplier; 847 private final Consumer<SelectionResult> mSelectionResultCallback; 848 private final Supplier<SelectionResult> mTimeOutResultSupplier; 849 private final TextView mTextView; 850 private final String mOriginalText; 851 852 /** 853 * @param textView the TextView 854 * @param timeOut time in milliseconds to timeout the query if it has not completed 855 * @param selectionResultSupplier fetches the selection results. Runs on a background thread 856 * @param selectionResultCallback receives the selection results. Runs on the UiThread 857 * @param timeOutResultSupplier default result if the task times out 858 */ 859 TextClassificationAsyncTask( 860 @NonNull TextView textView, int timeOut, 861 @NonNull Supplier<SelectionResult> selectionResultSupplier, 862 @NonNull Consumer<SelectionResult> selectionResultCallback, 863 @NonNull Supplier<SelectionResult> timeOutResultSupplier) { 864 super(textView != null ? textView.getHandler() : null); 865 mTextView = Preconditions.checkNotNull(textView); 866 mTimeOutDuration = timeOut; 867 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier); 868 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback); 869 mTimeOutResultSupplier = Preconditions.checkNotNull(timeOutResultSupplier); 870 // Make a copy of the original text. 871 mOriginalText = getText(mTextView).toString(); 872 } 873 874 @Override 875 @WorkerThread 876 protected SelectionResult doInBackground(Void... params) { 877 final Runnable onTimeOut = this::onTimeOut; 878 mTextView.postDelayed(onTimeOut, mTimeOutDuration); 879 final SelectionResult result = mSelectionResultSupplier.get(); 880 mTextView.removeCallbacks(onTimeOut); 881 return result; 882 } 883 884 @Override 885 @UiThread 886 protected void onPostExecute(SelectionResult result) { 887 result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null; 888 mSelectionResultCallback.accept(result); 889 } 890 891 private void onTimeOut() { 892 if (getStatus() == Status.RUNNING) { 893 onPostExecute(mTimeOutResultSupplier.get()); 894 } 895 cancel(true); 896 } 897 } 898 899 /** 900 * Helper class for querying the TextClassifier. 901 * It trims text so that only text necessary to provide context of the selected text is 902 * sent to the TextClassifier. 903 */ 904 private static final class TextClassificationHelper { 905 906 private static final int TRIM_DELTA = 120; // characters 907 908 private final Context mContext; 909 private final boolean mDarkLaunchEnabled; 910 private Supplier<TextClassifier> mTextClassifier; 911 912 /** The original TextView text. **/ 913 private String mText; 914 /** Start index relative to mText. */ 915 private int mSelectionStart; 916 /** End index relative to mText. */ 917 private int mSelectionEnd; 918 919 @Nullable 920 private LocaleList mDefaultLocales; 921 922 /** Trimmed text starting from mTrimStart in mText. */ 923 private CharSequence mTrimmedText; 924 /** Index indicating the start of mTrimmedText in mText. */ 925 private int mTrimStart; 926 /** Start index relative to mTrimmedText */ 927 private int mRelativeStart; 928 /** End index relative to mTrimmedText */ 929 private int mRelativeEnd; 930 931 /** Information about the last classified text to avoid re-running a query. */ 932 private CharSequence mLastClassificationText; 933 private int mLastClassificationSelectionStart; 934 private int mLastClassificationSelectionEnd; 935 private LocaleList mLastClassificationLocales; 936 private SelectionResult mLastClassificationResult; 937 938 /** Whether the TextClassifier has been initialized. */ 939 private boolean mHot; 940 941 TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier, 942 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { 943 init(textClassifier, text, selectionStart, selectionEnd, locales); 944 mContext = Preconditions.checkNotNull(context); 945 mDarkLaunchEnabled = TextClassificationManager.getSettings(mContext) 946 .isModelDarkLaunchEnabled(); 947 } 948 949 @UiThread 950 public void init(Supplier<TextClassifier> textClassifier, CharSequence text, 951 int selectionStart, int selectionEnd, LocaleList locales) { 952 mTextClassifier = Preconditions.checkNotNull(textClassifier); 953 mText = Preconditions.checkNotNull(text).toString(); 954 mLastClassificationText = null; // invalidate. 955 Preconditions.checkArgument(selectionEnd > selectionStart); 956 mSelectionStart = selectionStart; 957 mSelectionEnd = selectionEnd; 958 mDefaultLocales = locales; 959 } 960 961 @WorkerThread 962 public SelectionResult classifyText() { 963 mHot = true; 964 return performClassification(null /* selection */); 965 } 966 967 @WorkerThread 968 public SelectionResult suggestSelection() { 969 mHot = true; 970 trimText(); 971 final TextSelection selection; 972 if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.O_MR1) { 973 final TextSelection.Request request = new TextSelection.Request.Builder( 974 mTrimmedText, mRelativeStart, mRelativeEnd) 975 .setDefaultLocales(mDefaultLocales) 976 .setDarkLaunchAllowed(true) 977 .build(); 978 selection = mTextClassifier.get().suggestSelection(request); 979 } else { 980 // Use old APIs. 981 selection = mTextClassifier.get().suggestSelection( 982 mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales); 983 } 984 // Do not classify new selection boundaries if TextClassifier should be dark launched. 985 if (!mDarkLaunchEnabled) { 986 mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart); 987 mSelectionEnd = Math.min( 988 mText.length(), selection.getSelectionEndIndex() + mTrimStart); 989 } 990 return performClassification(selection); 991 } 992 993 public SelectionResult getOriginalSelection() { 994 return new SelectionResult(mSelectionStart, mSelectionEnd, null, null); 995 } 996 997 /** 998 * Maximum time (in milliseconds) to wait for a textclassifier result before timing out. 999 */ 1000 // TODO: Consider making this a ViewConfiguration. 1001 public int getTimeoutDuration() { 1002 if (mHot) { 1003 return 200; 1004 } else { 1005 // Return a slightly larger number than usual when the TextClassifier is first 1006 // initialized. Initialization would usually take longer than subsequent calls to 1007 // the TextClassifier. The impact of this on the UI is that we do not show the 1008 // selection handles or toolbar until after this timeout. 1009 return 500; 1010 } 1011 } 1012 1013 private SelectionResult performClassification(@Nullable TextSelection selection) { 1014 if (!Objects.equals(mText, mLastClassificationText) 1015 || mSelectionStart != mLastClassificationSelectionStart 1016 || mSelectionEnd != mLastClassificationSelectionEnd 1017 || !Objects.equals(mDefaultLocales, mLastClassificationLocales)) { 1018 1019 mLastClassificationText = mText; 1020 mLastClassificationSelectionStart = mSelectionStart; 1021 mLastClassificationSelectionEnd = mSelectionEnd; 1022 mLastClassificationLocales = mDefaultLocales; 1023 1024 trimText(); 1025 final TextClassification classification; 1026 if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.O_MR1) { 1027 final TextClassification.Request request = 1028 new TextClassification.Request.Builder( 1029 mTrimmedText, mRelativeStart, mRelativeEnd) 1030 .setDefaultLocales(mDefaultLocales) 1031 .build(); 1032 classification = mTextClassifier.get().classifyText(request); 1033 } else { 1034 // Use old APIs. 1035 classification = mTextClassifier.get().classifyText( 1036 mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales); 1037 } 1038 mLastClassificationResult = new SelectionResult( 1039 mSelectionStart, mSelectionEnd, classification, selection); 1040 1041 } 1042 return mLastClassificationResult; 1043 } 1044 1045 private void trimText() { 1046 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); 1047 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); 1048 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd); 1049 mRelativeStart = mSelectionStart - mTrimStart; 1050 mRelativeEnd = mSelectionEnd - mTrimStart; 1051 } 1052 } 1053 1054 /** 1055 * Selection result. 1056 */ 1057 private static final class SelectionResult { 1058 private final int mStart; 1059 private final int mEnd; 1060 @Nullable private final TextClassification mClassification; 1061 @Nullable private final TextSelection mSelection; 1062 1063 SelectionResult(int start, int end, 1064 @Nullable TextClassification classification, @Nullable TextSelection selection) { 1065 mStart = start; 1066 mEnd = end; 1067 mClassification = classification; 1068 mSelection = selection; 1069 } 1070 } 1071 1072 @SelectionEvent.ActionType 1073 private static int getActionType(int menuItemId) { 1074 switch (menuItemId) { 1075 case TextView.ID_SELECT_ALL: 1076 return SelectionEvent.ACTION_SELECT_ALL; 1077 case TextView.ID_CUT: 1078 return SelectionEvent.ACTION_CUT; 1079 case TextView.ID_COPY: 1080 return SelectionEvent.ACTION_COPY; 1081 case TextView.ID_PASTE: // fall through 1082 case TextView.ID_PASTE_AS_PLAIN_TEXT: 1083 return SelectionEvent.ACTION_PASTE; 1084 case TextView.ID_SHARE: 1085 return SelectionEvent.ACTION_SHARE; 1086 case TextView.ID_ASSIST: 1087 return SelectionEvent.ACTION_SMART_SHARE; 1088 default: 1089 return SelectionEvent.ACTION_OTHER; 1090 } 1091 } 1092 1093 private static CharSequence getText(TextView textView) { 1094 // Extracts the textView's text. 1095 // TODO: Investigate why/when TextView.getText() is null. 1096 final CharSequence text = textView.getText(); 1097 if (text != null) { 1098 return text; 1099 } 1100 return ""; 1101 } 1102} 1103