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