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.textclassifier.ExtrasUtils; 40 import android.view.textclassifier.SelectionEvent; 41 import android.view.textclassifier.SelectionEvent.InvocationMethod; 42 import android.view.textclassifier.SelectionSessionLogger; 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 = Preconditions.checkNotNull(editor); 91 mTextView = mEditor.getTextView(); 92 mTextClassificationHelper = new TextClassificationHelper( 93 mTextView.getContext(), 94 mTextView::getTextClassifier, 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 if (mEditor.startActionModeInternal(actionMode)) { 301 final SelectionModifierCursorController controller = mEditor.getSelectionController(); 302 if (controller != null 303 && (mTextView.isTextSelectable() || mTextView.isTextEditable())) { 304 controller.show(); 305 } 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 343 if (!didSelectionChange) { 344 onAnimationEndCallback.run(); 345 return; 346 } 347 348 final List<SmartSelectSprite.RectangleWithTextSelectionLayout> selectionRectangles = 349 convertSelectionToRectangles(layout, result.mStart, result.mEnd); 350 351 final PointF touchPoint = new PointF( 352 mEditor.getLastUpPositionX(), 353 mEditor.getLastUpPositionY()); 354 355 final PointF animationStartPoint = 356 movePointInsideNearestRectangle(touchPoint, selectionRectangles, 357 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle); 358 359 mSmartSelectSprite.startAnimation( 360 animationStartPoint, 361 selectionRectangles, 362 onAnimationEndCallback); 363 } 364 convertSelectionToRectangles( final Layout layout, final int start, final int end)365 private List<SmartSelectSprite.RectangleWithTextSelectionLayout> convertSelectionToRectangles( 366 final Layout layout, final int start, final int end) { 367 final List<SmartSelectSprite.RectangleWithTextSelectionLayout> result = new ArrayList<>(); 368 369 final Layout.SelectionRectangleConsumer consumer = 370 (left, top, right, bottom, textSelectionLayout) -> mergeRectangleIntoList( 371 result, 372 new RectF(left, top, right, bottom), 373 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, 374 r -> new SmartSelectSprite.RectangleWithTextSelectionLayout(r, 375 textSelectionLayout) 376 ); 377 378 layout.getSelection(start, end, consumer); 379 380 result.sort(Comparator.comparing( 381 SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, 382 SmartSelectSprite.RECTANGLE_COMPARATOR)); 383 384 return result; 385 } 386 387 // TODO: Move public pure functions out of this class and make it package-private. 388 /** 389 * Merges a {@link RectF} into an existing list of any objects which contain a rectangle. 390 * While merging, this method makes sure that: 391 * 392 * <ol> 393 * <li>No rectangle is redundant (contained within a bigger rectangle)</li> 394 * <li>Rectangles of the same height and vertical position that intersect get merged</li> 395 * </ol> 396 * 397 * @param list the list of rectangles (or other rectangle containers) to merge the new 398 * rectangle into 399 * @param candidate the {@link RectF} to merge into the list 400 * @param extractor a function that can extract a {@link RectF} from an element of the given 401 * list 402 * @param packer a function that can wrap the resulting {@link RectF} into an element that 403 * the list contains 404 * @hide 405 */ 406 @VisibleForTesting mergeRectangleIntoList(final List<T> list, final RectF candidate, final Function<T, RectF> extractor, final Function<RectF, T> packer)407 public static <T> void mergeRectangleIntoList(final List<T> list, 408 final RectF candidate, final Function<T, RectF> extractor, 409 final Function<RectF, T> packer) { 410 if (candidate.isEmpty()) { 411 return; 412 } 413 414 final int elementCount = list.size(); 415 for (int index = 0; index < elementCount; ++index) { 416 final RectF existingRectangle = extractor.apply(list.get(index)); 417 if (existingRectangle.contains(candidate)) { 418 return; 419 } 420 if (candidate.contains(existingRectangle)) { 421 existingRectangle.setEmpty(); 422 continue; 423 } 424 425 final boolean rectanglesContinueEachOther = candidate.left == existingRectangle.right 426 || candidate.right == existingRectangle.left; 427 final boolean canMerge = candidate.top == existingRectangle.top 428 && candidate.bottom == existingRectangle.bottom 429 && (RectF.intersects(candidate, existingRectangle) 430 || rectanglesContinueEachOther); 431 432 if (canMerge) { 433 candidate.union(existingRectangle); 434 existingRectangle.setEmpty(); 435 } 436 } 437 438 for (int index = elementCount - 1; index >= 0; --index) { 439 final RectF rectangle = extractor.apply(list.get(index)); 440 if (rectangle.isEmpty()) { 441 list.remove(index); 442 } 443 } 444 445 list.add(packer.apply(candidate)); 446 } 447 448 449 /** @hide */ 450 @VisibleForTesting movePointInsideNearestRectangle(final PointF point, final List<T> list, final Function<T, RectF> extractor)451 public static <T> PointF movePointInsideNearestRectangle(final PointF point, 452 final List<T> list, final Function<T, RectF> extractor) { 453 float bestX = -1; 454 float bestY = -1; 455 double bestDistance = Double.MAX_VALUE; 456 457 final int elementCount = list.size(); 458 for (int index = 0; index < elementCount; ++index) { 459 final RectF rectangle = extractor.apply(list.get(index)); 460 final float candidateY = rectangle.centerY(); 461 final float candidateX; 462 463 if (point.x > rectangle.right) { 464 candidateX = rectangle.right; 465 } else if (point.x < rectangle.left) { 466 candidateX = rectangle.left; 467 } else { 468 candidateX = point.x; 469 } 470 471 final double candidateDistance = Math.pow(point.x - candidateX, 2) 472 + Math.pow(point.y - candidateY, 2); 473 474 if (candidateDistance < bestDistance) { 475 bestX = candidateX; 476 bestY = candidateY; 477 bestDistance = candidateDistance; 478 } 479 } 480 481 return new PointF(bestX, bestY); 482 } 483 invalidateActionMode(@ullable SelectionResult result)484 private void invalidateActionMode(@Nullable SelectionResult result) { 485 cancelSmartSelectAnimation(); 486 mTextClassification = result != null ? result.mClassification : null; 487 final ActionMode actionMode = mEditor.getTextActionMode(); 488 if (actionMode != null) { 489 actionMode.invalidate(); 490 } 491 final int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); 492 mSelectionTracker.onSelectionUpdated( 493 sortedSelectionIndices[0], sortedSelectionIndices[1], mTextClassification); 494 mTextClassificationAsyncTask = null; 495 } 496 resetTextClassificationHelper(int selectionStart, int selectionEnd)497 private void resetTextClassificationHelper(int selectionStart, int selectionEnd) { 498 if (selectionStart < 0 || selectionEnd < 0) { 499 // Use selection indices 500 int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); 501 selectionStart = sortedSelectionIndices[0]; 502 selectionEnd = sortedSelectionIndices[1]; 503 } 504 mTextClassificationHelper.init( 505 mTextView::getTextClassifier, 506 getText(mTextView), 507 selectionStart, selectionEnd, 508 mTextView.getTextLocales()); 509 } 510 resetTextClassificationHelper()511 private void resetTextClassificationHelper() { 512 resetTextClassificationHelper(-1, -1); 513 } 514 cancelSmartSelectAnimation()515 private void cancelSmartSelectAnimation() { 516 if (mSmartSelectSprite != null) { 517 mSmartSelectSprite.cancelAnimation(); 518 } 519 } 520 521 /** 522 * Tracks and logs smart selection changes. 523 * It is important to trigger this object's methods at the appropriate event so that it tracks 524 * smart selection events appropriately. 525 */ 526 private static final class SelectionTracker { 527 528 private final TextView mTextView; 529 private SelectionMetricsLogger mLogger; 530 531 private int mOriginalStart; 532 private int mOriginalEnd; 533 private int mSelectionStart; 534 private int mSelectionEnd; 535 private boolean mAllowReset; 536 private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable(); 537 SelectionTracker(TextView textView)538 SelectionTracker(TextView textView) { 539 mTextView = Preconditions.checkNotNull(textView); 540 mLogger = new SelectionMetricsLogger(textView); 541 } 542 543 /** 544 * Called when the original selection happens, before smart selection is triggered. 545 */ onOriginalSelection( CharSequence text, int selectionStart, int selectionEnd, boolean isLink)546 public void onOriginalSelection( 547 CharSequence text, int selectionStart, int selectionEnd, boolean isLink) { 548 // If we abandoned a selection and created a new one very shortly after, we may still 549 // have a pending request to log ABANDON, which we flush here. 550 mDelayedLogAbandon.flush(); 551 552 mOriginalStart = mSelectionStart = selectionStart; 553 mOriginalEnd = mSelectionEnd = selectionEnd; 554 mAllowReset = false; 555 maybeInvalidateLogger(); 556 mLogger.logSelectionStarted( 557 mTextView.getTextClassificationSession(), 558 mTextView.getTextClassificationContext(), 559 text, 560 selectionStart, 561 isLink ? SelectionEvent.INVOCATION_LINK : SelectionEvent.INVOCATION_MANUAL); 562 } 563 564 /** 565 * Called when selection action mode is started and the results come from a classifier. 566 */ onSmartSelection(SelectionResult result)567 public void onSmartSelection(SelectionResult result) { 568 onClassifiedSelection(result); 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 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null); 600 } 601 } 602 603 /** 604 * Called when the selection action mode is destroyed. 605 */ onSelectionDestroyed()606 public void onSelectionDestroyed() { 607 mAllowReset = false; 608 // Wait a few ms to see if the selection was destroyed because of a text change event. 609 mDelayedLogAbandon.schedule(100 /* ms */); 610 } 611 612 /** 613 * Called when an action is taken on a smart selection. 614 */ onSelectionAction( int selectionStart, int selectionEnd, @SelectionEvent.ActionType int action, @Nullable String actionLabel, @Nullable TextClassification classification)615 public void onSelectionAction( 616 int selectionStart, int selectionEnd, 617 @SelectionEvent.ActionType int action, 618 @Nullable String actionLabel, 619 @Nullable TextClassification classification) { 620 if (isSelectionStarted()) { 621 mAllowReset = false; 622 mLogger.logSelectionAction( 623 selectionStart, selectionEnd, action, actionLabel, classification); 624 } 625 } 626 627 /** 628 * Returns true if the current smart selection should be reset to normal selection based on 629 * information that has been recorded about the original selection and the smart selection. 630 * The expected UX here is to allow the user to select a word inside of the smart selection 631 * on a single tap. 632 */ resetSelection(int textIndex, Editor editor)633 public boolean resetSelection(int textIndex, Editor editor) { 634 final TextView textView = editor.getTextView(); 635 if (isSelectionStarted() 636 && mAllowReset 637 && textIndex >= mSelectionStart && textIndex <= mSelectionEnd 638 && getText(textView) instanceof Spannable) { 639 mAllowReset = false; 640 boolean selected = editor.selectCurrentWord(); 641 if (selected) { 642 final int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(textView); 643 mSelectionStart = sortedSelectionIndices[0]; 644 mSelectionEnd = sortedSelectionIndices[1]; 645 mLogger.logSelectionAction( 646 sortedSelectionIndices[0], sortedSelectionIndices[1], 647 SelectionEvent.ACTION_RESET, 648 /* actionLabel= */ null, /* classification= */ null); 649 } 650 return selected; 651 } 652 return false; 653 } 654 onTextChanged(int start, int end, TextClassification classification)655 public void onTextChanged(int start, int end, TextClassification classification) { 656 if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) { 657 onSelectionAction( 658 start, end, SelectionEvent.ACTION_OVERTYPE, 659 /* actionLabel= */ null, classification); 660 } 661 } 662 maybeInvalidateLogger()663 private void maybeInvalidateLogger() { 664 if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) { 665 mLogger = new SelectionMetricsLogger(mTextView); 666 } 667 } 668 isSelectionStarted()669 private boolean isSelectionStarted() { 670 return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd; 671 } 672 673 /** A helper for keeping track of pending abandon logging requests. */ 674 private final class LogAbandonRunnable implements Runnable { 675 private boolean mIsPending; 676 677 /** Schedules an abandon to be logged with the given delay. Flush if necessary. */ schedule(int delayMillis)678 void schedule(int delayMillis) { 679 if (mIsPending) { 680 Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request"); 681 flush(); 682 } 683 mIsPending = true; 684 mTextView.postDelayed(this, delayMillis); 685 } 686 687 /** If there is a pending log request, execute it now. */ flush()688 void flush() { 689 mTextView.removeCallbacks(this); 690 run(); 691 } 692 693 @Override run()694 public void run() { 695 if (mIsPending) { 696 mLogger.logSelectionAction( 697 mSelectionStart, mSelectionEnd, 698 SelectionEvent.ACTION_ABANDON, 699 /* actionLabel= */ null, /* classification= */ null); 700 mSelectionStart = mSelectionEnd = -1; 701 mLogger.endTextClassificationSession(); 702 mIsPending = false; 703 } 704 } 705 } 706 } 707 708 // TODO: Write tests 709 /** 710 * Metrics logging helper. 711 * 712 * This logger logs selection by word indices. The initial (start) single word selection is 713 * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the 714 * initial single word selection. 715 * e.g. New York city, NY. Suppose the initial selection is "York" in 716 * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2). 717 * "New York" is at [-1, 1). 718 * Part selection of a word e.g. "or" is counted as selecting the 719 * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g. 720 * "," is at [2, 3). Whitespaces are ignored. 721 * 722 * NOTE that the definition of a word is defined by the TextClassifier's Logger's token 723 * iterator. 724 */ 725 private static final class SelectionMetricsLogger { 726 727 private static final String LOG_TAG = "SelectionMetricsLogger"; 728 private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+"); 729 730 private final boolean mEditTextLogger; 731 private final BreakIterator mTokenIterator; 732 733 @Nullable private TextClassifier mClassificationSession; 734 @Nullable private TextClassificationContext mClassificationContext; 735 736 @Nullable private TextClassifierEvent mTranslateViewEvent; 737 @Nullable private TextClassifierEvent mTranslateClickEvent; 738 739 private int mStartIndex; 740 private String mText; 741 SelectionMetricsLogger(TextView textView)742 SelectionMetricsLogger(TextView textView) { 743 Preconditions.checkNotNull(textView); 744 mEditTextLogger = textView.isTextEditable(); 745 mTokenIterator = SelectionSessionLogger.getTokenIterator(textView.getTextLocale()); 746 } 747 logSelectionStarted( TextClassifier classificationSession, TextClassificationContext classificationContext, CharSequence text, int index, @InvocationMethod int invocationMethod)748 public void logSelectionStarted( 749 TextClassifier classificationSession, 750 TextClassificationContext classificationContext, 751 CharSequence text, int index, 752 @InvocationMethod int invocationMethod) { 753 try { 754 Preconditions.checkNotNull(text); 755 Preconditions.checkArgumentInRange(index, 0, text.length(), "index"); 756 if (mText == null || !mText.contentEquals(text)) { 757 mText = text.toString(); 758 } 759 mTokenIterator.setText(mText); 760 mStartIndex = index; 761 mClassificationSession = classificationSession; 762 mClassificationContext = classificationContext; 763 if (hasActiveClassificationSession()) { 764 mClassificationSession.onSelectionEvent( 765 SelectionEvent.createSelectionStartedEvent(invocationMethod, 0)); 766 } 767 } catch (Exception e) { 768 // Avoid crashes due to logging. 769 Log.e(LOG_TAG, "" + e.getMessage(), e); 770 } 771 } 772 logSelectionModified(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)773 public void logSelectionModified(int start, int end, 774 @Nullable TextClassification classification, @Nullable TextSelection selection) { 775 try { 776 if (hasActiveClassificationSession()) { 777 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); 778 Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); 779 int[] wordIndices = getWordDelta(start, end); 780 if (selection != null) { 781 mClassificationSession.onSelectionEvent( 782 SelectionEvent.createSelectionModifiedEvent( 783 wordIndices[0], wordIndices[1], selection)); 784 } else if (classification != null) { 785 mClassificationSession.onSelectionEvent( 786 SelectionEvent.createSelectionModifiedEvent( 787 wordIndices[0], wordIndices[1], classification)); 788 } else { 789 mClassificationSession.onSelectionEvent( 790 SelectionEvent.createSelectionModifiedEvent( 791 wordIndices[0], wordIndices[1])); 792 } 793 maybeGenerateTranslateViewEvent(classification); 794 } 795 } catch (Exception e) { 796 // Avoid crashes due to logging. 797 Log.e(LOG_TAG, "" + e.getMessage(), e); 798 } 799 } 800 logSelectionAction( int start, int end, @SelectionEvent.ActionType int action, @Nullable String actionLabel, @Nullable TextClassification classification)801 public void logSelectionAction( 802 int start, int end, 803 @SelectionEvent.ActionType int action, 804 @Nullable String actionLabel, 805 @Nullable TextClassification classification) { 806 try { 807 if (hasActiveClassificationSession()) { 808 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); 809 Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); 810 int[] wordIndices = getWordDelta(start, end); 811 if (classification != null) { 812 mClassificationSession.onSelectionEvent( 813 SelectionEvent.createSelectionActionEvent( 814 wordIndices[0], wordIndices[1], action, 815 classification)); 816 } else { 817 mClassificationSession.onSelectionEvent( 818 SelectionEvent.createSelectionActionEvent( 819 wordIndices[0], wordIndices[1], action)); 820 } 821 822 maybeGenerateTranslateClickEvent(classification, actionLabel); 823 824 if (SelectionEvent.isTerminal(action)) { 825 endTextClassificationSession(); 826 } 827 } 828 } catch (Exception e) { 829 // Avoid crashes due to logging. 830 Log.e(LOG_TAG, "" + e.getMessage(), e); 831 } 832 } 833 isEditTextLogger()834 public boolean isEditTextLogger() { 835 return mEditTextLogger; 836 } 837 endTextClassificationSession()838 public void endTextClassificationSession() { 839 if (hasActiveClassificationSession()) { 840 maybeReportTranslateEvents(); 841 mClassificationSession.destroy(); 842 } 843 } 844 hasActiveClassificationSession()845 private boolean hasActiveClassificationSession() { 846 return mClassificationSession != null && !mClassificationSession.isDestroyed(); 847 } 848 getWordDelta(int start, int end)849 private int[] getWordDelta(int start, int end) { 850 int[] wordIndices = new int[2]; 851 852 if (start == mStartIndex) { 853 wordIndices[0] = 0; 854 } else if (start < mStartIndex) { 855 wordIndices[0] = -countWordsForward(start); 856 } else { // start > mStartIndex 857 wordIndices[0] = countWordsBackward(start); 858 859 // For the selection start index, avoid counting a partial word backwards. 860 if (!mTokenIterator.isBoundary(start) 861 && !isWhitespace( 862 mTokenIterator.preceding(start), 863 mTokenIterator.following(start))) { 864 // We counted a partial word. Remove it. 865 wordIndices[0]--; 866 } 867 } 868 869 if (end == mStartIndex) { 870 wordIndices[1] = 0; 871 } else if (end < mStartIndex) { 872 wordIndices[1] = -countWordsForward(end); 873 } else { // end > mStartIndex 874 wordIndices[1] = countWordsBackward(end); 875 } 876 877 return wordIndices; 878 } 879 countWordsBackward(int from)880 private int countWordsBackward(int from) { 881 Preconditions.checkArgument(from >= mStartIndex); 882 int wordCount = 0; 883 int offset = from; 884 while (offset > mStartIndex) { 885 int start = mTokenIterator.preceding(offset); 886 if (!isWhitespace(start, offset)) { 887 wordCount++; 888 } 889 offset = start; 890 } 891 return wordCount; 892 } 893 countWordsForward(int from)894 private int countWordsForward(int from) { 895 Preconditions.checkArgument(from <= mStartIndex); 896 int wordCount = 0; 897 int offset = from; 898 while (offset < mStartIndex) { 899 int end = mTokenIterator.following(offset); 900 if (!isWhitespace(offset, end)) { 901 wordCount++; 902 } 903 offset = end; 904 } 905 return wordCount; 906 } 907 isWhitespace(int start, int end)908 private boolean isWhitespace(int start, int end) { 909 return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches(); 910 } 911 maybeGenerateTranslateViewEvent(@ullable TextClassification classification)912 private void maybeGenerateTranslateViewEvent(@Nullable TextClassification classification) { 913 if (classification != null) { 914 final TextClassifierEvent event = generateTranslateEvent( 915 TextClassifierEvent.TYPE_ACTIONS_SHOWN, 916 classification, mClassificationContext, /* actionLabel= */null); 917 mTranslateViewEvent = (event != null) ? event : mTranslateViewEvent; 918 } 919 } 920 maybeGenerateTranslateClickEvent( @ullable TextClassification classification, String actionLabel)921 private void maybeGenerateTranslateClickEvent( 922 @Nullable TextClassification classification, String actionLabel) { 923 if (classification != null) { 924 mTranslateClickEvent = generateTranslateEvent( 925 TextClassifierEvent.TYPE_SMART_ACTION, 926 classification, mClassificationContext, actionLabel); 927 } 928 } 929 maybeReportTranslateEvents()930 private void maybeReportTranslateEvents() { 931 // Translate view and click events should only be logged once per selection session. 932 if (mTranslateViewEvent != null) { 933 mClassificationSession.onTextClassifierEvent(mTranslateViewEvent); 934 mTranslateViewEvent = null; 935 } 936 if (mTranslateClickEvent != null) { 937 mClassificationSession.onTextClassifierEvent(mTranslateClickEvent); 938 mTranslateClickEvent = null; 939 } 940 } 941 942 @Nullable generateTranslateEvent( int eventType, TextClassification classification, TextClassificationContext classificationContext, @Nullable String actionLabel)943 private static TextClassifierEvent generateTranslateEvent( 944 int eventType, TextClassification classification, 945 TextClassificationContext classificationContext, @Nullable String actionLabel) { 946 947 // The platform attempts to log "views" and "clicks" of the "Translate" action. 948 // Views are logged if a user is presented with the translate action during a selection 949 // session. 950 // Clicks are logged if the user clicks on the translate action. 951 // The index of the translate action is also logged to indicate whether it might have 952 // been in the main panel or overflow panel of the selection toolbar. 953 // NOTE that the "views" metric may be flawed if a TextView removes the translate menu 954 // item via a custom action mode callback or does not show a selection menu item. 955 956 final RemoteAction translateAction = ExtrasUtils.findTranslateAction(classification); 957 if (translateAction == null) { 958 // No translate action present. Nothing to log. Exit. 959 return null; 960 } 961 962 if (eventType == TextClassifierEvent.TYPE_SMART_ACTION 963 && !translateAction.getTitle().toString().equals(actionLabel)) { 964 // Clicked action is not a translate action. Nothing to log. Exit. 965 // Note that we don't expect an actionLabel for "view" events. 966 return null; 967 } 968 969 final Bundle foreignLanguageExtra = ExtrasUtils.getForeignLanguageExtra(classification); 970 final String language = ExtrasUtils.getEntityType(foreignLanguageExtra); 971 final float score = ExtrasUtils.getScore(foreignLanguageExtra); 972 final String model = ExtrasUtils.getModelName(foreignLanguageExtra); 973 return new TextClassifierEvent.LanguageDetectionEvent.Builder(eventType) 974 .setEventContext(classificationContext) 975 .setResultId(classification.getId()) 976 .setEntityTypes(language) 977 .setScores(score) 978 .setActionIndices(classification.getActions().indexOf(translateAction)) 979 .setModelName(model) 980 .build(); 981 } 982 } 983 984 /** 985 * AsyncTask for running a query on a background thread and returning the result on the 986 * UiThread. The AsyncTask times out after a specified time, returning a null result if the 987 * query has not yet returned. 988 */ 989 private static final class TextClassificationAsyncTask 990 extends AsyncTask<Void, Void, SelectionResult> { 991 992 private final int mTimeOutDuration; 993 private final Supplier<SelectionResult> mSelectionResultSupplier; 994 private final Consumer<SelectionResult> mSelectionResultCallback; 995 private final Supplier<SelectionResult> mTimeOutResultSupplier; 996 private final TextView mTextView; 997 private final String mOriginalText; 998 999 /** 1000 * @param textView the TextView 1001 * @param timeOut time in milliseconds to timeout the query if it has not completed 1002 * @param selectionResultSupplier fetches the selection results. Runs on a background thread 1003 * @param selectionResultCallback receives the selection results. Runs on the UiThread 1004 * @param timeOutResultSupplier default result if the task times out 1005 */ TextClassificationAsyncTask( @onNull TextView textView, int timeOut, @NonNull Supplier<SelectionResult> selectionResultSupplier, @NonNull Consumer<SelectionResult> selectionResultCallback, @NonNull Supplier<SelectionResult> timeOutResultSupplier)1006 TextClassificationAsyncTask( 1007 @NonNull TextView textView, int timeOut, 1008 @NonNull Supplier<SelectionResult> selectionResultSupplier, 1009 @NonNull Consumer<SelectionResult> selectionResultCallback, 1010 @NonNull Supplier<SelectionResult> timeOutResultSupplier) { 1011 super(textView != null ? textView.getHandler() : null); 1012 mTextView = Preconditions.checkNotNull(textView); 1013 mTimeOutDuration = timeOut; 1014 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier); 1015 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback); 1016 mTimeOutResultSupplier = Preconditions.checkNotNull(timeOutResultSupplier); 1017 // Make a copy of the original text. 1018 mOriginalText = getText(mTextView).toString(); 1019 } 1020 1021 @Override 1022 @WorkerThread doInBackground(Void... params)1023 protected SelectionResult doInBackground(Void... params) { 1024 final Runnable onTimeOut = this::onTimeOut; 1025 mTextView.postDelayed(onTimeOut, mTimeOutDuration); 1026 final SelectionResult result = mSelectionResultSupplier.get(); 1027 mTextView.removeCallbacks(onTimeOut); 1028 return result; 1029 } 1030 1031 @Override 1032 @UiThread onPostExecute(SelectionResult result)1033 protected void onPostExecute(SelectionResult result) { 1034 result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null; 1035 mSelectionResultCallback.accept(result); 1036 } 1037 onTimeOut()1038 private void onTimeOut() { 1039 Log.d(LOG_TAG, "Timeout in TextClassificationAsyncTask"); 1040 if (getStatus() == Status.RUNNING) { 1041 onPostExecute(mTimeOutResultSupplier.get()); 1042 } 1043 cancel(true); 1044 } 1045 } 1046 1047 /** 1048 * Helper class for querying the TextClassifier. 1049 * It trims text so that only text necessary to provide context of the selected text is 1050 * sent to the TextClassifier. 1051 */ 1052 private static final class TextClassificationHelper { 1053 1054 private static final int TRIM_DELTA = 120; // characters 1055 1056 private final Context mContext; 1057 private Supplier<TextClassifier> mTextClassifier; 1058 1059 /** The original TextView text. **/ 1060 private String mText; 1061 /** Start index relative to mText. */ 1062 private int mSelectionStart; 1063 /** End index relative to mText. */ 1064 private int mSelectionEnd; 1065 1066 @Nullable 1067 private LocaleList mDefaultLocales; 1068 1069 /** Trimmed text starting from mTrimStart in mText. */ 1070 private CharSequence mTrimmedText; 1071 /** Index indicating the start of mTrimmedText in mText. */ 1072 private int mTrimStart; 1073 /** Start index relative to mTrimmedText */ 1074 private int mRelativeStart; 1075 /** End index relative to mTrimmedText */ 1076 private int mRelativeEnd; 1077 1078 /** Information about the last classified text to avoid re-running a query. */ 1079 private CharSequence mLastClassificationText; 1080 private int mLastClassificationSelectionStart; 1081 private int mLastClassificationSelectionEnd; 1082 private LocaleList mLastClassificationLocales; 1083 private SelectionResult mLastClassificationResult; 1084 1085 /** Whether the TextClassifier has been initialized. */ 1086 private boolean mHot; 1087 TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)1088 TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier, 1089 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { 1090 init(textClassifier, text, selectionStart, selectionEnd, locales); 1091 mContext = Preconditions.checkNotNull(context); 1092 } 1093 1094 @UiThread init(Supplier<TextClassifier> textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)1095 public void init(Supplier<TextClassifier> textClassifier, CharSequence text, 1096 int selectionStart, int selectionEnd, LocaleList locales) { 1097 mTextClassifier = Preconditions.checkNotNull(textClassifier); 1098 mText = Preconditions.checkNotNull(text).toString(); 1099 mLastClassificationText = null; // invalidate. 1100 Preconditions.checkArgument(selectionEnd > selectionStart); 1101 mSelectionStart = selectionStart; 1102 mSelectionEnd = selectionEnd; 1103 mDefaultLocales = locales; 1104 } 1105 1106 @WorkerThread classifyText()1107 public SelectionResult classifyText() { 1108 mHot = true; 1109 return performClassification(null /* selection */); 1110 } 1111 1112 @WorkerThread suggestSelection()1113 public SelectionResult suggestSelection() { 1114 mHot = true; 1115 trimText(); 1116 final TextSelection selection; 1117 if (mContext.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.P) { 1118 final TextSelection.Request request = new TextSelection.Request.Builder( 1119 mTrimmedText, mRelativeStart, mRelativeEnd) 1120 .setDefaultLocales(mDefaultLocales) 1121 .setDarkLaunchAllowed(true) 1122 .build(); 1123 selection = mTextClassifier.get().suggestSelection(request); 1124 } else { 1125 // Use old APIs. 1126 selection = mTextClassifier.get().suggestSelection( 1127 mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales); 1128 } 1129 // Do not classify new selection boundaries if TextClassifier should be dark launched. 1130 if (!isDarkLaunchEnabled()) { 1131 mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart); 1132 mSelectionEnd = Math.min( 1133 mText.length(), selection.getSelectionEndIndex() + mTrimStart); 1134 } 1135 return performClassification(selection); 1136 } 1137 getOriginalSelection()1138 public SelectionResult getOriginalSelection() { 1139 return new SelectionResult(mSelectionStart, mSelectionEnd, null, null); 1140 } 1141 1142 /** 1143 * Maximum time (in milliseconds) to wait for a textclassifier result before timing out. 1144 */ 1145 // TODO: Consider making this a ViewConfiguration. getTimeoutDuration()1146 public int getTimeoutDuration() { 1147 if (mHot) { 1148 return 200; 1149 } else { 1150 // Return a slightly larger number than usual when the TextClassifier is first 1151 // initialized. Initialization would usually take longer than subsequent calls to 1152 // the TextClassifier. The impact of this on the UI is that we do not show the 1153 // selection handles or toolbar until after this timeout. 1154 return 500; 1155 } 1156 } 1157 isDarkLaunchEnabled()1158 private boolean isDarkLaunchEnabled() { 1159 return TextClassificationManager.getSettings(mContext).isModelDarkLaunchEnabled(); 1160 } 1161 performClassification(@ullable TextSelection selection)1162 private SelectionResult performClassification(@Nullable TextSelection selection) { 1163 if (!Objects.equals(mText, mLastClassificationText) 1164 || mSelectionStart != mLastClassificationSelectionStart 1165 || mSelectionEnd != mLastClassificationSelectionEnd 1166 || !Objects.equals(mDefaultLocales, mLastClassificationLocales)) { 1167 1168 mLastClassificationText = mText; 1169 mLastClassificationSelectionStart = mSelectionStart; 1170 mLastClassificationSelectionEnd = mSelectionEnd; 1171 mLastClassificationLocales = mDefaultLocales; 1172 1173 trimText(); 1174 final TextClassification classification; 1175 if (Linkify.containsUnsupportedCharacters(mText)) { 1176 // Do not show smart actions for text containing unsupported characters. 1177 android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, ""); 1178 classification = TextClassification.EMPTY; 1179 } else if (mContext.getApplicationInfo().targetSdkVersion 1180 >= Build.VERSION_CODES.P) { 1181 final TextClassification.Request request = 1182 new TextClassification.Request.Builder( 1183 mTrimmedText, mRelativeStart, mRelativeEnd) 1184 .setDefaultLocales(mDefaultLocales) 1185 .build(); 1186 classification = mTextClassifier.get().classifyText(request); 1187 } else { 1188 // Use old APIs. 1189 classification = mTextClassifier.get().classifyText( 1190 mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales); 1191 } 1192 mLastClassificationResult = new SelectionResult( 1193 mSelectionStart, mSelectionEnd, classification, selection); 1194 1195 } 1196 return mLastClassificationResult; 1197 } 1198 trimText()1199 private void trimText() { 1200 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); 1201 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); 1202 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd); 1203 mRelativeStart = mSelectionStart - mTrimStart; 1204 mRelativeEnd = mSelectionEnd - mTrimStart; 1205 } 1206 } 1207 1208 /** 1209 * Selection result. 1210 */ 1211 private static final class SelectionResult { 1212 private final int mStart; 1213 private final int mEnd; 1214 @Nullable private final TextClassification mClassification; 1215 @Nullable private final TextSelection mSelection; 1216 SelectionResult(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)1217 SelectionResult(int start, int end, 1218 @Nullable TextClassification classification, @Nullable TextSelection selection) { 1219 int[] sortedIndices = sortSelectionIndices(start, end); 1220 mStart = sortedIndices[0]; 1221 mEnd = sortedIndices[1]; 1222 mClassification = classification; 1223 mSelection = selection; 1224 } 1225 } 1226 1227 @SelectionEvent.ActionType getActionType(int menuItemId)1228 private static int getActionType(int menuItemId) { 1229 switch (menuItemId) { 1230 case TextView.ID_SELECT_ALL: 1231 return SelectionEvent.ACTION_SELECT_ALL; 1232 case TextView.ID_CUT: 1233 return SelectionEvent.ACTION_CUT; 1234 case TextView.ID_COPY: 1235 return SelectionEvent.ACTION_COPY; 1236 case TextView.ID_PASTE: // fall through 1237 case TextView.ID_PASTE_AS_PLAIN_TEXT: 1238 return SelectionEvent.ACTION_PASTE; 1239 case TextView.ID_SHARE: 1240 return SelectionEvent.ACTION_SHARE; 1241 case TextView.ID_ASSIST: 1242 return SelectionEvent.ACTION_SMART_SHARE; 1243 default: 1244 return SelectionEvent.ACTION_OTHER; 1245 } 1246 } 1247 getText(TextView textView)1248 private static CharSequence getText(TextView textView) { 1249 // Extracts the textView's text. 1250 // TODO: Investigate why/when TextView.getText() is null. 1251 final CharSequence text = textView.getText(); 1252 if (text != null) { 1253 return text; 1254 } 1255 return ""; 1256 } 1257 } 1258