• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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