| /* |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package android.widget; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.annotation.UiThread; |
| import android.annotation.WorkerThread; |
| import android.app.RemoteAction; |
| import android.content.Context; |
| import android.graphics.Canvas; |
| import android.graphics.PointF; |
| import android.graphics.RectF; |
| import android.os.AsyncTask; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.LocaleList; |
| import android.text.Layout; |
| import android.text.Selection; |
| import android.text.Spannable; |
| import android.text.TextUtils; |
| import android.text.method.OffsetMapping; |
| import android.text.util.Linkify; |
| import android.util.Log; |
| import android.view.ActionMode; |
| import android.view.ViewConfiguration; |
| import android.view.textclassifier.ExtrasUtils; |
| import android.view.textclassifier.SelectionEvent; |
| import android.view.textclassifier.SelectionEvent.InvocationMethod; |
| import android.view.textclassifier.TextClassification; |
| import android.view.textclassifier.TextClassificationConstants; |
| import android.view.textclassifier.TextClassificationContext; |
| import android.view.textclassifier.TextClassificationManager; |
| import android.view.textclassifier.TextClassifier; |
| import android.view.textclassifier.TextClassifierEvent; |
| import android.view.textclassifier.TextSelection; |
| import android.widget.Editor.SelectionModifierCursorController; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.util.Preconditions; |
| |
| import java.text.BreakIterator; |
| import java.util.ArrayList; |
| import java.util.Comparator; |
| import java.util.List; |
| import java.util.Objects; |
| import java.util.function.Consumer; |
| import java.util.function.Function; |
| import java.util.function.Supplier; |
| import java.util.regex.Pattern; |
| |
| /** |
| * Helper class for starting selection action mode |
| * (synchronously without the TextClassifier, asynchronously with the TextClassifier). |
| * @hide |
| */ |
| @UiThread |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) |
| public class SelectionActionModeHelper { |
| |
| private static final String LOG_TAG = "SelectActionModeHelper"; |
| |
| private final Editor mEditor; |
| private final TextView mTextView; |
| private final TextClassificationHelper mTextClassificationHelper; |
| |
| @Nullable private TextClassification mTextClassification; |
| private AsyncTask mTextClassificationAsyncTask; |
| |
| private final SelectionTracker mSelectionTracker; |
| |
| // TODO remove nullable marker once the switch gating the feature gets removed |
| @Nullable |
| private final SmartSelectSprite mSmartSelectSprite; |
| |
| SelectionActionModeHelper(@NonNull Editor editor) { |
| mEditor = Objects.requireNonNull(editor); |
| mTextView = mEditor.getTextView(); |
| mTextClassificationHelper = new TextClassificationHelper( |
| mTextView.getContext(), |
| mTextView::getTextClassificationSession, |
| getText(mTextView), |
| 0, 1, mTextView.getTextLocales()); |
| mSelectionTracker = new SelectionTracker(mTextView); |
| |
| if (getTextClassificationSettings().isSmartSelectionAnimationEnabled()) { |
| mSmartSelectSprite = new SmartSelectSprite(mTextView.getContext(), |
| editor.getTextView().mHighlightColor, mTextView::invalidate); |
| } else { |
| mSmartSelectSprite = null; |
| } |
| } |
| |
| /** |
| * Swap the selection index if the start index is greater than end index. |
| * |
| * @return the swap result, index 0 is the start index and index 1 is the end index. |
| */ |
| private static int[] sortSelectionIndices(int selectionStart, int selectionEnd) { |
| if (selectionStart < selectionEnd) { |
| return new int[]{selectionStart, selectionEnd}; |
| } |
| return new int[]{selectionEnd, selectionStart}; |
| } |
| |
| /** |
| * The {@link TextView} selection start and end index may not be sorted, this method will swap |
| * the {@link TextView} selection index if the start index is greater than end index. |
| * |
| * @param textView the selected TextView. |
| * @return the swap result, index 0 is the start index and index 1 is the end index. |
| */ |
| private static int[] sortSelectionIndicesFromTextView(TextView textView) { |
| int selectionStart = textView.getSelectionStart(); |
| int selectionEnd = textView.getSelectionEnd(); |
| |
| return sortSelectionIndices(selectionStart, selectionEnd); |
| } |
| |
| /** |
| * Starts Selection ActionMode. |
| */ |
| public void startSelectionActionModeAsync(boolean adjustSelection) { |
| // Check if the smart selection should run for editable text. |
| adjustSelection &= getTextClassificationSettings().isSmartSelectionEnabled(); |
| int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); |
| |
| mSelectionTracker.onOriginalSelection( |
| getText(mTextView), |
| sortedSelectionIndices[0], |
| sortedSelectionIndices[1], |
| false /*isLink*/); |
| cancelAsyncTask(); |
| if (skipTextClassification()) { |
| startSelectionActionMode(null); |
| } else { |
| resetTextClassificationHelper(); |
| if (mSmartSelectSprite != null && mSmartSelectSprite.isAnimationActive()) { |
| mSmartSelectSprite.cancelAnimation(); |
| } |
| mTextClassificationAsyncTask = new TextClassificationAsyncTask( |
| mTextView, |
| mTextClassificationHelper.getTimeoutDuration(), |
| adjustSelection |
| ? mTextClassificationHelper::suggestSelection |
| : mTextClassificationHelper::classifyText, |
| mSmartSelectSprite != null |
| ? this::startSelectionActionModeWithSmartSelectAnimation |
| : this::startSelectionActionMode, |
| mTextClassificationHelper::getOriginalSelection) |
| .execute(); |
| } |
| } |
| |
| /** |
| * Starts Link ActionMode. |
| */ |
| public void startLinkActionModeAsync(int start, int end) { |
| int[] indexResult = sortSelectionIndices(start, end); |
| mSelectionTracker.onOriginalSelection(getText(mTextView), indexResult[0], indexResult[1], |
| true /*isLink*/); |
| cancelAsyncTask(); |
| if (skipTextClassification()) { |
| startLinkActionMode(null); |
| } else { |
| resetTextClassificationHelper(indexResult[0], indexResult[1]); |
| mTextClassificationAsyncTask = new TextClassificationAsyncTask( |
| mTextView, |
| mTextClassificationHelper.getTimeoutDuration(), |
| mTextClassificationHelper::classifyText, |
| this::startLinkActionMode, |
| mTextClassificationHelper::getOriginalSelection) |
| .execute(); |
| } |
| } |
| |
| public void invalidateActionModeAsync() { |
| cancelAsyncTask(); |
| if (skipTextClassification()) { |
| invalidateActionMode(null); |
| } else { |
| resetTextClassificationHelper(); |
| mTextClassificationAsyncTask = new TextClassificationAsyncTask( |
| mTextView, |
| mTextClassificationHelper.getTimeoutDuration(), |
| mTextClassificationHelper::classifyText, |
| this::invalidateActionMode, |
| mTextClassificationHelper::getOriginalSelection) |
| .execute(); |
| } |
| } |
| |
| /** Reports a selection action event. */ |
| public void onSelectionAction(int menuItemId, @Nullable String actionLabel) { |
| int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); |
| mSelectionTracker.onSelectionAction( |
| sortedSelectionIndices[0], sortedSelectionIndices[1], |
| getActionType(menuItemId), actionLabel, mTextClassification); |
| } |
| |
| public void onSelectionDrag() { |
| int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); |
| mSelectionTracker.onSelectionAction( |
| sortedSelectionIndices[0], sortedSelectionIndices[1], |
| SelectionEvent.ACTION_DRAG, /* actionLabel= */ null, mTextClassification); |
| } |
| |
| public void onTextChanged(int start, int end) { |
| int[] sortedSelectionIndices = sortSelectionIndices(start, end); |
| mSelectionTracker.onTextChanged(sortedSelectionIndices[0], sortedSelectionIndices[1], |
| mTextClassification); |
| } |
| |
| public boolean resetSelection(int textIndex) { |
| if (mSelectionTracker.resetSelection(textIndex, mEditor)) { |
| invalidateActionModeAsync(); |
| return true; |
| } |
| return false; |
| } |
| |
| @Nullable |
| public TextClassification getTextClassification() { |
| return mTextClassification; |
| } |
| |
| public void onDestroyActionMode() { |
| cancelSmartSelectAnimation(); |
| mSelectionTracker.onSelectionDestroyed(); |
| cancelAsyncTask(); |
| } |
| |
| public void onDraw(final Canvas canvas) { |
| if (isDrawingHighlight() && mSmartSelectSprite != null) { |
| mSmartSelectSprite.draw(canvas); |
| } |
| } |
| |
| public boolean isDrawingHighlight() { |
| return mSmartSelectSprite != null && mSmartSelectSprite.isAnimationActive(); |
| } |
| |
| private TextClassificationConstants getTextClassificationSettings() { |
| return TextClassificationManager.getSettings(mTextView.getContext()); |
| } |
| |
| private void cancelAsyncTask() { |
| if (mTextClassificationAsyncTask != null) { |
| mTextClassificationAsyncTask.cancel(true); |
| mTextClassificationAsyncTask = null; |
| } |
| mTextClassification = null; |
| } |
| |
| private boolean skipTextClassification() { |
| // No need to make an async call for a no-op TextClassifier. |
| final boolean noOpTextClassifier = mTextView.usesNoOpTextClassifier(); |
| // Do not call the TextClassifier if there is no selection. |
| final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart(); |
| // Do not call the TextClassifier if this is a password field. |
| final boolean password = mTextView.hasPasswordTransformationMethod() |
| || TextView.isPasswordInputType(mTextView.getInputType()); |
| return noOpTextClassifier || noSelection || password; |
| } |
| |
| private void startLinkActionMode(@Nullable SelectionResult result) { |
| startActionMode(Editor.TextActionMode.TEXT_LINK, result); |
| } |
| |
| private void startSelectionActionMode(@Nullable SelectionResult result) { |
| startActionMode(Editor.TextActionMode.SELECTION, result); |
| } |
| |
| private void startActionMode( |
| @Editor.TextActionMode int actionMode, @Nullable SelectionResult result) { |
| final CharSequence text = getText(mTextView); |
| if (result != null && text instanceof Spannable |
| && (mTextView.isTextSelectable() || mTextView.isTextEditable())) { |
| // Do not change the selection if TextClassifier should be dark launched. |
| if (!getTextClassificationSettings().isModelDarkLaunchEnabled()) { |
| Selection.setSelection((Spannable) text, result.mStart, result.mEnd); |
| mTextView.invalidate(); |
| } |
| mTextClassification = result.mClassification; |
| } else if (result != null && actionMode == Editor.TextActionMode.TEXT_LINK) { |
| mTextClassification = result.mClassification; |
| } else { |
| mTextClassification = null; |
| } |
| if (mEditor.startActionModeInternal(actionMode)) { |
| final SelectionModifierCursorController controller = mEditor.getSelectionController(); |
| if (controller != null |
| && (mTextView.isTextSelectable() || mTextView.isTextEditable())) { |
| if (mTextView.showUIForTouchScreen()) { |
| controller.show(); |
| } else { |
| controller.hide(); |
| } |
| } |
| if (result != null) { |
| switch (actionMode) { |
| case Editor.TextActionMode.SELECTION: |
| mSelectionTracker.onSmartSelection(result); |
| break; |
| case Editor.TextActionMode.TEXT_LINK: |
| mSelectionTracker.onLinkSelected(result); |
| break; |
| default: |
| break; |
| } |
| } |
| } |
| mEditor.setRestartActionModeOnNextRefresh(false); |
| mTextClassificationAsyncTask = null; |
| } |
| |
| private void startSelectionActionModeWithSmartSelectAnimation( |
| @Nullable SelectionResult result) { |
| final Runnable onAnimationEndCallback = () -> { |
| final SelectionResult startSelectionResult; |
| if (result != null && result.mStart >= 0 && result.mEnd <= getText(mTextView).length() |
| && result.mStart <= result.mEnd) { |
| startSelectionResult = result; |
| } else { |
| startSelectionResult = null; |
| } |
| startSelectionActionMode(startSelectionResult); |
| }; |
| // TODO do not trigger the animation if the change included only non-printable characters |
| int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); |
| final boolean didSelectionChange = |
| result != null && (sortedSelectionIndices[0] != result.mStart |
| || sortedSelectionIndices[1] != result.mEnd); |
| if (!didSelectionChange) { |
| onAnimationEndCallback.run(); |
| return; |
| } |
| |
| final List<SmartSelectSprite.RectangleWithTextSelectionLayout> selectionRectangles = |
| convertSelectionToRectangles(mTextView, result.mStart, result.mEnd); |
| |
| final PointF touchPoint = new PointF( |
| mEditor.getLastUpPositionX(), |
| mEditor.getLastUpPositionY()); |
| |
| final PointF animationStartPoint = |
| movePointInsideNearestRectangle(touchPoint, selectionRectangles, |
| SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle); |
| |
| mSmartSelectSprite.startAnimation( |
| animationStartPoint, |
| selectionRectangles, |
| onAnimationEndCallback); |
| } |
| |
| private List<SmartSelectSprite.RectangleWithTextSelectionLayout> convertSelectionToRectangles( |
| final TextView textView, final int start, final int end) { |
| final List<SmartSelectSprite.RectangleWithTextSelectionLayout> result = new ArrayList<>(); |
| |
| final Layout.SelectionRectangleConsumer consumer = |
| (left, top, right, bottom, textSelectionLayout) -> mergeRectangleIntoList( |
| result, |
| new RectF(left, top, right, bottom), |
| SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, |
| r -> new SmartSelectSprite.RectangleWithTextSelectionLayout(r, |
| textSelectionLayout) |
| ); |
| |
| final int startTransformed = |
| textView.originalToTransformed(start, OffsetMapping.MAP_STRATEGY_CURSOR); |
| final int endTransformed = |
| textView.originalToTransformed(end, OffsetMapping.MAP_STRATEGY_CURSOR); |
| textView.getLayout().getSelection(startTransformed, endTransformed, consumer); |
| |
| result.sort(Comparator.comparing( |
| SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, |
| SmartSelectSprite.RECTANGLE_COMPARATOR)); |
| |
| return result; |
| } |
| |
| // TODO: Move public pure functions out of this class and make it package-private. |
| /** |
| * Merges a {@link RectF} into an existing list of any objects which contain a rectangle. |
| * While merging, this method makes sure that: |
| * |
| * <ol> |
| * <li>No rectangle is redundant (contained within a bigger rectangle)</li> |
| * <li>Rectangles of the same height and vertical position that intersect get merged</li> |
| * </ol> |
| * |
| * @param list the list of rectangles (or other rectangle containers) to merge the new |
| * rectangle into |
| * @param candidate the {@link RectF} to merge into the list |
| * @param extractor a function that can extract a {@link RectF} from an element of the given |
| * list |
| * @param packer a function that can wrap the resulting {@link RectF} into an element that |
| * the list contains |
| * @hide |
| */ |
| @VisibleForTesting |
| public static <T> void mergeRectangleIntoList(final List<T> list, |
| final RectF candidate, final Function<T, RectF> extractor, |
| final Function<RectF, T> packer) { |
| if (candidate.isEmpty()) { |
| return; |
| } |
| |
| final int elementCount = list.size(); |
| for (int index = 0; index < elementCount; ++index) { |
| final RectF existingRectangle = extractor.apply(list.get(index)); |
| if (existingRectangle.contains(candidate)) { |
| return; |
| } |
| if (candidate.contains(existingRectangle)) { |
| existingRectangle.setEmpty(); |
| continue; |
| } |
| |
| final boolean rectanglesContinueEachOther = candidate.left == existingRectangle.right |
| || candidate.right == existingRectangle.left; |
| final boolean canMerge = candidate.top == existingRectangle.top |
| && candidate.bottom == existingRectangle.bottom |
| && (RectF.intersects(candidate, existingRectangle) |
| || rectanglesContinueEachOther); |
| |
| if (canMerge) { |
| candidate.union(existingRectangle); |
| existingRectangle.setEmpty(); |
| } |
| } |
| |
| for (int index = elementCount - 1; index >= 0; --index) { |
| final RectF rectangle = extractor.apply(list.get(index)); |
| if (rectangle.isEmpty()) { |
| list.remove(index); |
| } |
| } |
| |
| list.add(packer.apply(candidate)); |
| } |
| |
| |
| /** @hide */ |
| @VisibleForTesting |
| public static <T> PointF movePointInsideNearestRectangle(final PointF point, |
| final List<T> list, final Function<T, RectF> extractor) { |
| float bestX = -1; |
| float bestY = -1; |
| double bestDistance = Double.MAX_VALUE; |
| |
| final int elementCount = list.size(); |
| for (int index = 0; index < elementCount; ++index) { |
| final RectF rectangle = extractor.apply(list.get(index)); |
| final float candidateY = rectangle.centerY(); |
| final float candidateX; |
| |
| if (point.x > rectangle.right) { |
| candidateX = rectangle.right; |
| } else if (point.x < rectangle.left) { |
| candidateX = rectangle.left; |
| } else { |
| candidateX = point.x; |
| } |
| |
| final double candidateDistance = Math.pow(point.x - candidateX, 2) |
| + Math.pow(point.y - candidateY, 2); |
| |
| if (candidateDistance < bestDistance) { |
| bestX = candidateX; |
| bestY = candidateY; |
| bestDistance = candidateDistance; |
| } |
| } |
| |
| return new PointF(bestX, bestY); |
| } |
| |
| private void invalidateActionMode(@Nullable SelectionResult result) { |
| cancelSmartSelectAnimation(); |
| mTextClassification = result != null ? result.mClassification : null; |
| final ActionMode actionMode = mEditor.getTextActionMode(); |
| if (actionMode != null) { |
| actionMode.invalidate(); |
| } |
| final int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); |
| mSelectionTracker.onSelectionUpdated( |
| sortedSelectionIndices[0], sortedSelectionIndices[1], mTextClassification); |
| mTextClassificationAsyncTask = null; |
| } |
| |
| private void resetTextClassificationHelper(int selectionStart, int selectionEnd) { |
| if (selectionStart < 0 || selectionEnd < 0) { |
| // Use selection indices |
| int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(mTextView); |
| selectionStart = sortedSelectionIndices[0]; |
| selectionEnd = sortedSelectionIndices[1]; |
| } |
| mTextClassificationHelper.init( |
| mTextView::getTextClassificationSession, |
| getText(mTextView), |
| selectionStart, selectionEnd, |
| mTextView.getTextLocales()); |
| } |
| |
| private void resetTextClassificationHelper() { |
| resetTextClassificationHelper(-1, -1); |
| } |
| |
| private void cancelSmartSelectAnimation() { |
| if (mSmartSelectSprite != null) { |
| mSmartSelectSprite.cancelAnimation(); |
| } |
| } |
| |
| /** |
| * Tracks and logs smart selection changes. |
| * It is important to trigger this object's methods at the appropriate event so that it tracks |
| * smart selection events appropriately. |
| */ |
| private static final class SelectionTracker { |
| |
| private final TextView mTextView; |
| private SelectionMetricsLogger mLogger; |
| |
| private int mOriginalStart; |
| private int mOriginalEnd; |
| private int mSelectionStart; |
| private int mSelectionEnd; |
| private boolean mAllowReset; |
| private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable(); |
| |
| SelectionTracker(TextView textView) { |
| mTextView = Objects.requireNonNull(textView); |
| mLogger = new SelectionMetricsLogger(textView); |
| } |
| |
| /** |
| * Called when the original selection happens, before smart selection is triggered. |
| */ |
| public void onOriginalSelection( |
| CharSequence text, int selectionStart, int selectionEnd, boolean isLink) { |
| // If we abandoned a selection and created a new one very shortly after, we may still |
| // have a pending request to log ABANDON, which we flush here. |
| mDelayedLogAbandon.flush(); |
| |
| mOriginalStart = mSelectionStart = selectionStart; |
| mOriginalEnd = mSelectionEnd = selectionEnd; |
| mAllowReset = false; |
| maybeInvalidateLogger(); |
| mLogger.logSelectionStarted( |
| mTextView.getTextClassificationSession(), |
| mTextView.getTextClassificationContext(), |
| text, |
| selectionStart, |
| isLink ? SelectionEvent.INVOCATION_LINK : SelectionEvent.INVOCATION_MANUAL); |
| } |
| |
| /** |
| * Called when selection action mode is started and the results come from a classifier. |
| */ |
| public void onSmartSelection(SelectionResult result) { |
| onClassifiedSelection(result); |
| mTextView.notifyContentCaptureTextChanged(); |
| mLogger.logSelectionModified( |
| result.mStart, result.mEnd, result.mClassification, result.mSelection); |
| } |
| |
| /** |
| * Called when link action mode is started and the classification comes from a classifier. |
| */ |
| public void onLinkSelected(SelectionResult result) { |
| onClassifiedSelection(result); |
| // TODO: log (b/70246800) |
| } |
| |
| private void onClassifiedSelection(SelectionResult result) { |
| if (isSelectionStarted()) { |
| mSelectionStart = result.mStart; |
| mSelectionEnd = result.mEnd; |
| mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; |
| } |
| } |
| |
| /** |
| * Called when selection bounds change. |
| */ |
| public void onSelectionUpdated( |
| int selectionStart, int selectionEnd, |
| @Nullable TextClassification classification) { |
| if (isSelectionStarted()) { |
| mSelectionStart = selectionStart; |
| mSelectionEnd = selectionEnd; |
| mAllowReset = false; |
| mTextView.notifyContentCaptureTextChanged(); |
| mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null); |
| } |
| } |
| |
| /** |
| * Called when the selection action mode is destroyed. |
| */ |
| public void onSelectionDestroyed() { |
| mAllowReset = false; |
| mTextView.notifyContentCaptureTextChanged(); |
| // Wait a few ms to see if the selection was destroyed because of a text change event. |
| mDelayedLogAbandon.schedule(100 /* ms */); |
| } |
| |
| /** |
| * Called when an action is taken on a smart selection. |
| */ |
| public void onSelectionAction( |
| int selectionStart, int selectionEnd, |
| @SelectionEvent.ActionType int action, |
| @Nullable String actionLabel, |
| @Nullable TextClassification classification) { |
| if (isSelectionStarted()) { |
| mAllowReset = false; |
| mLogger.logSelectionAction( |
| selectionStart, selectionEnd, action, actionLabel, classification); |
| } |
| } |
| |
| /** |
| * Returns true if the current smart selection should be reset to normal selection based on |
| * information that has been recorded about the original selection and the smart selection. |
| * The expected UX here is to allow the user to select a word inside of the smart selection |
| * on a single tap. |
| */ |
| public boolean resetSelection(int textIndex, Editor editor) { |
| final TextView textView = editor.getTextView(); |
| if (isSelectionStarted() |
| && mAllowReset |
| && textIndex >= mSelectionStart && textIndex <= mSelectionEnd |
| && getText(textView) instanceof Spannable) { |
| mAllowReset = false; |
| boolean selected = editor.selectCurrentWord(); |
| if (selected) { |
| final int[] sortedSelectionIndices = sortSelectionIndicesFromTextView(textView); |
| mSelectionStart = sortedSelectionIndices[0]; |
| mSelectionEnd = sortedSelectionIndices[1]; |
| mLogger.logSelectionAction( |
| sortedSelectionIndices[0], sortedSelectionIndices[1], |
| SelectionEvent.ACTION_RESET, |
| /* actionLabel= */ null, /* classification= */ null); |
| } |
| return selected; |
| } |
| return false; |
| } |
| |
| public void onTextChanged(int start, int end, TextClassification classification) { |
| if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) { |
| onSelectionAction( |
| start, end, SelectionEvent.ACTION_OVERTYPE, |
| /* actionLabel= */ null, classification); |
| } |
| } |
| |
| private void maybeInvalidateLogger() { |
| if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) { |
| mLogger = new SelectionMetricsLogger(mTextView); |
| } |
| } |
| |
| private boolean isSelectionStarted() { |
| return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd; |
| } |
| |
| /** A helper for keeping track of pending abandon logging requests. */ |
| private final class LogAbandonRunnable implements Runnable { |
| private boolean mIsPending; |
| |
| /** Schedules an abandon to be logged with the given delay. Flush if necessary. */ |
| void schedule(int delayMillis) { |
| if (mIsPending) { |
| Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request"); |
| flush(); |
| } |
| mIsPending = true; |
| mTextView.postDelayed(this, delayMillis); |
| } |
| |
| /** If there is a pending log request, execute it now. */ |
| void flush() { |
| mTextView.removeCallbacks(this); |
| run(); |
| } |
| |
| @Override |
| public void run() { |
| if (mIsPending) { |
| mLogger.logSelectionAction( |
| mSelectionStart, mSelectionEnd, |
| SelectionEvent.ACTION_ABANDON, |
| /* actionLabel= */ null, /* classification= */ null); |
| mSelectionStart = mSelectionEnd = -1; |
| mLogger.endTextClassificationSession(); |
| mIsPending = false; |
| } |
| } |
| } |
| } |
| |
| // TODO: Write tests |
| /** |
| * Metrics logging helper. |
| * |
| * This logger logs selection by word indices. The initial (start) single word selection is |
| * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the |
| * initial single word selection. |
| * e.g. New York city, NY. Suppose the initial selection is "York" in |
| * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2). |
| * "New York" is at [-1, 1). |
| * Part selection of a word e.g. "or" is counted as selecting the |
| * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g. |
| * "," is at [2, 3). Whitespaces are ignored. |
| * |
| * NOTE that the definition of a word is defined by the TextClassifier's Logger's token |
| * iterator. |
| */ |
| private static final class SelectionMetricsLogger { |
| |
| private static final String LOG_TAG = "SelectionMetricsLogger"; |
| private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+"); |
| |
| private final boolean mEditTextLogger; |
| private final BreakIterator mTokenIterator; |
| |
| @Nullable private TextClassifier mClassificationSession; |
| @Nullable private TextClassificationContext mClassificationContext; |
| |
| @Nullable private TextClassifierEvent mTranslateViewEvent; |
| @Nullable private TextClassifierEvent mTranslateClickEvent; |
| |
| private int mStartIndex; |
| private String mText; |
| |
| SelectionMetricsLogger(TextView textView) { |
| Objects.requireNonNull(textView); |
| mEditTextLogger = textView.isTextEditable(); |
| mTokenIterator = BreakIterator.getWordInstance(textView.getTextLocale()); |
| } |
| |
| public void logSelectionStarted( |
| TextClassifier classificationSession, |
| TextClassificationContext classificationContext, |
| CharSequence text, int index, |
| @InvocationMethod int invocationMethod) { |
| try { |
| Objects.requireNonNull(text); |
| Preconditions.checkArgumentInRange(index, 0, text.length(), "index"); |
| if (mText == null || !mText.contentEquals(text)) { |
| mText = text.toString(); |
| } |
| mTokenIterator.setText(mText); |
| mStartIndex = index; |
| mClassificationSession = classificationSession; |
| mClassificationContext = classificationContext; |
| if (hasActiveClassificationSession()) { |
| mClassificationSession.onSelectionEvent( |
| SelectionEvent.createSelectionStartedEvent(invocationMethod, 0)); |
| } |
| } catch (Exception e) { |
| // Avoid crashes due to logging. |
| Log.e(LOG_TAG, "" + e.getMessage(), e); |
| } |
| } |
| |
| public void logSelectionModified(int start, int end, |
| @Nullable TextClassification classification, @Nullable TextSelection selection) { |
| try { |
| if (hasActiveClassificationSession()) { |
| Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); |
| Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); |
| int[] wordIndices = getWordDelta(start, end); |
| if (selection != null) { |
| mClassificationSession.onSelectionEvent( |
| SelectionEvent.createSelectionModifiedEvent( |
| wordIndices[0], wordIndices[1], selection)); |
| } else if (classification != null) { |
| mClassificationSession.onSelectionEvent( |
| SelectionEvent.createSelectionModifiedEvent( |
| wordIndices[0], wordIndices[1], classification)); |
| } else { |
| mClassificationSession.onSelectionEvent( |
| SelectionEvent.createSelectionModifiedEvent( |
| wordIndices[0], wordIndices[1])); |
| } |
| maybeGenerateTranslateViewEvent(classification); |
| } |
| } catch (Exception e) { |
| // Avoid crashes due to logging. |
| Log.e(LOG_TAG, "" + e.getMessage(), e); |
| } |
| } |
| |
| public void logSelectionAction( |
| int start, int end, |
| @SelectionEvent.ActionType int action, |
| @Nullable String actionLabel, |
| @Nullable TextClassification classification) { |
| try { |
| if (hasActiveClassificationSession()) { |
| Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); |
| Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); |
| int[] wordIndices = getWordDelta(start, end); |
| if (classification != null) { |
| mClassificationSession.onSelectionEvent( |
| SelectionEvent.createSelectionActionEvent( |
| wordIndices[0], wordIndices[1], action, |
| classification)); |
| } else { |
| mClassificationSession.onSelectionEvent( |
| SelectionEvent.createSelectionActionEvent( |
| wordIndices[0], wordIndices[1], action)); |
| } |
| |
| maybeGenerateTranslateClickEvent(classification, actionLabel); |
| |
| if (SelectionEvent.isTerminal(action)) { |
| endTextClassificationSession(); |
| } |
| } |
| } catch (Exception e) { |
| // Avoid crashes due to logging. |
| Log.e(LOG_TAG, "" + e.getMessage(), e); |
| } |
| } |
| |
| public boolean isEditTextLogger() { |
| return mEditTextLogger; |
| } |
| |
| public void endTextClassificationSession() { |
| if (hasActiveClassificationSession()) { |
| maybeReportTranslateEvents(); |
| mClassificationSession.destroy(); |
| } |
| } |
| |
| private boolean hasActiveClassificationSession() { |
| return mClassificationSession != null && !mClassificationSession.isDestroyed(); |
| } |
| |
| private int[] getWordDelta(int start, int end) { |
| int[] wordIndices = new int[2]; |
| |
| if (start == mStartIndex) { |
| wordIndices[0] = 0; |
| } else if (start < mStartIndex) { |
| wordIndices[0] = -countWordsForward(start); |
| } else { // start > mStartIndex |
| wordIndices[0] = countWordsBackward(start); |
| |
| // For the selection start index, avoid counting a partial word backwards. |
| if (!mTokenIterator.isBoundary(start) |
| && !isWhitespace( |
| mTokenIterator.preceding(start), |
| mTokenIterator.following(start))) { |
| // We counted a partial word. Remove it. |
| wordIndices[0]--; |
| } |
| } |
| |
| if (end == mStartIndex) { |
| wordIndices[1] = 0; |
| } else if (end < mStartIndex) { |
| wordIndices[1] = -countWordsForward(end); |
| } else { // end > mStartIndex |
| wordIndices[1] = countWordsBackward(end); |
| } |
| |
| return wordIndices; |
| } |
| |
| private int countWordsBackward(int from) { |
| Preconditions.checkArgument(from >= mStartIndex); |
| int wordCount = 0; |
| int offset = from; |
| while (offset > mStartIndex) { |
| int start = mTokenIterator.preceding(offset); |
| if (!isWhitespace(start, offset)) { |
| wordCount++; |
| } |
| offset = start; |
| } |
| return wordCount; |
| } |
| |
| private int countWordsForward(int from) { |
| Preconditions.checkArgument(from <= mStartIndex); |
| int wordCount = 0; |
| int offset = from; |
| while (offset < mStartIndex) { |
| int end = mTokenIterator.following(offset); |
| if (!isWhitespace(offset, end)) { |
| wordCount++; |
| } |
| offset = end; |
| } |
| return wordCount; |
| } |
| |
| private boolean isWhitespace(int start, int end) { |
| return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches(); |
| } |
| |
| private void maybeGenerateTranslateViewEvent(@Nullable TextClassification classification) { |
| if (classification != null) { |
| final TextClassifierEvent event = generateTranslateEvent( |
| TextClassifierEvent.TYPE_ACTIONS_SHOWN, |
| classification, mClassificationContext, /* actionLabel= */null); |
| mTranslateViewEvent = (event != null) ? event : mTranslateViewEvent; |
| } |
| } |
| |
| private void maybeGenerateTranslateClickEvent( |
| @Nullable TextClassification classification, String actionLabel) { |
| if (classification != null) { |
| mTranslateClickEvent = generateTranslateEvent( |
| TextClassifierEvent.TYPE_SMART_ACTION, |
| classification, mClassificationContext, actionLabel); |
| } |
| } |
| |
| private void maybeReportTranslateEvents() { |
| // Translate view and click events should only be logged once per selection session. |
| if (mTranslateViewEvent != null) { |
| mClassificationSession.onTextClassifierEvent(mTranslateViewEvent); |
| mTranslateViewEvent = null; |
| } |
| if (mTranslateClickEvent != null) { |
| mClassificationSession.onTextClassifierEvent(mTranslateClickEvent); |
| mTranslateClickEvent = null; |
| } |
| } |
| |
| @Nullable |
| private static TextClassifierEvent generateTranslateEvent( |
| int eventType, TextClassification classification, |
| TextClassificationContext classificationContext, @Nullable String actionLabel) { |
| |
| // The platform attempts to log "views" and "clicks" of the "Translate" action. |
| // Views are logged if a user is presented with the translate action during a selection |
| // session. |
| // Clicks are logged if the user clicks on the translate action. |
| // The index of the translate action is also logged to indicate whether it might have |
| // been in the main panel or overflow panel of the selection toolbar. |
| // NOTE that the "views" metric may be flawed if a TextView removes the translate menu |
| // item via a custom action mode callback or does not show a selection menu item. |
| |
| final RemoteAction translateAction = ExtrasUtils.findTranslateAction(classification); |
| if (translateAction == null) { |
| // No translate action present. Nothing to log. Exit. |
| return null; |
| } |
| |
| if (eventType == TextClassifierEvent.TYPE_SMART_ACTION |
| && !translateAction.getTitle().toString().equals(actionLabel)) { |
| // Clicked action is not a translate action. Nothing to log. Exit. |
| // Note that we don't expect an actionLabel for "view" events. |
| return null; |
| } |
| |
| final Bundle foreignLanguageExtra = ExtrasUtils.getForeignLanguageExtra(classification); |
| final String language = ExtrasUtils.getEntityType(foreignLanguageExtra); |
| final float score = ExtrasUtils.getScore(foreignLanguageExtra); |
| final String model = ExtrasUtils.getModelName(foreignLanguageExtra); |
| return new TextClassifierEvent.LanguageDetectionEvent.Builder(eventType) |
| .setEventContext(classificationContext) |
| .setResultId(classification.getId()) |
| // b/158481016: Disable language logging. |
| //.setEntityTypes(language) |
| .setScores(score) |
| .setActionIndices(classification.getActions().indexOf(translateAction)) |
| .setModelName(model) |
| .build(); |
| } |
| } |
| |
| /** |
| * AsyncTask for running a query on a background thread and returning the result on the |
| * UiThread. The AsyncTask times out after a specified time, returning a null result if the |
| * query has not yet returned. |
| */ |
| private static final class TextClassificationAsyncTask |
| extends AsyncTask<Void, Void, SelectionResult> { |
| |
| private final int mTimeOutDuration; |
| private final Supplier<SelectionResult> mSelectionResultSupplier; |
| private final Consumer<SelectionResult> mSelectionResultCallback; |
| private final Supplier<SelectionResult> mTimeOutResultSupplier; |
| private final TextView mTextView; |
| private final String mOriginalText; |
| |
| /** |
| * @param textView the TextView |
| * @param timeOut time in milliseconds to timeout the query if it has not completed |
| * @param selectionResultSupplier fetches the selection results. Runs on a background thread |
| * @param selectionResultCallback receives the selection results. Runs on the UiThread |
| * @param timeOutResultSupplier default result if the task times out |
| */ |
| TextClassificationAsyncTask( |
| @NonNull TextView textView, int timeOut, |
| @NonNull Supplier<SelectionResult> selectionResultSupplier, |
| @NonNull Consumer<SelectionResult> selectionResultCallback, |
| @NonNull Supplier<SelectionResult> timeOutResultSupplier) { |
| super(textView != null ? textView.getHandler() : null); |
| mTextView = Objects.requireNonNull(textView); |
| mTimeOutDuration = timeOut; |
| mSelectionResultSupplier = Objects.requireNonNull(selectionResultSupplier); |
| mSelectionResultCallback = Objects.requireNonNull(selectionResultCallback); |
| mTimeOutResultSupplier = Objects.requireNonNull(timeOutResultSupplier); |
| // Make a copy of the original text. |
| mOriginalText = getText(mTextView).toString(); |
| } |
| |
| @Override |
| @WorkerThread |
| protected SelectionResult doInBackground(Void... params) { |
| final Runnable onTimeOut = this::onTimeOut; |
| mTextView.postDelayed(onTimeOut, mTimeOutDuration); |
| SelectionResult result = null; |
| try { |
| result = mSelectionResultSupplier.get(); |
| } catch (IllegalStateException e) { |
| // TODO(b/174300371): Only swallows the exception if the TCSession is destroyed |
| Log.w(LOG_TAG, "TextClassificationAsyncTask failed.", e); |
| } |
| mTextView.removeCallbacks(onTimeOut); |
| return result; |
| } |
| |
| @Override |
| @UiThread |
| protected void onPostExecute(SelectionResult result) { |
| result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null; |
| mSelectionResultCallback.accept(result); |
| } |
| |
| private void onTimeOut() { |
| Log.d(LOG_TAG, "Timeout in TextClassificationAsyncTask"); |
| if (getStatus() == Status.RUNNING) { |
| onPostExecute(mTimeOutResultSupplier.get()); |
| } |
| cancel(true); |
| } |
| } |
| |
| /** |
| * Helper class for querying the TextClassifier. |
| * It trims text so that only text necessary to provide context of the selected text is |
| * sent to the TextClassifier. |
| */ |
| private static final class TextClassificationHelper { |
| |
| // The fixed upper bound of context size. |
| private static final int TRIM_DELTA_UPPER_BOUND = 240; |
| |
| private final Context mContext; |
| private Supplier<TextClassifier> mTextClassifier; |
| private final ViewConfiguration mViewConfiguration; |
| |
| /** The original TextView text. **/ |
| private String mText; |
| /** Start index relative to mText. */ |
| private int mSelectionStart; |
| /** End index relative to mText. */ |
| private int mSelectionEnd; |
| |
| @Nullable |
| private LocaleList mDefaultLocales; |
| |
| /** Trimmed text starting from mTrimStart in mText. */ |
| private CharSequence mTrimmedText; |
| /** Index indicating the start of mTrimmedText in mText. */ |
| private int mTrimStart; |
| /** Start index relative to mTrimmedText */ |
| private int mRelativeStart; |
| /** End index relative to mTrimmedText */ |
| private int mRelativeEnd; |
| |
| /** Information about the last classified text to avoid re-running a query. */ |
| private CharSequence mLastClassificationText; |
| private int mLastClassificationSelectionStart; |
| private int mLastClassificationSelectionEnd; |
| private LocaleList mLastClassificationLocales; |
| private SelectionResult mLastClassificationResult; |
| |
| /** Whether the TextClassifier has been initialized. */ |
| private boolean mInitialized; |
| |
| TextClassificationHelper(Context context, Supplier<TextClassifier> textClassifier, |
| CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { |
| init(textClassifier, text, selectionStart, selectionEnd, locales); |
| mContext = Objects.requireNonNull(context); |
| mViewConfiguration = ViewConfiguration.get(mContext); |
| } |
| |
| @UiThread |
| public void init(Supplier<TextClassifier> textClassifier, CharSequence text, |
| int selectionStart, int selectionEnd, LocaleList locales) { |
| mTextClassifier = Objects.requireNonNull(textClassifier); |
| mText = Objects.requireNonNull(text).toString(); |
| mLastClassificationText = null; // invalidate. |
| Preconditions.checkArgument(selectionEnd > selectionStart); |
| mSelectionStart = selectionStart; |
| mSelectionEnd = selectionEnd; |
| mDefaultLocales = locales; |
| } |
| |
| @WorkerThread |
| public SelectionResult classifyText() { |
| mInitialized = true; |
| return performClassification(null /* selection */); |
| } |
| |
| @WorkerThread |
| public SelectionResult suggestSelection() { |
| mInitialized = true; |
| trimText(); |
| final TextSelection selection; |
| if (mContext.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.P) { |
| final TextSelection.Request request = new TextSelection.Request.Builder( |
| mTrimmedText, mRelativeStart, mRelativeEnd) |
| .setDefaultLocales(mDefaultLocales) |
| .setDarkLaunchAllowed(true) |
| .setIncludeTextClassification(true) |
| .build(); |
| selection = mTextClassifier.get().suggestSelection(request); |
| } else { |
| // Use old APIs. |
| selection = mTextClassifier.get().suggestSelection( |
| mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales); |
| } |
| // Do not classify new selection boundaries if TextClassifier should be dark launched. |
| if (!isDarkLaunchEnabled()) { |
| mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart); |
| mSelectionEnd = Math.min( |
| mText.length(), selection.getSelectionEndIndex() + mTrimStart); |
| } |
| return performClassification(selection); |
| } |
| |
| public SelectionResult getOriginalSelection() { |
| return new SelectionResult(mSelectionStart, mSelectionEnd, null, null); |
| } |
| |
| /** |
| * Maximum time (in milliseconds) to wait for a textclassifier result before timing out. |
| */ |
| public int getTimeoutDuration() { |
| if (mInitialized) { |
| return mViewConfiguration.getSmartSelectionInitializedTimeout(); |
| } else { |
| // Return a slightly larger number than usual when the TextClassifier is first |
| // initialized. Initialization would usually take longer than subsequent calls to |
| // the TextClassifier. The impact of this on the UI is that we do not show the |
| // selection handles or toolbar until after this timeout. |
| return mViewConfiguration.getSmartSelectionInitializingTimeout(); |
| } |
| } |
| |
| private boolean isDarkLaunchEnabled() { |
| return TextClassificationManager.getSettings(mContext).isModelDarkLaunchEnabled(); |
| } |
| |
| private SelectionResult performClassification(@Nullable TextSelection selection) { |
| if (!Objects.equals(mText, mLastClassificationText) |
| || mSelectionStart != mLastClassificationSelectionStart |
| || mSelectionEnd != mLastClassificationSelectionEnd |
| || !Objects.equals(mDefaultLocales, mLastClassificationLocales)) { |
| |
| mLastClassificationText = mText; |
| mLastClassificationSelectionStart = mSelectionStart; |
| mLastClassificationSelectionEnd = mSelectionEnd; |
| mLastClassificationLocales = mDefaultLocales; |
| |
| trimText(); |
| final TextClassification classification; |
| if (Linkify.containsUnsupportedCharacters(mText)) { |
| // Do not show smart actions for text containing unsupported characters. |
| android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, ""); |
| classification = TextClassification.EMPTY; |
| } else if (selection != null && selection.getTextClassification() != null) { |
| classification = selection.getTextClassification(); |
| } else if (mContext.getApplicationInfo().targetSdkVersion |
| >= Build.VERSION_CODES.P) { |
| final TextClassification.Request request = |
| new TextClassification.Request.Builder( |
| mTrimmedText, mRelativeStart, mRelativeEnd) |
| .setDefaultLocales(mDefaultLocales) |
| .build(); |
| classification = mTextClassifier.get().classifyText(request); |
| } else { |
| // Use old APIs. |
| classification = mTextClassifier.get().classifyText( |
| mTrimmedText, mRelativeStart, mRelativeEnd, mDefaultLocales); |
| } |
| mLastClassificationResult = new SelectionResult( |
| mSelectionStart, mSelectionEnd, classification, selection); |
| |
| } |
| return mLastClassificationResult; |
| } |
| |
| private void trimText() { |
| final int trimDelta = Math.min( |
| TextClassificationManager.getSettings(mContext).getSmartSelectionTrimDelta(), |
| TRIM_DELTA_UPPER_BOUND); |
| mTrimStart = Math.max(0, mSelectionStart - trimDelta); |
| final int referenceEnd = Math.min(mText.length(), mSelectionEnd + trimDelta); |
| mTrimmedText = mText.subSequence(mTrimStart, referenceEnd); |
| mRelativeStart = mSelectionStart - mTrimStart; |
| mRelativeEnd = mSelectionEnd - mTrimStart; |
| } |
| } |
| |
| /** |
| * Selection result. |
| */ |
| private static final class SelectionResult { |
| private final int mStart; |
| private final int mEnd; |
| @Nullable private final TextClassification mClassification; |
| @Nullable private final TextSelection mSelection; |
| |
| SelectionResult(int start, int end, |
| @Nullable TextClassification classification, @Nullable TextSelection selection) { |
| int[] sortedIndices = sortSelectionIndices(start, end); |
| mStart = sortedIndices[0]; |
| mEnd = sortedIndices[1]; |
| mClassification = classification; |
| mSelection = selection; |
| } |
| } |
| |
| @SelectionEvent.ActionType |
| private static int getActionType(int menuItemId) { |
| switch (menuItemId) { |
| case TextView.ID_SELECT_ALL: |
| return SelectionEvent.ACTION_SELECT_ALL; |
| case TextView.ID_CUT: |
| return SelectionEvent.ACTION_CUT; |
| case TextView.ID_COPY: |
| return SelectionEvent.ACTION_COPY; |
| case TextView.ID_PASTE: // fall through |
| case TextView.ID_PASTE_AS_PLAIN_TEXT: |
| return SelectionEvent.ACTION_PASTE; |
| case TextView.ID_SHARE: |
| return SelectionEvent.ACTION_SHARE; |
| case TextView.ID_ASSIST: |
| return SelectionEvent.ACTION_SMART_SHARE; |
| default: |
| return SelectionEvent.ACTION_OTHER; |
| } |
| } |
| |
| private static CharSequence getText(TextView textView) { |
| // Extracts the textView's text. |
| // TODO: Investigate why/when TextView.getText() is null. |
| final CharSequence text = textView.getText(); |
| if (text != null) { |
| return text; |
| } |
| return ""; |
| } |
| } |