diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java index c430a4d573383..4f05a599c1718 100644 --- a/core/java/android/view/View.java +++ b/core/java/android/view/View.java @@ -15164,6 +15164,42 @@ public class View implements Drawable.Callback, KeyEvent.Callback, return null; } + /** + * Called by the {@link android.view.inputmethod.InputMethodManager} to notify the application + * that the system has successfully initialized an {@link InputConnection} and it is ready for + * use. + * + *

The default implementation does nothing, since a view doesn't support input methods by + * default (see {@link #onCreateInputConnection}). + * + * @param inputConnection The {@link InputConnection} from {@link #onCreateInputConnection}, + * after it's been fully initialized by the system. + * @param editorInfo The {@link EditorInfo} that was used to create the {@link InputConnection}. + * @param handler The dedicated {@link Handler} on which IPC method calls from input methods + * will be dispatched. This is the handler returned by {@link InputConnection#getHandler()}. If + * that method returns null, this parameter will be null also. + * + * @hide + */ + public void onInputConnectionOpenedInternal(@NonNull InputConnection inputConnection, + @NonNull EditorInfo editorInfo, @Nullable Handler handler) {} + + /** + * Called by the {@link android.view.inputmethod.InputMethodManager} to notify the application + * that the {@link InputConnection} has been closed. + * + *

The default implementation does nothing, since a view doesn't support input methods by + * default (see {@link #onCreateInputConnection}). + * + *

Note: This callback is not invoked if the view is already detached when + * the {@link InputConnection} is closed or the connection is not valid and managed by + * {@link com.android.server.inputmethod.InputMethodManagerService}. + * TODO(b/170645312): Before un-hiding this API, handle the detached view scenario. + * + * @hide + */ + public void onInputConnectionClosedInternal() {} + /** * Called by the {@link android.view.inputmethod.InputMethodManager} * when a view who is not the current diff --git a/core/java/android/view/inputmethod/InputMethodManager.java b/core/java/android/view/inputmethod/InputMethodManager.java index b8f04159faa90..5785999e26726 100644 --- a/core/java/android/view/inputmethod/InputMethodManager.java +++ b/core/java/android/view/inputmethod/InputMethodManager.java @@ -1006,6 +1006,24 @@ public final class InputMethodManager { return; } closeConnection(); + + // Notify the app that the InputConnection was closed. + final View servedView = mServedView.get(); + if (servedView != null) { + final Handler handler = servedView.getHandler(); + // The handler is null if the view is already detached. When that's the case, for + // now, we simply don't dispatch this callback. + if (handler != null) { + if (DEBUG) { + Log.v(TAG, "Calling View.onInputConnectionClosed: view=" + servedView); + } + if (handler.getLooper().isCurrentThread()) { + servedView.onInputConnectionClosedInternal(); + } else { + handler.post(servedView::onInputConnectionClosedInternal); + } + } + } } @Override @@ -1940,6 +1958,8 @@ public final class InputMethodManager { InputConnection ic = view.onCreateInputConnection(tba); if (DEBUG) Log.v(TAG, "Starting input: tba=" + tba + " ic=" + ic); + final Handler icHandler; + InputBindResult res = null; synchronized (mH) { // Now that we are locked again, validate that our state hasn't // changed. @@ -1976,7 +1996,6 @@ public final class InputMethodManager { mCursorCandEnd = -1; mCursorRect.setEmpty(); mCursorAnchorInfo = null; - final Handler icHandler; missingMethodFlags = InputConnectionInspector.getMissingMethodFlags(ic); if ((missingMethodFlags & InputConnectionInspector.MissingMethodFlags.GET_HANDLER) != 0) { @@ -1990,6 +2009,7 @@ public final class InputMethodManager { } else { servedContext = null; missingMethodFlags = 0; + icHandler = null; } mServedInputConnectionWrapper = servedContext; @@ -1997,7 +2017,7 @@ public final class InputMethodManager { if (DEBUG) Log.v(TAG, "START INPUT: view=" + dumpViewInfo(view) + " ic=" + ic + " tba=" + tba + " startInputFlags=" + InputMethodDebug.startInputFlagsToString(startInputFlags)); - final InputBindResult res = mService.startInputOrWindowGainedFocus( + res = mService.startInputOrWindowGainedFocus( startInputReason, mClient, windowGainingFocus, startInputFlags, softInputMode, windowFlags, tba, servedContext, missingMethodFlags, view.getContext().getApplicationInfo().targetSdkVersion); @@ -2036,6 +2056,15 @@ public final class InputMethodManager { } } + // Notify the app that the InputConnection is initialized and ready for use. + if (ic != null && res != null && res.method != null) { + if (DEBUG) { + Log.v(TAG, "Calling View.onInputConnectionOpened: view= " + view + + ", ic=" + ic + ", tba=" + tba + ", handler=" + icHandler); + } + view.onInputConnectionOpenedInternal(ic, tba, icHandler); + } + return true; } diff --git a/core/tests/coretests/AndroidManifest.xml b/core/tests/coretests/AndroidManifest.xml index 56f18d5dc1d75..e28a28587854f 100644 --- a/core/tests/coretests/AndroidManifest.xml +++ b/core/tests/coretests/AndroidManifest.xml @@ -161,6 +161,14 @@ + + + + + + diff --git a/core/tests/coretests/res/layout/activity_view_ic_test.xml b/core/tests/coretests/res/layout/activity_view_ic_test.xml new file mode 100644 index 0000000000000..fa26869c59974 --- /dev/null +++ b/core/tests/coretests/res/layout/activity_view_ic_test.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/core/tests/coretests/src/android/view/ViewInputConnectionTest.java b/core/tests/coretests/src/android/view/ViewInputConnectionTest.java new file mode 100644 index 0000000000000..d667af39f5857 --- /dev/null +++ b/core/tests/coretests/src/android/view/ViewInputConnectionTest.java @@ -0,0 +1,292 @@ +/* + * 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. + */ + +package android.view; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertTrue; + +import android.app.Instrumentation; +import android.content.Context; +import android.os.Handler; +import android.text.format.DateUtils; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.MediumTest; +import androidx.test.rule.ActivityTestRule; + +import com.android.compatibility.common.util.PollingCheck; +import com.android.frameworks.coretests.R; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tests for internal APIs/behaviors of {@link View} and {@link InputConnection}. + */ +@MediumTest +@RunWith(AndroidJUnit4.class) +public class ViewInputConnectionTest { + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(ViewInputConnectionTestActivity.class); + + private Instrumentation mInstrumentation; + private ViewInputConnectionTestActivity mActivity; + private InputMethodManager mImm; + + @Before + public void before() { + mInstrumentation = InstrumentationRegistry.getInstrumentation(); + mActivity = mActivityRule.getActivity(); + PollingCheck.waitFor(5 * DateUtils.SECOND_IN_MILLIS, mActivity::hasWindowFocus); + assertTrue(mActivity.hasWindowFocus()); + mImm = mActivity.getSystemService(InputMethodManager.class); + } + + @Test + public void testInputConnectionCallbacks() throws Throwable { + // Add two EditText inputs to the layout view. + final ViewGroup viewGroup = mActivity.findViewById(R.id.root); + final TestEditText editText1 = new TestEditText(mActivity, false); + final TestEditText editText2 = new TestEditText(mActivity, false); + mActivityRule.runOnUiThread(() -> { + viewGroup.addView(editText1); + viewGroup.addView(editText2); + }); + mInstrumentation.waitForIdleSync(); + + // Focus into the first EditText. + mActivityRule.runOnUiThread(editText1::requestFocus); + mInstrumentation.waitForIdleSync(); + assertThat(editText1.isFocused()).isTrue(); + assertThat(editText2.isFocused()).isFalse(); + + // Show the IME for the first EditText. Assert that the appropriate opened/closed callbacks + // have been invoked (InputConnection opened for the first EditText). + mActivityRule.runOnUiThread(() -> mImm.showSoftInput(editText1, 0)); + mInstrumentation.waitForIdleSync(); + mActivityRule.runOnUiThread(() -> { + assertThat(editText1.mCalledOnCreateInputConnection).isTrue(); + assertThat(editText1.mCalledOnInputConnectionOpened).isTrue(); + assertThat(editText1.mCalledOnInputConnectionClosed).isFalse(); + + assertThat(editText2.mCalledOnCreateInputConnection).isFalse(); + assertThat(editText2.mCalledOnInputConnectionOpened).isFalse(); + assertThat(editText2.mCalledOnInputConnectionClosed).isFalse(); + }); + + // Focus into the second EditText. + mActivityRule.runOnUiThread(editText2::requestFocus); + mInstrumentation.waitForIdleSync(); + assertThat(editText1.isFocused()).isFalse(); + assertThat(editText2.isFocused()).isTrue(); + + // Show the IME for the second EditText. Assert that the appropriate opened/closed callbacks + // have been invoked (InputConnection closed for the first EditText and opened for the + // second EditText). + mActivityRule.runOnUiThread(() -> mImm.showSoftInput(editText2, 0)); + mInstrumentation.waitForIdleSync(); + mActivityRule.runOnUiThread(() -> { + assertThat(editText1.mCalledOnCreateInputConnection).isTrue(); + assertThat(editText1.mCalledOnInputConnectionOpened).isTrue(); + assertThat(editText1.mCalledOnInputConnectionClosed).isTrue(); + + assertThat(editText2.mCalledOnCreateInputConnection).isTrue(); + assertThat(editText2.mCalledOnInputConnectionOpened).isTrue(); + assertThat(editText2.mCalledOnInputConnectionClosed).isFalse(); + }); + } + + @Test + public void testInputConnectionCallbacks_nullInputConnection() throws Throwable { + // Add two EditText inputs to the layout view. + final ViewGroup viewGroup = mActivity.findViewById(R.id.root); + final TestEditText editText1 = new TestEditText(mActivity, true); + final TestEditText editText2 = new TestEditText(mActivity, true); + mActivityRule.runOnUiThread(() -> { + viewGroup.addView(editText1); + viewGroup.addView(editText2); + }); + mInstrumentation.waitForIdleSync(); + + // Focus into the first EditText. + mActivityRule.runOnUiThread(editText1::requestFocus); + mInstrumentation.waitForIdleSync(); + assertThat(editText1.isFocused()).isTrue(); + assertThat(editText2.isFocused()).isFalse(); + + // Show the IME for the first EditText. Assert that the opened/closed callbacks are not + // invoked since there's no input connection. + mActivityRule.runOnUiThread(() -> mImm.showSoftInput(editText1, 0)); + mInstrumentation.waitForIdleSync(); + mActivityRule.runOnUiThread(() -> { + assertThat(editText1.mCalledOnCreateInputConnection).isTrue(); + assertThat(editText1.mCalledOnInputConnectionOpened).isFalse(); + assertThat(editText1.mCalledOnInputConnectionClosed).isFalse(); + + assertThat(editText2.mCalledOnCreateInputConnection).isFalse(); + assertThat(editText2.mCalledOnInputConnectionOpened).isFalse(); + assertThat(editText2.mCalledOnInputConnectionClosed).isFalse(); + }); + + // Focus into the second EditText. + mActivityRule.runOnUiThread(editText2::requestFocus); + mInstrumentation.waitForIdleSync(); + assertThat(editText1.isFocused()).isFalse(); + assertThat(editText2.isFocused()).isTrue(); + + // Show the IME for the second EditText. Assert that the opened/closed callbacks are not + // invoked since there's no input connection. + mActivityRule.runOnUiThread(() -> mImm.showSoftInput(editText2, 0)); + mInstrumentation.waitForIdleSync(); + mActivityRule.runOnUiThread(() -> { + assertThat(editText1.mCalledOnCreateInputConnection).isTrue(); + assertThat(editText1.mCalledOnInputConnectionOpened).isFalse(); + assertThat(editText1.mCalledOnInputConnectionClosed).isFalse(); + + assertThat(editText2.mCalledOnCreateInputConnection).isTrue(); + assertThat(editText2.mCalledOnInputConnectionOpened).isFalse(); + assertThat(editText2.mCalledOnInputConnectionClosed).isFalse(); + }); + } + + @Test + public void testInputConnectionCallbacks_nonEditableInput() throws Throwable { + final ViewGroup viewGroup = mActivity.findViewById(R.id.root); + final TestButton view1 = new TestButton(mActivity); + final TestButton view2 = new TestButton(mActivity); + mActivityRule.runOnUiThread(() -> { + viewGroup.addView(view1); + viewGroup.addView(view2); + }); + mInstrumentation.waitForIdleSync(); + + // Request focus + IME on the first view. + mActivityRule.runOnUiThread(view1::requestFocus); + mInstrumentation.waitForIdleSync(); + assertThat(view1.isFocused()).isTrue(); + assertThat(view2.isFocused()).isFalse(); + mActivityRule.runOnUiThread(() -> mImm.showSoftInput(view1, 0)); + mInstrumentation.waitForIdleSync(); + + // Assert that the opened/closed callbacks are not invoked since there's no InputConnection. + mActivityRule.runOnUiThread(() -> { + assertThat(view1.mCalledOnCreateInputConnection).isTrue(); + assertThat(view1.mCalledOnInputConnectionOpened).isFalse(); + assertThat(view1.mCalledOnInputConnectionClosed).isFalse(); + + assertThat(view2.mCalledOnCreateInputConnection).isFalse(); + assertThat(view2.mCalledOnInputConnectionOpened).isFalse(); + assertThat(view2.mCalledOnInputConnectionClosed).isFalse(); + }); + + // Request focus + IME on the second view. + mActivityRule.runOnUiThread(view2::requestFocus); + mInstrumentation.waitForIdleSync(); + assertThat(view1.isFocused()).isFalse(); + assertThat(view2.isFocused()).isTrue(); + mActivityRule.runOnUiThread(() -> mImm.showSoftInput(view1, 0)); + mInstrumentation.waitForIdleSync(); + + // Assert that the opened/closed callbacks are not invoked since there's no InputConnection. + mActivityRule.runOnUiThread(() -> { + assertThat(view1.mCalledOnCreateInputConnection).isTrue(); + assertThat(view1.mCalledOnInputConnectionOpened).isFalse(); + assertThat(view1.mCalledOnInputConnectionClosed).isFalse(); + + assertThat(view2.mCalledOnCreateInputConnection).isTrue(); + assertThat(view2.mCalledOnInputConnectionOpened).isFalse(); + assertThat(view2.mCalledOnInputConnectionClosed).isFalse(); + }); + } + + private static class TestEditText extends EditText { + private final boolean mReturnNullInputConnection; + + public boolean mCalledOnCreateInputConnection = false; + public boolean mCalledOnInputConnectionOpened = false; + public boolean mCalledOnInputConnectionClosed = false; + + TestEditText(Context context, boolean returnNullInputConnection) { + super(context); + mReturnNullInputConnection = returnNullInputConnection; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + mCalledOnCreateInputConnection = true; + if (mReturnNullInputConnection) { + return null; + } else { + return super.onCreateInputConnection(outAttrs); + } + } + + @Override + public void onInputConnectionOpenedInternal(@NonNull InputConnection inputConnection, + @NonNull EditorInfo editorInfo, @Nullable Handler handler) { + mCalledOnInputConnectionOpened = true; + super.onInputConnectionOpenedInternal(inputConnection, editorInfo, handler); + } + + @Override + public void onInputConnectionClosedInternal() { + mCalledOnInputConnectionClosed = true; + super.onInputConnectionClosedInternal(); + } + } + + private static class TestButton extends Button { + public boolean mCalledOnCreateInputConnection = false; + public boolean mCalledOnInputConnectionOpened = false; + public boolean mCalledOnInputConnectionClosed = false; + + TestButton(Context context) { + super(context); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + mCalledOnCreateInputConnection = true; + return super.onCreateInputConnection(outAttrs); + } + + @Override + public void onInputConnectionOpenedInternal(@NonNull InputConnection inputConnection, + @NonNull EditorInfo editorInfo, @Nullable Handler handler) { + mCalledOnInputConnectionOpened = true; + super.onInputConnectionOpenedInternal(inputConnection, editorInfo, handler); + } + + @Override + public void onInputConnectionClosedInternal() { + mCalledOnInputConnectionClosed = true; + super.onInputConnectionClosedInternal(); + } + } +} diff --git a/core/tests/coretests/src/android/view/ViewInputConnectionTestActivity.java b/core/tests/coretests/src/android/view/ViewInputConnectionTestActivity.java new file mode 100644 index 0000000000000..55c812df6a6ae --- /dev/null +++ b/core/tests/coretests/src/android/view/ViewInputConnectionTestActivity.java @@ -0,0 +1,31 @@ +/* + * 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. + */ + +package android.view; + +import android.app.Activity; +import android.os.Bundle; + +import com.android.frameworks.coretests.R; + +public class ViewInputConnectionTestActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_view_ic_test); + } +}