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 17package android.widget; 18 19import android.annotation.NonNull; 20import android.annotation.Nullable; 21import android.annotation.UiThread; 22import android.annotation.WorkerThread; 23import android.os.AsyncTask; 24import android.os.LocaleList; 25import android.text.Selection; 26import android.text.Spannable; 27import android.text.TextUtils; 28import android.view.ActionMode; 29import android.view.textclassifier.TextClassification; 30import android.view.textclassifier.TextClassifier; 31import android.view.textclassifier.TextSelection; 32import android.widget.Editor.SelectionModifierCursorController; 33 34import com.android.internal.util.Preconditions; 35 36import java.util.Objects; 37import java.util.function.Consumer; 38import java.util.function.Supplier; 39 40/** 41 * Helper class for starting selection action mode 42 * (synchronously without the TextClassifier, asynchronously with the TextClassifier). 43 */ 44@UiThread 45final class SelectionActionModeHelper { 46 47 /** 48 * Maximum time (in milliseconds) to wait for a result before timing out. 49 */ 50 // TODO: Consider making this a ViewConfiguration. 51 private static final int TIMEOUT_DURATION = 200; 52 53 private final Editor mEditor; 54 private final TextClassificationHelper mTextClassificationHelper; 55 56 private TextClassification mTextClassification; 57 private AsyncTask mTextClassificationAsyncTask; 58 59 private final SelectionTracker mSelectionTracker; 60 61 SelectionActionModeHelper(@NonNull Editor editor) { 62 mEditor = Preconditions.checkNotNull(editor); 63 final TextView textView = mEditor.getTextView(); 64 mTextClassificationHelper = new TextClassificationHelper( 65 textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales()); 66 mSelectionTracker = new SelectionTracker(textView.getTextClassifier()); 67 } 68 69 public void startActionModeAsync(boolean adjustSelection) { 70 cancelAsyncTask(); 71 if (isNoOpTextClassifier() || !hasSelection()) { 72 // No need to make an async call for a no-op TextClassifier. 73 // Do not call the TextClassifier if there is no selection. 74 startActionMode(null); 75 } else { 76 resetTextClassificationHelper(true /* resetSelectionTag */); 77 final TextView tv = mEditor.getTextView(); 78 mTextClassificationAsyncTask = new TextClassificationAsyncTask( 79 tv, 80 TIMEOUT_DURATION, 81 adjustSelection 82 ? mTextClassificationHelper::suggestSelection 83 : mTextClassificationHelper::classifyText, 84 this::startActionMode) 85 .execute(); 86 } 87 } 88 89 public void invalidateActionModeAsync() { 90 cancelAsyncTask(); 91 if (isNoOpTextClassifier() || !hasSelection()) { 92 // No need to make an async call for a no-op TextClassifier. 93 // Do not call the TextClassifier if there is no selection. 94 invalidateActionMode(null); 95 } else { 96 resetTextClassificationHelper(false /* resetSelectionTag */); 97 mTextClassificationAsyncTask = new TextClassificationAsyncTask( 98 mEditor.getTextView(), TIMEOUT_DURATION, 99 mTextClassificationHelper::classifyText, this::invalidateActionMode) 100 .execute(); 101 } 102 } 103 104 public void onSelectionAction() { 105 mSelectionTracker.onSelectionAction(mTextClassificationHelper.getSelectionTag()); 106 } 107 108 public boolean resetSelection(int textIndex) { 109 if (mSelectionTracker.resetSelection( 110 textIndex, mEditor, mTextClassificationHelper.getSelectionTag())) { 111 invalidateActionModeAsync(); 112 return true; 113 } 114 return false; 115 } 116 117 @Nullable 118 public TextClassification getTextClassification() { 119 return mTextClassification; 120 } 121 122 public void onDestroyActionMode() { 123 mSelectionTracker.onSelectionDestroyed(); 124 cancelAsyncTask(); 125 } 126 127 private void cancelAsyncTask() { 128 if (mTextClassificationAsyncTask != null) { 129 mTextClassificationAsyncTask.cancel(true); 130 mTextClassificationAsyncTask = null; 131 } 132 mTextClassification = null; 133 } 134 135 private boolean isNoOpTextClassifier() { 136 return mEditor.getTextView().getTextClassifier() == TextClassifier.NO_OP; 137 } 138 139 private boolean hasSelection() { 140 final TextView textView = mEditor.getTextView(); 141 return textView.getSelectionEnd() > textView.getSelectionStart(); 142 } 143 144 private void startActionMode(@Nullable SelectionResult result) { 145 final TextView textView = mEditor.getTextView(); 146 final CharSequence text = textView.getText(); 147 mSelectionTracker.setOriginalSelection( 148 textView.getSelectionStart(), textView.getSelectionEnd()); 149 if (result != null && text instanceof Spannable) { 150 Selection.setSelection((Spannable) text, result.mStart, result.mEnd); 151 mTextClassification = result.mClassification; 152 } else { 153 mTextClassification = null; 154 } 155 if (mEditor.startSelectionActionModeInternal()) { 156 final SelectionModifierCursorController controller = mEditor.getSelectionController(); 157 if (controller != null) { 158 controller.show(); 159 } 160 if (result != null) { 161 mSelectionTracker.onSelectionStarted( 162 result.mStart, result.mEnd, mTextClassificationHelper.getSelectionTag()); 163 } 164 } 165 mEditor.setRestartActionModeOnNextRefresh(false); 166 mTextClassificationAsyncTask = null; 167 } 168 169 private void invalidateActionMode(@Nullable SelectionResult result) { 170 mTextClassification = result != null ? result.mClassification : null; 171 final ActionMode actionMode = mEditor.getTextActionMode(); 172 if (actionMode != null) { 173 actionMode.invalidate(); 174 } 175 final TextView textView = mEditor.getTextView(); 176 mSelectionTracker.onSelectionUpdated( 177 textView.getSelectionStart(), textView.getSelectionEnd(), 178 mTextClassificationHelper.getSelectionTag()); 179 mTextClassificationAsyncTask = null; 180 } 181 182 private void resetTextClassificationHelper(boolean resetSelectionTag) { 183 final TextView textView = mEditor.getTextView(); 184 mTextClassificationHelper.reset(textView.getTextClassifier(), textView.getText(), 185 textView.getSelectionStart(), textView.getSelectionEnd(), 186 resetSelectionTag, textView.getTextLocales()); 187 } 188 189 /** 190 * Tracks and logs smart selection changes. 191 * It is important to trigger this object's methods at the appropriate event so that it tracks 192 * smart selection events appropriately. 193 */ 194 private static final class SelectionTracker { 195 196 // Log event: Smart selection happened. 197 private static final String LOG_EVENT_MULTI_SELECTION = 198 "textClassifier_multiSelection"; 199 private static final String LOG_EVENT_SINGLE_SELECTION = 200 "textClassifier_singleSelection"; 201 202 // Log event: Smart selection acted upon. 203 private static final String LOG_EVENT_MULTI_SELECTION_ACTION = 204 "textClassifier_multiSelection_action"; 205 private static final String LOG_EVENT_SINGLE_SELECTION_ACTION = 206 "textClassifier_singleSelection_action"; 207 208 // Log event: Smart selection was reset to original selection. 209 private static final String LOG_EVENT_MULTI_SELECTION_RESET = 210 "textClassifier_multiSelection_reset"; 211 212 // Log event: Smart selection was user modified. 213 private static final String LOG_EVENT_MULTI_SELECTION_MODIFIED = 214 "textClassifier_multiSelection_modified"; 215 private static final String LOG_EVENT_SINGLE_SELECTION_MODIFIED = 216 "textClassifier_singleSelection_modified"; 217 218 private final TextClassifier mClassifier; 219 220 private int mOriginalStart; 221 private int mOriginalEnd; 222 private int mSelectionStart; 223 private int mSelectionEnd; 224 225 private boolean mMultiSelection; 226 private boolean mClassifierSelection; 227 228 SelectionTracker(TextClassifier classifier) { 229 mClassifier = classifier; 230 } 231 232 /** 233 * Called to initialize the original selection before smart selection is triggered. 234 */ 235 public void setOriginalSelection(int selectionStart, int selectionEnd) { 236 mOriginalStart = selectionStart; 237 mOriginalEnd = selectionEnd; 238 resetSelectionFlags(); 239 } 240 241 /** 242 * Called when selection action mode is started and the results come from a classifier. 243 * If the selection indices are different from the original selection indices, we have a 244 * smart selection. 245 */ 246 public void onSelectionStarted(int selectionStart, int selectionEnd, String logTag) { 247 mClassifierSelection = !logTag.isEmpty(); 248 mSelectionStart = selectionStart; 249 mSelectionEnd = selectionEnd; 250 // If the started selection is different from the original selection, we have a 251 // smart selection. 252 mMultiSelection = 253 mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; 254 if (mMultiSelection) { 255 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION); 256 } else if (mClassifierSelection) { 257 mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION); 258 } 259 } 260 261 /** 262 * Called when selection bounds change. 263 */ 264 public void onSelectionUpdated(int selectionStart, int selectionEnd, String logTag) { 265 final boolean selectionChanged = 266 selectionStart != mSelectionStart || selectionEnd != mSelectionEnd; 267 if (selectionChanged) { 268 if (mMultiSelection) { 269 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_MODIFIED); 270 } else if (mClassifierSelection) { 271 mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_MODIFIED); 272 } 273 resetSelectionFlags(); 274 } 275 } 276 277 /** 278 * Called when the selection action mode is destroyed. 279 */ 280 public void onSelectionDestroyed() { 281 resetSelectionFlags(); 282 } 283 284 /** 285 * Logs if the action was taken on a smart selection. 286 */ 287 public void onSelectionAction(String logTag) { 288 if (mMultiSelection) { 289 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_ACTION); 290 } else if (mClassifierSelection) { 291 mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_ACTION); 292 } 293 } 294 295 /** 296 * Returns true if the current smart selection should be reset to normal selection based on 297 * information that has been recorded about the original selection and the smart selection. 298 * The expected UX here is to allow the user to select a word inside of the smart selection 299 * on a single tap. 300 */ 301 public boolean resetSelection(int textIndex, Editor editor, String logTag) { 302 final CharSequence text = editor.getTextView().getText(); 303 if (mMultiSelection 304 && textIndex >= mSelectionStart && textIndex <= mSelectionEnd 305 && text instanceof Spannable) { 306 // Only allow a reset once. 307 resetSelectionFlags(); 308 mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_RESET); 309 return editor.selectCurrentWord(); 310 } 311 return false; 312 } 313 314 private void resetSelectionFlags() { 315 mMultiSelection = false; 316 mClassifierSelection = false; 317 } 318 } 319 320 /** 321 * AsyncTask for running a query on a background thread and returning the result on the 322 * UiThread. The AsyncTask times out after a specified time, returning a null result if the 323 * query has not yet returned. 324 */ 325 private static final class TextClassificationAsyncTask 326 extends AsyncTask<Void, Void, SelectionResult> { 327 328 private final int mTimeOutDuration; 329 private final Supplier<SelectionResult> mSelectionResultSupplier; 330 private final Consumer<SelectionResult> mSelectionResultCallback; 331 private final TextView mTextView; 332 private final String mOriginalText; 333 334 /** 335 * @param textView the TextView 336 * @param timeOut time in milliseconds to timeout the query if it has not completed 337 * @param selectionResultSupplier fetches the selection results. Runs on a background thread 338 * @param selectionResultCallback receives the selection results. Runs on the UiThread 339 */ 340 TextClassificationAsyncTask( 341 @NonNull TextView textView, int timeOut, 342 @NonNull Supplier<SelectionResult> selectionResultSupplier, 343 @NonNull Consumer<SelectionResult> selectionResultCallback) { 344 super(textView != null ? textView.getHandler() : null); 345 mTextView = Preconditions.checkNotNull(textView); 346 mTimeOutDuration = timeOut; 347 mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier); 348 mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback); 349 // Make a copy of the original text. 350 mOriginalText = mTextView.getText().toString(); 351 } 352 353 @Override 354 @WorkerThread 355 protected SelectionResult doInBackground(Void... params) { 356 final Runnable onTimeOut = this::onTimeOut; 357 mTextView.postDelayed(onTimeOut, mTimeOutDuration); 358 final SelectionResult result = mSelectionResultSupplier.get(); 359 mTextView.removeCallbacks(onTimeOut); 360 return result; 361 } 362 363 @Override 364 @UiThread 365 protected void onPostExecute(SelectionResult result) { 366 result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null; 367 mSelectionResultCallback.accept(result); 368 } 369 370 private void onTimeOut() { 371 if (getStatus() == Status.RUNNING) { 372 onPostExecute(null); 373 } 374 cancel(true); 375 } 376 } 377 378 /** 379 * Helper class for querying the TextClassifier. 380 * It trims text so that only text necessary to provide context of the selected text is 381 * sent to the TextClassifier. 382 */ 383 private static final class TextClassificationHelper { 384 385 private static final int TRIM_DELTA = 120; // characters 386 387 private TextClassifier mTextClassifier; 388 389 /** The original TextView text. **/ 390 private String mText; 391 /** Start index relative to mText. */ 392 private int mSelectionStart; 393 /** End index relative to mText. */ 394 private int mSelectionEnd; 395 private LocaleList mLocales; 396 /** A tag for the classifier that returned the latest smart selection. */ 397 private String mSelectionTag = ""; 398 399 /** Trimmed text starting from mTrimStart in mText. */ 400 private CharSequence mTrimmedText; 401 /** Index indicating the start of mTrimmedText in mText. */ 402 private int mTrimStart; 403 /** Start index relative to mTrimmedText */ 404 private int mRelativeStart; 405 /** End index relative to mTrimmedText */ 406 private int mRelativeEnd; 407 408 /** Information about the last classified text to avoid re-running a query. */ 409 private CharSequence mLastClassificationText; 410 private int mLastClassificationSelectionStart; 411 private int mLastClassificationSelectionEnd; 412 private LocaleList mLastClassificationLocales; 413 private SelectionResult mLastClassificationResult; 414 415 TextClassificationHelper(TextClassifier textClassifier, 416 CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { 417 reset(textClassifier, text, selectionStart, selectionEnd, true, locales); 418 } 419 420 @UiThread 421 public void reset(TextClassifier textClassifier, 422 CharSequence text, int selectionStart, int selectionEnd, 423 boolean resetSelectionTag, LocaleList locales) { 424 mTextClassifier = Preconditions.checkNotNull(textClassifier); 425 mText = Preconditions.checkNotNull(text).toString(); 426 mLastClassificationText = null; // invalidate. 427 Preconditions.checkArgument(selectionEnd > selectionStart); 428 mSelectionStart = selectionStart; 429 mSelectionEnd = selectionEnd; 430 mLocales = locales; 431 if (resetSelectionTag) { 432 mSelectionTag = ""; 433 } 434 } 435 436 @WorkerThread 437 public SelectionResult classifyText() { 438 if (!Objects.equals(mText, mLastClassificationText) 439 || mSelectionStart != mLastClassificationSelectionStart 440 || mSelectionEnd != mLastClassificationSelectionEnd 441 || !Objects.equals(mLocales, mLastClassificationLocales)) { 442 443 mLastClassificationText = mText; 444 mLastClassificationSelectionStart = mSelectionStart; 445 mLastClassificationSelectionEnd = mSelectionEnd; 446 mLastClassificationLocales = mLocales; 447 448 trimText(); 449 mLastClassificationResult = new SelectionResult( 450 mSelectionStart, 451 mSelectionEnd, 452 mTextClassifier.classifyText( 453 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales)); 454 455 } 456 return mLastClassificationResult; 457 } 458 459 @WorkerThread 460 public SelectionResult suggestSelection() { 461 trimText(); 462 final TextSelection sel = mTextClassifier.suggestSelection( 463 mTrimmedText, mRelativeStart, mRelativeEnd, mLocales); 464 mSelectionStart = Math.max(0, sel.getSelectionStartIndex() + mTrimStart); 465 mSelectionEnd = Math.min(mText.length(), sel.getSelectionEndIndex() + mTrimStart); 466 mSelectionTag = sel.getSourceClassifier(); 467 return classifyText(); 468 } 469 470 String getSelectionTag() { 471 return mSelectionTag; 472 } 473 474 private void trimText() { 475 mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); 476 final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); 477 mTrimmedText = mText.subSequence(mTrimStart, referenceEnd); 478 mRelativeStart = mSelectionStart - mTrimStart; 479 mRelativeEnd = mSelectionEnd - mTrimStart; 480 } 481 } 482 483 /** 484 * Selection result. 485 */ 486 private static final class SelectionResult { 487 private final int mStart; 488 private final int mEnd; 489 private final TextClassification mClassification; 490 491 SelectionResult(int start, int end, TextClassification classification) { 492 mStart = start; 493 mEnd = end; 494 mClassification = Preconditions.checkNotNull(classification); 495 } 496 } 497} 498