1/*
2 * Copyright (C) 2016 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.v13.view;
18
19import static org.mockito.Matchers.any;
20import static org.mockito.Matchers.argThat;
21import static org.mockito.Matchers.eq;
22import static org.mockito.Mockito.inOrder;
23import static org.mockito.Mockito.mock;
24import static org.mockito.Mockito.times;
25import static org.mockito.Mockito.verify;
26import static org.mockito.Mockito.verifyNoMoreInteractions;
27import static org.mockito.Mockito.when;
28
29import android.app.Instrumentation;
30import android.graphics.Point;
31import android.os.Build;
32import android.os.SystemClock;
33import android.support.annotation.NonNull;
34import android.support.annotation.RequiresApi;
35import android.support.test.InstrumentationRegistry;
36import android.support.test.filters.LargeTest;
37import android.support.test.filters.SdkSuppress;
38import android.support.test.filters.SmallTest;
39import android.support.test.rule.ActivityTestRule;
40import android.support.test.runner.AndroidJUnit4;
41import android.support.v13.test.R;
42import android.view.InputDevice;
43import android.view.MotionEvent;
44import android.view.View;
45import android.view.ViewConfiguration;
46
47import org.hamcrest.Description;
48import org.junit.Before;
49import org.junit.Rule;
50import org.junit.Test;
51import org.junit.runner.RunWith;
52import org.mockito.ArgumentMatcher;
53import org.mockito.InOrder;
54
55@RequiresApi(13)
56@RunWith(AndroidJUnit4.class)
57public class DragStartHelperTest {
58
59    @Rule
60    public ActivityTestRule<DragStartHelperTestActivity> mActivityRule =
61            new ActivityTestRule<>(DragStartHelperTestActivity.class);
62
63    private Instrumentation mInstrumentation;
64    private View mDragSource;
65
66    interface DragStartListener {
67        boolean onDragStart(View view, DragStartHelper helper, Point touchPosition);
68    }
69
70    @NonNull
71    private DragStartListener createListener(boolean returnValue) {
72        final DragStartListener listener = mock(DragStartListener.class);
73        when(listener.onDragStart(any(View.class), any(DragStartHelper.class), any(Point.class)))
74                .thenReturn(returnValue);
75        return listener;
76    }
77
78    @NonNull
79    private DragStartHelper createDragStartHelper(final DragStartListener listener) {
80        return new DragStartHelper(mDragSource, new DragStartHelper.OnDragStartListener() {
81            @Override
82            public boolean onDragStart(View v, DragStartHelper helper) {
83                Point touchPosition = new Point();
84                helper.getTouchPosition(touchPosition);
85                return listener.onDragStart(v, helper, touchPosition);
86            }
87        });
88    }
89
90    private static int[] getViewCenter(View view) {
91        final int[] xy = new int[2];
92        view.getLocationOnScreen(xy);
93        xy[0] += view.getWidth() / 2;
94        xy[1] += view.getHeight() / 2;
95        return xy;
96    }
97
98    private static MotionEvent obtainTouchEvent(
99            int action, View anchor, int offsetX, int offsetY) {
100        final long eventTime = SystemClock.uptimeMillis();
101        final int[] xy = getViewCenter(anchor);
102        return MotionEvent.obtain(
103                eventTime, eventTime, action, xy[0] + offsetX, xy[1] + offsetY, 0);
104    }
105
106    private void sendTouchEvent(int action, View anchor, int offsetX, int offsetY) {
107        mInstrumentation.sendPointerSync(obtainTouchEvent(action, anchor, offsetX, offsetY));
108    }
109
110    private static MotionEvent obtainMouseEvent(
111            int action, int buttonState, View anchor, int offsetX, int offsetY) {
112        final long eventTime = SystemClock.uptimeMillis();
113
114        final int[] xy = getViewCenter(anchor);
115
116        MotionEvent.PointerProperties[] props = new MotionEvent.PointerProperties[] {
117                new MotionEvent.PointerProperties()
118        };
119        props[0].id = 0;
120        props[0].toolType = MotionEvent.TOOL_TYPE_MOUSE;
121
122        MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[] {
123                new MotionEvent.PointerCoords()
124        };
125        coords[0].x = xy[0] + offsetX;
126        coords[0].y = xy[1] + offsetY;
127
128        return MotionEvent.obtain(eventTime, eventTime, action, 1, props, coords, 0,
129                buttonState, 0, 0, -1, 0, InputDevice.SOURCE_MOUSE, 0);
130    }
131
132    private void sendMouseEvent(
133            int action, int buttonState, View anchor, int offsetX, int offsetY) {
134        mInstrumentation.sendPointerSync(obtainMouseEvent(
135                action, buttonState, anchor, offsetX, offsetY));
136    }
137
138    static class TouchPositionMatcher extends ArgumentMatcher<Point> {
139
140        private final Point mExpectedPosition;
141
142        TouchPositionMatcher(int x, int y) {
143            mExpectedPosition = new Point(x, y);
144        }
145
146        TouchPositionMatcher(View anchor, int x, int y) {
147            this(anchor.getWidth() / 2 + x, anchor.getHeight() / 2 + y);
148        }
149
150        @Override
151        public boolean matches(Object actual) {
152            return mExpectedPosition.equals(actual);
153        }
154
155        @Override
156        public void describeTo(Description description) {
157            description.appendText("TouchPositionMatcher: " + mExpectedPosition);
158        }
159    }
160
161    private void waitForLongPress() {
162        SystemClock.sleep(ViewConfiguration.getLongPressTimeout() * 2);
163    }
164
165    @Before
166    public void setUp() {
167        mInstrumentation = InstrumentationRegistry.getInstrumentation();
168        mDragSource = mActivityRule.getActivity().findViewById(R.id.drag_source);
169    }
170
171    @SmallTest
172    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
173    @Test
174    public void mouseClick() throws Throwable {
175        final DragStartListener listener = createListener(true);
176        final DragStartHelper helper = createDragStartHelper(listener);
177        helper.attach();
178
179        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
180        sendMouseEvent(MotionEvent.ACTION_UP, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
181
182        // A simple mouse click does not trigger OnDragStart.
183        verifyNoMoreInteractions(listener);
184    }
185
186    @SmallTest
187    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
188    @Test
189    public void mousePressWithSecondaryButton() throws Throwable {
190        final DragStartListener listener = createListener(true);
191        final DragStartHelper helper = createDragStartHelper(listener);
192        helper.attach();
193
194        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
195        sendMouseEvent(MotionEvent.ACTION_MOVE,
196                MotionEvent.BUTTON_PRIMARY | MotionEvent.BUTTON_SECONDARY, mDragSource, 0, 0);
197        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
198
199        // ACTION_MOVE with the same position does not trigger OnDragStart.
200        verifyNoMoreInteractions(listener);
201    }
202
203    @SmallTest
204    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
205    @Test
206    public void mouseDrag() throws Throwable {
207        final DragStartListener listener = createListener(true);
208        final DragStartHelper helper = createDragStartHelper(listener);
209        helper.attach();
210
211        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
212        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
213        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 3, 4);
214        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 5, 6);
215
216        // Returning true from the callback prevents further callbacks.
217        verify(listener, times(1)).onDragStart(
218                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
219        verifyNoMoreInteractions(listener);
220    }
221
222    @SmallTest
223    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
224    @Test
225    public void mouseDragWithNonprimaryButton() throws Throwable {
226        final DragStartListener listener = createListener(true);
227        final DragStartHelper helper = createDragStartHelper(listener);
228        helper.attach();
229
230        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_SECONDARY, mDragSource, 0, 0);
231        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_SECONDARY, mDragSource, 1, 2);
232        sendMouseEvent(MotionEvent.ACTION_UP, MotionEvent.BUTTON_SECONDARY, mDragSource, 3, 4);
233
234        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_TERTIARY, mDragSource, 0, 0);
235        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_TERTIARY, mDragSource, 1, 2);
236        sendMouseEvent(MotionEvent.ACTION_UP, MotionEvent.BUTTON_TERTIARY, mDragSource, 3, 4);
237
238        // Dragging mouse with a non-primary button down does not trigger OnDragStart.
239        verifyNoMoreInteractions(listener);
240    }
241
242    @SmallTest
243    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
244    @Test
245    public void mouseDragUsingTouchListener() throws Throwable {
246        final DragStartListener listener = createListener(true);
247        final DragStartHelper helper = createDragStartHelper(listener);
248
249        mDragSource.setOnTouchListener(new View.OnTouchListener() {
250            @Override
251            public boolean onTouch(View view, MotionEvent motionEvent) {
252                helper.onTouch(view, motionEvent);
253                return true;
254            }
255        });
256
257        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
258        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
259        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 3, 4);
260        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 5, 6);
261
262        // Returning true from the callback prevents further callbacks.
263        verify(listener, times(1)).onDragStart(
264                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
265        verifyNoMoreInteractions(listener);
266    }
267
268    @SmallTest
269    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
270    @Test
271    public void mouseDragWhenListenerReturnsFalse() throws Throwable {
272        final DragStartListener listener = createListener(false);
273        final DragStartHelper helper = createDragStartHelper(listener);
274        helper.attach();
275
276        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 0, 0);
277        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
278        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 3, 4);
279        sendMouseEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY, mDragSource, 5, 6);
280
281        // When the listener returns false every ACTION_MOVE triggers OnDragStart.
282        InOrder inOrder = inOrder(listener);
283        inOrder.verify(listener, times(1)).onDragStart(
284                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
285        inOrder.verify(listener, times(1)).onDragStart(
286                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 3, 4)));
287        inOrder.verify(listener, times(1)).onDragStart(
288                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 5, 6)));
289        inOrder.verifyNoMoreInteractions();
290    }
291
292    @LargeTest
293    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.ICE_CREAM_SANDWICH)
294    @Test
295    public void mouseLongPress() throws Throwable {
296        final DragStartListener listener = createListener(true);
297        final DragStartHelper helper = createDragStartHelper(listener);
298        helper.attach();
299
300        sendMouseEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_PRIMARY, mDragSource, 1, 2);
301        waitForLongPress();
302
303        // Long press triggers OnDragStart.
304        verify(listener, times(1)).onDragStart(
305                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
306        verifyNoMoreInteractions(listener);
307    }
308
309    @SmallTest
310    @Test
311    public void touchDrag() throws Throwable {
312        final DragStartListener listener = createListener(false);
313        final DragStartHelper helper = createDragStartHelper(listener);
314        helper.attach();
315
316        sendTouchEvent(MotionEvent.ACTION_DOWN, mDragSource, 0, 0);
317        sendTouchEvent(MotionEvent.ACTION_MOVE, mDragSource, 1, 2);
318        sendTouchEvent(MotionEvent.ACTION_MOVE, mDragSource, 3, 4);
319        sendTouchEvent(MotionEvent.ACTION_MOVE, mDragSource, 5, 6);
320
321        // Touch and drag (without delay) does not trigger OnDragStart.
322        verifyNoMoreInteractions(listener);
323    }
324
325    @SmallTest
326    @Test
327    public void touchTap() throws Throwable {
328        final DragStartListener listener = createListener(false);
329        final DragStartHelper helper = createDragStartHelper(listener);
330        helper.attach();
331
332        sendTouchEvent(MotionEvent.ACTION_DOWN, mDragSource, 0, 0);
333        sendTouchEvent(MotionEvent.ACTION_UP, mDragSource, 0, 0);
334
335        // A simple tap does not trigger OnDragStart.
336        verifyNoMoreInteractions(listener);
337    }
338
339    @LargeTest
340    @Test
341    public void touchLongPress() throws Throwable {
342        final DragStartListener listener = createListener(true);
343        final DragStartHelper helper = createDragStartHelper(listener);
344        helper.attach();
345
346        sendTouchEvent(MotionEvent.ACTION_DOWN, mDragSource, 1, 2);
347        waitForLongPress();
348
349        // Long press triggers OnDragStart.
350        verify(listener, times(1)).onDragStart(
351                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(mDragSource, 1, 2)));
352        verifyNoMoreInteractions(listener);
353    }
354
355    @LargeTest
356    @Test
357    public void touchLongPressUsingLongClickListener() throws Throwable {
358        final DragStartListener listener = createListener(true);
359
360        final DragStartHelper helper = createDragStartHelper(listener);
361        mDragSource.setOnLongClickListener(new View.OnLongClickListener() {
362            @Override
363            public boolean onLongClick(View view) {
364                return helper.onLongClick(view);
365            }
366        });
367
368        sendTouchEvent(MotionEvent.ACTION_DOWN, mDragSource, 1, 2);
369        waitForLongPress();
370
371        // Long press triggers OnDragStart.
372        // Since ACTION_DOWN is not handled, the touch offset is not available.
373        verify(listener, times(1)).onDragStart(
374                eq(mDragSource), eq(helper), argThat(new TouchPositionMatcher(0, 0)));
375        verifyNoMoreInteractions(listener);
376    }
377}
378