1/*
2 * Copyright (C) 2015 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 */
16package android.support.v7.widget;
17
18import android.app.Instrumentation;
19import android.content.Context;
20import android.graphics.Rect;
21import android.os.SystemClock;
22import android.support.test.InstrumentationRegistry;
23import android.support.v7.app.BaseInstrumentationTestCase;
24import android.support.v7.appcompat.test.R;
25import android.test.suitebuilder.annotation.MediumTest;
26import android.test.suitebuilder.annotation.SmallTest;
27import android.view.LayoutInflater;
28import android.view.MenuItem;
29import android.view.MotionEvent;
30import android.view.View;
31import android.view.ViewGroup;
32import android.widget.AdapterView;
33import android.widget.BaseAdapter;
34import android.widget.Button;
35import android.widget.FrameLayout;
36import android.widget.PopupWindow;
37import android.widget.TextView;
38
39import org.junit.Before;
40import org.junit.Test;
41
42import static android.support.test.espresso.Espresso.onView;
43import static android.support.test.espresso.action.ViewActions.click;
44import static android.support.test.espresso.assertion.ViewAssertions.matches;
45import static android.support.test.espresso.matcher.RootMatchers.withDecorView;
46import static android.support.test.espresso.matcher.ViewMatchers.*;
47import static org.hamcrest.core.Is.is;
48import static org.hamcrest.core.IsNot.not;
49import static org.junit.Assert.assertEquals;
50import static org.junit.Assert.assertFalse;
51import static org.junit.Assert.assertNotNull;
52import static org.junit.Assert.assertTrue;
53import static org.mockito.Matchers.any;
54import static org.mockito.Mockito.*;
55
56public class ListPopupWindowTest extends BaseInstrumentationTestCase<PopupTestActivity> {
57    private FrameLayout mContainer;
58
59    private Button mButton;
60
61    private ListPopupWindow mListPopupWindow;
62
63    private BaseAdapter mListPopupAdapter;
64
65    private AdapterView.OnItemClickListener mItemClickListener;
66
67    /**
68     * Item click listener that dismisses our <code>ListPopupWindow</code> when any item
69     * is clicked. Note that this needs to be a separate class that is also protected (not
70     * private) so that Mockito can "spy" on it.
71     */
72    protected class PopupItemClickListener implements AdapterView.OnItemClickListener {
73        @Override
74        public void onItemClick(AdapterView<?> parent, View view, int position,
75                long id) {
76            mListPopupWindow.dismiss();
77        }
78    }
79
80    public ListPopupWindowTest() {
81        super(PopupTestActivity.class);
82    }
83
84    @Before
85    public void setUp() throws Exception {
86        final PopupTestActivity activity = mActivityTestRule.getActivity();
87        mContainer = (FrameLayout) activity.findViewById(R.id.container);
88        mButton = (Button) mContainer.findViewById(R.id.test_button);
89        mItemClickListener = new PopupItemClickListener();
90    }
91
92    @Test
93    @SmallTest
94    public void testBasicContent() {
95        Builder popupBuilder = new Builder();
96        popupBuilder.wireToActionButton();
97
98        onView(withId(R.id.test_button)).perform(click());
99        assertNotNull("Popup window created", mListPopupWindow);
100        assertTrue("Popup window showing", mListPopupWindow.isShowing());
101
102        final View mainDecorView = mActivityTestRule.getActivity().getWindow().getDecorView();
103        onView(withText("Alice"))
104                .inRoot(withDecorView(not(is(mainDecorView))))
105                .check(matches(isDisplayed()));
106        onView(withText("Bob"))
107                .inRoot(withDecorView(not(is(mainDecorView))))
108                .check(matches(isDisplayed()));
109        onView(withText("Charlie"))
110                .inRoot(withDecorView(not(is(mainDecorView))))
111                .check(matches(isDisplayed()));
112        onView(withText("Deirdre"))
113                .inRoot(withDecorView(not(is(mainDecorView))))
114                .check(matches(isDisplayed()));
115        onView(withText("El"))
116                .inRoot(withDecorView(not(is(mainDecorView))))
117                .check(matches(isDisplayed()));
118    }
119
120    @Test
121    @SmallTest
122    public void testAnchoring() {
123        Builder popupBuilder = new Builder();
124        popupBuilder.wireToActionButton();
125
126        onView(withId(R.id.test_button)).perform(click());
127        assertTrue("Popup window showing", mListPopupWindow.isShowing());
128        assertEquals("Popup window anchor", mButton, mListPopupWindow.getAnchorView());
129
130        final int[] anchorOnScreenXY = new int[2];
131        final int[] popupOnScreenXY = new int[2];
132        final int[] popupInWindowXY = new int[2];
133        final Rect rect = new Rect();
134
135        mListPopupWindow.getListView().getLocationOnScreen(popupOnScreenXY);
136        mButton.getLocationOnScreen(anchorOnScreenXY);
137        mListPopupWindow.getListView().getLocationInWindow(popupInWindowXY);
138        mListPopupWindow.getBackground().getPadding(rect);
139
140        assertEquals("Anchoring X", anchorOnScreenXY[0] + popupInWindowXY[0], popupOnScreenXY[0]);
141        assertEquals("Anchoring Y", anchorOnScreenXY[1] + popupInWindowXY[1] + mButton.getHeight(),
142                popupOnScreenXY[1] + rect.top);
143    }
144
145    @Test
146    @SmallTest
147    public void testDismissalViaAPI() {
148        Builder popupBuilder = new Builder().withDismissListener();
149        popupBuilder.wireToActionButton();
150
151        onView(withId(R.id.test_button)).perform(click());
152        assertTrue("Popup window showing", mListPopupWindow.isShowing());
153
154        InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
155            @Override
156            public void run() {
157                mListPopupWindow.dismiss();
158            }
159        });
160
161        // Verify that our dismiss listener has been called
162        verify(popupBuilder.mOnDismissListener, times(1)).onDismiss();
163        assertFalse("Popup window not showing after dismissal", mListPopupWindow.isShowing());
164    }
165
166    private void testDismissalViaTouch(boolean setupAsModal) throws Throwable {
167        Builder popupBuilder = new Builder().setModal(setupAsModal).withDismissListener();
168        popupBuilder.wireToActionButton();
169
170        // Also register a click listener on the top-level container
171        View.OnClickListener mockContainerClickListener = mock(View.OnClickListener.class);
172        mContainer.setOnClickListener(mockContainerClickListener);
173
174        onView(withId(R.id.test_button)).perform(click());
175        assertTrue("Popup window showing", mListPopupWindow.isShowing());
176        // Make sure that the modality of the popup window is set up correctly
177        assertEquals("Popup window modality", setupAsModal, mListPopupWindow.isModal());
178
179        // Determine the location of the popup on the screen so that we can emulate
180        // a tap outside of its bounds to dismiss it
181        final int[] popupOnScreenXY = new int[2];
182        final Rect rect = new Rect();
183        mListPopupWindow.getListView().getLocationOnScreen(popupOnScreenXY);
184        mListPopupWindow.getBackground().getPadding(rect);
185
186        int emulatedTapX = popupOnScreenXY[0] - rect.left - 20;
187        int emulatedTapY = popupOnScreenXY[1] - 20;
188
189        // The logic below uses Instrumentation to emulate a tap outside the bounds of the
190        // displayed list popup window. This tap is then treated by the framework to be "split" as
191        // the ACTION_OUTSIDE for the popup itself, as well as DOWN / MOVE / UP for the underlying
192        // view root if the popup is not modal.
193        // It is not correct to emulate these two sequences separately in the test, as it
194        // wouldn't emulate the user-facing interaction for this test. Note that usage
195        // of Instrumentation is necessary here since Espresso's actions operate at the level
196        // of view or data. Also, we don't want to use View.dispatchTouchEvent directly as
197        // that would require emulation of two separate sequences as well.
198
199        Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
200
201        // Inject DOWN event
202        long downTime = SystemClock.uptimeMillis();
203        MotionEvent eventDown = MotionEvent.obtain(
204                downTime, downTime, MotionEvent.ACTION_DOWN, emulatedTapX, emulatedTapY, 1);
205        instrumentation.sendPointerSync(eventDown);
206
207        // Inject MOVE event
208        long moveTime = SystemClock.uptimeMillis();
209        MotionEvent eventMove = MotionEvent.obtain(
210                moveTime, moveTime, MotionEvent.ACTION_MOVE, emulatedTapX, emulatedTapY, 1);
211        instrumentation.sendPointerSync(eventMove);
212
213        // Inject UP event
214        long upTime = SystemClock.uptimeMillis();
215        MotionEvent eventUp = MotionEvent.obtain(
216                upTime, upTime, MotionEvent.ACTION_UP, emulatedTapX, emulatedTapY, 1);
217        instrumentation.sendPointerSync(eventUp);
218
219        // Wait for the system to process all events in the queue
220        instrumentation.waitForIdleSync();
221
222        // At this point our popup should not be showing and should have notified its
223        // dismiss listener
224        verify(popupBuilder.mOnDismissListener, times(1)).onDismiss();
225        assertFalse("Popup window not showing after outside click", mListPopupWindow.isShowing());
226
227        // Also test that the click outside the popup bounds has been "delivered" to the main
228        // container only if the popup is not modal
229        verify(mockContainerClickListener, times(setupAsModal ? 0 : 1)).onClick(mContainer);
230    }
231
232    @Test
233    @SmallTest
234    public void testDismissalOutsideNonModal() throws Throwable {
235        testDismissalViaTouch(false);
236    }
237
238    @Test
239    @SmallTest
240    public void testDismissalOutsideModal() throws Throwable {
241        testDismissalViaTouch(true);
242    }
243
244    @Test
245    @SmallTest
246    public void testItemClickViaEvent() {
247        Builder popupBuilder = new Builder().withItemClickListener();
248        popupBuilder.wireToActionButton();
249
250        onView(withId(R.id.test_button)).perform(click());
251        assertTrue("Popup window showing", mListPopupWindow.isShowing());
252
253        // Verify that our menu item click listener hasn't been called yet
254        verify(popupBuilder.mOnItemClickListener, never()).onItemClick(
255                any(AdapterView.class), any(View.class), any(int.class), any(int.class));
256
257        final View mainDecorView = mActivityTestRule.getActivity().getWindow().getDecorView();
258        onView(withText("Charlie"))
259                .inRoot(withDecorView(not(is(mainDecorView))))
260                .perform(click());
261        // Verify that out menu item click listener has been called with the expected item
262        // position. Note that we use any() for other parameters, as we don't want to tie ourselves
263        // to the specific implementation details of how ListPopupWindow displays its content.
264        verify(popupBuilder.mOnItemClickListener, times(1)).onItemClick(
265                any(AdapterView.class), any(View.class), eq(2), any(int.class));
266
267        // Our item click listener also dismisses the popup
268        assertFalse("Popup window not showing after click", mListPopupWindow.isShowing());
269    }
270
271    @Test
272    @SmallTest
273    public void testItemClickViaAPI() {
274        Builder popupBuilder = new Builder().withItemClickListener();
275        popupBuilder.wireToActionButton();
276
277        onView(withId(R.id.test_button)).perform(click());
278        assertTrue("Popup window showing", mListPopupWindow.isShowing());
279
280        // Verify that our menu item click listener hasn't been called yet
281        verify(popupBuilder.mOnItemClickListener, never()).onItemClick(
282                any(AdapterView.class), any(View.class), any(int.class), any(int.class));
283
284        InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
285            @Override
286            public void run() {
287                mListPopupWindow.performItemClick(1);
288            }
289        });
290
291        // Verify that out menu item click listener has been called with the expected item
292        // position. Note that we use any() for other parameters, as we don't want to tie ourselves
293        // to the specific implementation details of how ListPopupWindow displays its content.
294        verify(popupBuilder.mOnItemClickListener, times(1)).onItemClick(
295                any(AdapterView.class), any(View.class), eq(1), any(int.class));
296        // Our item click listener also dismisses the popup
297        assertFalse("Popup window not showing after click", mListPopupWindow.isShowing());
298    }
299
300    /**
301     * Emulates a drag-down gestures by injecting ACTION events with {@link Instrumentation}.
302     */
303    private void emulateDragDownGesture(int emulatedX, int emulatedStartY, int swipeAmount) {
304        // The logic below uses Instrumentation to emulate a swipe / drag gesture to bring up
305        // the popup content. Note that we don't want to use Espresso's GeneralSwipeAction
306        // as that operates on the level of an individual view. Here we want to test correct
307        // forwarding of events that cross the boundary between the anchor and the popup menu.
308
309        final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
310
311        // Inject DOWN event
312        long downTime = SystemClock.uptimeMillis();
313        MotionEvent eventDown = MotionEvent.obtain(
314                downTime, downTime, MotionEvent.ACTION_DOWN, emulatedX, emulatedStartY, 1);
315        instrumentation.sendPointerSync(eventDown);
316
317        // Inject a sequence of MOVE events that emulate a "swipe down" gesture
318        for (int i = 0; i < 10; i++) {
319            long moveTime = SystemClock.uptimeMillis();
320            final int moveY = emulatedStartY + swipeAmount * i / 10;
321            MotionEvent eventMove = MotionEvent.obtain(
322                    moveTime, moveTime, MotionEvent.ACTION_MOVE, emulatedX, moveY, 1);
323            instrumentation.sendPointerSync(eventMove);
324            // sleep for a bit to emulate a 200ms swipe
325            SystemClock.sleep(20);
326        }
327
328        // Inject UP event
329        long upTime = SystemClock.uptimeMillis();
330        MotionEvent eventUp = MotionEvent.obtain(
331                upTime, upTime, MotionEvent.ACTION_UP, emulatedX, emulatedStartY + swipeAmount, 1);
332        instrumentation.sendPointerSync(eventUp);
333
334        // Wait for the system to process all events in the queue
335        instrumentation.waitForIdleSync();
336    }
337
338    @Test
339    @MediumTest
340    public void testCreateOnDragListener() throws Throwable {
341        // In this test we want precise control over the height of the popup content since
342        // we need to know by how much to swipe down to end the emulated gesture over the
343        // specific item in the popup. This is why we're using a popup style that removes
344        // all decoration around the popup content, as well as our own row layout with known
345        // height.
346        Builder popupBuilder = new Builder()
347                .withPopupStyleAttr(R.style.PopupEmptyStyle)
348                .withContentRowLayoutId(R.layout.popup_window_item)
349                .withItemClickListener().withDismissListener();
350
351        // Configure ListPopupWindow without showing it
352        popupBuilder.configure();
353
354        // Get the anchor view and configure it with ListPopupWindow's drag-to-open listener
355        final View anchor = mActivityTestRule.getActivity().findViewById(R.id.test_button);
356        View.OnTouchListener dragListener = mListPopupWindow.createDragToOpenListener(anchor);
357        anchor.setOnTouchListener(dragListener);
358        // And also configure it to show the popup window on click
359        anchor.setOnClickListener(new View.OnClickListener() {
360            @Override
361            public void onClick(View v) {
362                mListPopupWindow.show();
363            }
364        });
365
366        // Get the height of a row item in our popup window
367        final int popupRowHeight = mActivityTestRule.getActivity().getResources()
368                .getDimensionPixelSize(R.dimen.popup_row_height);
369
370        final int[] anchorOnScreenXY = new int[2];
371        anchor.getLocationOnScreen(anchorOnScreenXY);
372
373        // Compute the start coordinates of a downward swipe and the amount of swipe. We'll
374        // be swiping by twice the row height. That, combined with the swipe originating in the
375        // center of the anchor should result in clicking the second row in the popup.
376        int emulatedX = anchorOnScreenXY[0] + anchor.getWidth() / 2;
377        int emulatedStartY = anchorOnScreenXY[1] + anchor.getHeight() / 2;
378        int swipeAmount = 2 * popupRowHeight;
379
380        // Emulate drag-down gesture with a sequence of motion events
381        emulateDragDownGesture(emulatedX, emulatedStartY, swipeAmount);
382
383        // We expect the swipe / drag gesture to result in clicking the second item in our list.
384        verify(popupBuilder.mOnItemClickListener, times(1)).onItemClick(
385                any(AdapterView.class), any(View.class), eq(1), eq(1L));
386        // Since our item click listener calls dismiss() on the popup, we expect the popup to not
387        // be showing
388        assertFalse(mListPopupWindow.isShowing());
389        // At this point our popup should have notified its dismiss listener
390        verify(popupBuilder.mOnDismissListener, times(1)).onDismiss();
391    }
392
393    /**
394     * Inner helper class to configure an instance of <code>ListPopupWindow</code> for the
395     * specific test. The main reason for its existence is that once a popup window is shown
396     * with the show() method, most of its configuration APIs are no-ops. This means that
397     * we can't add logic that is specific to a certain test (such as dismissing a non-modal
398     * popup window) once it's shown and we have a reference to a displayed ListPopupWindow.
399     */
400    public class Builder {
401        private boolean mIsModal;
402        private boolean mHasDismissListener;
403        private boolean mHasItemClickListener;
404
405        private AdapterView.OnItemClickListener mOnItemClickListener;
406        private PopupWindow.OnDismissListener mOnDismissListener;
407
408        private int mContentRowLayoutId = R.layout.abc_popup_menu_item_layout;
409
410        private boolean mUseCustomPopupStyle;
411        private int mPopupStyleAttr;
412
413        public Builder setModal(boolean isModal) {
414            mIsModal = isModal;
415            return this;
416        }
417
418        public Builder withContentRowLayoutId(int contentRowLayoutId) {
419            mContentRowLayoutId = contentRowLayoutId;
420            return this;
421        }
422
423        public Builder withPopupStyleAttr(int popupStyleAttr) {
424            mUseCustomPopupStyle = true;
425            mPopupStyleAttr = popupStyleAttr;
426            return this;
427        }
428
429        public Builder withItemClickListener() {
430            mHasItemClickListener = true;
431            return this;
432        }
433
434        public Builder withDismissListener() {
435            mHasDismissListener = true;
436            return this;
437        }
438
439        private void configure() {
440            final Context context = mContainer.getContext();
441            if (mUseCustomPopupStyle) {
442                mListPopupWindow = new ListPopupWindow(context, null, mPopupStyleAttr, 0);
443            } else {
444                mListPopupWindow = new ListPopupWindow(context);
445            }
446
447            final String[] POPUP_CONTENT =
448                    new String[]{"Alice", "Bob", "Charlie", "Deirdre", "El"};
449            mListPopupAdapter = new BaseAdapter() {
450                class ViewHolder {
451                    private TextView title;
452                }
453
454                @Override
455                public int getCount() {
456                    return POPUP_CONTENT.length;
457                }
458
459                @Override
460                public Object getItem(int position) {
461                    return POPUP_CONTENT[position];
462                }
463
464                @Override
465                public long getItemId(int position) {
466                    return position;
467                }
468
469                @Override
470                public View getView(int position, View convertView, ViewGroup parent) {
471                    if (convertView == null) {
472                        convertView = LayoutInflater.from(parent.getContext()).inflate(
473                                mContentRowLayoutId, parent, false);
474                        ViewHolder viewHolder = new ViewHolder();
475                        viewHolder.title = (TextView) convertView.findViewById(R.id.title);
476                        convertView.setTag(viewHolder);
477                    }
478
479                    ViewHolder viewHolder = (ViewHolder) convertView.getTag();
480                    viewHolder.title.setText(POPUP_CONTENT[position]);
481                    return convertView;
482                }
483            };
484
485            mListPopupWindow.setAdapter(mListPopupAdapter);
486            mListPopupWindow.setAnchorView(mButton);
487
488            // The following mock listeners have to be set before the call to show() as
489            // they are set on the internally constructed drop down.
490            if (mHasItemClickListener) {
491                // Wrap our item click listener with a Mockito spy
492                mOnItemClickListener = spy(mItemClickListener);
493                // Register that spy as the item click listener on the ListPopupWindow
494                mListPopupWindow.setOnItemClickListener(mOnItemClickListener);
495                // And configure Mockito to call our original listener with onItemClick.
496                // This way we can have both our item click listener running to dismiss the popup
497                // window, and track the invocations of onItemClick with Mockito APIs.
498                doCallRealMethod().when(mOnItemClickListener).onItemClick(
499                        any(AdapterView.class), any(View.class), any(int.class), any(int.class));
500            }
501
502            if (mHasDismissListener) {
503                mOnDismissListener = mock(PopupWindow.OnDismissListener.class);
504                mListPopupWindow.setOnDismissListener(mOnDismissListener);
505            }
506
507            mListPopupWindow.setModal(mIsModal);
508        }
509
510        private void show() {
511            configure();
512            mListPopupWindow.show();
513        }
514
515        public void wireToActionButton() {
516            mButton.setOnClickListener(new View.OnClickListener() {
517                @Override
518                public void onClick(View v) {
519                    show();
520                }
521            });
522        }
523    }
524}
525