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.os.AsyncTask; 24 import android.os.LocaleList; 25 import android.text.Selection; 26 import android.text.Spannable; 27 import android.text.TextUtils; 28 import android.text.util.Linkify; 29 import android.util.Log; 30 import android.view.ActionMode; 31 import android.view.textclassifier.TextClassification; 32 import android.view.textclassifier.TextClassifier; 33 import android.view.textclassifier.TextSelection; 34 import android.view.textclassifier.logging.SmartSelectionEventTracker; 35 import android.view.textclassifier.logging.SmartSelectionEventTracker.SelectionEvent; 36 import android.widget.Editor.SelectionModifierCursorController; 37 38 import com.android.internal.util.Preconditions; 39 40 import java.text.BreakIterator; 41 import java.util.Objects; 42 import java.util.function.Consumer; 43 import java.util.function.Supplier; 44 import java.util.regex.Pattern; 45 46 /** 47 * Helper class for starting selection action mode 48 * (synchronously without the TextClassifier, asynchronously with the TextClassifier). 49 */ 50 @UiThread 51 final class SelectionActionModeHelper { 52 53 private static final String LOG_TAG = "SelectActionModeHelper"; 54 55 private final Editor mEditor; 56 private final TextView mTextView; 57 private final TextClassificationHelper mTextClassificationHelper; 58 59 private TextClassification mTextClassification; 60 private AsyncTask mTextClassificationAsyncTask; 61 62 private final SelectionTracker mSelectionTracker; 63 SelectionActionModeHelper(@onNull Editor editor)64 SelectionActionModeHelper(@NonNull Editor editor) { 65 mEditor = Preconditions.checkNotNull(editor); 66 mTextView = mEditor.getTextView(); 67 mTextClassificationHelper = new TextClassificationHelper( 68 mTextView.getTextClassifier(), 69 getText(mTextView), 70 0, 1, mTextView.getTextLocales()); 71 mSelectionTracker = new SelectionTracker(mTextView); 72 } 73 startActionModeAsync(boolean adjustSelection)74 public void startActionModeAsync(boolean adjustSelection) { 75 // Check if the smart selection should run for editable text. 76 adjustSelection &= !mTextView.isTextEditable() 77 || mTextView.getTextClassifier().getSettings() 78 .isSuggestSelectionEnabledForEditableText(); 79 80 mSelectionTracker.onOriginalSelection( 81 getText(mTextView), 82 mTextView.getSelectionStart(), 83 mTextView.getSelectionEnd()); 84 cancelAsyncTask(); 85 if (skipTextClassification()) { 86 startActionMode(null); 87 } else { 88 resetTextClassificationHelper(); 89 mTextClassificationAsyncTask = new TextClassificationAsyncTask( 90 mTextView, 91 mTextClassificationHelper.getTimeoutDuration(), 92 adjustSelection 93 ? mTextClassificationHelper::suggestSelection 94 : mTextClassificationHelper::classifyText, 95 this::startActionMode) 96 .execute(); 97 } 98 } 99 invalidateActionModeAsync()100 public void invalidateActionModeAsync() { 101 cancelAsyncTask(); 102 if (skipTextClassification()) { 103 invalidateActionMode(null); 104 } else { 105 resetTextClassificationHelper(); 106 mTextClassificationAsyncTask = new TextClassificationAsyncTask( 107 mTextView, 108 mTextClassificationHelper.getTimeoutDuration(), 109 mTextClassificationHelper::classifyText, 110 this::invalidateActionMode) 111 .execute(); 112 } 113 } 114 onSelectionAction(int menuItemId)115 public void onSelectionAction(int menuItemId) { 116 mSelectionTracker.onSelectionAction( 117 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), 118 getActionType(menuItemId), mTextClassification); 119 } 120 onSelectionDrag()121 public void onSelectionDrag() { 122 mSelectionTracker.onSelectionAction( 123 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), 124 SelectionEvent.ActionType.DRAG, mTextClassification); 125 } 126 onTextChanged(int start, int end)127 public void onTextChanged(int start, int end) { 128 mSelectionTracker.onTextChanged(start, end, mTextClassification); 129 } 130 resetSelection(int textIndex)131 public boolean resetSelection(int textIndex) { 132 if (mSelectionTracker.resetSelection(textIndex, mEditor)) { 133 invalidateActionModeAsync(); 134 return true; 135 } 136 return false; 137 } 138 139 @Nullable getTextClassification()140 public TextClassification getTextClassification() { 141 return mTextClassification; 142 } 143 onDestroyActionMode()144 public void onDestroyActionMode() { 145 mSelectionTracker.onSelectionDestroyed(); 146 cancelAsyncTask(); 147 } 148 cancelAsyncTask()149 private void cancelAsyncTask() { 150 if (mTextClassificationAsyncTask != null) { 151 mTextClassificationAsyncTask.cancel(true); 152 mTextClassificationAsyncTask = null; 153 } 154 mTextClassification = null; 155 } 156 skipTextClassification()157 private boolean skipTextClassification() { 158 // No need to make an async call for a no-op TextClassifier. 159 final boolean noOpTextClassifier = mTextView.getTextClassifier() == TextClassifier.NO_OP; 160 // Do not call the TextClassifier if there is no selection. 161 final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart(); 162 // Do not call the TextClassifier if this is a password field. 163 final boolean password = mTextView.hasPasswordTransformationMethod() 164 || TextView.isPasswordInputType(mTextView.getInputType()); 165 return noOpTextClassifier || noSelection || password; 166 } 167 startActionMode(@ullable SelectionResult result)168 private void startActionMode(@Nullable SelectionResult result) { 169 final CharSequence text = getText(mTextView); 170 if (result != null && text instanceof Spannable) { 171 // Do not change the selection if TextClassifier should be dark launched. 172 if (!mTextView.getTextClassifier().getSettings().isDarkLaunch()) { 173 Selection.setSelection((Spannable) text, result.mStart, result.mEnd); 174 } 175 mTextClassification = result.mClassification; 176 } else { 177 mTextClassification = null; 178 } 179 if (mEditor.startSelectionActionModeInternal()) { 180 final SelectionModifierCursorController controller = mEditor.getSelectionController(); 181 if (controller != null) { 182 controller.show(); 183 } 184 if (result != null) { 185 mSelectionTracker.onSmartSelection(result); 186 } 187 } 188 mEditor.setRestartActionModeOnNextRefresh(false); 189 mTextClassificationAsyncTask = null; 190 } 191 invalidateActionMode(@ullable SelectionResult result)192 private void invalidateActionMode(@Nullable SelectionResult result) { 193 mTextClassification = result != null ? result.mClassification : null; 194 final ActionMode actionMode = mEditor.getTextActionMode(); 195 if (actionMode != null) { 196 actionMode.invalidate(); 197 } 198 mSelectionTracker.onSelectionUpdated( 199 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), mTextClassification); 200 mTextClassificationAsyncTask = null; 201 } 202 resetTextClassificationHelper()203 private void resetTextClassificationHelper() { 204 mTextClassificationHelper.init( 205 mTextView.getTextClassifier(), 206 getText(mTextView), 207 mTextView.getSelectionStart(), mTextView.getSelectionEnd(), 208 mTextView.getTextLocales()); 209 } 210 211 /** 212 * Tracks and logs smart selection changes. 213 * It is important to trigger this object's methods at the appropriate event so that it tracks 214 * smart selection events appropriately. 215 */ 216 private static final class SelectionTracker { 217 218 private final TextView mTextView; 219 private SelectionMetricsLogger mLogger; 220 221 private int mOriginalStart; 222 private int mOriginalEnd; 223 private int mSelectionStart; 224 private int mSelectionEnd; 225 private boolean mAllowReset; 226 private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable(); 227 SelectionTracker(TextView textView)228 SelectionTracker(TextView textView) { 229 mTextView = Preconditions.checkNotNull(textView); 230 mLogger = new SelectionMetricsLogger(textView); 231 } 232 233 /** 234 * Called when the original selection happens, before smart selection is triggered. 235 */ onOriginalSelection(CharSequence text, int selectionStart, int selectionEnd)236 public void onOriginalSelection(CharSequence text, int selectionStart, int selectionEnd) { 237 // If we abandoned a selection and created a new one very shortly after, we may still 238 // have a pending request to log ABANDON, which we flush here. 239 mDelayedLogAbandon.flush(); 240 241 mOriginalStart = mSelectionStart = selectionStart; 242 mOriginalEnd = mSelectionEnd = selectionEnd; 243 mAllowReset = false; 244 maybeInvalidateLogger(); 245 mLogger.logSelectionStarted(text, selectionStart); 246 } 247 248 /** 249 * Called when selection action mode is started and the results come from a classifier. 250 */ onSmartSelection(SelectionResult result)251 public void onSmartSelection(SelectionResult result) { 252 if (isSelectionStarted()) { 253 mSelectionStart = result.mStart; 254 mSelectionEnd = result.mEnd; 255 mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; 256 mLogger.logSelectionModified( 257 result.mStart, result.mEnd, result.mClassification, result.mSelection); 258 } 259 } 260 261 /** 262 * Called when selection bounds change. 263 */ onSelectionUpdated( int selectionStart, int selectionEnd, @Nullable TextClassification classification)264 public void onSelectionUpdated( 265 int selectionStart, int selectionEnd, 266 @Nullable TextClassification classification) { 267 if (isSelectionStarted()) { 268 mSelectionStart = selectionStart; 269 mSelectionEnd = selectionEnd; 270 mAllowReset = false; 271 mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null); 272 } 273 } 274 275 /** 276 * Called when the selection action mode is destroyed. 277 */ onSelectionDestroyed()278 public void onSelectionDestroyed() { 279 mAllowReset = false; 280 // Wait a few ms to see if the selection was destroyed because of a text change event. 281 mDelayedLogAbandon.schedule(100 /* ms */); 282 } 283 284 /** 285 * Called when an action is taken on a smart selection. 286 */ onSelectionAction( int selectionStart, int selectionEnd, @SelectionEvent.ActionType int action, @Nullable TextClassification classification)287 public void onSelectionAction( 288 int selectionStart, int selectionEnd, 289 @SelectionEvent.ActionType int action, 290 @Nullable TextClassification classification) { 291 if (isSelectionStarted()) { 292 mAllowReset = false; 293 mLogger.logSelectionAction(selectionStart, selectionEnd, action, classification); 294 } 295 } 296 297 /** 298 * Returns true if the current smart selection should be reset to normal selection based on 299 * information that has been recorded about the original selection and the smart selection. 300 * The expected UX here is to allow the user to select a word inside of the smart selection 301 * on a single tap. 302 */ resetSelection(int textIndex, Editor editor)303 public boolean resetSelection(int textIndex, Editor editor) { 304 final TextView textView = editor.getTextView(); 305 if (isSelectionStarted() 306 && mAllowReset 307 && textIndex >= mSelectionStart && textIndex <= mSelectionEnd 308 && getText(textView) instanceof Spannable) { 309 mAllowReset = false; 310 boolean selected = editor.selectCurrentWord(); 311 if (selected) { 312 mSelectionStart = editor.getTextView().getSelectionStart(); 313 mSelectionEnd = editor.getTextView().getSelectionEnd(); 314 mLogger.logSelectionAction( 315 textView.getSelectionStart(), textView.getSelectionEnd(), 316 SelectionEvent.ActionType.RESET, null /* classification */); 317 } 318 return selected; 319 } 320 return false; 321 } 322 onTextChanged(int start, int end, TextClassification classification)323 public void onTextChanged(int start, int end, TextClassification classification) { 324 if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) { 325 onSelectionAction(start, end, SelectionEvent.ActionType.OVERTYPE, classification); 326 } 327 } 328 maybeInvalidateLogger()329 private void maybeInvalidateLogger() { 330 if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) { 331 mLogger = new SelectionMetricsLogger(mTextView); 332 } 333 } 334 isSelectionStarted()335 private boolean isSelectionStarted() { 336 return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd; 337 } 338 339 /** A helper for keeping track of pending abandon logging requests. */ 340 private final class LogAbandonRunnable implements Runnable { 341 private boolean mIsPending; 342 343 /** Schedules an abandon to be logged with the given delay. Flush if necessary. */ schedule(int delayMillis)344 void schedule(int delayMillis) { 345 if (mIsPending) { 346 Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request"); 347 flush(); 348 } 349 mIsPending = true; 350 mTextView.postDelayed(this, delayMillis); 351 } 352 353 /** If there is a pending log request, execute it now. */ flush()354 void flush() { 355 mTextView.removeCallbacks(this); 356 run(); 357 } 358 359 @Override run()360 public void run() { 361 if (mIsPending) { 362 mLogger.logSelectionAction( 363 mSelectionStart, mSelectionEnd, 364 SelectionEvent.ActionType.ABANDON, null /* classification */); 365 mSelectionStart = mSelectionEnd = -1; 366 mIsPending = false; 367 } 368 } 369 } 370 } 371 372 // TODO: Write tests 373 /** 374 * Metrics logging helper. 375 * 376 * This logger logs selection by word indices. The initial (start) single word selection is 377 * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the 378 * initial single word selection. 379 * e.g. New York city, NY. Suppose the initial selection is "York" in 380 * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2). 381 * "New York" is at [-1, 1). 382 * Part selection of a word e.g. "or" is counted as selecting the 383 * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g. 384 * "," is at [2, 3). Whitespaces are ignored. 385 */ 386 private static final class SelectionMetricsLogger { 387 388 private static final String LOG_TAG = "SelectionMetricsLogger"; 389 private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+"); 390 391 private final SmartSelectionEventTracker mDelegate; 392 private final boolean mEditTextLogger; 393 private final BreakIterator mWordIterator; 394 private int mStartIndex; 395 private String mText; 396 SelectionMetricsLogger(TextView textView)397 SelectionMetricsLogger(TextView textView) { 398 Preconditions.checkNotNull(textView); 399 final @SmartSelectionEventTracker.WidgetType int widgetType = textView.isTextEditable() 400 ? SmartSelectionEventTracker.WidgetType.EDITTEXT 401 : SmartSelectionEventTracker.WidgetType.TEXTVIEW; 402 mDelegate = new SmartSelectionEventTracker(textView.getContext(), widgetType); 403 mEditTextLogger = textView.isTextEditable(); 404 mWordIterator = BreakIterator.getWordInstance(textView.getTextLocale()); 405 } 406 logSelectionStarted(CharSequence text, int index)407 public void logSelectionStarted(CharSequence text, int index) { 408 try { 409 Preconditions.checkNotNull(text); 410 Preconditions.checkArgumentInRange(index, 0, text.length(), "index"); 411 if (mText == null || !mText.contentEquals(text)) { 412 mText = text.toString(); 413 } 414 mWordIterator.setText(mText); 415 mStartIndex = index; 416 mDelegate.logEvent(SelectionEvent.selectionStarted(0)); 417 } catch (Exception e) { 418 // Avoid crashes due to logging. 419 Log.d(LOG_TAG, e.getMessage()); 420 } 421 } 422 logSelectionModified(int start, int end, @Nullable TextClassification classification, @Nullable TextSelection selection)423 public void logSelectionModified(int start, int end, 424 @Nullable TextClassification classification, @Nullable TextSelection selection) { 425 try { 426 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); 427 Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); 428 int[] wordIndices = getWordDelta(start, end); 429 if (selection != null) { 430 mDelegate.logEvent(SelectionEvent.selectionModified( 431 wordIndices[0], wordIndices[1], selection)); 432 } else if (classification != null) { 433 mDelegate.logEvent(SelectionEvent.selectionModified( 434 wordIndices[0], wordIndices[1], classification)); 435 } else { 436 mDelegate.logEvent(SelectionEvent.selectionModified( 437 wordIndices[0], wordIndices[1])); 438 } 439 } catch (Exception e) { 440 // Avoid crashes due to logging. 441 Log.d(LOG_TAG, e.getMessage()); 442 } 443 } 444 logSelectionAction( int start, int end, @SelectionEvent.ActionType int action, @Nullable TextClassification classification)445 public void logSelectionAction( 446 int start, int end, 447 @SelectionEvent.ActionType int action, 448 @Nullable TextClassification classification) { 449 try { 450 Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); 451 Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); 452 int[] wordIndices = getWordDelta(start, end); 453 if (classification != null) { 454 mDelegate.logEvent(SelectionEvent.selectionAction( 455 wordIndices[0], wordIndices[1], action, classification)); 456 } else { 457 mDelegate.logEvent(SelectionEvent.selectionAction( 458 wordIndices[0], wordIndices[1], action)); 459 } 460 } catch (Exception e) { 461 // Avoid crashes due to logging. 462 Log.d(LOG_TAG, e.getMessage()); 463 } 464 } 465 isEditTextLogger()466 public boolean isEditTextLogger() { 467 return mEditTextLogger; 468 } 469 getWordDelta(int start, int end)470 private int[] getWordDelta(int start, int end) { 471 int[] wordIndices = new int[2]; 472 473 if (start == mStartIndex) { 474 wordIndices[0] = 0; 475 } else if (start < mStartIndex) { 476 wordIndices[0] = -countWordsForward(start); 477 } else { // start > mStartIndex 478 wordIndices[0] = countWordsBackward(start); 479 480 // For the selection start index, avoid counting a partial word backwards. 481 if (!mWordIterator.isBoundary(start) 482 && !isWhitespace( 483 mWordIterator.preceding(start), 484 mWordIterator.following(start))) { 485 // We counted a partial word. Remove it. 486 wordIndices[0]--; 487 } 488 } 489 490 if (end == mStartIndex) { 491 wordIndices[1] = 0; 492 } else if (end < mStartIndex) { 493 wordIndices[1] = -countWordsForward(end); 494 } else { // end > mStartIndex 495 wordIndices[1] = countWordsBackward(end); 496 } 497 498 return wordIndices; 499 } 500 countWordsBackward(int from)501 private int countWordsBackward(int from) { 502 Preconditions.checkArgument(from >= mStartIndex); 503 int wordCount = 0; 504 int offset = from; 505 while (offset > mStartIndex) { 506 int start = mWordIterator.preceding(offset); 507 if (!isWhitespace(start, offset)) { 508 wordCount++; 509 } 510 offset = start; 511 } 512 return wordCount; 513 } 514 countWordsForward(int from)515 private int countWordsForward(int from) { 516 Preconditions.checkArgument(from <= mStartIndex); 517 int wordCount = 0; 518 int offset = from; 519 while (offset < mStartIndex) { 520 int end = mWordIterator.following(offset); 521 if (!isWhitespace(offset, end)) { 522 wordCount++; 523 } 524 offset = end; 525 } 526 return wordCount; 527 } 528 isWhitespace(int start, int end)529 private boolean isWhitespace(int start, int end) { 530 return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches(); 531 } 532 } 533 534 /** 535 * AsyncTask for running a query on a background thread and returning the result on the 536 * UiThread. The AsyncTask times out after a specified time, returning a null result if the 537 * query has not yet returned. 538 */ 539 private static final class TextClassificationAsyncTask 540 extends AsyncTask<Void, Void, SelectionResult> { 541 542 private final long mTimeOutDuration; 543 private final Supplier<SelectionResult> mSelectionResultSupplier; 544 private final Consumer<SelectionResult> mSelectionResultCallback; 545 private final TextView mTextView; 546 private final String mOriginalText; 547 548 /** 549 * @param textView the TextView 550 * @param timeOut time in milliseconds to timeout the query if it has not completed 551 * @param selectionResultSupplier fetches the selection results. Runs on a background thread 552 * @param selectionResultCallback receives the selection results. Runs on the UiThread 553 */ TextClassificationAsyncTask( @onNull TextView textView, long timeOut, @NonNull Supplier<SelectionResult> selectionResultSupplier, @NonNull Consumer<SelectionResult> selectionResultCallback)554 TextClassificationAsyncTask( 555 @NonNull TextView textView, long timeOut, 556 @NonNull Supplier<SelectionResult> selectionResultSupplier, 557 @NonNull Consumer<SelectionResult> selectionResultCallback) { 558 super(textView != null ? textView.getHandler() : null); 559 mTextView = Preconditions.checkNotNull(textView); 560 mTimeOutDuration = timeOut; 561 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier); 562 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback); 563 // Make a copy of the original text. 564 mOriginalText = getText(mTextView).toString(); 565 } 566 567 @Override 568 @WorkerThread doInBackground(Void... params)569 protected SelectionResult doInBackground(Void... params) { 570 final Runnable onTimeOut = this::onTimeOut; 571 mTextView.postDelayed(onTimeOut, mTimeOutDuration); 572 final SelectionResult result = mSelectionResultSupplier.get(); 573 mTextView.removeCallbacks(onTimeOut); 574 return result; 575 } 576 577 @Override 578 @UiThread onPostExecute(SelectionResult result)579 protected void onPostExecute(SelectionResult result) { 580 result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null; 581 mSelectionResultCallback.accept(result); 582 } 583 onTimeOut()584 private void onTimeOut() { 585 if (getStatus() == Status.RUNNING) { 586 onPostExecute(null); 587 } 588 cancel(true); 589 } 590 } 591 592 /** 593 * Helper class for querying the TextClassifier. 594 * It trims text so that only text necessary to provide context of the selected text is 595 * sent to the TextClassifier. 596 */ 597 private static final class TextClassificationHelper { 598 599 private static final int TRIM_DELTA = 120; // characters 600 601 private TextClassifier mTextClassifier; 602 603 /** The original TextView text. **/ 604 private String mText; 605 /** Start index relative to mText. */ 606 private int mSelectionStart; 607 /** End index relative to mText. */ 608 private int mSelectionEnd; 609 private LocaleList mLocales; 610 611 /** Trimmed text starting from mTrimStart in mText. */ 612 private CharSequence mTrimmedText; 613 /** Index indicating the start of mTrimmedText in mText. */ 614 private int mTrimStart; 615 /** Start index relative to mTrimmedText */ 616 private int mRelativeStart; 617 /** End index relative to mTrimmedText */ 618 private int mRelativeEnd; 619 620 /** Information about the last classified text to avoid re-running a query. */ 621 private CharSequence mLastClassificationText; 622 private int mLastClassificationSelectionStart; 623 private int mLastClassificationSelectionEnd; 624 private LocaleList mLastClassificationLocales; 625 private SelectionResult mLastClassificationResult; 626 627 /** Whether the TextClassifier has been initialized. */ 628 private boolean mHot; 629 TextClassificationHelper(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)630 TextClassificationHelper(TextClassifier textClassifier, 631 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { 632 init(textClassifier, text, selectionStart, selectionEnd, locales); 633 } 634 635 @UiThread init(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales)636 public void init(TextClassifier textClassifier, 637 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { 638 mTextClassifier = Preconditions.checkNotNull(textClassifier); 639 mText = Preconditions.checkNotNull(text).toString(); 640 mLastClassificationText = null; // invalidate. 641 Preconditions.checkArgument(selectionEnd > selectionStart); 642 mSelectionStart = selectionStart; 643 mSelectionEnd = selectionEnd; 644 mLocales = locales; 645 } 646 647 @WorkerThread classifyText()648 public SelectionResult classifyText() { 649 mHot = true; 650 return performClassification(null /* selection */); 651 } 652 653 @WorkerThread suggestSelection()654 public SelectionResult suggestSelection() { 655 mHot = true; 656 trimText(); 657 final TextSelection selection = mTextClassifier.suggestSelection( 658 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales); 659 // Do not classify new selection boundaries if TextClassifier should be dark launched. 660 if (!mTextClassifier.getSettings().isDarkLaunch()) { 661 mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart); 662 mSelectionEnd = Math.min( 663 mText.length(), selection.getSelectionEndIndex() + mTrimStart); 664 } 665 return performClassification(selection); 666 } 667 668 /** 669 * Maximum time (in milliseconds) to wait for a textclassifier result before timing out. 670 */ 671 // TODO: Consider making this a ViewConfiguration. getTimeoutDuration()672 public long getTimeoutDuration() { 673 if (mHot) { 674 return 200; 675 } else { 676 // Return a slightly larger number than usual when the TextClassifier is first 677 // initialized. Initialization would usually take longer than subsequent calls to 678 // the TextClassifier. The impact of this on the UI is that we do not show the 679 // selection handles or toolbar until after this timeout. 680 return 500; 681 } 682 } 683 performClassification(@ullable TextSelection selection)684 private SelectionResult performClassification(@Nullable TextSelection selection) { 685 if (!Objects.equals(mText, mLastClassificationText) 686 || mSelectionStart != mLastClassificationSelectionStart 687 || mSelectionEnd != mLastClassificationSelectionEnd 688 || !Objects.equals(mLocales, mLastClassificationLocales)) { 689 690 mLastClassificationText = mText; 691 mLastClassificationSelectionStart = mSelectionStart; 692 mLastClassificationSelectionEnd = mSelectionEnd; 693 mLastClassificationLocales = mLocales; 694 695 trimText(); 696 final TextClassification classification; 697 if (Linkify.containsUnsupportedCharacters(mText)) { 698 // Do not show smart actions for text containing unsupported characters. 699 android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, ""); 700 classification = TextClassification.EMPTY; 701 } else { 702 classification = mTextClassifier.classifyText( 703 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales); 704 } 705 mLastClassificationResult = new SelectionResult( 706 mSelectionStart, 707 mSelectionEnd, 708 classification, 709 selection); 710 711 } 712 return mLastClassificationResult; 713 } 714 trimText()715 private void trimText() { 716 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); 717 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); 718 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd); 719 mRelativeStart = mSelectionStart - mTrimStart; 720 mRelativeEnd = mSelectionEnd - mTrimStart; 721 } 722 } 723 724 /** 725 * Selection result. 726 */ 727 private static final class SelectionResult { 728 private final int mStart; 729 private final int mEnd; 730 private final TextClassification mClassification; 731 @Nullable private final TextSelection mSelection; 732 SelectionResult(int start, int end, TextClassification classification, @Nullable TextSelection selection)733 SelectionResult(int start, int end, 734 TextClassification classification, @Nullable TextSelection selection) { 735 mStart = start; 736 mEnd = end; 737 mClassification = Preconditions.checkNotNull(classification); 738 mSelection = selection; 739 } 740 } 741 742 @SelectionEvent.ActionType getActionType(int menuItemId)743 private static int getActionType(int menuItemId) { 744 switch (menuItemId) { 745 case TextView.ID_SELECT_ALL: 746 return SelectionEvent.ActionType.SELECT_ALL; 747 case TextView.ID_CUT: 748 return SelectionEvent.ActionType.CUT; 749 case TextView.ID_COPY: 750 return SelectionEvent.ActionType.COPY; 751 case TextView.ID_PASTE: // fall through 752 case TextView.ID_PASTE_AS_PLAIN_TEXT: 753 return SelectionEvent.ActionType.PASTE; 754 case TextView.ID_SHARE: 755 return SelectionEvent.ActionType.SHARE; 756 case TextView.ID_ASSIST: 757 return SelectionEvent.ActionType.SMART_SHARE; 758 default: 759 return SelectionEvent.ActionType.OTHER; 760 } 761 } 762 getText(TextView textView)763 private static CharSequence getText(TextView textView) { 764 // Extracts the textView's text. 765 // TODO: Investigate why/when TextView.getText() is null. 766 final CharSequence text = textView.getText(); 767 if (text != null) { 768 return text; 769 } 770 return ""; 771 } 772 } 773