Ensure View#onInputConnectionClosedInternal() timing

This is a follow up CL to our previous CLs [1][2], which introduced
an @hide callback

  View#onInputConnectionClosedInternal()

to notify View when an is closed.

What this CL aims to do is to fix a potential problem in a code path
that has not been yet used.  Thus there should be, in theory, no
observable app compat impact.

The problem is that

  RemoteInputConnectionImpl#deactivate()

can dispatch

  A: View#onInputConnectionClosedInternal()

before

  B: InputConnection#closeConnection()

is completed when A and B need to be dispatched to two different
threads.  This can, in theory, happen when

  A: View#onInputConnectionClosedInternal()
  C: InputConnection#getHandler()

are both explicitly overridden.  That said, A is still @hide and only
by TextView, which basically does not support InputConnection with a
custom InputConnection#getHandler().

Anyway, with this CL A is guaranteed to happen after B under any
circumstances.

 [1]: Iaafb0a03126c9292c24415f866dbdd72cadfa239
      7b384751ea2a19beb7a635c52545742b2171ae8d
 [2]: I9280604e7ec7e8d08c1179e6bbf0068647a41040
      7b384751ea2a19beb7a635c52545742b2171ae8d

Bug: 163400105
Test: atest FrameworksCoreTests:ViewInputConnectionTest
Change-Id: I8a0e321ecf6e0b3be4b6ab1a35e6ac7259826c2e
diff --git a/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java b/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java
index 2d44054..0c27012 100644
--- a/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java
+++ b/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java
@@ -121,25 +121,54 @@
             // reportFinish() will take effect.
             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);
+        dispatch(() -> {
+            // Note that we do not need to worry about race condition here, because 1) mFinished is
+            // updated only inside this block, and 2) the code here is running on a Handler hence we
+            // assume multiple closeConnection() tasks will not be handled at the same time.
+            if (isFinished()) {
+                return;
+            }
+            Trace.traceBegin(Trace.TRACE_TAG_INPUT, "InputConnection#closeConnection");
+            try {
+                InputConnection ic = getInputConnection();
+                // Note we do NOT check isActive() here, because this is safe
+                // for an IME to call at any time, and we need to allow it
+                // through to clean up our state after the IME has switched to
+                // another client.
+                if (ic == null) {
+                    return;
                 }
-                if (handler.getLooper().isCurrentThread()) {
-                    servedView.onInputConnectionClosedInternal();
-                } else {
-                    handler.post(servedView::onInputConnectionClosedInternal);
+                @MissingMethodFlags
+                final int missingMethods = InputConnectionInspector.getMissingMethodFlags(ic);
+                if ((missingMethods & MissingMethodFlags.CLOSE_CONNECTION) == 0) {
+                    ic.closeConnection();
+                }
+            } finally {
+                synchronized (mLock) {
+                    mInputConnection = null;
+                    mFinished = true;
+                }
+                Trace.traceEnd(Trace.TRACE_TAG_INPUT);
+            }
+
+            // 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
@@ -705,39 +734,6 @@
         });
     }
 
-    private void closeConnection() {
-        dispatch(() -> {
-            // Note that we do not need to worry about race condition here, because 1) mFinished is
-            // updated only inside this block, and 2) the code here is running on a Handler hence we
-            // assume multiple closeConnection() tasks will not be handled at the same time.
-            if (isFinished()) {
-                return;
-            }
-            Trace.traceBegin(Trace.TRACE_TAG_INPUT, "InputConnection#closeConnection");
-            try {
-                InputConnection ic = getInputConnection();
-                // Note we do NOT check isActive() here, because this is safe
-                // for an IME to call at any time, and we need to allow it
-                // through to clean up our state after the IME has switched to
-                // another client.
-                if (ic == null) {
-                    return;
-                }
-                @MissingMethodFlags
-                final int missingMethods = InputConnectionInspector.getMissingMethodFlags(ic);
-                if ((missingMethods & MissingMethodFlags.CLOSE_CONNECTION) == 0) {
-                    ic.closeConnection();
-                }
-            } finally {
-                synchronized (mLock) {
-                    mInputConnection = null;
-                    mFinished = true;
-                }
-                Trace.traceEnd(Trace.TRACE_TAG_INPUT);
-            }
-        });
-    }
-
     @Override
     public void commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts,
             IBooleanResultCallback callback) {
diff --git a/core/tests/coretests/src/android/view/ViewInputConnectionTest.java b/core/tests/coretests/src/android/view/ViewInputConnectionTest.java
index d667af3..d60c0c6 100644
--- a/core/tests/coretests/src/android/view/ViewInputConnectionTest.java
+++ b/core/tests/coretests/src/android/view/ViewInputConnectionTest.java
@@ -19,13 +19,25 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
+import android.annotation.DurationMillisLong;
 import android.app.Instrumentation;
 import android.content.Context;
+import android.graphics.Color;
+import android.os.Bundle;
 import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Process;
+import android.text.InputType;
 import android.text.format.DateUtils;
+import android.view.inputmethod.CompletionInfo;
+import android.view.inputmethod.CorrectionInfo;
 import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
 import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputContentInfo;
 import android.view.inputmethod.InputMethodManager;
 import android.widget.Button;
 import android.widget.EditText;
@@ -45,12 +57,23 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
 /**
  * Tests for internal APIs/behaviors of {@link View} and {@link InputConnection}.
  */
 @MediumTest
 @RunWith(AndroidJUnit4.class)
 public class ViewInputConnectionTest {
+    @DurationMillisLong
+    private static final long TIMEOUT = 5000;
+    @DurationMillisLong
+    private static final long EXPECTED_TIMEOUT = 500;
+
     @Rule
     public ActivityTestRule<ViewInputConnectionTestActivity> mActivityRule =
             new ActivityTestRule<>(ViewInputConnectionTestActivity.class);
@@ -289,4 +312,282 @@
             super.onInputConnectionClosedInternal();
         }
     }
+
+
+    @Test
+    public void testInputConnectionCallbacks_nonUiThread() throws Throwable {
+        try (InputConnectionHandlingThread thread = new InputConnectionHandlingThread()) {
+            final ViewGroup viewGroup = getOnMainSync(() -> mActivity.findViewById(R.id.root));
+            final TestOffThreadEditor editor = getOnMainSync(() -> {
+                final TestOffThreadEditor myEditor =
+                        new TestOffThreadEditor(viewGroup.getContext(), thread.getHandler());
+                viewGroup.addView(myEditor);
+                myEditor.requestFocus();
+                return myEditor;
+            });
+
+            mInstrumentation.waitForIdleSync();
+
+            assertThat(editor.mOnCreateInputConnectionCalled.await(TIMEOUT, TimeUnit.MILLISECONDS))
+                    .isTrue();
+
+            // Invalidate the currently used InputConnection by moving the focus to a new EditText.
+            mActivityRule.runOnUiThread(() -> {
+                final EditText editText = new EditText(viewGroup.getContext());
+                viewGroup.addView(editText);
+                editText.requestFocus();
+            });
+
+            // Make sure that InputConnection#closeConnection() gets called on the handler thread.
+            assertThat(editor.mInputConnectionClosedCalled.await(TIMEOUT, TimeUnit.MILLISECONDS))
+                    .isTrue();
+            assertThat(editor.mInputConnectionClosedCallingThreadId.get())
+                    .isEqualTo(thread.getThreadId());
+
+            // Make sure that View#onInputConnectionClosed() is not yet dispatched, because
+            // InputConnection#closeConnection() is still blocked.
+            assertThat(editor.mOnInputConnectionClosedCalled.await(
+                    EXPECTED_TIMEOUT, TimeUnit.MILLISECONDS)).isFalse();
+
+            // Unblock InputConnection#closeConnection()
+            editor.mInputConnectionClosedBlocker.countDown();
+
+            // Make sure that View#onInputConnectionClosed() is dispatched on the main thread.
+            assertThat(editor.mOnInputConnectionClosedCalled.await(TIMEOUT, TimeUnit.MILLISECONDS))
+                    .isTrue();
+            assertThat(editor.mInputConnectionClosedBlockerTimedOut.get()).isFalse();
+            assertThat(editor.mOnInputConnectionClosedCallingThreadId.get())
+                    .isEqualTo(getOnMainSync(Process::myTid));
+        }
+    }
+
+    private <T> T getOnMainSync(@NonNull Supplier<T> supplier) throws Throwable {
+        final AtomicReference<T> result = new AtomicReference<>();
+        mActivityRule.runOnUiThread(() -> result.set(supplier.get()));
+        return result.get();
+    }
+
+    private static class TestOffThreadEditor extends View {
+        private static final int TEST_VIEW_HEIGHT = 10;
+
+        public CountDownLatch mOnCreateInputConnectionCalled = new CountDownLatch(1);
+        public CountDownLatch mInputConnectionClosedCalled = new CountDownLatch(1);
+        public CountDownLatch mInputConnectionClosedBlocker = new CountDownLatch(1);
+        public AtomicBoolean mInputConnectionClosedBlockerTimedOut = new AtomicBoolean();
+        public AtomicReference<Integer> mInputConnectionClosedCallingThreadId =
+                new AtomicReference<>();
+
+        public CountDownLatch mOnInputConnectionClosedCalled = new CountDownLatch(1);
+        public AtomicReference<Integer> mOnInputConnectionClosedCallingThreadId =
+                new AtomicReference<>();
+
+        private final Handler mInputConnectionHandler;
+
+        TestOffThreadEditor(Context context, @NonNull Handler inputConnectionHandler) {
+            super(context);
+            setBackgroundColor(Color.YELLOW);
+            setLayoutParams(new ViewGroup.LayoutParams(
+                    ViewGroup.LayoutParams.MATCH_PARENT, TEST_VIEW_HEIGHT));
+            setFocusableInTouchMode(true);
+            setFocusable(true);
+            mInputConnectionHandler = inputConnectionHandler;
+        }
+
+        @Override
+        public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+            mOnCreateInputConnectionCalled.countDown();
+            outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
+            return new NoOpInputConnection() {
+                @Override
+                public Handler getHandler() {
+                    return mInputConnectionHandler;
+                }
+
+                @Override
+                public void closeConnection() {
+                    mInputConnectionClosedCallingThreadId.compareAndSet(null, Process.myTid());
+                    mInputConnectionClosedCalled.countDown();
+                    try {
+                        if (mInputConnectionClosedBlocker.await(TIMEOUT, TimeUnit.MILLISECONDS)) {
+                            return;
+                        }
+                    } catch (InterruptedException e) {
+                    }
+                    mInputConnectionClosedBlockerTimedOut.set(true);
+                }
+            };
+        }
+
+        @Override
+        public boolean onCheckIsTextEditor() {
+            return true;
+        }
+
+        @Override
+        public void onInputConnectionClosedInternal() {
+            mOnInputConnectionClosedCallingThreadId.compareAndSet(null, Process.myTid());
+            mOnInputConnectionClosedCalled.countDown();
+            super.onInputConnectionClosedInternal();
+        }
+    }
+
+    static class NoOpInputConnection implements InputConnection {
+
+        @Override
+        public CharSequence getTextBeforeCursor(int n, int flags) {
+            return null;
+        }
+
+        @Override
+        public CharSequence getTextAfterCursor(int n, int flags) {
+            return null;
+        }
+
+        @Override
+        public CharSequence getSelectedText(int flags) {
+            return null;
+        }
+
+        @Override
+        public int getCursorCapsMode(int reqModes) {
+            return 0;
+        }
+
+        @Override
+        public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
+            return null;
+        }
+
+        @Override
+        public boolean deleteSurroundingText(int beforeLength, int afterLength) {
+            return false;
+        }
+
+        @Override
+        public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
+            return false;
+        }
+
+        @Override
+        public boolean setComposingText(CharSequence text, int newCursorPosition) {
+            return false;
+        }
+
+        @Override
+        public boolean setComposingRegion(int start, int end) {
+            return false;
+        }
+
+        @Override
+        public boolean finishComposingText() {
+            return false;
+        }
+
+        @Override
+        public boolean commitText(CharSequence text, int newCursorPosition) {
+            return false;
+        }
+
+        @Override
+        public boolean commitCompletion(CompletionInfo text) {
+            return false;
+        }
+
+        @Override
+        public boolean commitCorrection(CorrectionInfo correctionInfo) {
+            return false;
+        }
+
+        @Override
+        public boolean setSelection(int start, int end) {
+            return false;
+        }
+
+        @Override
+        public boolean performEditorAction(int editorAction) {
+            return false;
+        }
+
+        @Override
+        public boolean performContextMenuAction(int id) {
+            return false;
+        }
+
+        @Override
+        public boolean beginBatchEdit() {
+            return false;
+        }
+
+        @Override
+        public boolean endBatchEdit() {
+            return false;
+        }
+
+        @Override
+        public boolean sendKeyEvent(KeyEvent event) {
+            return false;
+        }
+
+        @Override
+        public boolean clearMetaKeyStates(int states) {
+            return false;
+        }
+
+        @Override
+        public boolean reportFullscreenMode(boolean enabled) {
+            return false;
+        }
+
+        @Override
+        public boolean performPrivateCommand(String action, Bundle data) {
+            return false;
+        }
+
+        @Override
+        public boolean requestCursorUpdates(int cursorUpdateMode) {
+            return false;
+        }
+
+        @Override
+        public Handler getHandler() {
+            return null;
+        }
+
+        @Override
+        public void closeConnection() {
+
+        }
+
+        @Override
+        public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
+            return false;
+        }
+    }
+
+    private static final class InputConnectionHandlingThread extends HandlerThread
+            implements AutoCloseable {
+
+        private final Handler mHandler;
+
+        InputConnectionHandlingThread() {
+            super("IC-callback");
+            start();
+            mHandler = Handler.createAsync(getLooper());
+        }
+
+        @NonNull
+        Handler getHandler() {
+            return mHandler;
+        }
+
+        @Override
+        public void close() {
+            quitSafely();
+            try {
+                join(TIMEOUT);
+            } catch (InterruptedException e) {
+                fail("Failed to stop the thread: " + e);
+            }
+        }
+    }
 }