Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 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; |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 23 | import android.content.Context; |
Petar Šegina | 5ab7bb2 | 2017-09-05 20:48:42 +0100 | [diff] [blame] | 24 | import android.graphics.Canvas; |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 25 | import android.graphics.PointF; |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 26 | import android.graphics.RectF; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 27 | import android.os.AsyncTask; |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 28 | import android.os.Build; |
Abodunrinwa Toki | 4cfda0b | 2017-02-28 18:56:47 +0000 | [diff] [blame] | 29 | import android.os.LocaleList; |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 30 | import android.text.Layout; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 31 | import android.text.Selection; |
| 32 | import android.text.Spannable; |
| 33 | import android.text.TextUtils; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 34 | import android.util.Log; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 35 | import android.view.ActionMode; |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 36 | import android.view.textclassifier.TextClassification; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 37 | import android.view.textclassifier.TextClassifier; |
| 38 | import android.view.textclassifier.TextSelection; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 39 | import android.view.textclassifier.logging.SmartSelectionEventTracker; |
| 40 | import android.view.textclassifier.logging.SmartSelectionEventTracker.SelectionEvent; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 41 | import android.widget.Editor.SelectionModifierCursorController; |
| 42 | |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 43 | import com.android.internal.annotations.VisibleForTesting; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 44 | import com.android.internal.util.Preconditions; |
| 45 | |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 46 | import java.text.BreakIterator; |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 47 | import java.util.ArrayList; |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 48 | import java.util.Comparator; |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 49 | import java.util.List; |
Abodunrinwa Toki | fdf57ba | 2017-05-02 20:32:23 +0100 | [diff] [blame] | 50 | import java.util.Objects; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 51 | import java.util.function.Consumer; |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 52 | import java.util.function.Function; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 53 | import java.util.function.Supplier; |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 54 | import java.util.regex.Pattern; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 55 | |
| 56 | /** |
| 57 | * Helper class for starting selection action mode |
| 58 | * (synchronously without the TextClassifier, asynchronously with the TextClassifier). |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 59 | * @hide |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 60 | */ |
| 61 | @UiThread |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 62 | @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 63 | public final class SelectionActionModeHelper { |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 64 | |
Jan Althaus | b3513a1 | 2017-09-22 18:26:06 +0200 | [diff] [blame] | 65 | private static final String LOG_TAG = "SelectActionModeHelper"; |
| 66 | |
Petar Šegina | e2f8263 | 2017-08-14 17:09:01 +0100 | [diff] [blame] | 67 | private static final boolean SMART_SELECT_ANIMATION_ENABLED = true; |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 68 | |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 69 | private final Editor mEditor; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 70 | private final TextView mTextView; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 71 | private final TextClassificationHelper mTextClassificationHelper; |
| 72 | |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 73 | private TextClassification mTextClassification; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 74 | private AsyncTask mTextClassificationAsyncTask; |
| 75 | |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 76 | private final SelectionTracker mSelectionTracker; |
Petar Šegina | 5ab7bb2 | 2017-09-05 20:48:42 +0100 | [diff] [blame] | 77 | |
| 78 | // TODO remove nullable marker once the switch gating the feature gets removed |
| 79 | @Nullable |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 80 | private final SmartSelectSprite mSmartSelectSprite; |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 81 | |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 82 | SelectionActionModeHelper(@NonNull Editor editor) { |
| 83 | mEditor = Preconditions.checkNotNull(editor); |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 84 | mTextView = mEditor.getTextView(); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 85 | mTextClassificationHelper = new TextClassificationHelper( |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 86 | mTextView.getContext(), |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 87 | mTextView.getTextClassifier(), |
| 88 | getText(mTextView), |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 89 | 0, 1, mTextView.getTextLocales()); |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 90 | mSelectionTracker = new SelectionTracker(mTextView); |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 91 | |
| 92 | if (SMART_SELECT_ANIMATION_ENABLED) { |
Petar Šegina | 5ab7bb2 | 2017-09-05 20:48:42 +0100 | [diff] [blame] | 93 | mSmartSelectSprite = new SmartSelectSprite(mTextView.getContext(), |
| 94 | mTextView::invalidate); |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 95 | } else { |
| 96 | mSmartSelectSprite = null; |
| 97 | } |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 98 | } |
| 99 | |
Abodunrinwa Toki | 66c1627 | 2017-05-03 20:22:55 +0100 | [diff] [blame] | 100 | public void startActionModeAsync(boolean adjustSelection) { |
Abodunrinwa Toki | 0e6b43e | 2017-09-19 23:18:40 +0100 | [diff] [blame] | 101 | // Check if the smart selection should run for editable text. |
| 102 | adjustSelection &= !mTextView.isTextEditable() |
| 103 | || mTextView.getTextClassifier().getSettings() |
| 104 | .isSuggestSelectionEnabledForEditableText(); |
| 105 | |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 106 | mSelectionTracker.onOriginalSelection( |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 107 | getText(mTextView), |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 108 | mTextView.getSelectionStart(), |
Abodunrinwa Toki | 0e6b43e | 2017-09-19 23:18:40 +0100 | [diff] [blame] | 109 | mTextView.getSelectionEnd()); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 110 | cancelAsyncTask(); |
Abodunrinwa Toki | 76b51dc | 2017-07-13 23:37:11 +0100 | [diff] [blame] | 111 | if (skipTextClassification()) { |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 112 | startActionMode(null); |
| 113 | } else { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 114 | resetTextClassificationHelper(); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 115 | mTextClassificationAsyncTask = new TextClassificationAsyncTask( |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 116 | mTextView, |
Abodunrinwa Toki | 3521f3f | 2017-09-27 02:38:55 +0100 | [diff] [blame] | 117 | mTextClassificationHelper.getTimeoutDuration(), |
Abodunrinwa Toki | 66c1627 | 2017-05-03 20:22:55 +0100 | [diff] [blame] | 118 | adjustSelection |
| 119 | ? mTextClassificationHelper::suggestSelection |
| 120 | : mTextClassificationHelper::classifyText, |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 121 | mSmartSelectSprite != null |
| 122 | ? this::startActionModeWithSmartSelectAnimation |
| 123 | : this::startActionMode) |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 124 | .execute(); |
| 125 | } |
| 126 | } |
| 127 | |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 128 | public void invalidateActionModeAsync() { |
| 129 | cancelAsyncTask(); |
Abodunrinwa Toki | 76b51dc | 2017-07-13 23:37:11 +0100 | [diff] [blame] | 130 | if (skipTextClassification()) { |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 131 | invalidateActionMode(null); |
| 132 | } else { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 133 | resetTextClassificationHelper(); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 134 | mTextClassificationAsyncTask = new TextClassificationAsyncTask( |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 135 | mTextView, |
Abodunrinwa Toki | 3521f3f | 2017-09-27 02:38:55 +0100 | [diff] [blame] | 136 | mTextClassificationHelper.getTimeoutDuration(), |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 137 | mTextClassificationHelper::classifyText, |
| 138 | this::invalidateActionMode) |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 139 | .execute(); |
| 140 | } |
| 141 | } |
| 142 | |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 143 | public void onSelectionAction(int menuItemId) { |
| 144 | mSelectionTracker.onSelectionAction( |
| 145 | mTextView.getSelectionStart(), mTextView.getSelectionEnd(), |
| 146 | getActionType(menuItemId), mTextClassification); |
| 147 | } |
| 148 | |
| 149 | public void onSelectionDrag() { |
| 150 | mSelectionTracker.onSelectionAction( |
| 151 | mTextView.getSelectionStart(), mTextView.getSelectionEnd(), |
| 152 | SelectionEvent.ActionType.DRAG, mTextClassification); |
| 153 | } |
| 154 | |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 155 | public void onTextChanged(int start, int end) { |
| 156 | mSelectionTracker.onTextChanged(start, end, mTextClassification); |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 157 | } |
| 158 | |
Abodunrinwa Toki | 8dd3a74 | 2017-05-02 18:44:19 +0100 | [diff] [blame] | 159 | public boolean resetSelection(int textIndex) { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 160 | if (mSelectionTracker.resetSelection(textIndex, mEditor)) { |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 161 | invalidateActionModeAsync(); |
| 162 | return true; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 163 | } |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 164 | return false; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 165 | } |
| 166 | |
| 167 | @Nullable |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 168 | public TextClassification getTextClassification() { |
| 169 | return mTextClassification; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 170 | } |
| 171 | |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 172 | public void onDestroyActionMode() { |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 173 | cancelSmartSelectAnimation(); |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 174 | mSelectionTracker.onSelectionDestroyed(); |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 175 | cancelAsyncTask(); |
| 176 | } |
| 177 | |
Petar Šegina | 5ab7bb2 | 2017-09-05 20:48:42 +0100 | [diff] [blame] | 178 | public void onDraw(final Canvas canvas) { |
| 179 | if (mSmartSelectSprite != null) { |
| 180 | mSmartSelectSprite.draw(canvas); |
| 181 | } |
| 182 | } |
| 183 | |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 184 | private void cancelAsyncTask() { |
| 185 | if (mTextClassificationAsyncTask != null) { |
| 186 | mTextClassificationAsyncTask.cancel(true); |
| 187 | mTextClassificationAsyncTask = null; |
| 188 | } |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 189 | mTextClassification = null; |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 190 | } |
| 191 | |
Abodunrinwa Toki | 76b51dc | 2017-07-13 23:37:11 +0100 | [diff] [blame] | 192 | private boolean skipTextClassification() { |
Abodunrinwa Toki | 76b51dc | 2017-07-13 23:37:11 +0100 | [diff] [blame] | 193 | // No need to make an async call for a no-op TextClassifier. |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 194 | final boolean noOpTextClassifier = mTextView.getTextClassifier() == TextClassifier.NO_OP; |
Abodunrinwa Toki | 76b51dc | 2017-07-13 23:37:11 +0100 | [diff] [blame] | 195 | // Do not call the TextClassifier if there is no selection. |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 196 | final boolean noSelection = mTextView.getSelectionEnd() == mTextView.getSelectionStart(); |
Abodunrinwa Toki | 76b51dc | 2017-07-13 23:37:11 +0100 | [diff] [blame] | 197 | // Do not call the TextClassifier if this is a password field. |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 198 | final boolean password = mTextView.hasPasswordTransformationMethod() |
| 199 | || TextView.isPasswordInputType(mTextView.getInputType()); |
Abodunrinwa Toki | 76b51dc | 2017-07-13 23:37:11 +0100 | [diff] [blame] | 200 | return noOpTextClassifier || noSelection || password; |
Abodunrinwa Toki | 792d820 | 2017-03-06 23:51:11 +0000 | [diff] [blame] | 201 | } |
| 202 | |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 203 | private void startActionMode(@Nullable SelectionResult result) { |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 204 | final CharSequence text = getText(mTextView); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 205 | if (result != null && text instanceof Spannable) { |
Abodunrinwa Toki | 0e6b43e | 2017-09-19 23:18:40 +0100 | [diff] [blame] | 206 | // Do not change the selection if TextClassifier should be dark launched. |
| 207 | if (!mTextView.getTextClassifier().getSettings().isDarkLaunch()) { |
| 208 | Selection.setSelection((Spannable) text, result.mStart, result.mEnd); |
| 209 | } |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 210 | mTextClassification = result.mClassification; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 211 | } else { |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 212 | mTextClassification = null; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 213 | } |
| 214 | if (mEditor.startSelectionActionModeInternal()) { |
| 215 | final SelectionModifierCursorController controller = mEditor.getSelectionController(); |
| 216 | if (controller != null) { |
| 217 | controller.show(); |
| 218 | } |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 219 | if (result != null) { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 220 | mSelectionTracker.onSmartSelection(result); |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 221 | } |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 222 | } |
| 223 | mEditor.setRestartActionModeOnNextRefresh(false); |
| 224 | mTextClassificationAsyncTask = null; |
| 225 | } |
| 226 | |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 227 | private void startActionModeWithSmartSelectAnimation(@Nullable SelectionResult result) { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 228 | final Layout layout = mTextView.getLayout(); |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 229 | |
| 230 | final Runnable onAnimationEndCallback = () -> startActionMode(result); |
| 231 | // TODO do not trigger the animation if the change included only non-printable characters |
| 232 | final boolean didSelectionChange = |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 233 | result != null && (mTextView.getSelectionStart() != result.mStart |
| 234 | || mTextView.getSelectionEnd() != result.mEnd); |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 235 | |
| 236 | if (!didSelectionChange) { |
| 237 | onAnimationEndCallback.run(); |
| 238 | return; |
| 239 | } |
| 240 | |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 241 | final List<SmartSelectSprite.RectangleWithTextSelectionLayout> selectionRectangles = |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 242 | convertSelectionToRectangles(layout, result.mStart, result.mEnd); |
| 243 | |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 244 | final PointF touchPoint = new PointF( |
| 245 | mEditor.getLastUpPositionX(), |
| 246 | mEditor.getLastUpPositionY()); |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 247 | |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 248 | final PointF animationStartPoint = |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 249 | movePointInsideNearestRectangle(touchPoint, selectionRectangles, |
| 250 | SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle); |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 251 | |
| 252 | mSmartSelectSprite.startAnimation( |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 253 | animationStartPoint, |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 254 | selectionRectangles, |
| 255 | onAnimationEndCallback); |
| 256 | } |
| 257 | |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 258 | private List<SmartSelectSprite.RectangleWithTextSelectionLayout> convertSelectionToRectangles( |
| 259 | final Layout layout, final int start, final int end) { |
| 260 | final List<SmartSelectSprite.RectangleWithTextSelectionLayout> result = new ArrayList<>(); |
Petar Šegina | 7272925 | 2017-08-31 15:25:06 +0100 | [diff] [blame] | 261 | |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 262 | final Layout.SelectionRectangleConsumer consumer = |
| 263 | (left, top, right, bottom, textSelectionLayout) -> mergeRectangleIntoList( |
| 264 | result, |
| 265 | new RectF(left, top, right, bottom), |
| 266 | SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, |
| 267 | r -> new SmartSelectSprite.RectangleWithTextSelectionLayout(r, |
| 268 | textSelectionLayout) |
| 269 | ); |
| 270 | |
| 271 | layout.getSelection(start, end, consumer); |
| 272 | |
| 273 | result.sort(Comparator.comparing( |
| 274 | SmartSelectSprite.RectangleWithTextSelectionLayout::getRectangle, |
| 275 | SmartSelectSprite.RECTANGLE_COMPARATOR)); |
| 276 | |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 277 | return result; |
| 278 | } |
| 279 | |
Abodunrinwa Toki | 3521f3f | 2017-09-27 02:38:55 +0100 | [diff] [blame] | 280 | // TODO: Move public pure functions out of this class and make it package-private. |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 281 | /** |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 282 | * Merges a {@link RectF} into an existing list of any objects which contain a rectangle. |
| 283 | * While merging, this method makes sure that: |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 284 | * |
| 285 | * <ol> |
| 286 | * <li>No rectangle is redundant (contained within a bigger rectangle)</li> |
| 287 | * <li>Rectangles of the same height and vertical position that intersect get merged</li> |
| 288 | * </ol> |
| 289 | * |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 290 | * @param list the list of rectangles (or other rectangle containers) to merge the new |
| 291 | * rectangle into |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 292 | * @param candidate the {@link RectF} to merge into the list |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 293 | * @param extractor a function that can extract a {@link RectF} from an element of the given |
| 294 | * list |
| 295 | * @param packer a function that can wrap the resulting {@link RectF} into an element that |
| 296 | * the list contains |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 297 | * @hide |
| 298 | */ |
| 299 | @VisibleForTesting |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 300 | public static <T> void mergeRectangleIntoList(final List<T> list, |
| 301 | final RectF candidate, final Function<T, RectF> extractor, |
| 302 | final Function<RectF, T> packer) { |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 303 | if (candidate.isEmpty()) { |
| 304 | return; |
| 305 | } |
| 306 | |
| 307 | final int elementCount = list.size(); |
| 308 | for (int index = 0; index < elementCount; ++index) { |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 309 | final RectF existingRectangle = extractor.apply(list.get(index)); |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 310 | if (existingRectangle.contains(candidate)) { |
| 311 | return; |
| 312 | } |
| 313 | if (candidate.contains(existingRectangle)) { |
| 314 | existingRectangle.setEmpty(); |
| 315 | continue; |
| 316 | } |
| 317 | |
| 318 | final boolean rectanglesContinueEachOther = candidate.left == existingRectangle.right |
| 319 | || candidate.right == existingRectangle.left; |
| 320 | final boolean canMerge = candidate.top == existingRectangle.top |
| 321 | && candidate.bottom == existingRectangle.bottom |
| 322 | && (RectF.intersects(candidate, existingRectangle) |
| 323 | || rectanglesContinueEachOther); |
| 324 | |
| 325 | if (canMerge) { |
| 326 | candidate.union(existingRectangle); |
| 327 | existingRectangle.setEmpty(); |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | for (int index = elementCount - 1; index >= 0; --index) { |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 332 | final RectF rectangle = extractor.apply(list.get(index)); |
| 333 | if (rectangle.isEmpty()) { |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 334 | list.remove(index); |
| 335 | } |
| 336 | } |
| 337 | |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 338 | list.add(packer.apply(candidate)); |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 339 | } |
| 340 | |
| 341 | |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 342 | /** @hide */ |
| 343 | @VisibleForTesting |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 344 | public static <T> PointF movePointInsideNearestRectangle(final PointF point, |
| 345 | final List<T> list, final Function<T, RectF> extractor) { |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 346 | float bestX = -1; |
| 347 | float bestY = -1; |
| 348 | double bestDistance = Double.MAX_VALUE; |
| 349 | |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 350 | final int elementCount = list.size(); |
Petar Šegina | ba1b856 | 2017-08-31 18:09:16 +0100 | [diff] [blame] | 351 | for (int index = 0; index < elementCount; ++index) { |
Petar Šegina | 7c8196f | 2017-09-11 18:03:14 +0100 | [diff] [blame] | 352 | final RectF rectangle = extractor.apply(list.get(index)); |
Petar Šegina | 91df3f9 | 2017-08-15 16:20:43 +0100 | [diff] [blame] | 353 | final float candidateY = rectangle.centerY(); |
| 354 | final float candidateX; |
| 355 | |
| 356 | if (point.x > rectangle.right) { |
| 357 | candidateX = rectangle.right; |
| 358 | } else if (point.x < rectangle.left) { |
| 359 | candidateX = rectangle.left; |
| 360 | } else { |
| 361 | candidateX = point.x; |
| 362 | } |
| 363 | |
| 364 | final double candidateDistance = Math.pow(point.x - candidateX, 2) |
| 365 | + Math.pow(point.y - candidateY, 2); |
| 366 | |
| 367 | if (candidateDistance < bestDistance) { |
| 368 | bestX = candidateX; |
| 369 | bestY = candidateY; |
| 370 | bestDistance = candidateDistance; |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | return new PointF(bestX, bestY); |
| 375 | } |
| 376 | |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 377 | private void invalidateActionMode(@Nullable SelectionResult result) { |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 378 | cancelSmartSelectAnimation(); |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 379 | mTextClassification = result != null ? result.mClassification : null; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 380 | final ActionMode actionMode = mEditor.getTextActionMode(); |
| 381 | if (actionMode != null) { |
| 382 | actionMode.invalidate(); |
| 383 | } |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 384 | mSelectionTracker.onSelectionUpdated( |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 385 | mTextView.getSelectionStart(), mTextView.getSelectionEnd(), mTextClassification); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 386 | mTextClassificationAsyncTask = null; |
| 387 | } |
| 388 | |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 389 | private void resetTextClassificationHelper() { |
Abodunrinwa Toki | 0e6b43e | 2017-09-19 23:18:40 +0100 | [diff] [blame] | 390 | mTextClassificationHelper.init( |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 391 | mTextView.getContext(), |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 392 | mTextView.getTextClassifier(), |
| 393 | getText(mTextView), |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 394 | mTextView.getSelectionStart(), mTextView.getSelectionEnd(), |
| 395 | mTextView.getTextLocales()); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 396 | } |
| 397 | |
Petar Šegina | 701ba33 | 2017-08-01 17:57:26 +0100 | [diff] [blame] | 398 | private void cancelSmartSelectAnimation() { |
| 399 | if (mSmartSelectSprite != null) { |
| 400 | mSmartSelectSprite.cancelAnimation(); |
| 401 | } |
| 402 | } |
| 403 | |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 404 | /** |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 405 | * Tracks and logs smart selection changes. |
| 406 | * It is important to trigger this object's methods at the appropriate event so that it tracks |
| 407 | * smart selection events appropriately. |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 408 | */ |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 409 | private static final class SelectionTracker { |
| 410 | |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 411 | private final TextView mTextView; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 412 | private SelectionMetricsLogger mLogger; |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 413 | |
| 414 | private int mOriginalStart; |
| 415 | private int mOriginalEnd; |
| 416 | private int mSelectionStart; |
| 417 | private int mSelectionEnd; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 418 | private boolean mAllowReset; |
Jan Althaus | b3513a1 | 2017-09-22 18:26:06 +0200 | [diff] [blame] | 419 | private final LogAbandonRunnable mDelayedLogAbandon = new LogAbandonRunnable(); |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 420 | |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 421 | SelectionTracker(TextView textView) { |
| 422 | mTextView = Preconditions.checkNotNull(textView); |
| 423 | mLogger = new SelectionMetricsLogger(textView); |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 424 | } |
| 425 | |
| 426 | /** |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 427 | * Called when the original selection happens, before smart selection is triggered. |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 428 | */ |
Abodunrinwa Toki | 0e6b43e | 2017-09-19 23:18:40 +0100 | [diff] [blame] | 429 | public void onOriginalSelection(CharSequence text, int selectionStart, int selectionEnd) { |
Jan Althaus | b3513a1 | 2017-09-22 18:26:06 +0200 | [diff] [blame] | 430 | // If we abandoned a selection and created a new one very shortly after, we may still |
| 431 | // have a pending request to log ABANDON, which we flush here. |
| 432 | mDelayedLogAbandon.flush(); |
| 433 | |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 434 | mOriginalStart = mSelectionStart = selectionStart; |
| 435 | mOriginalEnd = mSelectionEnd = selectionEnd; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 436 | mAllowReset = false; |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 437 | maybeInvalidateLogger(); |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 438 | mLogger.logSelectionStarted(text, selectionStart); |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 439 | } |
| 440 | |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 441 | /** |
Abodunrinwa Toki | e78ac52 | 2017-05-23 14:51:22 +0100 | [diff] [blame] | 442 | * Called when selection action mode is started and the results come from a classifier. |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 443 | */ |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 444 | public void onSmartSelection(SelectionResult result) { |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 445 | if (isSelectionStarted()) { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 446 | mSelectionStart = result.mStart; |
| 447 | mSelectionEnd = result.mEnd; |
| 448 | mAllowReset = mSelectionStart != mOriginalStart || mSelectionEnd != mOriginalEnd; |
| 449 | mLogger.logSelectionModified( |
| 450 | result.mStart, result.mEnd, result.mClassification, result.mSelection); |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 451 | } |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 452 | } |
| 453 | |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 454 | /** |
| 455 | * Called when selection bounds change. |
| 456 | */ |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 457 | public void onSelectionUpdated( |
| 458 | int selectionStart, int selectionEnd, |
| 459 | @Nullable TextClassification classification) { |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 460 | if (isSelectionStarted()) { |
| 461 | mSelectionStart = selectionStart; |
| 462 | mSelectionEnd = selectionEnd; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 463 | mAllowReset = false; |
| 464 | mLogger.logSelectionModified(selectionStart, selectionEnd, classification, null); |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 465 | } |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 466 | } |
| 467 | |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 468 | /** |
| 469 | * Called when the selection action mode is destroyed. |
| 470 | */ |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 471 | public void onSelectionDestroyed() { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 472 | mAllowReset = false; |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 473 | // Wait a few ms to see if the selection was destroyed because of a text change event. |
Jan Althaus | b3513a1 | 2017-09-22 18:26:06 +0200 | [diff] [blame] | 474 | mDelayedLogAbandon.schedule(100 /* ms */); |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 475 | } |
| 476 | |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 477 | /** |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 478 | * Called when an action is taken on a smart selection. |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 479 | */ |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 480 | public void onSelectionAction( |
| 481 | int selectionStart, int selectionEnd, |
| 482 | @SelectionEvent.ActionType int action, |
| 483 | @Nullable TextClassification classification) { |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 484 | if (isSelectionStarted()) { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 485 | mAllowReset = false; |
| 486 | mLogger.logSelectionAction(selectionStart, selectionEnd, action, classification); |
Abodunrinwa Toki | 1d77557 | 2017-05-08 16:03:01 +0100 | [diff] [blame] | 487 | } |
| 488 | } |
| 489 | |
| 490 | /** |
| 491 | * Returns true if the current smart selection should be reset to normal selection based on |
| 492 | * information that has been recorded about the original selection and the smart selection. |
| 493 | * The expected UX here is to allow the user to select a word inside of the smart selection |
| 494 | * on a single tap. |
| 495 | */ |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 496 | public boolean resetSelection(int textIndex, Editor editor) { |
| 497 | final TextView textView = editor.getTextView(); |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 498 | if (isSelectionStarted() |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 499 | && mAllowReset |
Abodunrinwa Toki | 8dd3a74 | 2017-05-02 18:44:19 +0100 | [diff] [blame] | 500 | && textIndex >= mSelectionStart && textIndex <= mSelectionEnd |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 501 | && getText(textView) instanceof Spannable) { |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 502 | mAllowReset = false; |
| 503 | boolean selected = editor.selectCurrentWord(); |
| 504 | if (selected) { |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 505 | mSelectionStart = editor.getTextView().getSelectionStart(); |
| 506 | mSelectionEnd = editor.getTextView().getSelectionEnd(); |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 507 | mLogger.logSelectionAction( |
| 508 | textView.getSelectionStart(), textView.getSelectionEnd(), |
| 509 | SelectionEvent.ActionType.RESET, null /* classification */); |
| 510 | } |
| 511 | return selected; |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 512 | } |
| 513 | return false; |
| 514 | } |
Abodunrinwa Toki | e78ac52 | 2017-05-23 14:51:22 +0100 | [diff] [blame] | 515 | |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 516 | public void onTextChanged(int start, int end, TextClassification classification) { |
| 517 | if (isSelectionStarted() && start == mSelectionStart && end == mSelectionEnd) { |
| 518 | onSelectionAction(start, end, SelectionEvent.ActionType.OVERTYPE, classification); |
| 519 | } |
| 520 | } |
| 521 | |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 522 | private void maybeInvalidateLogger() { |
| 523 | if (mLogger.isEditTextLogger() != mTextView.isTextEditable()) { |
| 524 | mLogger = new SelectionMetricsLogger(mTextView); |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 525 | } |
| 526 | } |
Abodunrinwa Toki | 78940eb | 2017-09-12 02:58:49 +0100 | [diff] [blame] | 527 | |
| 528 | private boolean isSelectionStarted() { |
| 529 | return mSelectionStart >= 0 && mSelectionEnd >= 0 && mSelectionStart != mSelectionEnd; |
| 530 | } |
Jan Althaus | b3513a1 | 2017-09-22 18:26:06 +0200 | [diff] [blame] | 531 | |
| 532 | /** A helper for keeping track of pending abandon logging requests. */ |
| 533 | private final class LogAbandonRunnable implements Runnable { |
| 534 | private boolean mIsPending; |
| 535 | |
| 536 | /** Schedules an abandon to be logged with the given delay. Flush if necessary. */ |
| 537 | void schedule(int delayMillis) { |
| 538 | if (mIsPending) { |
| 539 | Log.e(LOG_TAG, "Force flushing abandon due to new scheduling request"); |
| 540 | flush(); |
| 541 | } |
| 542 | mIsPending = true; |
| 543 | mTextView.postDelayed(this, delayMillis); |
| 544 | } |
| 545 | |
| 546 | /** If there is a pending log request, execute it now. */ |
| 547 | void flush() { |
| 548 | mTextView.removeCallbacks(this); |
| 549 | run(); |
| 550 | } |
| 551 | |
| 552 | @Override |
| 553 | public void run() { |
| 554 | if (mIsPending) { |
| 555 | mLogger.logSelectionAction( |
| 556 | mSelectionStart, mSelectionEnd, |
| 557 | SelectionEvent.ActionType.ABANDON, null /* classification */); |
| 558 | mSelectionStart = mSelectionEnd = -1; |
| 559 | mIsPending = false; |
| 560 | } |
| 561 | } |
| 562 | } |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 563 | } |
| 564 | |
| 565 | // TODO: Write tests |
| 566 | /** |
| 567 | * Metrics logging helper. |
| 568 | * |
| 569 | * This logger logs selection by word indices. The initial (start) single word selection is |
| 570 | * logged at [0, 1) -- end index is exclusive. Other word indices are logged relative to the |
| 571 | * initial single word selection. |
| 572 | * e.g. New York city, NY. Suppose the initial selection is "York" in |
| 573 | * "New York city, NY", then "York" is at [0, 1), "New" is at [-1, 0], and "city" is at [1, 2). |
| 574 | * "New York" is at [-1, 1). |
| 575 | * Part selection of a word e.g. "or" is counted as selecting the |
| 576 | * entire word i.e. equivalent to "York", and each special character is counted as a word, e.g. |
| 577 | * "," is at [2, 3). Whitespaces are ignored. |
| 578 | */ |
| 579 | private static final class SelectionMetricsLogger { |
| 580 | |
| 581 | private static final String LOG_TAG = "SelectionMetricsLogger"; |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 582 | private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s+"); |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 583 | |
| 584 | private final SmartSelectionEventTracker mDelegate; |
| 585 | private final boolean mEditTextLogger; |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 586 | private final BreakIterator mWordIterator; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 587 | private int mStartIndex; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 588 | private String mText; |
| 589 | |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 590 | SelectionMetricsLogger(TextView textView) { |
| 591 | Preconditions.checkNotNull(textView); |
| 592 | final @SmartSelectionEventTracker.WidgetType int widgetType = textView.isTextEditable() |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 593 | ? SmartSelectionEventTracker.WidgetType.EDITTEXT |
Jan Althaus | b3c6ece | 2017-11-14 15:40:16 +0100 | [diff] [blame] | 594 | : (textView.isTextSelectable() |
| 595 | ? SmartSelectionEventTracker.WidgetType.TEXTVIEW |
| 596 | : SmartSelectionEventTracker.WidgetType.UNSELECTABLE_TEXTVIEW); |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 597 | mDelegate = new SmartSelectionEventTracker(textView.getContext(), widgetType); |
| 598 | mEditTextLogger = textView.isTextEditable(); |
| 599 | mWordIterator = BreakIterator.getWordInstance(textView.getTextLocale()); |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 600 | } |
| 601 | |
| 602 | public void logSelectionStarted(CharSequence text, int index) { |
| 603 | try { |
| 604 | Preconditions.checkNotNull(text); |
| 605 | Preconditions.checkArgumentInRange(index, 0, text.length(), "index"); |
| 606 | if (mText == null || !mText.contentEquals(text)) { |
| 607 | mText = text.toString(); |
| 608 | } |
| 609 | mWordIterator.setText(mText); |
| 610 | mStartIndex = index; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 611 | mDelegate.logEvent(SelectionEvent.selectionStarted(0)); |
| 612 | } catch (Exception e) { |
| 613 | // Avoid crashes due to logging. |
| 614 | Log.d(LOG_TAG, e.getMessage()); |
| 615 | } |
| 616 | } |
| 617 | |
| 618 | public void logSelectionModified(int start, int end, |
| 619 | @Nullable TextClassification classification, @Nullable TextSelection selection) { |
| 620 | try { |
| 621 | Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); |
| 622 | Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); |
| 623 | int[] wordIndices = getWordDelta(start, end); |
| 624 | if (selection != null) { |
| 625 | mDelegate.logEvent(SelectionEvent.selectionModified( |
| 626 | wordIndices[0], wordIndices[1], selection)); |
| 627 | } else if (classification != null) { |
| 628 | mDelegate.logEvent(SelectionEvent.selectionModified( |
| 629 | wordIndices[0], wordIndices[1], classification)); |
| 630 | } else { |
| 631 | mDelegate.logEvent(SelectionEvent.selectionModified( |
| 632 | wordIndices[0], wordIndices[1])); |
| 633 | } |
| 634 | } catch (Exception e) { |
| 635 | // Avoid crashes due to logging. |
| 636 | Log.d(LOG_TAG, e.getMessage()); |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | public void logSelectionAction( |
| 641 | int start, int end, |
| 642 | @SelectionEvent.ActionType int action, |
| 643 | @Nullable TextClassification classification) { |
| 644 | try { |
| 645 | Preconditions.checkArgumentInRange(start, 0, mText.length(), "start"); |
| 646 | Preconditions.checkArgumentInRange(end, start, mText.length(), "end"); |
| 647 | int[] wordIndices = getWordDelta(start, end); |
| 648 | if (classification != null) { |
| 649 | mDelegate.logEvent(SelectionEvent.selectionAction( |
| 650 | wordIndices[0], wordIndices[1], action, classification)); |
| 651 | } else { |
| 652 | mDelegate.logEvent(SelectionEvent.selectionAction( |
| 653 | wordIndices[0], wordIndices[1], action)); |
| 654 | } |
| 655 | } catch (Exception e) { |
| 656 | // Avoid crashes due to logging. |
| 657 | Log.d(LOG_TAG, e.getMessage()); |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | public boolean isEditTextLogger() { |
| 662 | return mEditTextLogger; |
| 663 | } |
| 664 | |
| 665 | private int[] getWordDelta(int start, int end) { |
| 666 | int[] wordIndices = new int[2]; |
| 667 | |
| 668 | if (start == mStartIndex) { |
| 669 | wordIndices[0] = 0; |
| 670 | } else if (start < mStartIndex) { |
| 671 | wordIndices[0] = -countWordsForward(start); |
| 672 | } else { // start > mStartIndex |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 673 | wordIndices[0] = countWordsBackward(start); |
| 674 | |
| 675 | // For the selection start index, avoid counting a partial word backwards. |
| 676 | if (!mWordIterator.isBoundary(start) |
| 677 | && !isWhitespace( |
| 678 | mWordIterator.preceding(start), |
| 679 | mWordIterator.following(start))) { |
| 680 | // We counted a partial word. Remove it. |
| 681 | wordIndices[0]--; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 682 | } |
| 683 | } |
| 684 | |
| 685 | if (end == mStartIndex) { |
| 686 | wordIndices[1] = 0; |
| 687 | } else if (end < mStartIndex) { |
| 688 | wordIndices[1] = -countWordsForward(end); |
| 689 | } else { // end > mStartIndex |
| 690 | wordIndices[1] = countWordsBackward(end); |
| 691 | } |
| 692 | |
| 693 | return wordIndices; |
| 694 | } |
| 695 | |
| 696 | private int countWordsBackward(int from) { |
| 697 | Preconditions.checkArgument(from >= mStartIndex); |
| 698 | int wordCount = 0; |
| 699 | int offset = from; |
| 700 | while (offset > mStartIndex) { |
| 701 | int start = mWordIterator.preceding(offset); |
| 702 | if (!isWhitespace(start, offset)) { |
| 703 | wordCount++; |
| 704 | } |
| 705 | offset = start; |
| 706 | } |
| 707 | return wordCount; |
| 708 | } |
| 709 | |
| 710 | private int countWordsForward(int from) { |
| 711 | Preconditions.checkArgument(from <= mStartIndex); |
| 712 | int wordCount = 0; |
| 713 | int offset = from; |
| 714 | while (offset < mStartIndex) { |
| 715 | int end = mWordIterator.following(offset); |
| 716 | if (!isWhitespace(offset, end)) { |
| 717 | wordCount++; |
| 718 | } |
| 719 | offset = end; |
| 720 | } |
| 721 | return wordCount; |
| 722 | } |
| 723 | |
| 724 | private boolean isWhitespace(int start, int end) { |
Abodunrinwa Toki | d62a86e | 2017-09-11 18:19:53 +0100 | [diff] [blame] | 725 | return PATTERN_WHITESPACE.matcher(mText.substring(start, end)).matches(); |
Abodunrinwa Toki | e78ac52 | 2017-05-23 14:51:22 +0100 | [diff] [blame] | 726 | } |
Abodunrinwa Toki | 89ba5fb | 2017-02-23 02:52:05 +0000 | [diff] [blame] | 727 | } |
| 728 | |
| 729 | /** |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 730 | * AsyncTask for running a query on a background thread and returning the result on the |
| 731 | * UiThread. The AsyncTask times out after a specified time, returning a null result if the |
| 732 | * query has not yet returned. |
| 733 | */ |
| 734 | private static final class TextClassificationAsyncTask |
| 735 | extends AsyncTask<Void, Void, SelectionResult> { |
| 736 | |
| 737 | private final int mTimeOutDuration; |
| 738 | private final Supplier<SelectionResult> mSelectionResultSupplier; |
| 739 | private final Consumer<SelectionResult> mSelectionResultCallback; |
| 740 | private final TextView mTextView; |
| 741 | private final String mOriginalText; |
| 742 | |
| 743 | /** |
| 744 | * @param textView the TextView |
| 745 | * @param timeOut time in milliseconds to timeout the query if it has not completed |
| 746 | * @param selectionResultSupplier fetches the selection results. Runs on a background thread |
| 747 | * @param selectionResultCallback receives the selection results. Runs on the UiThread |
| 748 | */ |
| 749 | TextClassificationAsyncTask( |
| 750 | @NonNull TextView textView, int timeOut, |
| 751 | @NonNull Supplier<SelectionResult> selectionResultSupplier, |
| 752 | @NonNull Consumer<SelectionResult> selectionResultCallback) { |
Makoto Onuki | 1488a3a | 2017-05-24 12:25:46 -0700 | [diff] [blame] | 753 | super(textView != null ? textView.getHandler() : null); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 754 | mTextView = Preconditions.checkNotNull(textView); |
| 755 | mTimeOutDuration = timeOut; |
| 756 | mSelectionResultSupplier = Preconditions.checkNotNull(selectionResultSupplier); |
| 757 | mSelectionResultCallback = Preconditions.checkNotNull(selectionResultCallback); |
| 758 | // Make a copy of the original text. |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 759 | mOriginalText = getText(mTextView).toString(); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 760 | } |
| 761 | |
| 762 | @Override |
| 763 | @WorkerThread |
| 764 | protected SelectionResult doInBackground(Void... params) { |
| 765 | final Runnable onTimeOut = this::onTimeOut; |
| 766 | mTextView.postDelayed(onTimeOut, mTimeOutDuration); |
| 767 | final SelectionResult result = mSelectionResultSupplier.get(); |
| 768 | mTextView.removeCallbacks(onTimeOut); |
| 769 | return result; |
| 770 | } |
| 771 | |
| 772 | @Override |
| 773 | @UiThread |
| 774 | protected void onPostExecute(SelectionResult result) { |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 775 | result = TextUtils.equals(mOriginalText, getText(mTextView)) ? result : null; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 776 | mSelectionResultCallback.accept(result); |
| 777 | } |
| 778 | |
| 779 | private void onTimeOut() { |
| 780 | if (getStatus() == Status.RUNNING) { |
| 781 | onPostExecute(null); |
| 782 | } |
| 783 | cancel(true); |
| 784 | } |
| 785 | } |
| 786 | |
| 787 | /** |
| 788 | * Helper class for querying the TextClassifier. |
| 789 | * It trims text so that only text necessary to provide context of the selected text is |
| 790 | * sent to the TextClassifier. |
| 791 | */ |
| 792 | private static final class TextClassificationHelper { |
| 793 | |
Abodunrinwa Toki | d2d1399 | 2017-03-24 21:43:13 +0000 | [diff] [blame] | 794 | private static final int TRIM_DELTA = 120; // characters |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 795 | |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 796 | private Context mContext; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 797 | private TextClassifier mTextClassifier; |
| 798 | |
| 799 | /** The original TextView text. **/ |
| 800 | private String mText; |
| 801 | /** Start index relative to mText. */ |
| 802 | private int mSelectionStart; |
| 803 | /** End index relative to mText. */ |
| 804 | private int mSelectionEnd; |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 805 | |
| 806 | private final TextSelection.Options mSelectionOptions = new TextSelection.Options(); |
| 807 | private final TextClassification.Options mClassificationOptions = |
| 808 | new TextClassification.Options(); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 809 | |
| 810 | /** Trimmed text starting from mTrimStart in mText. */ |
| 811 | private CharSequence mTrimmedText; |
| 812 | /** Index indicating the start of mTrimmedText in mText. */ |
| 813 | private int mTrimStart; |
| 814 | /** Start index relative to mTrimmedText */ |
| 815 | private int mRelativeStart; |
| 816 | /** End index relative to mTrimmedText */ |
| 817 | private int mRelativeEnd; |
| 818 | |
Abodunrinwa Toki | fdf57ba | 2017-05-02 20:32:23 +0100 | [diff] [blame] | 819 | /** Information about the last classified text to avoid re-running a query. */ |
| 820 | private CharSequence mLastClassificationText; |
| 821 | private int mLastClassificationSelectionStart; |
| 822 | private int mLastClassificationSelectionEnd; |
| 823 | private LocaleList mLastClassificationLocales; |
| 824 | private SelectionResult mLastClassificationResult; |
| 825 | |
Abodunrinwa Toki | 3521f3f | 2017-09-27 02:38:55 +0100 | [diff] [blame] | 826 | /** Whether the TextClassifier has been initialized. */ |
| 827 | private boolean mHot; |
| 828 | |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 829 | TextClassificationHelper(Context context, TextClassifier textClassifier, |
Abodunrinwa Toki | 4cfda0b | 2017-02-28 18:56:47 +0000 | [diff] [blame] | 830 | CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 831 | init(context, textClassifier, text, selectionStart, selectionEnd, locales); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 832 | } |
| 833 | |
| 834 | @UiThread |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 835 | public void init(Context context, TextClassifier textClassifier, |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 836 | CharSequence text, int selectionStart, int selectionEnd, LocaleList locales) { |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 837 | mContext = Preconditions.checkNotNull(context); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 838 | mTextClassifier = Preconditions.checkNotNull(textClassifier); |
| 839 | mText = Preconditions.checkNotNull(text).toString(); |
Abodunrinwa Toki | 08925e6 | 2017-05-12 13:48:50 +0100 | [diff] [blame] | 840 | mLastClassificationText = null; // invalidate. |
Abodunrinwa Toki | 792d820 | 2017-03-06 23:51:11 +0000 | [diff] [blame] | 841 | Preconditions.checkArgument(selectionEnd > selectionStart); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 842 | mSelectionStart = selectionStart; |
| 843 | mSelectionEnd = selectionEnd; |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 844 | mClassificationOptions.setDefaultLocales(locales); |
| 845 | mSelectionOptions.setDefaultLocales(locales) |
| 846 | .setDarkLaunchAllowed(true); |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 847 | } |
| 848 | |
| 849 | @WorkerThread |
| 850 | public SelectionResult classifyText() { |
Abodunrinwa Toki | 3521f3f | 2017-09-27 02:38:55 +0100 | [diff] [blame] | 851 | mHot = true; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 852 | return performClassification(null /* selection */); |
| 853 | } |
| 854 | |
| 855 | @WorkerThread |
| 856 | public SelectionResult suggestSelection() { |
Abodunrinwa Toki | 3521f3f | 2017-09-27 02:38:55 +0100 | [diff] [blame] | 857 | mHot = true; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 858 | trimText(); |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 859 | final TextSelection selection; |
| 860 | if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.O_MR1) { |
| 861 | selection = mTextClassifier.suggestSelection( |
| 862 | mTrimmedText, mRelativeStart, mRelativeEnd, mSelectionOptions); |
| 863 | } else { |
| 864 | // Use old APIs. |
| 865 | selection = mTextClassifier.suggestSelection( |
| 866 | mTrimmedText, mRelativeStart, mRelativeEnd, |
| 867 | mSelectionOptions.getDefaultLocales()); |
| 868 | } |
Abodunrinwa Toki | 0e6b43e | 2017-09-19 23:18:40 +0100 | [diff] [blame] | 869 | // Do not classify new selection boundaries if TextClassifier should be dark launched. |
| 870 | if (!mTextClassifier.getSettings().isDarkLaunch()) { |
| 871 | mSelectionStart = Math.max(0, selection.getSelectionStartIndex() + mTrimStart); |
| 872 | mSelectionEnd = Math.min( |
| 873 | mText.length(), selection.getSelectionEndIndex() + mTrimStart); |
| 874 | } |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 875 | return performClassification(selection); |
| 876 | } |
| 877 | |
Abodunrinwa Toki | 3521f3f | 2017-09-27 02:38:55 +0100 | [diff] [blame] | 878 | /** |
| 879 | * Maximum time (in milliseconds) to wait for a textclassifier result before timing out. |
| 880 | */ |
| 881 | // TODO: Consider making this a ViewConfiguration. |
| 882 | public int getTimeoutDuration() { |
| 883 | if (mHot) { |
| 884 | return 200; |
| 885 | } else { |
| 886 | // Return a slightly larger number than usual when the TextClassifier is first |
| 887 | // initialized. Initialization would usually take longer than subsequent calls to |
| 888 | // the TextClassifier. The impact of this on the UI is that we do not show the |
| 889 | // selection handles or toolbar until after this timeout. |
| 890 | return 500; |
| 891 | } |
| 892 | } |
| 893 | |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 894 | private SelectionResult performClassification(@Nullable TextSelection selection) { |
Abodunrinwa Toki | fdf57ba | 2017-05-02 20:32:23 +0100 | [diff] [blame] | 895 | if (!Objects.equals(mText, mLastClassificationText) |
| 896 | || mSelectionStart != mLastClassificationSelectionStart |
| 897 | || mSelectionEnd != mLastClassificationSelectionEnd |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 898 | || !Objects.equals( |
| 899 | mClassificationOptions.getDefaultLocales(), |
| 900 | mLastClassificationLocales)) { |
Abodunrinwa Toki | fdf57ba | 2017-05-02 20:32:23 +0100 | [diff] [blame] | 901 | |
| 902 | mLastClassificationText = mText; |
| 903 | mLastClassificationSelectionStart = mSelectionStart; |
| 904 | mLastClassificationSelectionEnd = mSelectionEnd; |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 905 | mLastClassificationLocales = mClassificationOptions.getDefaultLocales(); |
Abodunrinwa Toki | fdf57ba | 2017-05-02 20:32:23 +0100 | [diff] [blame] | 906 | |
| 907 | trimText(); |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 908 | final TextClassification classification; |
| 909 | if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.O_MR1) { |
| 910 | classification = mTextClassifier.classifyText( |
| 911 | mTrimmedText, mRelativeStart, mRelativeEnd, mClassificationOptions); |
| 912 | } else { |
| 913 | // Use old APIs. |
| 914 | classification = mTextClassifier.classifyText( |
| 915 | mTrimmedText, mRelativeStart, mRelativeEnd, |
| 916 | mClassificationOptions.getDefaultLocales()); |
| 917 | } |
Abodunrinwa Toki | fdf57ba | 2017-05-02 20:32:23 +0100 | [diff] [blame] | 918 | mLastClassificationResult = new SelectionResult( |
Abodunrinwa Toki | 2b6020f | 2017-10-28 02:28:45 +0100 | [diff] [blame] | 919 | mSelectionStart, mSelectionEnd, classification, selection); |
Abodunrinwa Toki | fdf57ba | 2017-05-02 20:32:23 +0100 | [diff] [blame] | 920 | |
| 921 | } |
| 922 | return mLastClassificationResult; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 923 | } |
| 924 | |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 925 | private void trimText() { |
| 926 | mTrimStart = Math.max(0, mSelectionStart - TRIM_DELTA); |
| 927 | final int referenceEnd = Math.min(mText.length(), mSelectionEnd + TRIM_DELTA); |
| 928 | mTrimmedText = mText.subSequence(mTrimStart, referenceEnd); |
| 929 | mRelativeStart = mSelectionStart - mTrimStart; |
| 930 | mRelativeEnd = mSelectionEnd - mTrimStart; |
| 931 | } |
| 932 | } |
| 933 | |
| 934 | /** |
| 935 | * Selection result. |
| 936 | */ |
| 937 | private static final class SelectionResult { |
| 938 | private final int mStart; |
| 939 | private final int mEnd; |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 940 | private final TextClassification mClassification; |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 941 | @Nullable private final TextSelection mSelection; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 942 | |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 943 | SelectionResult(int start, int end, |
| 944 | TextClassification classification, @Nullable TextSelection selection) { |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 945 | mStart = start; |
| 946 | mEnd = end; |
Abodunrinwa Toki | e0b5789 | 2017-04-28 19:59:57 +0100 | [diff] [blame] | 947 | mClassification = Preconditions.checkNotNull(classification); |
Abodunrinwa Toki | 57ec6ea | 2017-09-04 20:32:31 +0100 | [diff] [blame] | 948 | mSelection = selection; |
| 949 | } |
| 950 | } |
| 951 | |
| 952 | @SelectionEvent.ActionType |
| 953 | private static int getActionType(int menuItemId) { |
| 954 | switch (menuItemId) { |
| 955 | case TextView.ID_SELECT_ALL: |
| 956 | return SelectionEvent.ActionType.SELECT_ALL; |
| 957 | case TextView.ID_CUT: |
| 958 | return SelectionEvent.ActionType.CUT; |
| 959 | case TextView.ID_COPY: |
| 960 | return SelectionEvent.ActionType.COPY; |
| 961 | case TextView.ID_PASTE: // fall through |
| 962 | case TextView.ID_PASTE_AS_PLAIN_TEXT: |
| 963 | return SelectionEvent.ActionType.PASTE; |
| 964 | case TextView.ID_SHARE: |
| 965 | return SelectionEvent.ActionType.SHARE; |
| 966 | case TextView.ID_ASSIST: |
| 967 | return SelectionEvent.ActionType.SMART_SHARE; |
| 968 | default: |
| 969 | return SelectionEvent.ActionType.OTHER; |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 970 | } |
| 971 | } |
Abodunrinwa Toki | 7c1e425 | 2017-09-26 20:09:06 +0100 | [diff] [blame] | 972 | |
| 973 | private static CharSequence getText(TextView textView) { |
| 974 | // Extracts the textView's text. |
| 975 | // TODO: Investigate why/when TextView.getText() is null. |
| 976 | final CharSequence text = textView.getText(); |
| 977 | if (text != null) { |
| 978 | return text; |
| 979 | } |
| 980 | return ""; |
| 981 | } |
Abodunrinwa Toki | 8710ea1 | 2017-01-24 10:34:13 -0800 | [diff] [blame] | 982 | } |