1 /* 2 * Copyright (C) 2020 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 static android.content.ContentResolver.SCHEME_CONTENT; 20 import static android.view.ContentInfo.FLAG_CONVERT_TO_PLAIN_TEXT; 21 import static android.view.ContentInfo.SOURCE_AUTOFILL; 22 import static android.view.ContentInfo.SOURCE_INPUT_METHOD; 23 24 import android.annotation.NonNull; 25 import android.annotation.Nullable; 26 import android.compat.Compatibility; 27 import android.compat.annotation.ChangeId; 28 import android.compat.annotation.EnabledAfter; 29 import android.content.ClipData; 30 import android.content.ClipDescription; 31 import android.content.Context; 32 import android.net.Uri; 33 import android.os.Build; 34 import android.os.Bundle; 35 import android.text.Editable; 36 import android.text.Selection; 37 import android.text.SpannableStringBuilder; 38 import android.text.Spanned; 39 import android.util.Log; 40 import android.view.ContentInfo; 41 import android.view.ContentInfo.Flags; 42 import android.view.ContentInfo.Source; 43 import android.view.OnReceiveContentListener; 44 import android.view.View; 45 import android.view.inputmethod.EditorInfo; 46 import android.view.inputmethod.InputConnection; 47 import android.view.inputmethod.InputContentInfo; 48 49 import com.android.internal.annotations.VisibleForTesting; 50 51 import java.lang.ref.WeakReference; 52 import java.util.ArrayList; 53 import java.util.Arrays; 54 55 /** 56 * Default implementation for {@link View#onReceiveContent} for editable {@link TextView} 57 * components. This class handles insertion of text (plain text, styled text, HTML, etc) but not 58 * images or other content. 59 * 60 * @hide 61 */ 62 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) 63 public final class TextViewOnReceiveContentListener implements OnReceiveContentListener { 64 private static final String LOG_TAG = "ReceiveContent"; 65 66 @Nullable private InputConnectionInfo mInputConnectionInfo; 67 68 @Nullable 69 @Override onReceiveContent(@onNull View view, @NonNull ContentInfo payload)70 public ContentInfo onReceiveContent(@NonNull View view, @NonNull ContentInfo payload) { 71 if (Log.isLoggable(LOG_TAG, Log.DEBUG)) { 72 Log.d(LOG_TAG, "onReceive: " + payload); 73 } 74 final @Source int source = payload.getSource(); 75 if (source == SOURCE_INPUT_METHOD) { 76 // InputConnection.commitContent() should only be used for non-text input which is not 77 // supported by the default implementation. 78 return payload; 79 } 80 if (source == SOURCE_AUTOFILL) { 81 onReceiveForAutofill((TextView) view, payload); 82 return null; 83 } 84 85 // The code here follows the original paste logic from TextView: 86 // https://cs.android.com/android/_/android/platform/frameworks/base/+/9fefb65aa9e7beae9ca8306b925b9fbfaeffecc9:core/java/android/widget/TextView.java;l=12644 87 // In particular, multiple items within the given ClipData will trigger separate calls to 88 // replace/insert. This is to preserve the original behavior with respect to TextWatcher 89 // notifications fired from SpannableStringBuilder when replace/insert is called. 90 final ClipData clip = payload.getClip(); 91 final @Flags int flags = payload.getFlags(); 92 final Editable editable = (Editable) ((TextView) view).getText(); 93 final Context context = view.getContext(); 94 boolean didFirst = false; 95 for (int i = 0; i < clip.getItemCount(); i++) { 96 CharSequence itemText; 97 if ((flags & FLAG_CONVERT_TO_PLAIN_TEXT) != 0) { 98 itemText = clip.getItemAt(i).coerceToText(context); 99 itemText = (itemText instanceof Spanned) ? itemText.toString() : itemText; 100 } else { 101 itemText = clip.getItemAt(i).coerceToStyledText(context); 102 } 103 if (itemText != null) { 104 if (!didFirst) { 105 replaceSelection(editable, itemText); 106 didFirst = true; 107 } else { 108 editable.insert(Selection.getSelectionEnd(editable), "\n"); 109 editable.insert(Selection.getSelectionEnd(editable), itemText); 110 } 111 } 112 } 113 return null; 114 } 115 replaceSelection(@onNull Editable editable, @NonNull CharSequence replacement)116 private static void replaceSelection(@NonNull Editable editable, 117 @NonNull CharSequence replacement) { 118 final int selStart = Selection.getSelectionStart(editable); 119 final int selEnd = Selection.getSelectionEnd(editable); 120 final int start = Math.max(0, Math.min(selStart, selEnd)); 121 final int end = Math.max(0, Math.max(selStart, selEnd)); 122 Selection.setSelection(editable, end); 123 editable.replace(start, end, replacement); 124 } 125 onReceiveForAutofill(@onNull TextView view, @NonNull ContentInfo payload)126 private void onReceiveForAutofill(@NonNull TextView view, @NonNull ContentInfo payload) { 127 ClipData clip = payload.getClip(); 128 if (isUsageOfImeCommitContentEnabled(view)) { 129 clip = handleNonTextViaImeCommitContent(clip); 130 if (clip == null) { 131 if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { 132 Log.v(LOG_TAG, "onReceive: Handled via IME"); 133 } 134 return; 135 } 136 } 137 final CharSequence text = coerceToText(clip, view.getContext(), payload.getFlags()); 138 // First autofill it... 139 view.setText(text); 140 // ...then move cursor to the end. 141 final Editable editable = (Editable) view.getText(); 142 Selection.setSelection(editable, editable.length()); 143 } 144 coerceToText(@onNull ClipData clip, @NonNull Context context, @Flags int flags)145 private static @NonNull CharSequence coerceToText(@NonNull ClipData clip, 146 @NonNull Context context, @Flags int flags) { 147 SpannableStringBuilder ssb = new SpannableStringBuilder(); 148 for (int i = 0; i < clip.getItemCount(); i++) { 149 CharSequence itemText; 150 if ((flags & FLAG_CONVERT_TO_PLAIN_TEXT) != 0) { 151 itemText = clip.getItemAt(i).coerceToText(context); 152 itemText = (itemText instanceof Spanned) ? itemText.toString() : itemText; 153 } else { 154 itemText = clip.getItemAt(i).coerceToStyledText(context); 155 } 156 if (itemText != null) { 157 ssb.append(itemText); 158 } 159 } 160 return ssb; 161 } 162 163 /** 164 * On Android S and above, the platform can provide non-text suggestions (e.g. images) via the 165 * augmented autofill framework (see 166 * <a href="/guide/topics/text/autofill-services">autofill services</a>). In order for an app to 167 * be able to handle these suggestions, it must normally implement the 168 * {@link android.view.OnReceiveContentListener} API. To make the adoption of this smoother for 169 * apps that have previously implemented the 170 * {@link android.view.inputmethod.InputConnection#commitContent(InputContentInfo, int, Bundle)} 171 * API, we reuse that API as a fallback if {@link android.view.OnReceiveContentListener} is not 172 * yet implemented by the app. This fallback is only enabled on Android S. This change ID 173 * disables the fallback, such that apps targeting Android T and above must implement the 174 * {@link android.view.OnReceiveContentListener} API in order to accept non-text suggestions. 175 */ 176 @ChangeId 177 @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.S) // Enabled on Android T and higher 178 private static final long AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_LISTENER = 163400105L; 179 180 /** 181 * Returns true if we can use the IME {@link InputConnection#commitContent} API in order handle 182 * non-text content. 183 */ isUsageOfImeCommitContentEnabled(@onNull View view)184 private static boolean isUsageOfImeCommitContentEnabled(@NonNull View view) { 185 if (view.getReceiveContentMimeTypes() != null) { 186 return false; 187 } 188 if (Compatibility.isChangeEnabled(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_LISTENER)) { 189 return false; 190 } 191 return true; 192 } 193 194 private static final class InputConnectionInfo { 195 @NonNull private final WeakReference<InputConnection> mInputConnection; 196 @NonNull private final String[] mEditorInfoContentMimeTypes; 197 InputConnectionInfo(@onNull InputConnection inputConnection, @NonNull String[] editorInfoContentMimeTypes)198 private InputConnectionInfo(@NonNull InputConnection inputConnection, 199 @NonNull String[] editorInfoContentMimeTypes) { 200 mInputConnection = new WeakReference<>(inputConnection); 201 mEditorInfoContentMimeTypes = editorInfoContentMimeTypes; 202 } 203 204 @Override toString()205 public String toString() { 206 return "InputConnectionInfo{" 207 + "mimeTypes=" + Arrays.toString(mEditorInfoContentMimeTypes) 208 + ", ic=" + mInputConnection 209 + '}'; 210 } 211 } 212 213 /** 214 * Invoked by the platform when an {@link InputConnection} is successfully created for the view 215 * that owns this callback instance. 216 */ setInputConnectionInfo(@onNull TextView view, @NonNull InputConnection ic, @NonNull EditorInfo editorInfo)217 void setInputConnectionInfo(@NonNull TextView view, @NonNull InputConnection ic, 218 @NonNull EditorInfo editorInfo) { 219 if (!isUsageOfImeCommitContentEnabled(view)) { 220 mInputConnectionInfo = null; 221 return; 222 } 223 String[] contentMimeTypes = editorInfo.contentMimeTypes; 224 if (contentMimeTypes == null || contentMimeTypes.length == 0) { 225 mInputConnectionInfo = null; 226 } else { 227 mInputConnectionInfo = new InputConnectionInfo(ic, contentMimeTypes); 228 } 229 } 230 231 /** 232 * Invoked by the platform when an {@link InputConnection} is closed for the view that owns this 233 * callback instance. 234 */ clearInputConnectionInfo()235 void clearInputConnectionInfo() { 236 mInputConnectionInfo = null; 237 } 238 239 /** 240 * Returns the MIME types accepted by {@link View#performReceiveContent} for the given view, 241 * <strong>for autofill purposes</strong>. This will be non-null only if fallback to the 242 * keyboard image API {@link #isUsageOfImeCommitContentEnabled is enabled} and the view has an 243 * {@link InputConnection} with {@link EditorInfo#contentMimeTypes} populated. 244 * 245 * @hide 246 */ 247 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) 248 @Nullable getFallbackMimeTypesForAutofill(@onNull TextView view)249 public String[] getFallbackMimeTypesForAutofill(@NonNull TextView view) { 250 if (!isUsageOfImeCommitContentEnabled(view)) { 251 return null; 252 } 253 final InputConnectionInfo icInfo = mInputConnectionInfo; 254 if (icInfo == null) { 255 return null; 256 } 257 return icInfo.mEditorInfoContentMimeTypes; 258 } 259 260 /** 261 * Tries to insert the content in the clip into the app via the image keyboard API. If all the 262 * items in the clip are successfully inserted, returns null. If one or more of the items in the 263 * clip cannot be inserted, returns a non-null clip that contains the items that were not 264 * inserted. 265 */ 266 @Nullable handleNonTextViaImeCommitContent(@onNull ClipData clip)267 private ClipData handleNonTextViaImeCommitContent(@NonNull ClipData clip) { 268 ClipDescription description = clip.getDescription(); 269 if (!containsUri(clip) || containsOnlyText(clip)) { 270 if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { 271 Log.v(LOG_TAG, "onReceive: Clip doesn't contain any non-text URIs: " 272 + description); 273 } 274 return clip; 275 } 276 277 InputConnectionInfo icInfo = mInputConnectionInfo; 278 InputConnection inputConnection = (icInfo != null) ? icInfo.mInputConnection.get() : null; 279 if (inputConnection == null) { 280 if (Log.isLoggable(LOG_TAG, Log.DEBUG)) { 281 Log.d(LOG_TAG, "onReceive: No usable EditorInfo/InputConnection"); 282 } 283 return clip; 284 } 285 String[] editorInfoContentMimeTypes = icInfo.mEditorInfoContentMimeTypes; 286 if (!isClipMimeTypeSupported(editorInfoContentMimeTypes, clip.getDescription())) { 287 if (Log.isLoggable(LOG_TAG, Log.DEBUG)) { 288 Log.d(LOG_TAG, 289 "onReceive: MIME type is not supported by the app's commitContent impl"); 290 } 291 return clip; 292 } 293 294 if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { 295 Log.v(LOG_TAG, "onReceive: Trying to insert via IME: " + description); 296 } 297 ArrayList<ClipData.Item> remainingItems = new ArrayList<>(0); 298 for (int i = 0; i < clip.getItemCount(); i++) { 299 ClipData.Item item = clip.getItemAt(i); 300 Uri uri = item.getUri(); 301 if (uri == null || !SCHEME_CONTENT.equals(uri.getScheme())) { 302 if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { 303 Log.v(LOG_TAG, "onReceive: No content URI in item: uri=" + uri); 304 } 305 remainingItems.add(item); 306 continue; 307 } 308 if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { 309 Log.v(LOG_TAG, "onReceive: Calling commitContent: uri=" + uri); 310 } 311 InputContentInfo contentInfo = new InputContentInfo(uri, description); 312 if (!inputConnection.commitContent(contentInfo, 0, null)) { 313 if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { 314 Log.v(LOG_TAG, "onReceive: Call to commitContent returned false: uri=" + uri); 315 } 316 remainingItems.add(item); 317 } 318 } 319 if (remainingItems.isEmpty()) { 320 return null; 321 } 322 return new ClipData(description, remainingItems); 323 } 324 isClipMimeTypeSupported(@onNull String[] supportedMimeTypes, @NonNull ClipDescription description)325 private static boolean isClipMimeTypeSupported(@NonNull String[] supportedMimeTypes, 326 @NonNull ClipDescription description) { 327 for (String imeSupportedMimeType : supportedMimeTypes) { 328 if (description.hasMimeType(imeSupportedMimeType)) { 329 return true; 330 } 331 } 332 return false; 333 } 334 containsUri(@onNull ClipData clip)335 private static boolean containsUri(@NonNull ClipData clip) { 336 for (int i = 0; i < clip.getItemCount(); i++) { 337 ClipData.Item item = clip.getItemAt(i); 338 if (item.getUri() != null) { 339 return true; 340 } 341 } 342 return false; 343 } 344 containsOnlyText(@onNull ClipData clip)345 private static boolean containsOnlyText(@NonNull ClipData clip) { 346 ClipDescription description = clip.getDescription(); 347 for (int i = 0; i < description.getMimeTypeCount(); i++) { 348 String mimeType = description.getMimeType(i); 349 if (!mimeType.startsWith("text/")) { 350 return false; 351 } 352 } 353 return true; 354 } 355 } 356