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