diff options
| author | 2017-09-05 10:50:14 +0000 | |
|---|---|---|
| committer | 2017-09-05 10:50:14 +0000 | |
| commit | d0470d7ebd8c78aaada0afa1de7dc155053d220c (patch) | |
| tree | 4b30fd2c616478ced745f79b4cec00b69f0f51e1 | |
| parent | 6620b3c55bc969f0fa376963d76143eb8a990129 (diff) | |
| parent | 563a5637d71db27e0fc95082601198e29d80a15e (diff) | |
Merge "TextView text selection interaction logging." into oc-mr1-dev
am: 563a5637d7
Change-Id: Ie9a211b657d9fa370db699d9c11ad553ab5f22d6
| -rw-r--r-- | core/java/android/widget/Editor.java | 4 | ||||
| -rw-r--r-- | core/java/android/widget/SelectionActionModeHelper.java | 441 |
2 files changed, 314 insertions, 131 deletions
diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java index 04a826514a83..a20ce4eb69fa 100644 --- a/core/java/android/widget/Editor.java +++ b/core/java/android/widget/Editor.java @@ -1066,6 +1066,8 @@ public class Editor { } private void startDragAndDrop() { + getSelectionActionModeHelper().onSelectionDrag(); + // TODO: Fix drag and drop in full screen extracted mode. if (mTextView.isInExtractedMode()) { return; @@ -3940,7 +3942,7 @@ public class Editor { @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - getSelectionActionModeHelper().onSelectionAction(); + getSelectionActionModeHelper().onSelectionAction(item.getItemId()); if (mProcessTextIntentActionsHandler.performMenuItemAction(item)) { return true; diff --git a/core/java/android/widget/SelectionActionModeHelper.java b/core/java/android/widget/SelectionActionModeHelper.java index 3f4ce4462ad1..1285c108b1c3 100644 --- a/core/java/android/widget/SelectionActionModeHelper.java +++ b/core/java/android/widget/SelectionActionModeHelper.java @@ -20,19 +20,24 @@ import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.UiThread; import android.annotation.WorkerThread; +import android.content.Context; import android.os.AsyncTask; import android.os.LocaleList; import android.text.Selection; import android.text.Spannable; import android.text.TextUtils; +import android.util.Log; import android.view.ActionMode; import android.view.textclassifier.TextClassification; import android.view.textclassifier.TextClassifier; import android.view.textclassifier.TextSelection; +import android.view.textclassifier.logging.SmartSelectionEventTracker; +import android.view.textclassifier.logging.SmartSelectionEventTracker.SelectionEvent; import android.widget.Editor.SelectionModifierCursorController; import com.android.internal.util.Preconditions; +import java.text.BreakIterator; import java.util.Objects; import java.util.function.Consumer; import java.util.function.Supplier; @@ -51,6 +56,7 @@ final class SelectionActionModeHelper { private static final int TIMEOUT_DURATION = 200; private final Editor mEditor; + private final TextView mTextView; private final TextClassificationHelper mTextClassificationHelper; private TextClassification mTextClassification; @@ -60,21 +66,27 @@ final class SelectionActionModeHelper { SelectionActionModeHelper(@NonNull Editor editor) { mEditor = Preconditions.checkNotNull(editor); - final TextView textView = mEditor.getTextView(); + mTextView = mEditor.getTextView(); mTextClassificationHelper = new TextClassificationHelper( - textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales()); - mSelectionTracker = new SelectionTracker(textView.getTextClassifier()); + mTextView.getTextClassifier(), mTextView.getText(), + 0, 1, mTextView.getTextLocales()); + mSelectionTracker = + new SelectionTracker(mTextView.getContext(), mTextView.isTextEditable()); } public void startActionModeAsync(boolean adjustSelection) { + mSelectionTracker.onOriginalSelection( + mTextView.getText(), + mTextView.getSelectionStart(), + mTextView.getSelectionEnd(), + mTextView.isTextEditable()); cancelAsyncTask(); if (skipTextClassification()) { startActionMode(null); } else { - resetTextClassificationHelper(true /* resetSelectionTag */); - final TextView tv = mEditor.getTextView(); + resetTextClassificationHelper(); mTextClassificationAsyncTask = new TextClassificationAsyncTask( - tv, + mTextView, TIMEOUT_DURATION, adjustSelection ? mTextClassificationHelper::suggestSelection @@ -89,21 +101,36 @@ final class SelectionActionModeHelper { if (skipTextClassification()) { invalidateActionMode(null); } else { - resetTextClassificationHelper(false /* resetSelectionTag */); + resetTextClassificationHelper(); mTextClassificationAsyncTask = new TextClassificationAsyncTask( - mEditor.getTextView(), TIMEOUT_DURATION, - mTextClassificationHelper::classifyText, this::invalidateActionMode) + mTextView, + TIMEOUT_DURATION, + mTextClassificationHelper::classifyText, + this::invalidateActionMode) .execute(); } } - public void onSelectionAction() { - mSelectionTracker.onSelectionAction(mTextClassificationHelper.getSelectionTag()); + public void onSelectionAction(int menuItemId) { + mSelectionTracker.onSelectionAction( + mTextView.getSelectionStart(), mTextView.getSelectionEnd(), + getActionType(menuItemId), mTextClassification); + } + + public void onSelectionDrag() { + mSelectionTracker.onSelectionAction( + mTextView.getSelectionStart(), mTextView.getSelectionEnd(), + SelectionEvent.ActionType.DRAG, mTextClassification); + } + + public void onTypeOverSelection() { + mSelectionTracker.onSelectionAction( + mTextView.getSelectionStart(), mTextView.getSelectionEnd(), + SelectionEvent.ActionType.OVERTYPE, mTextClassification); } public boolean resetSelection(int textIndex) { - if (mSelectionTracker.resetSelection( - textIndex, mEditor, mTextClassificationHelper.getSelectionTag())) { + if (mSelectionTracker.resetSelection(textIndex, mEditor)) { invalidateActionModeAsync(); return true; } @@ -129,22 +156,18 @@ final class SelectionActionModeHelper { } private boolean skipTextClassification() { - final TextView textView = mEditor.getTextView(); // No need to make an async call for a no-op TextClassifier. - final boolean noOpTextClassifier = textView.getTextClassifier() == TextClassifier.NO_OP; + final boolean noOpTextClassifier = mTextView.getTextClassifier() == TextClassifier.NO_OP; // Do not call the TextClassifier if there is no selection. - final boolean noSelection = textView.getSelectionEnd() == textView.getSelectionStart(); + final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart(); // Do not call the TextClassifier if this is a password field. - final boolean password = textView.hasPasswordTransformationMethod() - || TextView.isPasswordInputType(textView.getInputType()); + final boolean password = mTextView.hasPasswordTransformationMethod() + || TextView.isPasswordInputType(mTextView.getInputType()); return noOpTextClassifier || noSelection || password; } private void startActionMode(@Nullable SelectionResult result) { - final TextView textView = mEditor.getTextView(); - final CharSequence text = textView.getText(); - mSelectionTracker.setOriginalSelection( - textView.getSelectionStart(), textView.getSelectionEnd()); + final CharSequence text = mTextView.getText(); if (result != null && text instanceof Spannable) { Selection.setSelection((Spannable) text, result.mStart, result.mEnd); mTextClassification = result.mClassification; @@ -157,8 +180,7 @@ final class SelectionActionModeHelper { controller.show(); } if (result != null) { - mSelectionTracker.onSelectionStarted( - result.mStart, result.mEnd, mTextClassificationHelper.getSelectionTag()); + mSelectionTracker.onSmartSelection(result); } } mEditor.setRestartActionModeOnNextRefresh(false); @@ -171,18 +193,15 @@ final class SelectionActionModeHelper { if (actionMode != null) { actionMode.invalidate(); } - final TextView textView = mEditor.getTextView(); mSelectionTracker.onSelectionUpdated( - textView.getSelectionStart(), textView.getSelectionEnd(), - mTextClassificationHelper.getSelectionTag()); + mTextView.getSelectionStart(), mTextView.getSelectionEnd(), mTextClassification); mTextClassificationAsyncTask = null; } - private void resetTextClassificationHelper(boolean resetSelectionTag) { - final TextView textView = mEditor.getTextView(); - mTextClassificationHelper.reset(textView.getTextClassifier(), textView.getText(), - textView.getSelectionStart(), textView.getSelectionEnd(), - resetSelectionTag, textView.getTextLocales()); + private void resetTextClassificationHelper() { + mTextClassificationHelper.reset(mTextView.getTextClassifier(), mTextView.getText(), + mTextView.getSelectionStart(), mTextView.getSelectionEnd(), + mTextView.getTextLocales()); } /** @@ -192,84 +211,56 @@ final class SelectionActionModeHelper { */ private static final class SelectionTracker { - // Log event: Smart selection happened. - private static final String LOG_EVENT_MULTI_SELECTION = - "textClassifier_multiSelection"; - private static final String LOG_EVENT_SINGLE_SELECTION = - "textClassifier_singleSelection"; - - // Log event: Smart selection acted upon. - private static final String LOG_EVENT_MULTI_SELECTION_ACTION = - "textClassifier_multiSelection_action"; - private static final String LOG_EVENT_SINGLE_SELECTION_ACTION = - "textClassifier_singleSelection_action"; - - // Log event: Smart selection was reset to original selection. - private static final String LOG_EVENT_MULTI_SELECTION_RESET = - "textClassifier_multiSelection_reset"; - - // Log event: Smart selection was user modified. - private static final String LOG_EVENT_MULTI_SELECTION_MODIFIED = - "textClassifier_multiSelection_modified"; - private static final String LOG_EVENT_SINGLE_SELECTION_MODIFIED = - "textClassifier_singleSelection_modified"; - - private final TextClassifier mClassifier; + private final Context mContext; + private SelectionMetricsLogger mLogger; private int mOriginalStart; private int mOriginalEnd; private int mSelectionStart; private int mSelectionEnd; + private boolean mSelectionStarted; + private boolean mAllowReset; - private boolean mMultiSelection; - private boolean mClassifierSelection; - - SelectionTracker(TextClassifier classifier) { - mClassifier = classifier; + SelectionTracker(Context context, boolean editable) { + mContext = Preconditions.checkNotNull(context); + mLogger = new SelectionMetricsLogger(context, editable); } /** - * Called to initialize the original selection before smart selection is triggered. + * Called when the original selection happens, before smart selection is triggered. */ - public void setOriginalSelection(int selectionStart, int selectionEnd) { + public void onOriginalSelection( + CharSequence text, int selectionStart, int selectionEnd, boolean editableText) { mOriginalStart = selectionStart; mOriginalEnd = selectionEnd; - resetSelectionFlags(); + mSelectionStarted = true; + mAllowReset = false; + maybeInvalidateLogger(editableText); + mLogger.logSelectionStarted(text, selectionStart); } /** * Called when selection action mode is started and the results come from a classifier. - * If the selection indices are different from the original selection indices, we have a - * smart selection. */ - public void onSelectionStarted(int selectionStart, int selectionEnd, String logTag) { - mClassifierSelection = !logTag.isEmpty(); - mSelectionStart = selectionStart; - mSelectionEnd = selectionEnd; - // If the started selection is different from the original selection, we have a - // smart selection. - mMultiSelection = - mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; - if (mMultiSelection) { - mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION); - } else if (mClassifierSelection) { - mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION); + public void onSmartSelection(SelectionResult result) { + if (mSelectionStarted) { + mSelectionStart = result.mStart; + mSelectionEnd = result.mEnd; + mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; + mLogger.logSelectionModified( + result.mStart, result.mEnd, result.mClassification, result.mSelection); } } /** * Called when selection bounds change. */ - public void onSelectionUpdated(int selectionStart, int selectionEnd, String logTag) { - final boolean selectionChanged = - selectionStart != mSelectionStart || selectionEnd != mSelectionEnd; - if (selectionChanged) { - if (mMultiSelection) { - mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_MODIFIED); - } else if (mClassifierSelection) { - mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_MODIFIED); - } - resetSelectionFlags(); + public void onSelectionUpdated( + int selectionStart, int selectionEnd, + @Nullable TextClassification classification) { + if (mSelectionStarted) { + mAllowReset = false; + mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null); } } @@ -277,17 +268,23 @@ final class SelectionActionModeHelper { * Called when the selection action mode is destroyed. */ public void onSelectionDestroyed() { - resetSelectionFlags(); + mAllowReset = false; + mSelectionStarted = false; + mLogger.logSelectionAction( + mSelectionStart, mSelectionEnd, + SelectionEvent.ActionType.ABANDON, null /* classification */); } /** - * Logs if the action was taken on a smart selection. + * Called when an action is taken on a smart selection. */ - public void onSelectionAction(String logTag) { - if (mMultiSelection) { - mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_ACTION); - } else if (mClassifierSelection) { - mClassifier.logEvent(logTag, LOG_EVENT_SINGLE_SELECTION_ACTION); + public void onSelectionAction( + int selectionStart, int selectionEnd, + @SelectionEvent.ActionType int action, + @Nullable TextClassification classification) { + if (mSelectionStarted) { + mAllowReset = false; + mLogger.logSelectionAction(selectionStart, selectionEnd, action, classification); } } @@ -297,22 +294,186 @@ final class SelectionActionModeHelper { * 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, String logTag) { - final CharSequence text = editor.getTextView().getText(); - if (mMultiSelection + public boolean resetSelection(int textIndex, Editor editor) { + final TextView textView = editor.getTextView(); + if (mSelectionStarted + && mAllowReset && textIndex >= mSelectionStart && textIndex <= mSelectionEnd - && text instanceof Spannable) { - // Only allow a reset once. - resetSelectionFlags(); - mClassifier.logEvent(logTag, LOG_EVENT_MULTI_SELECTION_RESET); - return editor.selectCurrentWord(); + && textView.getText() instanceof Spannable) { + mAllowReset = false; + boolean selected = editor.selectCurrentWord(); + if (selected) { + mLogger.logSelectionAction( + textView.getSelectionStart(), textView.getSelectionEnd(), + SelectionEvent.ActionType.RESET, null /* classification */); + } + return selected; } return false; } - private void resetSelectionFlags() { - mMultiSelection = false; - mClassifierSelection = false; + private void maybeInvalidateLogger(boolean editableText) { + if (mLogger.isEditTextLogger() != editableText) { + mLogger = new SelectionMetricsLogger(mContext, editableText); + } + } + } + + // 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. + */ + private static final class SelectionMetricsLogger { + + private static final String LOG_TAG = "SelectionMetricsLogger"; + + private final SmartSelectionEventTracker mDelegate; + private final boolean mEditTextLogger; + private final BreakIterator mWordIterator = BreakIterator.getWordInstance(); + private int mStartIndex; + private int mEndIndex; + private String mText; + + SelectionMetricsLogger(Context context, boolean editable) { + final @SmartSelectionEventTracker.WidgetType int widgetType = editable + ? SmartSelectionEventTracker.WidgetType.EDITTEXT + : SmartSelectionEventTracker.WidgetType.TEXTVIEW; + mDelegate = new SmartSelectionEventTracker(context, widgetType); + mEditTextLogger = editable; + } + + public void logSelectionStarted(CharSequence text, int index) { + try { + Preconditions.checkNotNull(text); + Preconditions.checkArgumentInRange(index, 0, text.length(), "index"); + if (mText == null || !mText.contentEquals(text)) { + mText = text.toString(); + } + mWordIterator.setText(mText); + mStartIndex = index; + mEndIndex = mWordIterator.following(index); + mDelegate.logEvent(SelectionEvent.selectionStarted(0)); + } catch (Exception e) { + // Avoid crashes due to logging. + Log.d(LOG_TAG, e.getMessage()); + } + } + + public void logSelectionModified(int start, int end, + @Nullable TextClassification classification, @Nullable TextSelection selection) { + try { + Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); + Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); + int[] wordIndices = getWordDelta(start, end); + if (selection != null) { + mDelegate.logEvent(SelectionEvent.selectionModified( + wordIndices[0], wordIndices[1], selection)); + } else if (classification != null) { + mDelegate.logEvent(SelectionEvent.selectionModified( + wordIndices[0], wordIndices[1], classification)); + } else { + mDelegate.logEvent(SelectionEvent.selectionModified( + wordIndices[0], wordIndices[1])); + } + } catch (Exception e) { + // Avoid crashes due to logging. + Log.d(LOG_TAG, e.getMessage()); + } + } + + public void logSelectionAction( + int start, int end, + @SelectionEvent.ActionType int action, + @Nullable TextClassification classification) { + try { + Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); + Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); + int[] wordIndices = getWordDelta(start, end); + if (classification != null) { + mDelegate.logEvent(SelectionEvent.selectionAction( + wordIndices[0], wordIndices[1], action, classification)); + } else { + mDelegate.logEvent(SelectionEvent.selectionAction( + wordIndices[0], wordIndices[1], action)); + } + } catch (Exception e) { + // Avoid crashes due to logging. + Log.d(LOG_TAG, e.getMessage()); + } + } + + public boolean isEditTextLogger() { + return mEditTextLogger; + } + + 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 + if (mStartIndex < start && start < mEndIndex) { + // If the new selection did not move past the original word, + // assume it has not moved. + wordIndices[0] = 0; + } else { + wordIndices[0] = countWordsBackward(start); + } + } + + 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 = mWordIterator.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 = mWordIterator.following(offset); + if (!isWhitespace(offset, end)) { + wordCount++; + } + offset = end; + } + return wordCount; + } + + private boolean isWhitespace(int start, int end) { + return mText.substring(start, end).trim().isEmpty(); } } @@ -392,8 +553,6 @@ final class SelectionActionModeHelper { /** End index relative to mText. */ private int mSelectionEnd; private LocaleList mLocales; - /** A tag for the classifier that returned the latest smart selection. */ - private String mSelectionTag = ""; /** Trimmed text starting from mTrimStart in mText. */ private CharSequence mTrimmedText; @@ -413,13 +572,12 @@ final class SelectionActionModeHelper { TextClassificationHelper(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { - reset(textClassifier, text, selectionStart, selectionEnd, true, locales); + reset(textClassifier, text, selectionStart, selectionEnd, locales); } @UiThread public void reset(TextClassifier textClassifier, - CharSequence text, int selectionStart, int selectionEnd, - boolean resetSelectionTag, LocaleList locales) { + CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { mTextClassifier = Preconditions.checkNotNull(textClassifier); mText = Preconditions.checkNotNull(text).toString(); mLastClassificationText = null; // invalidate. @@ -427,13 +585,24 @@ final class SelectionActionModeHelper { mSelectionStart = selectionStart; mSelectionEnd = selectionEnd; mLocales = locales; - if (resetSelectionTag) { - mSelectionTag = ""; - } } @WorkerThread public SelectionResult classifyText() { + return performClassification(null); + } + + @WorkerThread + public SelectionResult suggestSelection() { + trimText(); + final TextSelection selection = mTextClassifier.suggestSelection( + mTrimmedText, mRelativeStart, mRelativeEnd, mLocales); + mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart); + mSelectionEnd = Math.min(mText.length(), selection.getSelectionEndIndex() + mTrimStart); + return performClassification(selection); + } + + private SelectionResult performClassification(@Nullable TextSelection selection) { if (!Objects.equals(mText, mLastClassificationText) || mSelectionStart != mLastClassificationSelectionStart || mSelectionEnd != mLastClassificationSelectionEnd @@ -449,27 +618,13 @@ final class SelectionActionModeHelper { mSelectionStart, mSelectionEnd, mTextClassifier.classifyText( - mTrimmedText, mRelativeStart, mRelativeEnd, mLocales)); + mTrimmedText, mRelativeStart, mRelativeEnd, mLocales), + selection); } return mLastClassificationResult; } - @WorkerThread - public SelectionResult suggestSelection() { - trimText(); - final TextSelection sel = mTextClassifier.suggestSelection( - mTrimmedText, mRelativeStart, mRelativeEnd, mLocales); - mSelectionStart = Math.max(0, sel.getSelectionStartIndex() + mTrimStart); - mSelectionEnd = Math.min(mText.length(), sel.getSelectionEndIndex() + mTrimStart); - mSelectionTag = sel.getSourceClassifier(); - return classifyText(); - } - - String getSelectionTag() { - return mSelectionTag; - } - private void trimText() { mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); @@ -486,11 +641,37 @@ final class SelectionActionModeHelper { private final int mStart; private final int mEnd; private final TextClassification mClassification; + @Nullable private final TextSelection mSelection; - SelectionResult(int start, int end, TextClassification classification) { + SelectionResult(int start, int end, + TextClassification classification, @Nullable TextSelection selection) { mStart = start; mEnd = end; mClassification = Preconditions.checkNotNull(classification); + mSelection = selection; + } + } + + + + @SelectionEvent.ActionType + private static int getActionType(int menuItemId) { + switch (menuItemId) { + case TextView.ID_SELECT_ALL: + return SelectionEvent.ActionType.SELECT_ALL; + case TextView.ID_CUT: + return SelectionEvent.ActionType.CUT; + case TextView.ID_COPY: + return SelectionEvent.ActionType.COPY; + case TextView.ID_PASTE: // fall through + case TextView.ID_PASTE_AS_PLAIN_TEXT: + return SelectionEvent.ActionType.PASTE; + case TextView.ID_SHARE: + return SelectionEvent.ActionType.SHARE; + case TextView.ID_ASSIST: + return SelectionEvent.ActionType.SMART_SHARE; + default: + return SelectionEvent.ActionType.OTHER; } } } |