Merge "Don't call IMMS on focusing across non-edit views"
diff --git a/core/java/android/view/inputmethod/EditorInfo.java b/core/java/android/view/inputmethod/EditorInfo.java
index 9492375..fdff7a3 100644
--- a/core/java/android/view/inputmethod/EditorInfo.java
+++ b/core/java/android/view/inputmethod/EditorInfo.java
@@ -1099,4 +1099,41 @@
     public int describeContents() {
         return 0;
     }
+
+    /**
+     * Performs a loose equality check, which means there can be false negatives, but if the method
+     * returns {@code true}, then both objects are guaranteed to be equal.
+     * <ul>
+     *     <li>{@link #extras} is compared with {@link Bundle#kindofEquals}</li>
+     *     <li>{@link #actionLabel}, {@link #hintText}, and {@link #label} are compared with
+     *     {@link TextUtils#equals}, which does not account for Spans. </li>
+     * </ul>
+     * @hide
+     */
+    public boolean kindofEquals(@Nullable EditorInfo that) {
+        if (that == null) return false;
+        if (this == that) return true;
+        return inputType == that.inputType
+                && imeOptions == that.imeOptions
+                && internalImeOptions == that.internalImeOptions
+                && actionId == that.actionId
+                && initialSelStart == that.initialSelStart
+                && initialSelEnd == that.initialSelEnd
+                && initialCapsMode == that.initialCapsMode
+                && fieldId == that.fieldId
+                && Objects.equals(autofillId, that.autofillId)
+                && Objects.equals(privateImeOptions, that.privateImeOptions)
+                && Objects.equals(packageName, that.packageName)
+                && Objects.equals(fieldName, that.fieldName)
+                && Objects.equals(hintLocales, that.hintLocales)
+                && Objects.equals(targetInputMethodUser, that.targetInputMethodUser)
+                && Arrays.equals(contentMimeTypes, that.contentMimeTypes)
+                && TextUtils.equals(actionLabel, that.actionLabel)
+                && TextUtils.equals(hintText, that.hintText)
+                && TextUtils.equals(label, that.label)
+                && (extras == that.extras || (extras != null && extras.kindofEquals(that.extras)))
+                && (mInitialSurroundingText == that.mInitialSurroundingText
+                    || (mInitialSurroundingText != null
+                    && mInitialSurroundingText.isEqualTo(that.mInitialSurroundingText)));
+    }
 }
diff --git a/core/java/android/view/inputmethod/InputMethodManager.java b/core/java/android/view/inputmethod/InputMethodManager.java
index adeed25..aacd4ec 100644
--- a/core/java/android/view/inputmethod/InputMethodManager.java
+++ b/core/java/android/view/inputmethod/InputMethodManager.java
@@ -70,6 +70,7 @@
 import android.os.ResultReceiver;
 import android.os.ServiceManager;
 import android.os.ServiceManager.ServiceNotFoundException;
+import android.os.SystemProperties;
 import android.os.Trace;
 import android.os.UserHandle;
 import android.provider.Settings;
@@ -398,6 +399,18 @@
     public static final long CLEAR_SHOW_FORCED_FLAG_WHEN_LEAVING = 214016041L; // This is a bug id.
 
     /**
+     * If {@code true}, avoid calling the
+     * {@link com.android.server.inputmethod.InputMethodManagerService InputMethodManagerService}
+     * by skipping the call to {@link IInputMethodManager#startInputOrWindowGainedFocus}
+     * when we are switching focus between two non-editable views. This saves the cost of a binder
+     * call into the system server.
+     * <p><b>Note:</b>
+     * The default value is {@code true}.
+     */
+    private static final boolean OPTIMIZE_NONEDITABLE_VIEWS =
+            SystemProperties.getBoolean("debug.imm.optimize_noneditable_views", true);
+
+    /**
      * @deprecated Use {@link #mServiceInvoker} instead.
      */
     @Deprecated
@@ -646,6 +659,26 @@
 
     private final class DelegateImpl implements
             ImeFocusController.InputMethodManagerDelegate {
+        @GuardedBy("mH")
+        @Nullable
+        private ViewFocusParameterInfo mPreviousViewFocusParameters;
+
+        @GuardedBy("mH")
+        private void updatePreviousViewFocusParametersLocked(
+                @Nullable EditorInfo currentEditorInfo,
+                @StartInputFlags int startInputFlags,
+                @StartInputReason int startInputReason,
+                @SoftInputModeFlags int softInputMode,
+                int windowFlags) {
+            mPreviousViewFocusParameters = new ViewFocusParameterInfo(currentEditorInfo,
+                    startInputFlags, startInputReason, softInputMode, windowFlags);
+        }
+
+        @GuardedBy("mH")
+        private void clearStateLocked() {
+            mPreviousViewFocusParameters = null;
+        }
+
         /**
          * Used by {@link ImeFocusController} to start input connection.
          */
@@ -1692,8 +1725,10 @@
      * Reset all of the state associated with a served view being connected
      * to an input method
      */
+    @GuardedBy("mH")
     private void clearConnectionLocked() {
         mCurrentEditorInfo = null;
+        mDelegate.clearStateLocked();
         if (mServedInputConnection != null) {
             mServedInputConnection.deactivate();
             mServedInputConnection = null;
@@ -2344,6 +2379,9 @@
 
             // Hook 'em up and let 'er rip.
             mCurrentEditorInfo = tba.createCopyInternal();
+            // Store the previously served connection so that we can determine whether it is safe
+            // to skip the call to startInputOrWindowGainedFocus in the IMMS
+            final RemoteInputConnectionImpl previouslyServedConnection = mServedInputConnection;
 
             mServedConnecting = false;
             if (mServedInputConnection != null) {
@@ -2383,6 +2421,22 @@
                         + ic + " tba=" + tba + " startInputFlags="
                         + InputMethodDebug.startInputFlagsToString(startInputFlags));
             }
+
+            // When we switch between non-editable views, do not call into the IMMS.
+            final boolean canSkip = OPTIMIZE_NONEDITABLE_VIEWS
+                    && previouslyServedConnection == null
+                    && ic == null
+                    && isSwitchingBetweenEquivalentNonEditableViews(
+                            mDelegate.mPreviousViewFocusParameters, startInputFlags,
+                            startInputReason, softInputMode, windowFlags);
+            updatePreviousViewFocusParametersLocked(mCurrentEditorInfo, startInputFlags,
+                    startInputReason, softInputMode, windowFlags);
+            if (canSkip) {
+                if (DEBUG) {
+                    Log.d(TAG, "Not calling IMMS due to switching between non-editable views.");
+                }
+                return false;
+            }
             res = mServiceInvoker.startInputOrWindowGainedFocus(
                     startInputReason, mClient, windowGainingFocus, startInputFlags,
                     softInputMode, windowFlags, tba, servedInputConnection,
@@ -2445,6 +2499,47 @@
         return true;
     }
 
+    /**
+     * This method exists only so that the
+     * <a href="https://errorprone.info/bugpattern/GuardedBy">errorprone</a> false positive warning
+     * can be suppressed without granting a blanket exception to the {@link #startInputInner}
+     * method.
+     * <p>
+     * The warning in question implies that the access to the
+     * {@link DelegateImpl#updatePreviousViewFocusParametersLocked} method should be guarded by
+     * {@code InputMethodManager.this.mH}, but instead {@code mDelegate.mH} is held in the caller.
+     * In this case errorprone fails to realize that it is the same object.
+     */
+    @GuardedBy("mH")
+    @SuppressWarnings("GuardedBy")
+    private void updatePreviousViewFocusParametersLocked(
+            @Nullable EditorInfo currentEditorInfo,
+            @StartInputFlags int startInputFlags,
+            @StartInputReason int startInputReason,
+            @SoftInputModeFlags int softInputMode,
+            int windowFlags) {
+        mDelegate.updatePreviousViewFocusParametersLocked(currentEditorInfo, startInputFlags,
+                startInputReason, softInputMode, windowFlags);
+    }
+
+    /**
+     * @return {@code true} when we are switching focus between two non-editable views
+     * so that we can avoid calling {@link IInputMethodManager#startInputOrWindowGainedFocus}.
+     */
+    @GuardedBy("mH")
+    private boolean isSwitchingBetweenEquivalentNonEditableViews(
+            @Nullable ViewFocusParameterInfo previousViewFocusParameters,
+            @StartInputFlags int startInputFlags,
+            @StartInputReason int startInputReason,
+            @SoftInputModeFlags int softInputMode,
+            int windowFlags) {
+        return (startInputFlags & StartInputFlags.WINDOW_GAINED_FOCUS) == 0
+                && (startInputFlags & StartInputFlags.IS_TEXT_EDITOR) == 0
+                && previousViewFocusParameters != null
+                && previousViewFocusParameters.sameAs(mCurrentEditorInfo,
+                    startInputFlags, startInputReason, softInputMode, windowFlags);
+    }
+
     private void reportInputConnectionOpened(
             InputConnection ic, EditorInfo tba, Handler icHandler, View view) {
         view.onInputConnectionOpenedInternal(ic, tba, icHandler);
diff --git a/core/java/android/view/inputmethod/SurroundingText.java b/core/java/android/view/inputmethod/SurroundingText.java
index c85a18a1..6bfd63b 100644
--- a/core/java/android/view/inputmethod/SurroundingText.java
+++ b/core/java/android/view/inputmethod/SurroundingText.java
@@ -181,4 +181,14 @@
             }
         }
     }
+
+    /** @hide */
+    public boolean isEqualTo(@Nullable SurroundingText that) {
+        if (that == null) return false;
+        if (this == that) return true;
+        return mSelectionStart == that.mSelectionStart
+                && mSelectionEnd == that.mSelectionEnd
+                && mOffset == that.mOffset
+                && TextUtils.equals(mText, that.mText);
+    }
 }
diff --git a/core/java/android/view/inputmethod/ViewFocusParameterInfo.java b/core/java/android/view/inputmethod/ViewFocusParameterInfo.java
new file mode 100644
index 0000000..44c33fa
--- /dev/null
+++ b/core/java/android/view/inputmethod/ViewFocusParameterInfo.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2022 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.inputmethod;
+
+import android.annotation.Nullable;
+import android.view.WindowManager.LayoutParams.SoftInputModeFlags;
+
+import com.android.internal.inputmethod.StartInputFlags;
+import com.android.internal.inputmethod.StartInputReason;
+import com.android.internal.view.IInputMethodManager;
+
+/**
+ * This data class is a container for storing the last arguments used when calling into
+ * {@link IInputMethodManager#startInputOrWindowGainedFocus}. They are used to determine if we
+ * are switching from a non-editable view to another non-editable view, in which case we avoid
+ * a binder call into the {@link com.android.server.inputmethod.InputMethodManagerService}.
+ */
+final class ViewFocusParameterInfo {
+    @Nullable final EditorInfo mPreviousEditorInfo;
+    @StartInputFlags final int mPreviousStartInputFlags;
+    @StartInputReason final int mPreviousStartInputReason;
+    @SoftInputModeFlags final int mPreviousSoftInputMode;
+    final int mPreviousWindowFlags;
+
+    ViewFocusParameterInfo(@Nullable EditorInfo previousEditorInfo,
+            @StartInputFlags int previousStartInputFlags,
+            @StartInputReason int previousStartInputReason,
+            @SoftInputModeFlags int previousSoftInputMode,
+            int previousWindowFlags) {
+        mPreviousEditorInfo = previousEditorInfo;
+        mPreviousStartInputFlags = previousStartInputFlags;
+        mPreviousStartInputReason = previousStartInputReason;
+        mPreviousSoftInputMode = previousSoftInputMode;
+        mPreviousWindowFlags = previousWindowFlags;
+    }
+
+    boolean sameAs(@Nullable EditorInfo currentEditorInfo,
+            @StartInputFlags int startInputFlags,
+            @StartInputReason int startInputReason,
+            @SoftInputModeFlags int softInputMode,
+            int windowFlags) {
+        return mPreviousStartInputFlags == startInputFlags
+                && mPreviousStartInputReason == startInputReason
+                && mPreviousSoftInputMode == softInputMode
+                && mPreviousWindowFlags == windowFlags
+                && (mPreviousEditorInfo == currentEditorInfo
+                    || (mPreviousEditorInfo != null
+                    && mPreviousEditorInfo.kindofEquals(currentEditorInfo)));
+    }
+}
diff --git a/core/tests/coretests/src/android/view/inputmethod/EditorInfoTest.java b/core/tests/coretests/src/android/view/inputmethod/EditorInfoTest.java
index fe7d289..b867e44 100644
--- a/core/tests/coretests/src/android/view/inputmethod/EditorInfoTest.java
+++ b/core/tests/coretests/src/android/view/inputmethod/EditorInfoTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
@@ -59,6 +60,28 @@
     private static final int TEST_USER_ID = 42;
     private static final int LONG_EXP_TEXT_LENGTH = EditorInfo.MEMORY_EFFICIENT_TEXT_LENGTH * 2;
 
+    private static final EditorInfo TEST_EDITOR_INFO = new EditorInfo();
+
+    static {
+        TEST_EDITOR_INFO.inputType = InputType.TYPE_CLASS_TEXT; // 0x1
+        TEST_EDITOR_INFO.imeOptions = EditorInfo.IME_ACTION_GO; // 0x2
+        TEST_EDITOR_INFO.privateImeOptions = "testOptions";
+        TEST_EDITOR_INFO.initialSelStart = 0;
+        TEST_EDITOR_INFO.initialSelEnd = 1;
+        TEST_EDITOR_INFO.initialCapsMode = TextUtils.CAP_MODE_CHARACTERS; // 0x1000
+        TEST_EDITOR_INFO.hintText = "testHintText";
+        TEST_EDITOR_INFO.label = "testLabel";
+        TEST_EDITOR_INFO.packageName = "android.view.inputmethod";
+        TEST_EDITOR_INFO.fieldId = 0;
+        TEST_EDITOR_INFO.autofillId = AutofillId.NO_AUTOFILL_ID;
+        TEST_EDITOR_INFO.fieldName = "testField";
+        TEST_EDITOR_INFO.extras = new Bundle();
+        TEST_EDITOR_INFO.extras.putString("testKey", "testValue");
+        TEST_EDITOR_INFO.hintLocales = LocaleList.forLanguageTags("en,de,ua");
+        TEST_EDITOR_INFO.contentMimeTypes = new String[] {"image/png"};
+        TEST_EDITOR_INFO.targetInputMethodUser = UserHandle.of(TEST_USER_ID);
+    }
+
     /**
      * Makes sure that {@code null} {@link EditorInfo#targetInputMethodUser} can be copied via
      * {@link Parcel}.
@@ -526,4 +549,47 @@
                         + "prefix: hintLocales=null\n"
                         + "prefix: contentMimeTypes=null\n");
     }
+
+    @Test
+    public void testKindofEqualsAfterCopyInternal() {
+        final EditorInfo infoCopy = TEST_EDITOR_INFO.createCopyInternal();
+        assertTrue(TEST_EDITOR_INFO.kindofEquals(infoCopy));
+    }
+
+    @Test
+    public void testKindofEqualsAfterCloneViaParcel() {
+        // This test demonstrates a false negative case when an EditorInfo is
+        // created from a Parcel and its extras are still parcelled, which in turn
+        // runs into the edge case in Bundle.kindofEquals
+        final EditorInfo infoCopy = cloneViaParcel(TEST_EDITOR_INFO);
+        assertFalse(TEST_EDITOR_INFO.kindofEquals(infoCopy));
+    }
+
+    @Test
+    public void testKindofEqualsComparesAutofillId() {
+        final EditorInfo infoCopy = TEST_EDITOR_INFO.createCopyInternal();
+        infoCopy.autofillId = new AutofillId(42);
+        assertFalse(TEST_EDITOR_INFO.kindofEquals(infoCopy));
+    }
+
+    @Test
+    public void testKindofEqualsComparesFieldId() {
+        final EditorInfo infoCopy = TEST_EDITOR_INFO.createCopyInternal();
+        infoCopy.fieldId = 42;
+        assertFalse(TEST_EDITOR_INFO.kindofEquals(infoCopy));
+    }
+
+    @Test
+    public void testKindofEqualsComparesMimeTypes() {
+        final EditorInfo infoCopy = TEST_EDITOR_INFO.createCopyInternal();
+        infoCopy.contentMimeTypes = new String[] {"image/png", "image/gif"};
+        assertFalse(TEST_EDITOR_INFO.kindofEquals(infoCopy));
+    }
+
+    @Test
+    public void testKindofEqualsComparesExtras() {
+        final EditorInfo infoCopy = TEST_EDITOR_INFO.createCopyInternal();
+        infoCopy.extras.putString("testKey2", "testValue");
+        assertFalse(TEST_EDITOR_INFO.kindofEquals(infoCopy));
+    }
 }
diff --git a/core/tests/coretests/src/android/view/inputmethod/SurroundingTextTest.java b/core/tests/coretests/src/android/view/inputmethod/SurroundingTextTest.java
index dfbc39c..50ce335 100644
--- a/core/tests/coretests/src/android/view/inputmethod/SurroundingTextTest.java
+++ b/core/tests/coretests/src/android/view/inputmethod/SurroundingTextTest.java
@@ -17,7 +17,8 @@
 package android.view.inputmethod;
 
 import static org.hamcrest.CoreMatchers.is;
-import static org.junit.Assert.assertThat;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertFalse;
 
 import android.os.Parcel;
 
@@ -70,4 +71,11 @@
         assertThat(surroundingTextFromParcel.getSelectionEnd(), is(1));
         assertThat(surroundingTextFromParcel.getOffset(), is(2));
     }
+
+    @Test
+    public void testIsEqualComparesText() {
+        final SurroundingText text1 = new SurroundingText("hello", 0, 1, 0);
+        final SurroundingText text2 = new SurroundingText("there", 0, 1, 0);
+        assertFalse(text1.isEqualTo(text2));
+    }
 }