Improve mouse handling in DragStartHelper

Ignoring ACTION_MOVE events not caused by an actual
mouse move (such as secondary button press).

Ensuring that OnDragStart is not called repeatedly
after it has returned true.

Adding tests for all major use cases.

Bug: 29570793
Test: android.support.v13.view.DragStartHelperTest
Change-Id: I457e8cd4f32ae6ad3f3b7de443e456e60ca755d5
diff --git a/v13/build.gradle b/v13/build.gradle
index 5e1a026..694d767 100644
--- a/v13/build.gradle
+++ b/v13/build.gradle
@@ -4,6 +4,16 @@
 dependencies {
     compile project(':support-annotations')
     compile project(':support-v4')
+
+    androidTestCompile ("com.android.support.test:runner:${project.rootProject.ext.testRunnerVersion}") {
+        exclude module: 'support-annotations'
+    }
+    androidTestCompile ("com.android.support.test.espresso:espresso-core:${project.rootProject.ext.espressoVersion}") {
+        exclude module: 'support-annotations'
+    }
+    androidTestCompile "org.mockito:mockito-core:1.9.5"
+    androidTestCompile "com.google.dexmaker:dexmaker:1.2"
+    androidTestCompile "com.google.dexmaker:dexmaker-mockito:1.2"
 }
 
 android {
@@ -11,6 +21,7 @@
 
     defaultConfig {
         minSdkVersion 13
+        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
     }
 
     sourceSets {
@@ -23,6 +34,11 @@
                 'api25',
                 'java'
         ]
+
+        androidTest.setRoot('tests')
+        androidTest.java.srcDir 'tests/java'
+        androidTest.res.srcDir 'tests/res'
+        androidTest.manifest.srcFile 'tests/AndroidManifest.xml'
     }
 
     lintOptions {
diff --git a/v13/java/android/support/v13/view/DragStartHelper.java b/v13/java/android/support/v13/view/DragStartHelper.java
index b1fd913..16c54b9 100644
--- a/v13/java/android/support/v13/view/DragStartHelper.java
+++ b/v13/java/android/support/v13/view/DragStartHelper.java
@@ -77,6 +77,7 @@
     final private OnDragStartListener mListener;
 
     private int mLastTouchX, mLastTouchY;
+    private boolean mDragging;
 
     /**
      * Interface definition for a callback to be invoked when a drag start gesture is detected.
@@ -87,7 +88,7 @@
          *
          * @param v The view over which the drag start gesture has been detected.
          * @param helper The DragStartHelper object which detected the gesture.
-         * @return True if the listener has consumed the event, false otherwise.
+         * @return True if the listener has started the drag operation, false otherwise.
          */
         boolean onDragStart(View v, DragStartHelper helper);
     }
@@ -131,15 +132,37 @@
      * @return True if the listener has consumed the event, false otherwise.
      */
     public boolean onTouch(View v, MotionEvent event) {
-        if (event.getAction() == MotionEvent.ACTION_DOWN ||
-                event.getAction() == MotionEvent.ACTION_MOVE) {
-            mLastTouchX = (int) event.getX();
-            mLastTouchY = (int) event.getY();
-        }
-        if (event.getAction() == MotionEvent.ACTION_MOVE &&
-                MotionEventCompat.isFromSource(event, InputDeviceCompat.SOURCE_MOUSE) &&
-                (MotionEventCompat.getButtonState(event) & MotionEventCompat.BUTTON_PRIMARY) != 0) {
-            return mListener.onDragStart(v, this);
+        final int x = (int) event.getX();
+        final int y = (int) event.getY();
+        switch (event.getAction()) {
+            case MotionEvent.ACTION_DOWN:
+                mLastTouchX = x;
+                mLastTouchY = y;
+                break;
+
+            case MotionEvent.ACTION_MOVE:
+                if (!MotionEventCompat.isFromSource(event, InputDeviceCompat.SOURCE_MOUSE)
+                        || (MotionEventCompat.getButtonState(event)
+                                & MotionEventCompat.BUTTON_PRIMARY) == 0) {
+                    break;
+                }
+                if (mDragging) {
+                    // Ignore ACTION_MOVE events once the drag operation is in progress.
+                    break;
+                }
+                if (mLastTouchX == x && mLastTouchY == y) {
+                    // Do not call the listener unless the pointer position has actually changed.
+                    break;
+                }
+                mLastTouchX = x;
+                mLastTouchY = y;
+                mDragging = mListener.onDragStart(v, this);
+                return mDragging;
+
+            case MotionEvent.ACTION_UP:
+            case MotionEvent.ACTION_CANCEL:
+                mDragging = false;
+                break;
         }
         return false;
     }
diff --git a/v13/tests/AndroidManifest.xml b/v13/tests/AndroidManifest.xml
new file mode 100644
index 0000000..4ce99ee
--- /dev/null
+++ b/v13/tests/AndroidManifest.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+   Copyright (C) 2016 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.
+  -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    package="android.support.v13.test">
+
+    <uses-sdk
+        android:minSdkVersion="13"
+        android:targetSdkVersion="24"
+        tools:overrideLibrary="android.support.test, android.app, android.support.test.rule,
+                      android.support.test.espresso, android.support.test.espresso.idling"/>
+
+    <application>
+
+        <uses-library android:name="android.test.runner"/>
+
+        <activity android:name="android.support.v13.view.DragStartHelperTestActivity"/>
+
+    </application>
+
+    <instrumentation
+        android:name="android.test.InstrumentationTestRunner"
+        android:targetPackage="android.support.v13.test"/>
+
+</manifest>
diff --git a/v13/tests/java/android/support/v13/view/DragStartHelperTest.java b/v13/tests/java/android/support/v13/view/DragStartHelperTest.java
new file mode 100644
index 0000000..04e58d7
--- /dev/null
+++ b/v13/tests/java/android/support/v13/view/DragStartHelperTest.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright (C) 2016 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.support.v13.view;
+
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.argThat;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.annotation.TargetApi;
+import android.app.Instrumentation;
+import android.graphics.Point;
+import android.os.Build;
+import android.os.SystemClock;
+import android.support.annotation.NonNull;
+import android.support.annotation.RequiresApi;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SdkSuppress;
+import android.support.test.filters.SmallTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+import android.support.v13.test.R;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import org.hamcrest.Description;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatcher;
+import org.mockito.InOrder;
+
+@RequiresApi(13)
+@TargetApi(13)
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class DragStartHelperTest {
+
+    @Rule
+    public ActivityTestRule<DragStartHelperTestActivity> mActivityRule =
+            new ActivityTestRule<>(DragStartHelperTestActivity.class);
+
+    private Instrumentation mInstrumentation;
+    private View mDragSource;
+
+    interface DragStartListener {
+        boolean onDragStart(View view, DragStartHelper helper, Point touchPosition);
+    }
+
+    @NonNull
+    private DragStartListener createListener(boolean returnValue) {
+        final DragStartListener listener = mock(DragStartListener.class);
+        when(listener.onDragStart(any(View.class), any(DragStartHelper.class), any(Point.class)))
+                .thenReturn(returnValue);
+        return listener;
+    }
+
+    @NonNull
+    private DragStartHelper createDragStartHelper(final DragStartListener listener) {
+        return new DragStartHelper(mDragSource, new DragStartHelper.OnDragStartListener() {
+            @Override
+            public boolean onDragStart(View v, DragStartHelper helper) {
+                Point touchPosition = new Point();
+                helper.getTouchPosition(touchPosition);
+                return listener.onDragStart(v, helper, touchPosition);
+            }
+        });
+    }
+
+    private static int[] getViewCenter(View view) {
+        final int[] xy = new int[2];
+        view.getLocationOnScreen(xy);
+        xy[0] += view.getWidth() / 2;
+        xy[1] += view.getHeight() / 2;
+        return xy;
+    }
+
+    private static MotionEvent obtainTouchEvent(
+            int action, View anchor, int offsetX, int offsetY) {
+        final long eventTime = SystemClock.uptimeMillis();
+        final int[] xy = getViewCenter(anchor);
+        return MotionEvent.obtain(
+                eventTime, eventTime, action, xy[0] + offsetX, xy[1] + offsetY, 0);
+    }
+
+    private void sendTouchEvent(int action, View anchor, int offsetX, int offsetY) {
+        mInstrumentation.sendPointerSync(obtainTouchEvent(action, anchor, offsetX, offsetY));
+    }
+
+    private static MotionEvent obtainMouseEvent(
+            int action, int buttonState, View anchor, int offsetX, int offsetY) {
+        final long eventTime = SystemClock.uptimeMillis();
+
+        final int[] xy = getViewCenter(anchor);
+
+        MotionEvent.PointerProperties[] props = new MotionEvent.PointerProperties[] {
+                new MotionEvent.PointerProperties()
+        };
+        props[0].id = 0;
+        props[0].toolType = MotionEvent.TOOL_TYPE_MOUSE;
+
+        MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[] {
+                new MotionEvent.PointerCoords()
+        };
+        coords[0].x = xy[0] + offsetX;
+        coords[0].y = xy[1] + offsetY;
+
+        return MotionEvent.obtain(eventTime, eventTime, action, 1, props, coords, 0,
+                buttonState, 0, 0, -1, 0, InputDevice.SOURCE_MOUSE, 0);
+    }
+
+    private void sendMouseEvent(
+            int action, int buttonState, View anchor, int offsetX, int offsetY) {
+        mInstrumentation.sendPointerSync(obtainMouseEvent(
+                action, buttonState, anchor, offsetX, offsetY));
+    }
+
+    static class TouchPositionMatcher extends ArgumentMatcher<Point> {
+
+        private final Point mExpectedPosition;
+
+        TouchPositionMatcher(int x, int y) {
+            mExpectedPosition = new Point(x, y);
+        }
+
+        TouchPositionMatcher(View anchor, int x, int y) {
+            this(anchor.getWidth() / 2 + x, anchor.getHeight() / 2 + y);
+        }
+
+        public boolean matches(Object actual) {
+            return mExpectedPosition.equals(actual);
+        }
+
+        public void describeTo(Description description) {
+            description.appendText("TouchPositionMatcher: " + mExpectedPosition);
+        }
+    }
+
+    private void waitForLongPress() {
+        SystemClock.sleep(ViewConfiguration.getLongPressTimeout() * 2);
+    }
+
+    @Before
+    public void setUp() {
+        mInstrumentation = InstrumentationRegistry.getInstrumentation();
+        mDragSource = mActivityRule.getActivity().findViewById(R.id.drag_source);
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+    @Test
+    public void mouseDrag() throws Throwable {
+        final DragStartListener listener = createListener(true);
+        final DragStartHelper helper = createDragStartHelper(listener);
+        helper.attach();
+
+        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 3, 4);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 5, 6);
+
+        // Returning true from the callback prevents further callbacks.
+        verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
+        verifyNoMoreInteractions(listener);
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+    @Test
+    public void mouseDragWithNonprimaryButton() throws Throwable {
+        final DragStartListener listener = createListener(true);
+        final DragStartHelper helper = createDragStartHelper(listener);
+        helper.attach();
+
+        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_SECONDARY, mDragSource, 0, 0);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_SECONDARY, mDragSource, 1, 2);
+        sendMouseEvent(MotionEvent.ACTION_UP, MotionEvent.BUTTON_SECONDARY, mDragSource, 3, 4);
+
+        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_TERTIARY, mDragSource, 0, 0);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_TERTIARY, mDragSource, 1, 2);
+        sendMouseEvent(MotionEvent.ACTION_UP, MotionEvent.BUTTON_TERTIARY, mDragSource, 3, 4);
+
+        // Dragging mouse with a non-primary button down should not trigger OnDragStart.
+        verifyNoMoreInteractions(listener);
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+    @Test
+    public void mouseDragUsingTouchListener() throws Throwable {
+        final DragStartListener listener = createListener(true);
+        final DragStartHelper helper = createDragStartHelper(listener);
+
+        mDragSource.setOnTouchListener(new View.OnTouchListener() {
+            @Override
+            public boolean onTouch(View view, MotionEvent motionEvent) {
+                helper.onTouch(view, motionEvent);
+                return true;
+            }
+        });
+
+        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 3, 4);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 5, 6);
+
+        // Returning true from the callback prevents further callbacks.
+        verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
+        verifyNoMoreInteractions(listener);
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+    @Test
+    public void mouseDragWhenListenerReturnsFalse() throws Throwable {
+        final DragStartListener listener = createListener(false);
+        final DragStartHelper helper = createDragStartHelper(listener);
+        helper.attach();
+
+        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 3, 4);
+        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 5, 6);
+
+        // When the listener returns false every ACTION_MOVE triggers OnDragStart.
+        InOrder inOrder = inOrder(listener);
+        inOrder.verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
+        inOrder.verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 3, 4)));
+        inOrder.verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 5, 6)));
+        inOrder.verifyNoMoreInteractions();
+    }
+
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
+    @Test
+    public void mouseLongPress() throws Throwable {
+        final DragStartListener listener = createListener(true);
+        final DragStartHelper helper = createDragStartHelper(listener);
+        helper.attach();
+
+        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
+        waitForLongPress();
+
+        // Long press triggers OnDragStart.
+        verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
+        verifyNoMoreInteractions(listener);
+    }
+
+    @Test
+    public void touchDrag() throws Throwable {
+        final DragStartListener listener = createListener(false);
+        final DragStartHelper helper = createDragStartHelper(listener);
+        helper.attach();
+
+        sendTouchEvent(MotionEvent.ACTION_DOWN, mDragSource, 0, 0);
+        sendTouchEvent(MotionEvent.ACTION_MOVE, mDragSource, 1, 2);
+        sendTouchEvent(MotionEvent.ACTION_MOVE, mDragSource, 3, 4);
+        sendTouchEvent(MotionEvent.ACTION_MOVE, mDragSource, 5, 6);
+
+        // Touch and drag (without delay) does not trigger OnDragStart.
+        verifyNoMoreInteractions(listener);
+    }
+
+    @Test
+    public void touchLongPress() throws Throwable {
+        final DragStartListener listener = createListener(true);
+        final DragStartHelper helper = createDragStartHelper(listener);
+        helper.attach();
+
+        sendTouchEvent(MotionEvent.ACTION_DOWN, mDragSource, 1, 2);
+        waitForLongPress();
+
+        // Long press triggers OnDragStart.
+        verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
+        verifyNoMoreInteractions(listener);
+    }
+
+    @Test
+    public void touchLongPressUsingLongClickListener() throws Throwable {
+        final DragStartListener listener = createListener(true);
+
+        final DragStartHelper helper = createDragStartHelper(listener);
+        mDragSource.setOnLongClickListener(new View.OnLongClickListener() {
+            @Override
+            public boolean onLongClick(View view) {
+                return helper.onLongClick(view);
+            }
+        });
+
+        sendTouchEvent(MotionEvent.ACTION_DOWN, mDragSource, 1, 2);
+        waitForLongPress();
+
+        // Long press triggers OnDragStart.
+        // Since ACTION_DOWN is not handled, the touch offset is not available.
+        verify(listener, times(1)).onDragStart(
+                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(0, 0)));
+        verifyNoMoreInteractions(listener);
+    }
+}
diff --git a/v13/tests/java/android/support/v13/view/DragStartHelperTestActivity.java b/v13/tests/java/android/support/v13/view/DragStartHelperTestActivity.java
new file mode 100644
index 0000000..6a3605b
--- /dev/null
+++ b/v13/tests/java/android/support/v13/view/DragStartHelperTestActivity.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2016 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.support.v13.view;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.support.v13.test.R;
+
+public class DragStartHelperTestActivity extends Activity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.drag_source_activity);
+    }
+}
diff --git a/v13/tests/res/layout/drag_source_activity.xml b/v13/tests/res/layout/drag_source_activity.xml
new file mode 100644
index 0000000..27cae38
--- /dev/null
+++ b/v13/tests/res/layout/drag_source_activity.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2016 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.
+  -->
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+    <View
+        android:id="@+id/drag_source"
+        android:layout_width="100dp"
+        android:layout_height="100dp"
+        android:layout_margin="10dp"
+        android:background="#ccc"/>
+</LinearLayout>