diff --git a/core/java/android/view/HandwritingInitiator.java b/core/java/android/view/HandwritingInitiator.java new file mode 100644 index 0000000000000..8524ac846d1ad --- /dev/null +++ b/core/java/android/view/HandwritingInitiator.java @@ -0,0 +1,303 @@ +/* + * Copyright (C) 2021 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.annotation.NonNull; +import android.annotation.Nullable; +import android.graphics.Rect; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; + +import com.android.internal.annotations.VisibleForTesting; + +import java.lang.ref.WeakReference; + +/** + * Initiates handwriting mode once it detects stylus movement in handwritable areas. + * + * It is designed to be used by {@link ViewRootImpl}. For every stylus related MotionEvent that is + * dispatched to view tree, ViewRootImpl should call {@link #onTouchEvent} method of this class. + * And it will automatically request to enter the handwriting mode when the conditions meet. + * + * Notice that ViewRootImpl should still dispatch MotionEvents to view tree as usual. + * And if it successfully enters the handwriting mode, the ongoing MotionEvent stream will be + * routed to the input method. Input system will fabricate an ACTION_CANCEL and send to + * ViewRootImpl. + * + * This class does nothing if: + * a) MotionEvents are not from stylus. + * b) The user taps or long-clicks with a stylus etc. + * c) Stylus pointer down position is not within a handwritable area. + * + * Used by InputMethodManager. + * @hide + */ +public class HandwritingInitiator { + /** + * The touchSlop from {@link ViewConfiguration} used to decide whether a pointer is considered + * moving or stationary. + */ + private final int mTouchSlop; + /** + * The timeout used to distinguish tap from handwriting. If the stylus doesn't move before this + * timeout, it's not considered as handwriting. + */ + private final long mTapTimeoutInMillis; + + private State mState = new State(); + + /** + * Helper method to reset the internal state of this class. + * Calling this method will also prevent the following MotionEvents + * triggers handwriting until the next stylus ACTION_DOWN/ACTION_POINTER_DOWN + * arrives. + */ + private void reset() { + mState = new State(); + } + + /** The reference to the View that currently has the input connection. */ + @Nullable + @VisibleForTesting + public WeakReference mConnectedView = null; + + /** The editor bound reported by the connected View. */ + @Nullable + @VisibleForTesting + public Rect mEditorBound = null; + + /** + * When InputConnection restarts for a View, View#onInputConnectionCreatedInternal + * might be called before View#onInputConnectionClosedInternal, so we need to count the input + * connections and only set mConnectedView to null when mConnectionCount is zero. + */ + private int mConnectionCount = 0; + private final InputMethodManager mImm; + + @VisibleForTesting + public HandwritingInitiator(ViewConfiguration viewConfiguration, + InputMethodManager inputMethodManager) { + mTouchSlop = viewConfiguration.getScaledTouchSlop(); + mTapTimeoutInMillis = ViewConfiguration.getTapTimeout(); + mImm = inputMethodManager; + } + + /** + * Notify the HandwritingInitiator that a new MotionEvent has arrived. + * This method is non-block, and the event passed to this method should be dispatched to the + * View tree as usual. If HandwritingInitiator triggers the handwriting mode, an fabricated + * ACTION_CANCEL event will be sent to the ViewRootImpl. + * @param motionEvent the stylus MotionEvent. + */ + @VisibleForTesting + public void onTouchEvent(MotionEvent motionEvent) { + final int maskedAction = motionEvent.getActionMasked(); + switch (maskedAction) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + final int actionIndex = motionEvent.getActionIndex(); + final int toolType = motionEvent.getToolType(actionIndex); + // TOOL_TYPE_ERASER is also from stylus. This indicates that the user is holding + // the eraser button during handwriting. + if (toolType != MotionEvent.TOOL_TYPE_STYLUS + && toolType != MotionEvent.TOOL_TYPE_ERASER) { + // The motion event is not from a stylus event, ignore it. + return; + } + mState.mStylusPointerId = motionEvent.getPointerId(actionIndex); + mState.mStylusDownTimeInMillis = motionEvent.getEventTime(); + mState.mStylusDownX = motionEvent.getX(actionIndex); + mState.mStylusDownY = motionEvent.getY(actionIndex); + mState.mShouldInitHandwriting = true; + mState.mExceedTouchSlop = false; + break; + case MotionEvent.ACTION_POINTER_UP: + final int pointerId = motionEvent.getPointerId(motionEvent.getActionIndex()); + if (pointerId != mState.mStylusPointerId) { + // ACTION_POINTER_UP is from another stylus pointer, ignore the event. + return; + } + // Deliberately fall through. + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + // If it's ACTION_CANCEL or ACTION_UP, all the pointers go up. There is no need to + // check whether the stylus we are tracking goes up. + reset(); + break; + case MotionEvent.ACTION_MOVE: + // Either we've already tried to initiate handwriting, or the ongoing MotionEvent + // sequence is considered to be tap, long-click or other gestures. + if (!mState.mShouldInitHandwriting || mState.mExceedTouchSlop) { + return; + } + + final long timeElapsed = + motionEvent.getEventTime() - mState.mStylusDownTimeInMillis; + if (timeElapsed > mTapTimeoutInMillis) { + reset(); + return; + } + + final int pointerIndex = motionEvent.findPointerIndex(mState.mStylusPointerId); + final float x = motionEvent.getX(pointerIndex); + final float y = motionEvent.getY(pointerIndex); + if (largerThanTouchSlop(x, y, mState.mStylusDownX, mState.mStylusDownY)) { + mState.mExceedTouchSlop = true; + tryStartHandwriting(); + } + } + } + + private View getConnectedView() { + if (mConnectedView == null) return null; + return mConnectedView.get(); + } + + /** + * Notify HandwritingInitiator that a new InputConnection is created. + * The caller of this method should guarantee that each onInputConnectionCreated call + * is paired with a onInputConnectionClosed call. + * @param view the view that created the current InputConnection. + * @see #onInputConnectionClosed(View) + */ + public void onInputConnectionCreated(@NonNull View view, @NonNull EditorInfo editorInfo) { + final View connectedView = getConnectedView(); +// updateEditorBound(editorInfo.getInitialEditorBound()); + if (connectedView == view) { + ++mConnectionCount; + } else { + mConnectedView = new WeakReference<>(view); + mConnectionCount = 1; + tryStartHandwriting(); + } + } + + /** + * Notify HandwritingInitiator that the InputConnection has closed for the given view. + * The caller of this method should guarantee that each onInputConnectionClosed call + * is paired with a onInputConnectionCreated call. + * @param view the view that closed the InputConnection. + */ + public void onInputConnectionClosed(@NonNull View view) { + final View connectedView = getConnectedView(); + if (connectedView == view) { + --mConnectionCount; + if (mConnectionCount == 0) { + mConnectedView = null; + mEditorBound = null; + } + } else { + // Unexpected branch, set mConnectedView to null to avoid further problem. + mConnectedView = null; + mEditorBound = null; + mConnectionCount = 0; + } + } + + /** + * Notify the HandwritingInitiator that editor bound of the connected view(the view with + * active InputConnection) has be updated. + * @param editorBound new the editor bounds of the connected view. + */ + public void updateEditorBound(@NonNull Rect editorBound) { + if (mEditorBound == null) { + mEditorBound = new Rect(editorBound); + } else { + mEditorBound.left = editorBound.left; + mEditorBound.top = editorBound.top; + mEditorBound.right = editorBound.right; + mEditorBound.bottom = editorBound.bottom; + } + } + + /** + * Try to initiate handwriting. For this method to successfully send startHandwriting signal, + * the following 3 conditions should meet: + * a) The stylus movement exceeds the touchSlop. + * b) A View has built InputConnection with IME. + * c) The stylus event lands into the connected View's boundary. + * This method will immediately fail without any side effect if condition a or b is not met. + * However, if both condition a and b are met but the condition c is not met, it will reset the + * internal states. And HandwritingInitiator won't attempt to call startHandwriting until the + * next ACTION_DOWN. + */ + private void tryStartHandwriting() { + if (!mState.mExceedTouchSlop) { + return; + } + final View connectedView = getConnectedView(); + if (connectedView == null || mEditorBound == null) { + return; + } + final ViewParent viewParent = connectedView.getParent(); + // Do a final check before startHandwriting. + if (viewParent != null && connectedView.isAttachedToWindow()) { + final Rect editorBounds = new Rect(mEditorBound); + if (viewParent.getChildVisibleRect(connectedView, editorBounds, null)) { + final int roundedInitX = Math.round(mState.mStylusDownX); + final int roundedInitY = Math.round(mState.mStylusDownY); + if (editorBounds.contains(roundedInitX, roundedInitY)) { + startHandwriting(mConnectedView.get()); + } + } + } + reset(); + } + + /** For test only. */ + @VisibleForTesting + public void startHandwriting(View view) { + // mImm.startHandwriting(view); + } + + private boolean largerThanTouchSlop(float x1, float y1, float x2, float y2) { + float dx = x1 - x2; + float dy = y1 - y2; + return dx * dx + dy * dy > mTouchSlop * mTouchSlop; + } + + /** Object that keeps the MotionEvent related states for HandwritingInitiator. */ + private static class State { + /** + * Whether it should initiate handwriting mode for the current MotionEvent sequence. + * (A series of MotionEvents from ACTION_DOWN to ACTION_UP) + * + * The purpose of this boolean value is: + * a) We should only request to start handwriting mode ONCE for each MotionEvent sequence. + * If we've already requested to enter handwriting mode for the ongoing MotionEvent + * sequence, this boolean is set to false. And it won't request to start handwriting again. + * + * b) If the MotionEvent sequence is considered to be tap, long-click or other gestures. + * This boolean will be set to false, and it won't request to start handwriting. + */ + private boolean mShouldInitHandwriting = false; + /** + * Whether the current ongoing stylus MotionEvent sequence already exceeds the touchSlop. + * It's used for the case where the stylus exceeds touchSlop before the target View built + * InputConnection. + */ + private boolean mExceedTouchSlop = false; + + /** The pointer id of the stylus pointer that is being tracked. */ + private int mStylusPointerId = -1; + /** The time stamp when the stylus pointer goes down. */ + private long mStylusDownTimeInMillis = -1; + /** The initial location where the stylus pointer goes down. */ + private float mStylusDownX = Float.NaN; + private float mStylusDownY = Float.NaN; + } +} diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java index 70505fcc09b53..96e35be439e0e 100644 --- a/core/java/android/view/ViewRootImpl.java +++ b/core/java/android/view/ViewRootImpl.java @@ -734,6 +734,17 @@ public final class ViewRootImpl implements ViewParent, int localChanges; } + private final HandwritingInitiator mHandwritingInitiator; + + /** + * Used by InputMethodManager. + * @hide + */ + @NonNull + public HandwritingInitiator getHandwritingInitiator() { + return mHandwritingInitiator; + } + /** * This is only used on the RenderThread when handling a blast sync. Specifically, it's only * used when calling {@link BLASTBufferQueue#setSyncTransaction(Transaction)} and then merged @@ -808,6 +819,8 @@ public final class ViewRootImpl implements ViewParent, ? Choreographer.getSfInstance() : Choreographer.getInstance(); mDisplayManager = (DisplayManager)context.getSystemService(Context.DISPLAY_SERVICE); mInsetsController = new InsetsController(new ViewRootInsetsControllerHost(this)); + mHandwritingInitiator = new HandwritingInitiator(mViewConfiguration, + mContext.getSystemService(InputMethodManager.class)); String processorOverrideName = context.getResources().getString( R.string.config_inputEventCompatProcessorOverrideClassName); @@ -6349,6 +6362,7 @@ public final class ViewRootImpl implements ViewParent, private int processPointerEvent(QueuedInputEvent q) { final MotionEvent event = (MotionEvent)q.mEvent; + mHandwritingInitiator.onTouchEvent(event); mAttachInfo.mUnbufferedDispatchRequested = false; mAttachInfo.mHandlingPointerEvent = true; diff --git a/core/java/android/view/inputmethod/InputMethodManager.java b/core/java/android/view/inputmethod/InputMethodManager.java index 7566ceab334d5..7ca836caee51c 100644 --- a/core/java/android/view/inputmethod/InputMethodManager.java +++ b/core/java/android/view/inputmethod/InputMethodManager.java @@ -2072,6 +2072,10 @@ public final class InputMethodManager { + ", ic=" + ic + ", tba=" + tba + ", handler=" + icHandler); } view.onInputConnectionOpenedInternal(ic, tba, icHandler); + final ViewRootImpl viewRoot = view.getViewRootImpl(); + if (viewRoot != null) { + viewRoot.getHandwritingInitiator().onInputConnectionCreated(view, tba); + } } return true; diff --git a/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java b/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java index 6c2b330611f37..fc3708805f3f3 100644 --- a/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java +++ b/core/java/com/android/internal/inputmethod/RemoteInputConnectionImpl.java @@ -35,6 +35,7 @@ import android.util.Log; import android.util.proto.ProtoOutputStream; import android.view.KeyEvent; import android.view.View; +import android.view.ViewRootImpl; import android.view.inputmethod.CompletionInfo; import android.view.inputmethod.CorrectionInfo; import android.view.inputmethod.DumpableInputConnection; @@ -242,8 +243,19 @@ public final class RemoteInputConnectionImpl extends IInputContext.Stub { } if (handler.getLooper().isCurrentThread()) { servedView.onInputConnectionClosedInternal(); + final ViewRootImpl viewRoot = servedView.getViewRootImpl(); + if (viewRoot != null) { + viewRoot.getHandwritingInitiator().onInputConnectionClosed(servedView); + } } else { handler.post(servedView::onInputConnectionClosedInternal); + handler.post(() -> { + final ViewRootImpl viewRoot = servedView.getViewRootImpl(); + if (viewRoot != null) { + viewRoot.getHandwritingInitiator() + .onInputConnectionClosed(servedView); + } + }); } } } diff --git a/core/tests/coretests/src/android/view/HandwritingInitiatorTest.java b/core/tests/coretests/src/android/view/HandwritingInitiatorTest.java new file mode 100644 index 0000000000000..8f044616e3239 --- /dev/null +++ b/core/tests/coretests/src/android/view/HandwritingInitiatorTest.java @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2021 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 android.view.MotionEvent.ACTION_DOWN; +import static android.view.MotionEvent.ACTION_MOVE; +import static android.view.MotionEvent.ACTION_UP; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Instrumentation; +import android.content.Context; +import android.graphics.Rect; +import android.platform.test.annotations.Presubmit; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tests for {@link HandwritingInitiator} + * + * Build/Install/Run: + * atest FrameworksCoreTests:HandwritingInitiatorTest + */ +@Presubmit +@SmallTest +@RunWith(AndroidJUnit4.class) +public class HandwritingInitiatorTest { + private static final int TOUCH_SLOP = 8; + private static final long TAP_TIMEOUT = ViewConfiguration.getTapTimeout(); + private static final Rect sHwArea = new Rect(100, 200, 500, 500); + private static final EditorInfo sFakeEditorInfo = new EditorInfo(); + + private HandwritingInitiator mHandwritingInitiator; + private View mTestView; + + @Before + public void setup() { + final Instrumentation mInstrumentation = InstrumentationRegistry.getInstrumentation(); + Context context = mInstrumentation.getTargetContext(); + ViewConfiguration viewConfiguration = mock(ViewConfiguration.class); + when(viewConfiguration.getScaledTouchSlop()).thenReturn(TOUCH_SLOP); + + InputMethodManager inputMethodManager = context.getSystemService(InputMethodManager.class); + mHandwritingInitiator = + spy(new HandwritingInitiator(viewConfiguration, inputMethodManager)); + mHandwritingInitiator.updateEditorBound(sHwArea); + + // mock a parent so that HandwritingInitiator can get + ViewGroup parent = new ViewGroup(context) { + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + // We don't layout this view. + } + @Override + public boolean getChildVisibleRect(View child, Rect r, android.graphics.Point offset) { + r.left = sHwArea.left; + r.top = sHwArea.top; + r.right = sHwArea.right; + r.bottom = sHwArea.bottom; + return true; + } + }; + + mTestView = mock(View.class); + when(mTestView.isAttachedToWindow()).thenReturn(true); + parent.addView(mTestView); + } + + @Test + public void onTouchEvent_startHandwriting_when_stylusMoveOnce_withinHWArea() { + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + final int x1 = (sHwArea.left + sHwArea.right) / 2; + final int y1 = (sHwArea.top + sHwArea.bottom) / 2; + MotionEvent stylusEvent1 = createStylusEvent(ACTION_DOWN, x1, y1, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent1); + + final int x2 = x1 + TOUCH_SLOP * 2; + final int y2 = y1; + + MotionEvent stylusEvent2 = createStylusEvent(ACTION_MOVE, x2, y2, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent2); + + // Stylus movement win HandwritingArea should trigger IMM.startHandwriting once. + verify(mHandwritingInitiator, times(1)).startHandwriting(mTestView); + } + + @Test + public void onTouchEvent_startHandwritingOnce_when_stylusMoveMultiTimes_withinHWArea() { + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + final int x1 = (sHwArea.left + sHwArea.right) / 2; + final int y1 = (sHwArea.top + sHwArea.bottom) / 2; + MotionEvent stylusEvent1 = createStylusEvent(ACTION_DOWN, x1, y1, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent1); + + final int x2 = x1 + TOUCH_SLOP * 2; + final int y2 = y1; + MotionEvent stylusEvent2 = createStylusEvent(ACTION_MOVE, x2, y2, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent2); + + + final int x3 = x2 + TOUCH_SLOP * 2; + final int y3 = y2; + MotionEvent stylusEvent3 = createStylusEvent(ACTION_MOVE, x3, y3, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent3); + + MotionEvent stylusEvent4 = createStylusEvent(ACTION_UP, x2, y2, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent4); + + // It only calls startHandwriting once for each ACTION_DOWN. + verify(mHandwritingInitiator, times(1)).startHandwriting(mTestView); + } + + @Test + public void onTouchEvent_startHandwriting_inputConnectionBuiltAfterStylusMove() { + final int x1 = (sHwArea.left + sHwArea.right) / 2; + final int y1 = (sHwArea.top + sHwArea.bottom) / 2; + MotionEvent stylusEvent1 = createStylusEvent(ACTION_DOWN, x1, y1, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent1); + + final int x2 = x1 + TOUCH_SLOP * 2; + final int y2 = y1; + MotionEvent stylusEvent2 = createStylusEvent(ACTION_MOVE, x2, y2, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent2); + + // InputConnection is created after stylus movement. + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + + verify(mHandwritingInitiator, times(1)).startHandwriting(mTestView); + } + + @Test + public void onTouchEvent_notStartHandwriting_when_stylusTap_withinHWArea() { + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + final int x1 = 200; + final int y1 = 200; + MotionEvent stylusEvent1 = createStylusEvent(ACTION_DOWN, x1, y1, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent1); + + final int x2 = x1 + TOUCH_SLOP / 2; + final int y2 = y1; + MotionEvent stylusEvent2 = createStylusEvent(ACTION_UP, x2, y2, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent2); + + verify(mHandwritingInitiator, never()).startHandwriting(mTestView); + } + + @Test + public void onTouchEvent_notStartHandwriting_when_stylusMove_outOfHWArea() { + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + final int x1 = 10; + final int y1 = 10; + MotionEvent stylusEvent1 = createStylusEvent(ACTION_DOWN, x1, y1, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent1); + + final int x2 = x1 + TOUCH_SLOP * 2; + final int y2 = y1; + MotionEvent stylusEvent2 = createStylusEvent(ACTION_MOVE, x2, y2, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent2); + + verify(mHandwritingInitiator, never()).startHandwriting(mTestView); + } + + @Test + public void onTouchEvent_notStartHandwriting_when_stylusMove_afterTapTimeOut() { + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + final int x1 = 10; + final int y1 = 10; + final long time1 = 10L; + MotionEvent stylusEvent1 = createStylusEvent(ACTION_DOWN, x1, y1, 0); + mHandwritingInitiator.onTouchEvent(stylusEvent1); + + final int x2 = x1 + TOUCH_SLOP * 2; + final int y2 = y1; + final long time2 = time1 + TAP_TIMEOUT + 10L; + MotionEvent stylusEvent2 = createStylusEvent(ACTION_MOVE, x2, y2, time2); + mHandwritingInitiator.onTouchEvent(stylusEvent2); + + // stylus movement is after TAP_TIMEOUT it shouldn't call startHandwriting. + verify(mHandwritingInitiator, never()).startHandwriting(mTestView); + } + + @Test + public void onInputConnectionCreated_inputConnectionCreated() { + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + assertThat(mHandwritingInitiator.mConnectedView).isNotNull(); + assertThat(mHandwritingInitiator.mConnectedView.get()).isEqualTo(mTestView); + } + + @Test + public void onInputConnectionCreated_inputConnectionClosed() { + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + mHandwritingInitiator.onInputConnectionClosed(mTestView); + + assertThat(mHandwritingInitiator.mConnectedView).isNull(); + assertThat(mHandwritingInitiator.mEditorBound).isNull(); + } + + @Test + public void onInputConnectionCreated_inputConnectionRestarted() { + // When IMM restarts input connection, View#onInputConnectionCreatedInternal might be + // called before View#onInputConnectionClosedInternal. As a result, we need to handle the + // case where "one view "2 InputConnections". + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + mHandwritingInitiator.onInputConnectionCreated(mTestView, sFakeEditorInfo); + mHandwritingInitiator.onInputConnectionClosed(mTestView); + + assertThat(mHandwritingInitiator.mConnectedView).isNotNull(); + assertThat(mHandwritingInitiator.mConnectedView.get()).isEqualTo(mTestView); + } + + @Test + public void updateEditorBound() { + Rect rect = new Rect(1, 2, 3, 4); + mHandwritingInitiator.updateEditorBound(rect); + + assertThat(mHandwritingInitiator.mEditorBound).isEqualTo(rect); + } + + private MotionEvent createStylusEvent(int action, int x, int y, long eventTime) { + MotionEvent.PointerProperties[] properties = MotionEvent.PointerProperties.createArray(1); + properties[0].toolType = MotionEvent.TOOL_TYPE_STYLUS; + + MotionEvent.PointerCoords[] coords = MotionEvent.PointerCoords.createArray(1); + coords[0].x = x; + coords[0].y = y; + + return MotionEvent.obtain(0 /* downTime */, eventTime /* eventTime */, action, 1, + properties, coords, 0 /* metaState */, 0 /* buttonState */, 1 /* xPrecision */, + 1 /* yPrecision */, 0 /* deviceId */, 0 /* edgeFlags */, + InputDevice.SOURCE_TOUCHSCREEN, 0 /* flags */); + } +}