/* * 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.os.AsyncTask; import android.os.LocaleList; import android.text.Selection; import android.text.Spannable; import android.text.TextUtils; import android.view.ActionMode; import android.view.textclassifier.TextClassificationResult; import android.view.textclassifier.TextClassifier; import android.view.textclassifier.TextSelection; import android.widget.Editor.SelectionModifierCursorController; import com.android.internal.util.Preconditions; import java.util.function.Consumer; import java.util.function.Supplier; /** * Helper class for starting selection action mode * (synchronously without the TextClassifier, asynchronously with the TextClassifier). */ @UiThread final class SelectionActionModeHelper { /** * Maximum time (in milliseconds) to wait for a result before timing out. */ // TODO: Consider making this a ViewConfiguration. private static final int TIMEOUT_DURATION = 200; private final Editor mEditor; private final TextClassificationHelper mTextClassificationHelper; private TextClassificationResult mTextClassificationResult; private AsyncTask mTextClassificationAsyncTask; private final SelectionInfo mSelectionInfo = new SelectionInfo(); SelectionActionModeHelper(@NonNull Editor editor) { mEditor = Preconditions.checkNotNull(editor); final TextView textView = mEditor.getTextView(); mTextClassificationHelper = new TextClassificationHelper( textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales()); } public void startActionModeAsync() { cancelAsyncTask(); if (isNoOpTextClassifier() || !hasSelection()) { // No need to make an async call for a no-op TextClassifier. // Do not call the TextClassifier if there is no selection. startActionMode(null); } else { resetTextClassificationHelper(); mTextClassificationAsyncTask = new TextClassificationAsyncTask( mEditor.getTextView(), TIMEOUT_DURATION, mTextClassificationHelper::suggestSelection, this::startActionMode) .execute(); } } public void startActionMode() { startActionMode(null); } public void invalidateActionModeAsync() { cancelAsyncTask(); if (isNoOpTextClassifier() || !hasSelection()) { // No need to make an async call for a no-op TextClassifier. // Do not call the TextClassifier if there is no selection. invalidateActionMode(null); } else { resetTextClassificationHelper(); mTextClassificationAsyncTask = new TextClassificationAsyncTask( mEditor.getTextView(), TIMEOUT_DURATION, mTextClassificationHelper::classifyText, this::invalidateActionMode) .execute(); } } public boolean resetOriginalSelection(int textIndex) { if (mSelectionInfo.resetOriginalSelection(textIndex, mEditor.getTextView().getText())) { invalidateActionModeAsync(); return true; } return false; } @Nullable public TextClassificationResult getTextClassificationResult() { return mTextClassificationResult; } public void onDestroyActionMode() { mSelectionInfo.onSelectionDestroyed(); cancelAsyncTask(); } private void cancelAsyncTask() { if (mTextClassificationAsyncTask != null) { mTextClassificationAsyncTask.cancel(true); mTextClassificationAsyncTask = null; } mTextClassificationResult = null; } private boolean isNoOpTextClassifier() { return mEditor.getTextView().getTextClassifier() == TextClassifier.NO_OP; } private boolean hasSelection() { final TextView textView = mEditor.getTextView(); return textView.getSelectionEnd() > textView.getSelectionStart(); } private void startActionMode(@Nullable SelectionResult result) { final TextView textView = mEditor.getTextView(); final CharSequence text = textView.getText(); mSelectionInfo.setOriginalSelection( textView.getSelectionStart(), textView.getSelectionEnd()); if (result != null && text instanceof Spannable) { Selection.setSelection((Spannable) text, result.mStart, result.mEnd); mTextClassificationResult = result.mResult; } else { mTextClassificationResult = null; } if (mEditor.startSelectionActionModeInternal()) { final SelectionModifierCursorController controller = mEditor.getSelectionController(); if (controller != null) { controller.show(); } if (result != null) { mSelectionInfo.onSelectionStarted(result.mStart, result.mEnd); } } mEditor.setRestartActionModeOnNextRefresh(false); mTextClassificationAsyncTask = null; } private void invalidateActionMode(@Nullable SelectionResult result) { mTextClassificationResult = result != null ? result.mResult : null; final ActionMode actionMode = mEditor.getTextActionMode(); if (actionMode != null) { actionMode.invalidate(); } final TextView textView = mEditor.getTextView(); mSelectionInfo.onSelectionUpdated(textView.getSelectionStart(), textView.getSelectionEnd()); mTextClassificationAsyncTask = null; } private void resetTextClassificationHelper() { final TextView textView = mEditor.getTextView(); mTextClassificationHelper.reset(textView.getTextClassifier(), textView.getText(), textView.getSelectionStart(), textView.getSelectionEnd(), textView.getTextLocales()); } /** * Holds information about the selection and uses it to decide on whether or not to update * the selection when resetOriginalSelection is called. * The expected UX here is to allow the user to re-snap the selection back to the original word * that was selected with one tap on that word. */ private static final class SelectionInfo { private int mOriginalStart; private int mOriginalEnd; private int mSelectionStart; private int mSelectionEnd; private boolean mResetOriginal; public void setOriginalSelection(int selectionStart, int selectionEnd) { mOriginalStart = selectionStart; mOriginalEnd = selectionEnd; mResetOriginal = false; } public void onSelectionStarted(int selectionStart, int selectionEnd) { // Set the reset flag to true if the selection changed. mSelectionStart = selectionStart; mSelectionEnd = selectionEnd; mResetOriginal = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; } public void onSelectionUpdated(int selectionStart, int selectionEnd) { // If the selection did not change, maintain the reset state. Otherwise, disable reset. mResetOriginal &= selectionStart == mSelectionStart && selectionEnd == mSelectionEnd; } public void onSelectionDestroyed() { mResetOriginal = false; } public boolean resetOriginalSelection(int textIndex, CharSequence text) { if (mResetOriginal && textIndex >= mOriginalStart && textIndex <= mOriginalEnd && text instanceof Spannable) { Selection.setSelection((Spannable) text, mOriginalStart, mOriginalEnd); // Only allow a reset once. mResetOriginal = false; return true; } return false; } } /** * 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 { private final int mTimeOutDuration; private final Supplier mSelectionResultSupplier; private final Consumer mSelectionResultCallback; 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 */ TextClassificationAsyncTask( @NonNull TextView textView, int timeOut, @NonNull Supplier selectionResultSupplier, @NonNull Consumer selectionResultCallback) { mTextView = Preconditions.checkNotNull(textView); mTimeOutDuration = timeOut; mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier); mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback); // Make a copy of the original text. mOriginalText = mTextView.getText().toString(); } @Override @WorkerThread protected SelectionResult doInBackground(Void... params) { final Runnable onTimeOut = this::onTimeOut; mTextView.postDelayed(onTimeOut, mTimeOutDuration); final SelectionResult result = mSelectionResultSupplier.get(); mTextView.removeCallbacks(onTimeOut); return result; } @Override @UiThread protected void onPostExecute(SelectionResult result) { result = TextUtils.equals(mOriginalText, mTextView.getText()) ? result : null; mSelectionResultCallback.accept(result); } private void onTimeOut() { if (getStatus() == Status.RUNNING) { onPostExecute(null); } 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 { private static final int TRIM_DELTA = 120; // characters private TextClassifier mTextClassifier; /** The original TextView text. **/ private String mText; /** Start index relative to mText. */ private int mSelectionStart; /** End index relative to mText. */ private int mSelectionEnd; private LocaleList mLocales; /** 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; TextClassificationHelper(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { reset(textClassifier, text, selectionStart, selectionEnd, locales); } @UiThread public void reset(TextClassifier textClassifier, CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { mTextClassifier = Preconditions.checkNotNull(textClassifier); mText = Preconditions.checkNotNull(text).toString(); Preconditions.checkArgument(selectionEnd > selectionStart); mSelectionStart = selectionStart; mSelectionEnd = selectionEnd; mLocales = locales; } @WorkerThread public SelectionResult classifyText() { trimText(); return new SelectionResult( mSelectionStart, mSelectionEnd, mTextClassifier.getTextClassificationResult( mTrimmedText, mRelativeStart, mRelativeEnd, mLocales)); } @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); return classifyText(); } private void trimText() { mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); 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; private final TextClassificationResult mResult; SelectionResult(int start, int end, TextClassificationResult result) { mStart = start; mEnd = end; mResult = Preconditions.checkNotNull(result); } } }