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