Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c841a4ae authored by czq's avatar czq
Browse files

Add unit test for InputMethodBindingController

Also slightly modified InputMethodBindingController to make it
unit testable.

Bug: b/240359838

Test: atest com.android.server.inputmethod.InputMethodBindingControllerTest
Change-Id: I9cd7d1e33fc366369da0c46deec8172b4bbac0d4
parent 3daff41c
Loading
Loading
Loading
Loading
+33 −12
Original line number Diff line number Diff line
@@ -42,12 +42,15 @@ import android.view.inputmethod.InputMethod;
import android.view.inputmethod.InputMethodInfo;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.inputmethod.IInputMethod;
import com.android.internal.inputmethod.InputBindResult;
import com.android.internal.inputmethod.UnbindReason;
import com.android.server.EventLogTags;
import com.android.server.wm.WindowManagerInternal;

import java.util.concurrent.CountDownLatch;

/**
 * A controller managing the state of the input method binding.
 */
@@ -77,19 +80,26 @@ final class InputMethodBindingController {
    @GuardedBy("ImfLock.class") private boolean mVisibleBound;
    @GuardedBy("ImfLock.class") private boolean mSupportsStylusHw;

    @Nullable private CountDownLatch mLatchForTesting;

    /**
     * Binding flags for establishing connection to the {@link InputMethodService}.
     */
    private static final int IME_CONNECTION_BIND_FLAGS =
    @VisibleForTesting
    static final int IME_CONNECTION_BIND_FLAGS =
            Context.BIND_AUTO_CREATE
                    | Context.BIND_NOT_VISIBLE
                    | Context.BIND_NOT_FOREGROUND
                    | Context.BIND_IMPORTANT_BACKGROUND
                    | Context.BIND_SCHEDULE_LIKE_TOP_APP;

    private final int mImeConnectionBindFlags;

    /**
     * Binding flags used only while the {@link InputMethodService} is showing window.
     */
    private static final int IME_VISIBLE_BIND_FLAGS =
    @VisibleForTesting
    static final int IME_VISIBLE_BIND_FLAGS =
            Context.BIND_AUTO_CREATE
                    | Context.BIND_TREAT_LIKE_ACTIVITY
                    | Context.BIND_FOREGROUND_SERVICE
@@ -97,12 +107,19 @@ final class InputMethodBindingController {
                    | Context.BIND_SHOWING_UI;

    InputMethodBindingController(@NonNull InputMethodManagerService service) {
        this(service, IME_CONNECTION_BIND_FLAGS, null /* latchForTesting */);
    }

    InputMethodBindingController(@NonNull InputMethodManagerService service,
            int imeConnectionBindFlags, CountDownLatch latchForTesting) {
        mService = service;
        mContext = mService.mContext;
        mMethodMap = mService.mMethodMap;
        mSettings = mService.mSettings;
        mPackageManagerInternal = mService.mPackageManagerInternal;
        mWindowManagerInternal = mService.mWindowManagerInternal;
        mImeConnectionBindFlags = imeConnectionBindFlags;
        mLatchForTesting = latchForTesting;
    }

    /**
@@ -242,7 +259,7 @@ final class InputMethodBindingController {
        @Override public void onBindingDied(ComponentName name) {
            synchronized (ImfLock.class) {
                mService.invalidateAutofillSessionLocked();
                if (mVisibleBound) {
                if (isVisibleBound()) {
                    unbindVisibleConnection();
                }
            }
@@ -291,6 +308,10 @@ final class InputMethodBindingController {
                mService.scheduleResetStylusHandwriting();
            }
            Trace.traceEnd(TRACE_TAG_WINDOW_MANAGER);

            if (mLatchForTesting != null) {
                mLatchForTesting.countDown(); // Notify the finish to tests
            }
        }

        @GuardedBy("ImfLock.class")
@@ -338,15 +359,15 @@ final class InputMethodBindingController {

    @GuardedBy("ImfLock.class")
    void unbindCurrentMethod() {
        if (mVisibleBound) {
        if (isVisibleBound()) {
            unbindVisibleConnection();
        }

        if (mHasConnection) {
        if (hasConnection()) {
            unbindMainConnection();
        }

        if (mCurToken != null) {
        if (getCurToken() != null) {
            removeCurrentToken();
            mService.resetSystemUiLocked();
        }
@@ -448,17 +469,17 @@ final class InputMethodBindingController {

    @GuardedBy("ImfLock.class")
    private boolean bindCurrentInputMethodService(ServiceConnection conn, int flags) {
        if (mCurIntent == null || conn == null) {
        if (getCurIntent() == null || conn == null) {
            Slog.e(TAG, "--- bind failed: service = " + mCurIntent + ", conn = " + conn);
            return false;
        }
        return mContext.bindServiceAsUser(mCurIntent, conn, flags,
        return mContext.bindServiceAsUser(getCurIntent(), conn, flags,
                new UserHandle(mSettings.getCurrentUserId()));
    }

    @GuardedBy("ImfLock.class")
    private boolean bindCurrentInputMethodServiceMainConnection() {
        mHasConnection = bindCurrentInputMethodService(mMainConnection, IME_CONNECTION_BIND_FLAGS);
        mHasConnection = bindCurrentInputMethodService(mMainConnection, mImeConnectionBindFlags);
        return mHasConnection;
    }

@@ -472,7 +493,7 @@ final class InputMethodBindingController {
    void setCurrentMethodVisible() {
        if (mCurMethod != null) {
            if (DEBUG) Slog.d(TAG, "setCurrentMethodVisible: mCurToken=" + mCurToken);
            if (mHasConnection && !mVisibleBound) {
            if (hasConnection() && !isVisibleBound()) {
                mVisibleBound = bindCurrentInputMethodService(mVisibleConnection,
                        IME_VISIBLE_BIND_FLAGS);
            }
@@ -480,7 +501,7 @@ final class InputMethodBindingController {
        }

        // No IME is currently connected. Reestablish the main connection.
        if (!mHasConnection) {
        if (!hasConnection()) {
            if (DEBUG) {
                Slog.d(TAG, "Cannot show input: no IME bound. Rebinding.");
            }
@@ -512,7 +533,7 @@ final class InputMethodBindingController {
     */
    @GuardedBy("ImfLock.class")
    void setCurrentMethodNotVisible() {
        if (mVisibleBound) {
        if (isVisibleBound()) {
            unbindVisibleConnection();
        }
    }
+19 −0
Original line number Diff line number Diff line
@@ -18,6 +18,11 @@
          package="com.android.frameworks.inputmethodtests">

    <uses-sdk android:targetSdkVersion="31" />
    <queries>
        <intent>
            <action android:name="android.view.InputMethod" />
        </intent>
    </queries>

    <!-- Permissions required for granting and logging -->
    <uses-permission android:name="android.permission.LOG_COMPAT_CHANGE"/>
@@ -29,9 +34,23 @@
    <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
    <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL" />

    <uses-permission android:name="android.permission.MANAGE_ACTIVITY_TASKS"/>
    <uses-permission android:name="android.permission.BIND_INPUT_METHOD" />

    <application android:testOnly="true"
                 android:debuggable="true">
        <uses-library android:name="android.test.runner" />
        <service android:name="com.android.server.inputmethod.InputMethodBindingControllerTest$EmptyInputMethodService"
                 android:label="Empty IME"
                 android:permission="android.permission.BIND_INPUT_METHOD"
                 android:process=":service"
                 android:exported="true">
            <intent-filter>
                <action android:name="android.view.InputMethod"/>
            </intent-filter>
            <meta-data android:name="android.view.im"
                       android:resource="@xml/method"/>
        </service>
    </application>

    <instrumentation
+18 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<!--
  ~ 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.
  -->

<input-method xmlns:android="http://schemas.android.com/apk/res/android" />
 No newline at end of file
+224 −0
Original line number Diff line number Diff line
/*
 * 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 com.android.server.inputmethod;

import static com.google.common.truth.Truth.assertThat;

import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import android.app.Instrumentation;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.inputmethodservice.InputMethodService;
import android.os.Process;
import android.os.RemoteException;
import android.os.UserHandle;
import android.view.inputmethod.InputMethodInfo;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

import com.android.internal.inputmethod.InputBindResult;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

@RunWith(AndroidJUnit4.class)
public class InputMethodBindingControllerTest extends InputMethodManagerServiceTestBase {

    private static final String PACKAGE_NAME = "com.android.frameworks.inputmethodtests";
    private static final String TEST_SERVICE_NAME =
            "com.android.server.inputmethod.InputMethodBindingControllerTest"
                    + "$EmptyInputMethodService";
    private static final String TEST_IME_ID = PACKAGE_NAME + "/" + TEST_SERVICE_NAME;
    private static final long TIMEOUT_IN_SECONDS = 3;

    private InputMethodBindingController mBindingController;
    private Instrumentation mInstrumentation;
    private final int mImeConnectionBindFlags =
            InputMethodBindingController.IME_CONNECTION_BIND_FLAGS
                    & ~Context.BIND_SCHEDULE_LIKE_TOP_APP;
    private CountDownLatch mCountDownLatch;

    public static class EmptyInputMethodService extends InputMethodService {}

    @Before
    public void setUp() throws RemoteException {
        super.setUp();
        mInstrumentation = InstrumentationRegistry.getInstrumentation();
        mCountDownLatch = new CountDownLatch(1);
        // Remove flag Context.BIND_SCHEDULE_LIKE_TOP_APP because in tests we are not calling
        // from system.
        mBindingController =
                new InputMethodBindingController(
                        mInputMethodManagerService, mImeConnectionBindFlags, mCountDownLatch);
    }

    @Test
    public void testBindCurrentMethod_noIme() {
        synchronized (ImfLock.class) {
            mBindingController.setSelectedMethodId(null);
            InputBindResult result = mBindingController.bindCurrentMethod();
            assertThat(result).isEqualTo(InputBindResult.NO_IME);
        }
    }

    @Test
    public void testBindCurrentMethod_unknownId() {
        synchronized (ImfLock.class) {
            mBindingController.setSelectedMethodId("unknown ime id");
        }
        assertThrows(IllegalArgumentException.class, () -> {
            synchronized (ImfLock.class) {
                mBindingController.bindCurrentMethod();
            }
        });
    }

    @Test
    public void testBindCurrentMethod_notConnected() {
        synchronized (ImfLock.class) {
            mBindingController.setSelectedMethodId(TEST_IME_ID);
            doReturn(false)
                    .when(mContext)
                    .bindServiceAsUser(
                            any(Intent.class),
                            any(ServiceConnection.class),
                            anyInt(),
                            any(UserHandle.class));

            InputBindResult result = mBindingController.bindCurrentMethod();
            assertThat(result).isEqualTo(InputBindResult.IME_NOT_CONNECTED);
        }
    }

    @Test
    public void testBindAndUnbindMethod() throws Exception {
        // Bind with main connection
        testBindCurrentMethodWithMainConnection();

        // Bind with visible connection
        testBindCurrentMethodWithVisibleConnection();

        // Unbind both main and visible connections
        testUnbindCurrentMethod();
    }

    private void testBindCurrentMethodWithMainConnection() throws Exception {
        synchronized (ImfLock.class) {
            mBindingController.setSelectedMethodId(TEST_IME_ID);
        }
        InputMethodInfo info = mInputMethodManagerService.mMethodMap.get(TEST_IME_ID);
        assertThat(info).isNotNull();
        assertThat(info.getId()).isEqualTo(TEST_IME_ID);
        assertThat(info.getServiceName()).isEqualTo(TEST_SERVICE_NAME);

        // Bind input method with main connection. It is called on another thread because we should
        // wait for onServiceConnected() to finish.
        InputBindResult result = callOnMainSync(() -> {
            synchronized (ImfLock.class) {
                return mBindingController.bindCurrentMethod();
            }
        });

        verify(mContext, times(1))
                .bindServiceAsUser(
                        any(Intent.class),
                        any(ServiceConnection.class),
                        eq(mImeConnectionBindFlags),
                        any(UserHandle.class));
        assertThat(result.result).isEqualTo(InputBindResult.ResultCode.SUCCESS_WAITING_IME_BINDING);
        assertThat(result.id).isEqualTo(info.getId());
        synchronized (ImfLock.class) {
            assertThat(mBindingController.hasConnection()).isTrue();
            assertThat(mBindingController.getCurId()).isEqualTo(info.getId());
            assertThat(mBindingController.getCurToken()).isNotNull();
        }
        // Wait for onServiceConnected()
        mCountDownLatch.await(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS);

        // Verify onServiceConnected() is called and bound successfully.
        synchronized (ImfLock.class) {
            assertThat(mBindingController.getCurMethod()).isNotNull();
            assertThat(mBindingController.getCurMethodUid()).isNotEqualTo(Process.INVALID_UID);
        }
    }

    private void testBindCurrentMethodWithVisibleConnection() {
        mInstrumentation.runOnMainSync(() -> {
            synchronized (ImfLock.class) {
                mBindingController.setCurrentMethodVisible();
            }
        });
        // Bind input method with visible connection
        verify(mContext, times(1))
                .bindServiceAsUser(
                        any(Intent.class),
                        any(ServiceConnection.class),
                        eq(InputMethodBindingController.IME_VISIBLE_BIND_FLAGS),
                        any(UserHandle.class));
        synchronized (ImfLock.class) {
            assertThat(mBindingController.isVisibleBound()).isTrue();
        }
    }

    private void testUnbindCurrentMethod() {
        mInstrumentation.runOnMainSync(() -> {
            synchronized (ImfLock.class) {
                mBindingController.unbindCurrentMethod();
            }
        });

        synchronized (ImfLock.class) {
            // Unbind both main connection and visible connection
            assertThat(mBindingController.hasConnection()).isFalse();
            assertThat(mBindingController.isVisibleBound()).isFalse();
            verify(mContext, times(2)).unbindService(any(ServiceConnection.class));
            assertThat(mBindingController.getCurToken()).isNull();
            assertThat(mBindingController.getCurId()).isNull();
            assertThat(mBindingController.getCurMethod()).isNull();
            assertThat(mBindingController.getCurMethodUid()).isEqualTo(Process.INVALID_UID);
        }
    }

    private static <V> V callOnMainSync(Callable<V> callable) {
        AtomicReference<V> result = new AtomicReference<>();
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            try {
                                result.set(callable.call());
                            } catch (Exception e) {
                                throw new RuntimeException("Exception was thrown", e);
                            }
                        });
        return result.get();
    }
}