chromium/content/public/android/junit/src/org/chromium/content/browser/input/ThreadedInputConnectionTest.java

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.content.browser.input;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.os.Handler;
import android.view.KeyCharacterMap;
import android.view.View;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;

import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.LooperMode;

import org.chromium.base.ThreadUtils;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Feature;

import java.util.concurrent.Callable;

/** Unit tests for {@ThreadedInputConnection}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
@LooperMode(LooperMode.Mode.LEGACY)
public class ThreadedInputConnectionTest {
    @Mock ImeAdapterImpl mImeAdapter;

    ThreadedInputConnection mConnection;
    InOrder mInOrder;
    View mView;
    Context mContext;
    boolean mRunningOnUiThread;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);

        mImeAdapter = Mockito.mock(ImeAdapterImpl.class);
        mInOrder = inOrder(mImeAdapter);

        // Mocks required to create a ThreadedInputConnection object
        mView = Mockito.mock(View.class);
        mContext = Mockito.mock(Context.class);
        when(mView.getContext()).thenReturn(mContext);
        when(mContext.getSystemService(Context.INPUT_METHOD_SERVICE))
                .thenReturn(Mockito.mock(InputMethodManager.class));
        // Let's create Handler for test thread and pretend that it is running on IME thread.
        mConnection =
                new ThreadedInputConnection(mView, mImeAdapter, new Handler()) {
                    @Override
                    protected boolean runningOnUiThread() {
                        return mRunningOnUiThread;
                    }
                };
    }

    @Test
    @Feature({"TextInput"})
    public void testComposeGetTextFinishGetText() {
        // IME app calls setComposingText().
        mConnection.setComposingText("hello", 1);
        mInOrder.verify(mImeAdapter).sendCompositionToNative("hello", 1, false, 0);

        // Renderer updates states asynchronously.
        mConnection.updateStateOnUiThread("hello", 5, 5, 0, 5, true, false);
        mInOrder.verify(mImeAdapter).updateSelection(5, 5, 0, 5);
        assertEquals(0, mConnection.getQueueForTest().size());

        // Prepare to call requestTextInputStateUpdate.
        mConnection.updateStateOnUiThread("hello", 5, 5, 0, 5, true, true);
        assertEquals(1, mConnection.getQueueForTest().size());
        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(true);

        // IME app calls getTextBeforeCursor().
        assertEquals("hello", mConnection.getTextBeforeCursor(20, 0));

        // IME app calls finishComposingText().
        mConnection.finishComposingText();
        mInOrder.verify(mImeAdapter).finishComposingText();
        mConnection.updateStateOnUiThread("hello", 5, 5, -1, -1, true, false);
        mInOrder.verify(mImeAdapter).updateSelection(5, 5, -1, -1);

        // Prepare to call requestTextInputStateUpdate.
        mConnection.updateStateOnUiThread("hello", 5, 5, -1, -1, true, true);
        assertEquals(1, mConnection.getQueueForTest().size());
        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(true);

        // IME app calls getTextBeforeCursor().
        assertEquals("hello", mConnection.getTextBeforeCursor(20, 0));

        assertEquals(0, mConnection.getQueueForTest().size());
    }

    @Test
    @Feature({"TextInput"})
    public void testPressingDeadKey() {
        // On default keyboard "Alt+i" produces a dead key '\u0302'.
        mConnection.setCombiningAccentOnUiThread(0x0302);
        mConnection.updateComposingText("\u0302", 1, true);
        mInOrder.verify(mImeAdapter)
                .sendCompositionToNative(
                        "\u0302", 1, false, 0x0302 | KeyCharacterMap.COMBINING_ACCENT);
    }

    @Test
    @Feature({"TextInput"})
    public void testRenderChangeUpdatesSelection() {
        // User moves the cursor.
        mConnection.updateStateOnUiThread("hello", 4, 4, -1, -1, true, false);
        mInOrder.verify(mImeAdapter).updateSelection(4, 4, -1, -1);
        assertEquals(0, mConnection.getQueueForTest().size());
    }

    @Test
    @Feature({"TextInput"})
    public void testBatchEdit() {
        // IME app calls beginBatchEdit().
        assertTrue(mConnection.beginBatchEdit());
        // Type hello real fast.
        mConnection.commitText("hello", 1);
        mInOrder.verify(mImeAdapter).sendCompositionToNative("hello", 1, true, 0);

        // Renderer updates states asynchronously.
        mConnection.updateStateOnUiThread("hello", 5, 5, -1, -1, true, false);
        mInOrder.verify(mImeAdapter, never()).updateSelection(5, 5, -1, -1);
        assertEquals(0, mConnection.getQueueForTest().size());

        {
            // Nest another batch edit.
            assertTrue(mConnection.beginBatchEdit());
            // Move the cursor to the left.
            mConnection.setSelection(4, 4);
            assertTrue(mConnection.endBatchEdit());
        }
        // We still have one outer batch edit, so should not update selection yet.
        mInOrder.verify(mImeAdapter, never()).updateSelection(4, 4, -1, -1);

        // Prepare to call requestTextInputStateUpdate.
        mConnection.updateStateOnUiThread("hello", 4, 4, -1, -1, true, true);
        assertEquals(1, mConnection.getQueueForTest().size());
        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(true);

        // IME app calls endBatchEdit().
        assertFalse(mConnection.endBatchEdit());
        // Batch edit is finished, now update selection.
        mInOrder.verify(mImeAdapter).updateSelection(4, 4, -1, -1);
        assertEquals(0, mConnection.getQueueForTest().size());
    }

    @Test
    @Feature({"TextInput"})
    @Ignore("crbug/632792")
    public void testFailToRequestToRenderer() {
        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(false);
        // Should not hang here. Return null to indicate failure.
        assertNull(null, mConnection.getTextBeforeCursor(10, 0));
    }

    @Test
    @Feature({"TextInput"})
    @Ignore("crbug/632792")
    public void testRendererCannotUpdateState() {
        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(true);
        // We found that renderer cannot update state, e.g., due to a crash.
        PostTask.postTask(
                TaskTraits.UI_DEFAULT,
                new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // TODO(changwan): find a way to avoid this.
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            fail();
                        }
                        mConnection.unblockOnUiThread();
                    }
                });
        // Should not hang here. Return null to indicate failure.
        assertEquals(null, mConnection.getTextBeforeCursor(10, 0));
    }

    // crbug.com/643477
    @Test
    @Feature({"TextInput"})
    public void testUiThreadAccess() {
        assertTrue(mConnection.commitText("hello", 1));
        mRunningOnUiThread = true;
        // Depending on the timing, the result may not be up-to-date.
        assertNotEquals(
                "hello",
                ThreadUtils.runOnUiThreadBlocking(
                        new Callable<CharSequence>() {
                            @Override
                            public CharSequence call() {
                                return mConnection.getTextBeforeCursor(10, 0);
                            }
                        }));
        // Or it could be.
        mConnection.updateStateOnUiThread("hello", 5, 5, -1, -1, true, false);
        assertEquals(
                "hello",
                ThreadUtils.runOnUiThreadBlocking(
                        new Callable<CharSequence>() {
                            @Override
                            public CharSequence call() {
                                return mConnection.getTextBeforeCursor(10, 0);
                            }
                        }));

        mRunningOnUiThread = false;
    }

    @Test
    @Feature("TextInput")
    public void testUpdateSelectionBehaviorWhenUpdatesRequested() {
        // Arrange.
        final ExtractedTextRequest request = new ExtractedTextRequest();

        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(true);

        // Populate the TextInputState BlockingQueue for the getExtractedText() call.
        mConnection.updateStateOnUiThread("bello", 1, 1, -1, -1, true, true);

        // Act.
        final ExtractedText extractedText =
                mConnection.getExtractedText(request, InputConnection.GET_EXTRACTED_TEXT_MONITOR);

        // Assert.
        assertEquals("bello", extractedText.text);
        assertEquals(-1, extractedText.partialStartOffset);
        assertEquals("bello".length(), extractedText.partialEndOffset);
        assertEquals(1, extractedText.selectionStart);
        assertEquals(1, extractedText.selectionEnd);

        // Ensure that the next updateState events will invoke
        // both updateExtractedText() and updateSelection().
        mConnection.updateStateOnUiThread("mello", 2, 2, -1, -1, true, false);
        mInOrder.verify(mImeAdapter).updateExtractedText(anyInt(), any(ExtractedText.class));
        mInOrder.verify(mImeAdapter).updateSelection(2, 2, -1, -1);

        mConnection.updateStateOnUiThread("cello", 3, 3, -1, -1, true, false);
        mInOrder.verify(mImeAdapter).updateExtractedText(anyInt(), any(ExtractedText.class));
        mInOrder.verify(mImeAdapter).updateSelection(3, 3, -1, -1);
    }

    @Test
    @Feature("TextInput")
    public void testUpdateSelectionBehaviorWhenUpdatesNotRequested() {
        // Arrange.
        final ExtractedTextRequest request = new ExtractedTextRequest();

        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(true);

        // Populate the TextInputState BlockingQueue for the getExtractedText() call.
        mConnection.updateStateOnUiThread("hello", 1, 2, 3, 4, true, true);

        // Initially we want to monitor extracted text updates.
        final ExtractedText extractedText1 =
                mConnection.getExtractedText(request, InputConnection.GET_EXTRACTED_TEXT_MONITOR);

        mConnection.updateStateOnUiThread("bello", 1, 1, 3, 4, true, false);

        // Assert.
        assertEquals("hello", extractedText1.text);
        assertEquals(-1, extractedText1.partialStartOffset);
        assertEquals("hello".length(), extractedText1.partialEndOffset);
        assertEquals(1, extractedText1.selectionStart);
        assertEquals(2, extractedText1.selectionEnd);

        mInOrder.verify(mImeAdapter).updateExtractedText(anyInt(), any(ExtractedText.class));
        mInOrder.verify(mImeAdapter).updateSelection(1, 1, 3, 4);

        // Populate the TextInputState BlockingQueue for the getExtractedText() call.
        mConnection.updateStateOnUiThread("cello", 2, 2, 3, 4, true, true);

        // Act: Now we want to stop monitoring extracted text changes.
        final ExtractedText extractedText2 = mConnection.getExtractedText(request, 0);

        // Assert
        assertEquals("cello", extractedText2.text);
        assertEquals(-1, extractedText2.partialStartOffset);
        assertEquals("cello".length(), extractedText2.partialEndOffset);
        assertEquals(2, extractedText2.selectionStart);
        assertEquals(2, extractedText2.selectionEnd);

        // Perform another updateState
        mConnection.updateStateOnUiThread("ello", 0, 0, -1, -1, true, false);

        // Assert: No more update extracted text updates sent to ImeAdapter.
        mInOrder.verify(mImeAdapter, never())
                .updateExtractedText(anyInt(), any(ExtractedText.class));
        mInOrder.verify(mImeAdapter).updateSelection(0, 0, -1, -1);
    }

    @Test
    @Feature("TextInput")
    public void testExtractedTextNotSentAfterInputConnectionReset() {
        // Arrange.
        final ExtractedTextRequest request = new ExtractedTextRequest();

        when(mImeAdapter.requestTextInputStateUpdate()).thenReturn(true);

        // Populate the TextInputState BlockingQueue for the getExtractedText() call.
        mConnection.updateStateOnUiThread("hello", 1, 2, 3, 4, true, true);

        // Start monitoring for extracted text updates
        final ExtractedText extractedText =
                mConnection.getExtractedText(request, InputConnection.GET_EXTRACTED_TEXT_MONITOR);

        // Assert.
        assertEquals("hello", extractedText.text);
        assertEquals(-1, extractedText.partialStartOffset);
        assertEquals("hello".length(), extractedText.partialEndOffset);
        assertEquals(1, extractedText.selectionStart);
        assertEquals(2, extractedText.selectionEnd);

        mConnection.updateStateOnUiThread("bello", 1, 1, 3, 4, true, false);

        mInOrder.verify(mImeAdapter).updateExtractedText(anyInt(), any(ExtractedText.class));
        mInOrder.verify(mImeAdapter).updateSelection(1, 1, 3, 4);

        // Act: Force a connection reset Instead of calling ImeAdapter#onCreateInputConnection()
        // To stop monitoring extracted text changes.
        mConnection.resetOnUiThread();

        // Perform another updateState
        mConnection.updateStateOnUiThread("ello", 0, 0, -1, -1, true, false);

        // Assert: No more update extracted text updates sent to ImeAdapter.
        mInOrder.verify(mImeAdapter, never())
                .updateExtractedText(anyInt(), any(ExtractedText.class));
        mInOrder.verify(mImeAdapter).updateSelection(0, 0, -1, -1);
    }
}