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