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