Loading core/java/android/widget/Editor.java +11 −0 Original line number Diff line number Diff line Loading @@ -206,6 +206,10 @@ public class Editor { int TEXT_LINK = 2; } // Default content insertion handler. private final TextViewOnReceiveContentCallback mDefaultOnReceiveContentCallback = new TextViewOnReceiveContentCallback(); // Each Editor manages its own undo stack. private final UndoManager mUndoManager = new UndoManager(); private UndoOwner mUndoOwner = mUndoManager.getOwner(UNDO_OWNER_TAG, this); Loading Loading @@ -584,6 +588,11 @@ public class Editor { mUndoOwner = mUndoManager.getOwner(UNDO_OWNER_TAG, this); } @VisibleForTesting public @NonNull TextViewOnReceiveContentCallback getDefaultOnReceiveContentCallback() { return mDefaultOnReceiveContentCallback; } /** * Forgets all undo and redo operations for this Editor. */ Loading Loading @@ -709,6 +718,8 @@ public class Editor { hideCursorAndSpanControllers(); stopTextActionModeWithPreservingSelection(); mDefaultOnReceiveContentCallback.clearInputConnectionInfo(); } private void discardTextDisplayLists() { Loading core/java/android/widget/TextView.java +20 −9 Original line number Diff line number Diff line Loading @@ -80,6 +80,7 @@ import android.os.AsyncTask; import android.os.Build; import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.os.Handler; import android.os.LocaleList; import android.os.Parcel; import android.os.Parcelable; Loading Loading @@ -890,13 +891,6 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener @UnsupportedAppUsage private Editor mEditor; /** * The default content insertion callback used by {@link TextView}. See * {@link #setOnReceiveContentCallback} for more info. */ private static final TextViewOnReceiveContentCallback DEFAULT_ON_RECEIVE_CONTENT_CALLBACK = new TextViewOnReceiveContentCallback(); private static final int DEVICE_PROVISIONED_UNKNOWN = 0; private static final int DEVICE_PROVISIONED_NO = 1; private static final int DEVICE_PROVISIONED_YES = 2; Loading Loading @@ -13723,6 +13717,23 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener } } /** @hide */ @Override public void onInputConnectionOpenedInternal(@NonNull InputConnection ic, @NonNull EditorInfo editorInfo, @Nullable Handler handler) { if (mEditor != null) { mEditor.getDefaultOnReceiveContentCallback().setInputConnectionInfo(ic, editorInfo); } } /** @hide */ @Override public void onInputConnectionClosedInternal() { if (mEditor != null) { mEditor.getDefaultOnReceiveContentCallback().clearInputConnectionInfo(); } } /** * Returns the callback used for handling insertion of content into this view. See * {@link #setOnReceiveContentCallback} for more info. Loading Loading @@ -13773,8 +13784,8 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener ClipDescription description = payload.getClip().getDescription(); if (receiver != null && receiver.supports(this, description)) { receiver.onReceiveContent(this, payload); } else { DEFAULT_ON_RECEIVE_CONTENT_CALLBACK.onReceiveContent(this, payload); } else if (mEditor != null) { mEditor.getDefaultOnReceiveContentCallback().onReceiveContent(this, payload); } } core/java/android/widget/TextViewOnReceiveContentCallback.java +290 −8 Original line number Diff line number Diff line Loading @@ -16,24 +16,44 @@ package android.widget; import static android.content.ContentResolver.SCHEME_CONTENT; import static android.view.OnReceiveContentCallback.Payload.FLAG_CONVERT_TO_PLAIN_TEXT; import static android.view.OnReceiveContentCallback.Payload.SOURCE_AUTOFILL; import static android.view.OnReceiveContentCallback.Payload.SOURCE_DRAG_AND_DROP; import static java.util.Collections.singleton; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.compat.Compatibility; import android.compat.annotation.ChangeId; import android.compat.annotation.EnabledAfter; import android.content.ClipData; import android.content.ClipDescription; import android.content.Context; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.Editable; import android.text.Selection; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.util.ArraySet; import android.util.Log; import android.view.OnReceiveContentCallback; import android.view.OnReceiveContentCallback.Payload.Flags; import android.view.OnReceiveContentCallback.Payload.Source; import android.view.View; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputContentInfo; import com.android.internal.annotations.VisibleForTesting; import java.util.Collections; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.Set; /** Loading @@ -46,14 +66,21 @@ import java.util.Set; public class TextViewOnReceiveContentCallback implements OnReceiveContentCallback<TextView> { private static final String LOG_TAG = "OnReceiveContent"; private static final Set<String> MIME_TYPES_ALL_TEXT = Collections.singleton("text/*"); private static final String MIME_TYPE_ALL_TEXT = "text/*"; private static final Set<String> MIME_TYPES_ALL_TEXT = singleton(MIME_TYPE_ALL_TEXT); @Nullable private InputConnectionInfo mInputConnectionInfo; @Nullable private ArraySet<String> mCachedSupportedMimeTypes; @SuppressLint("CallbackMethodName") @NonNull @Override public Set<String> getSupportedMimeTypes(@NonNull TextView view) { if (!isUsageOfImeCommitContentEnabled(view)) { return MIME_TYPES_ALL_TEXT; } return getSupportedMimeTypesAugmentedWithImeCommitContentMimeTypes(); } @Override public boolean onReceiveContent(@NonNull TextView view, @NonNull Payload payload) { Loading Loading @@ -109,13 +136,22 @@ public class TextViewOnReceiveContentCallback implements OnReceiveContentCallbac editable.replace(start, end, replacement); } private static boolean onReceiveForAutofill(@NonNull TextView textView, @NonNull ClipData clip, private boolean onReceiveForAutofill(@NonNull TextView view, @NonNull ClipData clip, @Flags int flags) { final CharSequence text = coerceToText(clip, textView.getContext(), flags); if (isUsageOfImeCommitContentEnabled(view)) { clip = handleNonTextViaImeCommitContent(clip); if (clip == null) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Handled via IME"); } return true; } } final CharSequence text = coerceToText(clip, view.getContext(), flags); // First autofill it... textView.setText(text); view.setText(text); // ...then move cursor to the end. final Editable editable = (Editable) textView.getText(); final Editable editable = (Editable) view.getText(); Selection.setSelection(editable, editable.length()); return true; } Loading Loading @@ -146,4 +182,250 @@ public class TextViewOnReceiveContentCallback implements OnReceiveContentCallbac } return ssb; } /** * On Android S and above, the platform can provide non-text suggestions (e.g. images) via the * augmented autofill framework (see * <a href="/guide/topics/text/autofill-services">autofill services</a>). In order for an app to * be able to handle these suggestions, it must normally implement the * {@link android.view.OnReceiveContentCallback} API. To make the adoption of this smoother for * apps that have previously implemented the * {@link android.view.inputmethod.InputConnection#commitContent(InputContentInfo, int, Bundle)} * API, we reuse that API as a fallback if {@link android.view.OnReceiveContentCallback} is not * yet implemented by the app. This fallback is only enabled on Android S. This change ID * disables the fallback, such that apps targeting Android T and above must implement the * {@link android.view.OnReceiveContentCallback} API in order to accept non-text suggestions. */ @ChangeId @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.S) // Enabled on Android T and higher private static final long AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_CALLBACK = 163400105L; /** * Returns true if we can use the IME {@link InputConnection#commitContent} API in order handle * non-text content. */ private static boolean isUsageOfImeCommitContentEnabled(@NonNull View view) { if (view.getOnReceiveContentCallback() != null) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "Fallback to commitContent disabled (custom callback is set)"); } return false; } if (Compatibility.isChangeEnabled(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_CALLBACK)) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "Fallback to commitContent disabled (target SDK is above S)"); } return false; } return true; } private static final class InputConnectionInfo { @NonNull private final WeakReference<InputConnection> mInputConnection; @NonNull private final String[] mEditorInfoContentMimeTypes; private InputConnectionInfo(@NonNull InputConnection inputConnection, @NonNull String[] editorInfoContentMimeTypes) { mInputConnection = new WeakReference<>(inputConnection); mEditorInfoContentMimeTypes = editorInfoContentMimeTypes; } @Override public String toString() { return "InputConnectionInfo{" + "mimeTypes=" + Arrays.toString(mEditorInfoContentMimeTypes) + ", ic=" + mInputConnection + '}'; } } /** * Invoked by the platform when an {@link InputConnection} is successfully created for the view * that owns this callback instance. */ void setInputConnectionInfo(@NonNull InputConnection ic, @NonNull EditorInfo editorInfo) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "setInputConnectionInfo: " + Arrays.toString(editorInfo.contentMimeTypes)); } String[] contentMimeTypes = editorInfo.contentMimeTypes; if (contentMimeTypes == null || contentMimeTypes.length == 0) { mInputConnectionInfo = null; } else { mInputConnectionInfo = new InputConnectionInfo(ic, contentMimeTypes); } } /** * Invoked by the platform when an {@link InputConnection} is closed for the view that owns this * callback instance. */ void clearInputConnectionInfo() { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "clearInputConnectionInfo: " + mInputConnectionInfo); } mInputConnectionInfo = null; } private Set<String> getSupportedMimeTypesAugmentedWithImeCommitContentMimeTypes() { InputConnectionInfo icInfo = mInputConnectionInfo; if (icInfo == null) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "getSupportedMimeTypes: No usable EditorInfo/InputConnection"); } return MIME_TYPES_ALL_TEXT; } String[] editorInfoContentMimeTypes = icInfo.mEditorInfoContentMimeTypes; if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "getSupportedMimeTypes: Augmenting with EditorInfo.contentMimeTypes: " + Arrays.toString(editorInfoContentMimeTypes)); } ArraySet<String> supportedMimeTypes = mCachedSupportedMimeTypes; if (canReuse(supportedMimeTypes, editorInfoContentMimeTypes)) { return supportedMimeTypes; } supportedMimeTypes = new ArraySet<>(editorInfoContentMimeTypes); supportedMimeTypes.add(MIME_TYPE_ALL_TEXT); mCachedSupportedMimeTypes = supportedMimeTypes; return supportedMimeTypes; } /** * We want to avoid creating a new set on every invocation of {@link #getSupportedMimeTypes}. * This method will check if the cached set of MIME types matches the data in the given array * from {@link EditorInfo} or if a new set should be created. The custom logic is needed for * comparing the data because the set contains the additional "text/*" MIME type. * * @param cachedMimeTypes Previously cached set of MIME types. * @param newEditorInfoMimeTypes MIME types from {@link EditorInfo}. * * @return Returns true if the data in the given cached set matches the data in the array. * * @hide */ @VisibleForTesting public static boolean canReuse(@Nullable ArraySet<String> cachedMimeTypes, @NonNull String[] newEditorInfoMimeTypes) { if (cachedMimeTypes == null) { return false; } if (newEditorInfoMimeTypes.length != cachedMimeTypes.size() && newEditorInfoMimeTypes.length != (cachedMimeTypes.size() - 1)) { return false; } final boolean ignoreAllTextMimeType = newEditorInfoMimeTypes.length == (cachedMimeTypes.size() - 1); for (String mimeType : cachedMimeTypes) { if (ignoreAllTextMimeType && mimeType.equals(MIME_TYPE_ALL_TEXT)) { continue; } boolean present = false; for (String editorInfoContentMimeType : newEditorInfoMimeTypes) { if (editorInfoContentMimeType.equals(mimeType)) { present = true; break; } } if (!present) { return false; } } return true; } /** * Tries to insert the content in the clip into the app via the image keyboard API. If all the * items in the clip are successfully inserted, returns null. If one or more of the items in the * clip cannot be inserted, returns a non-null clip that contains the items that were not * inserted. */ @Nullable private ClipData handleNonTextViaImeCommitContent(@NonNull ClipData clip) { ClipDescription description = clip.getDescription(); if (!containsUri(clip) || containsOnlyText(clip)) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Clip doesn't contain any non-text URIs: " + description); } return clip; } InputConnectionInfo icInfo = mInputConnectionInfo; InputConnection inputConnection = (icInfo != null) ? icInfo.mInputConnection.get() : null; if (inputConnection == null) { if (Log.isLoggable(LOG_TAG, Log.DEBUG)) { Log.d(LOG_TAG, "onReceive: No usable EditorInfo/InputConnection"); } return clip; } String[] editorInfoContentMimeTypes = icInfo.mEditorInfoContentMimeTypes; if (!isClipMimeTypeSupported(editorInfoContentMimeTypes, clip.getDescription())) { if (Log.isLoggable(LOG_TAG, Log.DEBUG)) { Log.d(LOG_TAG, "onReceive: MIME type is not supported by the app's commitContent impl"); } return clip; } if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Trying to insert via IME: " + description); } ArrayList<ClipData.Item> remainingItems = new ArrayList<>(0); for (int i = 0; i < clip.getItemCount(); i++) { ClipData.Item item = clip.getItemAt(i); Uri uri = item.getUri(); if (uri == null || !SCHEME_CONTENT.equals(uri.getScheme())) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: No content URI in item: uri=" + uri); } remainingItems.add(item); continue; } if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Calling commitContent: uri=" + uri); } InputContentInfo contentInfo = new InputContentInfo(uri, description); if (!inputConnection.commitContent(contentInfo, 0, null)) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Call to commitContent returned false: uri=" + uri); } remainingItems.add(item); } } if (remainingItems.isEmpty()) { return null; } return new ClipData(description, remainingItems); } private static boolean isClipMimeTypeSupported(@NonNull String[] supportedMimeTypes, @NonNull ClipDescription description) { for (String imeSupportedMimeType : supportedMimeTypes) { if (description.hasMimeType(imeSupportedMimeType)) { return true; } } return false; } private static boolean containsUri(@NonNull ClipData clip) { for (int i = 0; i < clip.getItemCount(); i++) { ClipData.Item item = clip.getItemAt(i); if (item.getUri() != null) { return true; } } return false; } private static boolean containsOnlyText(@NonNull ClipData clip) { ClipDescription description = clip.getDescription(); for (int i = 0; i < description.getMimeTypeCount(); i++) { String mimeType = description.getMimeType(i); if (!mimeType.startsWith("text/")) { return false; } } return true; } } core/tests/coretests/AndroidManifest.xml +11 −0 Original line number Diff line number Diff line Loading @@ -190,6 +190,17 @@ </intent-filter> </activity> <activity android:name="android.widget.CustomInputConnectionEditTextActivity" android:label="CustomInputConnectionEditTextActivity" android:screenOrientation="portrait" android:exported="true" android:theme="@android:style/Theme.Material.Light"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.FRAMEWORK_INSTRUMENTATION_TEST" /> </intent-filter> </activity> <activity android:name="android.widget.TextViewActivity" android:label="TextViewActivity" android:screenOrientation="portrait" Loading core/tests/coretests/res/layout/activity_custom_input_connection_edit_text.xml 0 → 100644 +33 −0 Original line number Diff line number Diff line <?xml version="1.0" encoding="utf-8"?> <!-- ~ Copyright (C) 2020 The Android Open Source Project ~ ~ Licensed under the Apache License, Version 2.0 (the "License"); ~ you may not use this file except in compliance with the License. ~ You may obtain a copy of the License at ~ ~ http://www.apache.org/licenses/LICENSE-2.0 ~ ~ Unless required by applicable law or agreed to in writing, software ~ distributed under the License is distributed on an "AS IS" BASIS, ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ~ See the License for the specific language governing permissions and ~ limitations under the License. --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <android.widget.CustomInputConnectionEditText android:id="@+id/edittext1" android:layout_width="match_parent" android:layout_height="wrap_content" /> <android.widget.CustomInputConnectionEditText android:id="@+id/edittext2" android:layout_width="match_parent" android:layout_height="wrap_content" /> </LinearLayout> Loading
core/java/android/widget/Editor.java +11 −0 Original line number Diff line number Diff line Loading @@ -206,6 +206,10 @@ public class Editor { int TEXT_LINK = 2; } // Default content insertion handler. private final TextViewOnReceiveContentCallback mDefaultOnReceiveContentCallback = new TextViewOnReceiveContentCallback(); // Each Editor manages its own undo stack. private final UndoManager mUndoManager = new UndoManager(); private UndoOwner mUndoOwner = mUndoManager.getOwner(UNDO_OWNER_TAG, this); Loading Loading @@ -584,6 +588,11 @@ public class Editor { mUndoOwner = mUndoManager.getOwner(UNDO_OWNER_TAG, this); } @VisibleForTesting public @NonNull TextViewOnReceiveContentCallback getDefaultOnReceiveContentCallback() { return mDefaultOnReceiveContentCallback; } /** * Forgets all undo and redo operations for this Editor. */ Loading Loading @@ -709,6 +718,8 @@ public class Editor { hideCursorAndSpanControllers(); stopTextActionModeWithPreservingSelection(); mDefaultOnReceiveContentCallback.clearInputConnectionInfo(); } private void discardTextDisplayLists() { Loading
core/java/android/widget/TextView.java +20 −9 Original line number Diff line number Diff line Loading @@ -80,6 +80,7 @@ import android.os.AsyncTask; import android.os.Build; import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.os.Handler; import android.os.LocaleList; import android.os.Parcel; import android.os.Parcelable; Loading Loading @@ -890,13 +891,6 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener @UnsupportedAppUsage private Editor mEditor; /** * The default content insertion callback used by {@link TextView}. See * {@link #setOnReceiveContentCallback} for more info. */ private static final TextViewOnReceiveContentCallback DEFAULT_ON_RECEIVE_CONTENT_CALLBACK = new TextViewOnReceiveContentCallback(); private static final int DEVICE_PROVISIONED_UNKNOWN = 0; private static final int DEVICE_PROVISIONED_NO = 1; private static final int DEVICE_PROVISIONED_YES = 2; Loading Loading @@ -13723,6 +13717,23 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener } } /** @hide */ @Override public void onInputConnectionOpenedInternal(@NonNull InputConnection ic, @NonNull EditorInfo editorInfo, @Nullable Handler handler) { if (mEditor != null) { mEditor.getDefaultOnReceiveContentCallback().setInputConnectionInfo(ic, editorInfo); } } /** @hide */ @Override public void onInputConnectionClosedInternal() { if (mEditor != null) { mEditor.getDefaultOnReceiveContentCallback().clearInputConnectionInfo(); } } /** * Returns the callback used for handling insertion of content into this view. See * {@link #setOnReceiveContentCallback} for more info. Loading Loading @@ -13773,8 +13784,8 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener ClipDescription description = payload.getClip().getDescription(); if (receiver != null && receiver.supports(this, description)) { receiver.onReceiveContent(this, payload); } else { DEFAULT_ON_RECEIVE_CONTENT_CALLBACK.onReceiveContent(this, payload); } else if (mEditor != null) { mEditor.getDefaultOnReceiveContentCallback().onReceiveContent(this, payload); } }
core/java/android/widget/TextViewOnReceiveContentCallback.java +290 −8 Original line number Diff line number Diff line Loading @@ -16,24 +16,44 @@ package android.widget; import static android.content.ContentResolver.SCHEME_CONTENT; import static android.view.OnReceiveContentCallback.Payload.FLAG_CONVERT_TO_PLAIN_TEXT; import static android.view.OnReceiveContentCallback.Payload.SOURCE_AUTOFILL; import static android.view.OnReceiveContentCallback.Payload.SOURCE_DRAG_AND_DROP; import static java.util.Collections.singleton; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.compat.Compatibility; import android.compat.annotation.ChangeId; import android.compat.annotation.EnabledAfter; import android.content.ClipData; import android.content.ClipDescription; import android.content.Context; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.Editable; import android.text.Selection; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.util.ArraySet; import android.util.Log; import android.view.OnReceiveContentCallback; import android.view.OnReceiveContentCallback.Payload.Flags; import android.view.OnReceiveContentCallback.Payload.Source; import android.view.View; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputContentInfo; import com.android.internal.annotations.VisibleForTesting; import java.util.Collections; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.Set; /** Loading @@ -46,14 +66,21 @@ import java.util.Set; public class TextViewOnReceiveContentCallback implements OnReceiveContentCallback<TextView> { private static final String LOG_TAG = "OnReceiveContent"; private static final Set<String> MIME_TYPES_ALL_TEXT = Collections.singleton("text/*"); private static final String MIME_TYPE_ALL_TEXT = "text/*"; private static final Set<String> MIME_TYPES_ALL_TEXT = singleton(MIME_TYPE_ALL_TEXT); @Nullable private InputConnectionInfo mInputConnectionInfo; @Nullable private ArraySet<String> mCachedSupportedMimeTypes; @SuppressLint("CallbackMethodName") @NonNull @Override public Set<String> getSupportedMimeTypes(@NonNull TextView view) { if (!isUsageOfImeCommitContentEnabled(view)) { return MIME_TYPES_ALL_TEXT; } return getSupportedMimeTypesAugmentedWithImeCommitContentMimeTypes(); } @Override public boolean onReceiveContent(@NonNull TextView view, @NonNull Payload payload) { Loading Loading @@ -109,13 +136,22 @@ public class TextViewOnReceiveContentCallback implements OnReceiveContentCallbac editable.replace(start, end, replacement); } private static boolean onReceiveForAutofill(@NonNull TextView textView, @NonNull ClipData clip, private boolean onReceiveForAutofill(@NonNull TextView view, @NonNull ClipData clip, @Flags int flags) { final CharSequence text = coerceToText(clip, textView.getContext(), flags); if (isUsageOfImeCommitContentEnabled(view)) { clip = handleNonTextViaImeCommitContent(clip); if (clip == null) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Handled via IME"); } return true; } } final CharSequence text = coerceToText(clip, view.getContext(), flags); // First autofill it... textView.setText(text); view.setText(text); // ...then move cursor to the end. final Editable editable = (Editable) textView.getText(); final Editable editable = (Editable) view.getText(); Selection.setSelection(editable, editable.length()); return true; } Loading Loading @@ -146,4 +182,250 @@ public class TextViewOnReceiveContentCallback implements OnReceiveContentCallbac } return ssb; } /** * On Android S and above, the platform can provide non-text suggestions (e.g. images) via the * augmented autofill framework (see * <a href="/guide/topics/text/autofill-services">autofill services</a>). In order for an app to * be able to handle these suggestions, it must normally implement the * {@link android.view.OnReceiveContentCallback} API. To make the adoption of this smoother for * apps that have previously implemented the * {@link android.view.inputmethod.InputConnection#commitContent(InputContentInfo, int, Bundle)} * API, we reuse that API as a fallback if {@link android.view.OnReceiveContentCallback} is not * yet implemented by the app. This fallback is only enabled on Android S. This change ID * disables the fallback, such that apps targeting Android T and above must implement the * {@link android.view.OnReceiveContentCallback} API in order to accept non-text suggestions. */ @ChangeId @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.S) // Enabled on Android T and higher private static final long AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_CALLBACK = 163400105L; /** * Returns true if we can use the IME {@link InputConnection#commitContent} API in order handle * non-text content. */ private static boolean isUsageOfImeCommitContentEnabled(@NonNull View view) { if (view.getOnReceiveContentCallback() != null) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "Fallback to commitContent disabled (custom callback is set)"); } return false; } if (Compatibility.isChangeEnabled(AUTOFILL_NON_TEXT_REQUIRES_ON_RECEIVE_CONTENT_CALLBACK)) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "Fallback to commitContent disabled (target SDK is above S)"); } return false; } return true; } private static final class InputConnectionInfo { @NonNull private final WeakReference<InputConnection> mInputConnection; @NonNull private final String[] mEditorInfoContentMimeTypes; private InputConnectionInfo(@NonNull InputConnection inputConnection, @NonNull String[] editorInfoContentMimeTypes) { mInputConnection = new WeakReference<>(inputConnection); mEditorInfoContentMimeTypes = editorInfoContentMimeTypes; } @Override public String toString() { return "InputConnectionInfo{" + "mimeTypes=" + Arrays.toString(mEditorInfoContentMimeTypes) + ", ic=" + mInputConnection + '}'; } } /** * Invoked by the platform when an {@link InputConnection} is successfully created for the view * that owns this callback instance. */ void setInputConnectionInfo(@NonNull InputConnection ic, @NonNull EditorInfo editorInfo) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "setInputConnectionInfo: " + Arrays.toString(editorInfo.contentMimeTypes)); } String[] contentMimeTypes = editorInfo.contentMimeTypes; if (contentMimeTypes == null || contentMimeTypes.length == 0) { mInputConnectionInfo = null; } else { mInputConnectionInfo = new InputConnectionInfo(ic, contentMimeTypes); } } /** * Invoked by the platform when an {@link InputConnection} is closed for the view that owns this * callback instance. */ void clearInputConnectionInfo() { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "clearInputConnectionInfo: " + mInputConnectionInfo); } mInputConnectionInfo = null; } private Set<String> getSupportedMimeTypesAugmentedWithImeCommitContentMimeTypes() { InputConnectionInfo icInfo = mInputConnectionInfo; if (icInfo == null) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "getSupportedMimeTypes: No usable EditorInfo/InputConnection"); } return MIME_TYPES_ALL_TEXT; } String[] editorInfoContentMimeTypes = icInfo.mEditorInfoContentMimeTypes; if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "getSupportedMimeTypes: Augmenting with EditorInfo.contentMimeTypes: " + Arrays.toString(editorInfoContentMimeTypes)); } ArraySet<String> supportedMimeTypes = mCachedSupportedMimeTypes; if (canReuse(supportedMimeTypes, editorInfoContentMimeTypes)) { return supportedMimeTypes; } supportedMimeTypes = new ArraySet<>(editorInfoContentMimeTypes); supportedMimeTypes.add(MIME_TYPE_ALL_TEXT); mCachedSupportedMimeTypes = supportedMimeTypes; return supportedMimeTypes; } /** * We want to avoid creating a new set on every invocation of {@link #getSupportedMimeTypes}. * This method will check if the cached set of MIME types matches the data in the given array * from {@link EditorInfo} or if a new set should be created. The custom logic is needed for * comparing the data because the set contains the additional "text/*" MIME type. * * @param cachedMimeTypes Previously cached set of MIME types. * @param newEditorInfoMimeTypes MIME types from {@link EditorInfo}. * * @return Returns true if the data in the given cached set matches the data in the array. * * @hide */ @VisibleForTesting public static boolean canReuse(@Nullable ArraySet<String> cachedMimeTypes, @NonNull String[] newEditorInfoMimeTypes) { if (cachedMimeTypes == null) { return false; } if (newEditorInfoMimeTypes.length != cachedMimeTypes.size() && newEditorInfoMimeTypes.length != (cachedMimeTypes.size() - 1)) { return false; } final boolean ignoreAllTextMimeType = newEditorInfoMimeTypes.length == (cachedMimeTypes.size() - 1); for (String mimeType : cachedMimeTypes) { if (ignoreAllTextMimeType && mimeType.equals(MIME_TYPE_ALL_TEXT)) { continue; } boolean present = false; for (String editorInfoContentMimeType : newEditorInfoMimeTypes) { if (editorInfoContentMimeType.equals(mimeType)) { present = true; break; } } if (!present) { return false; } } return true; } /** * Tries to insert the content in the clip into the app via the image keyboard API. If all the * items in the clip are successfully inserted, returns null. If one or more of the items in the * clip cannot be inserted, returns a non-null clip that contains the items that were not * inserted. */ @Nullable private ClipData handleNonTextViaImeCommitContent(@NonNull ClipData clip) { ClipDescription description = clip.getDescription(); if (!containsUri(clip) || containsOnlyText(clip)) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Clip doesn't contain any non-text URIs: " + description); } return clip; } InputConnectionInfo icInfo = mInputConnectionInfo; InputConnection inputConnection = (icInfo != null) ? icInfo.mInputConnection.get() : null; if (inputConnection == null) { if (Log.isLoggable(LOG_TAG, Log.DEBUG)) { Log.d(LOG_TAG, "onReceive: No usable EditorInfo/InputConnection"); } return clip; } String[] editorInfoContentMimeTypes = icInfo.mEditorInfoContentMimeTypes; if (!isClipMimeTypeSupported(editorInfoContentMimeTypes, clip.getDescription())) { if (Log.isLoggable(LOG_TAG, Log.DEBUG)) { Log.d(LOG_TAG, "onReceive: MIME type is not supported by the app's commitContent impl"); } return clip; } if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Trying to insert via IME: " + description); } ArrayList<ClipData.Item> remainingItems = new ArrayList<>(0); for (int i = 0; i < clip.getItemCount(); i++) { ClipData.Item item = clip.getItemAt(i); Uri uri = item.getUri(); if (uri == null || !SCHEME_CONTENT.equals(uri.getScheme())) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: No content URI in item: uri=" + uri); } remainingItems.add(item); continue; } if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Calling commitContent: uri=" + uri); } InputContentInfo contentInfo = new InputContentInfo(uri, description); if (!inputConnection.commitContent(contentInfo, 0, null)) { if (Log.isLoggable(LOG_TAG, Log.VERBOSE)) { Log.v(LOG_TAG, "onReceive: Call to commitContent returned false: uri=" + uri); } remainingItems.add(item); } } if (remainingItems.isEmpty()) { return null; } return new ClipData(description, remainingItems); } private static boolean isClipMimeTypeSupported(@NonNull String[] supportedMimeTypes, @NonNull ClipDescription description) { for (String imeSupportedMimeType : supportedMimeTypes) { if (description.hasMimeType(imeSupportedMimeType)) { return true; } } return false; } private static boolean containsUri(@NonNull ClipData clip) { for (int i = 0; i < clip.getItemCount(); i++) { ClipData.Item item = clip.getItemAt(i); if (item.getUri() != null) { return true; } } return false; } private static boolean containsOnlyText(@NonNull ClipData clip) { ClipDescription description = clip.getDescription(); for (int i = 0; i < description.getMimeTypeCount(); i++) { String mimeType = description.getMimeType(i); if (!mimeType.startsWith("text/")) { return false; } } return true; } }
core/tests/coretests/AndroidManifest.xml +11 −0 Original line number Diff line number Diff line Loading @@ -190,6 +190,17 @@ </intent-filter> </activity> <activity android:name="android.widget.CustomInputConnectionEditTextActivity" android:label="CustomInputConnectionEditTextActivity" android:screenOrientation="portrait" android:exported="true" android:theme="@android:style/Theme.Material.Light"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.FRAMEWORK_INSTRUMENTATION_TEST" /> </intent-filter> </activity> <activity android:name="android.widget.TextViewActivity" android:label="TextViewActivity" android:screenOrientation="portrait" Loading
core/tests/coretests/res/layout/activity_custom_input_connection_edit_text.xml 0 → 100644 +33 −0 Original line number Diff line number Diff line <?xml version="1.0" encoding="utf-8"?> <!-- ~ Copyright (C) 2020 The Android Open Source Project ~ ~ Licensed under the Apache License, Version 2.0 (the "License"); ~ you may not use this file except in compliance with the License. ~ You may obtain a copy of the License at ~ ~ http://www.apache.org/licenses/LICENSE-2.0 ~ ~ Unless required by applicable law or agreed to in writing, software ~ distributed under the License is distributed on an "AS IS" BASIS, ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ~ See the License for the specific language governing permissions and ~ limitations under the License. --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <android.widget.CustomInputConnectionEditText android:id="@+id/edittext1" android:layout_width="match_parent" android:layout_height="wrap_content" /> <android.widget.CustomInputConnectionEditText android:id="@+id/edittext2" android:layout_width="match_parent" android:layout_height="wrap_content" /> </LinearLayout>