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 */
16package androidx.appcompat.widget;
17
18import static android.support.test.espresso.Espresso.onData;
19import static android.support.test.espresso.Espresso.onView;
20import static android.support.test.espresso.action.ViewActions.click;
21import static android.support.test.espresso.assertion.ViewAssertions.doesNotExist;
22import static android.support.test.espresso.assertion.ViewAssertions.matches;
23import static android.support.test.espresso.matcher.RootMatchers.isPlatformPopup;
24import static android.support.test.espresso.matcher.RootMatchers.withDecorView;
25import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
26import static android.support.test.espresso.matcher.ViewMatchers.withClassName;
27import static android.support.test.espresso.matcher.ViewMatchers.withId;
28import static android.support.test.espresso.matcher.ViewMatchers.withText;
29
30import static org.hamcrest.Matchers.allOf;
31import static org.hamcrest.Matchers.anything;
32import static org.hamcrest.core.Is.is;
33import static org.hamcrest.core.IsNot.not;
34import static org.junit.Assert.assertEquals;
35import static org.junit.Assert.assertNotEquals;
36import static org.junit.Assert.assertNotNull;
37import static org.mockito.Mockito.any;
38import static org.mockito.Mockito.mock;
39import static org.mockito.Mockito.never;
40import static org.mockito.Mockito.times;
41import static org.mockito.Mockito.verify;
42
43import android.app.Instrumentation;
44import android.content.res.Resources;
45import android.graphics.Rect;
46import android.graphics.drawable.Drawable;
47import android.os.Build;
48import android.os.SystemClock;
49import android.support.test.InstrumentationRegistry;
50import android.support.test.espresso.Root;
51import android.support.test.espresso.UiController;
52import android.support.test.espresso.ViewAction;
53import android.support.test.filters.FlakyTest;
54import android.support.test.filters.LargeTest;
55import android.support.test.filters.MediumTest;
56import android.support.test.filters.SdkSuppress;
57import android.support.test.rule.ActivityTestRule;
58import android.support.test.runner.AndroidJUnit4;
59import android.text.TextUtils;
60import android.view.InputDevice;
61import android.view.MenuInflater;
62import android.view.MenuItem;
63import android.view.MotionEvent;
64import android.view.View;
65import android.view.ViewParent;
66import android.widget.Button;
67import android.widget.FrameLayout;
68import android.widget.ListView;
69
70import androidx.appcompat.test.R;
71import androidx.core.view.MenuItemCompat;
72import androidx.testutils.PollingCheck;
73
74import org.hamcrest.Description;
75import org.hamcrest.Matcher;
76import org.hamcrest.Matchers;
77import org.hamcrest.TypeSafeMatcher;
78import org.junit.After;
79import org.junit.Before;
80import org.junit.Rule;
81import org.junit.Test;
82import org.junit.runner.RunWith;
83
84@RunWith(AndroidJUnit4.class)
85public class PopupMenuTest {
86    @Rule
87    public final ActivityTestRule<PopupTestActivity> mActivityTestRule =
88            new ActivityTestRule<>(PopupTestActivity.class);
89
90    // Since PopupMenu doesn't expose any access to the underlying
91    // implementation (like ListPopupWindow.getListView), we're relying on the
92    // class name of the list view from MenuPopupWindow that is being used
93    // in PopupMenu. This is not the cleanest, but it's not making any assumptions
94    // on the platform-specific details of the popup windows.
95    private static final String DROP_DOWN_CLASS_NAME =
96            "androidx.appcompat.widget.MenuPopupWindow$MenuDropDownListView";
97    private FrameLayout mContainer;
98
99    private Button mButton;
100
101    private PopupMenu mPopupMenu;
102
103    private Resources mResources;
104
105    private View mMainDecorView;
106
107    @Before
108    public void setUp() throws Exception {
109        final PopupTestActivity activity = mActivityTestRule.getActivity();
110        mContainer = activity.findViewById(R.id.container);
111        mButton = mContainer.findViewById(R.id.test_button);
112        mResources = mActivityTestRule.getActivity().getResources();
113        mMainDecorView = mActivityTestRule.getActivity().getWindow().getDecorView();
114    }
115
116    @After
117    public void tearDown() {
118        if (mPopupMenu != null) {
119            InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
120                @Override
121                public void run() {
122                    mPopupMenu.dismiss();
123                }
124            });
125        }
126    }
127
128    @Test
129    @MediumTest
130    public void testBasicContent() throws Throwable {
131        final Builder menuBuilder = new Builder();
132        menuBuilder.wireToActionButton();
133
134        onView(withId(R.id.test_button)).perform(click());
135        assertNotNull("Popup menu created", mPopupMenu);
136
137        final MenuItem hightlightItem = mPopupMenu.getMenu().findItem(R.id.action_highlight);
138        assertEquals(mResources.getString(R.string.popup_menu_highlight_description),
139                MenuItemCompat.getContentDescription(hightlightItem));
140        assertEquals(mResources.getString(R.string.popup_menu_highlight_tooltip),
141                MenuItemCompat.getTooltipText(hightlightItem));
142
143        final MenuItem editItem = mPopupMenu.getMenu().findItem(R.id.action_edit);
144        assertNotNull(MenuItemCompat.getContentDescription(hightlightItem));
145        assertNotNull(MenuItemCompat.getTooltipText(hightlightItem));
146        mActivityTestRule.runOnUiThread(new Runnable() {
147            @Override
148            public void run() {
149                MenuItemCompat.setContentDescription(editItem,
150                        mResources.getString(R.string.popup_menu_edit_description));
151                MenuItemCompat.setTooltipText(editItem,
152                        mResources.getString(R.string.popup_menu_edit_tooltip));
153            }
154        });
155
156        // Unlike ListPopupWindow, PopupMenu doesn't have an API to check whether it is showing.
157        // Use a custom matcher to check the visibility of the drop down list view instead.
158        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME)))
159                .inRoot(isPlatformPopup()).check(matches(isDisplayed()));
160
161        // Note that MenuItem.isVisible() refers to the current "data" visibility state
162        // and not the "on screen" visibility state. This is why we're testing the display
163        // visibility of our main and sub menu items.
164        // onData(anything()).atPosition(x) operates on the content of the popup. It is used to
165        // scroll the popup if necessary to make sure the menu item views are visible.
166        onData(anything()).atPosition(0).check(matches(isDisplayed()));
167        onView(withText(mResources.getString(R.string.popup_menu_highlight)))
168                .inRoot(withDecorView(not(is(mMainDecorView))))
169                .check(matches(isDisplayed()))
170                .check(matches(selfOrParentWithContentDescription(
171                        mResources.getString(R.string.popup_menu_highlight_description))));
172        onData(anything()).atPosition(1).check(matches(isDisplayed()));
173        onView(withText(mResources.getString(R.string.popup_menu_edit)))
174                .inRoot(withDecorView(not(is(mMainDecorView))))
175                .check(matches(isDisplayed()))
176                .check(matches(selfOrParentWithContentDescription(
177                        mResources.getString(R.string.popup_menu_edit_description))));
178        onData(anything()).atPosition(2).check(matches(isDisplayed()));
179        onView(withText(mResources.getString(R.string.popup_menu_delete)))
180                .inRoot(withDecorView(not(is(mMainDecorView))))
181                .check(matches(isDisplayed()));
182        onData(anything()).atPosition(3).check(matches(isDisplayed()));
183        onView(withText(mResources.getString(R.string.popup_menu_ignore)))
184                .inRoot(withDecorView(not(is(mMainDecorView))))
185                .check(matches(isDisplayed()));
186        onData(anything()).atPosition(4).check(matches(isDisplayed()));
187        onView(withText(mResources.getString(R.string.popup_menu_share)))
188                .inRoot(withDecorView(not(is(mMainDecorView))))
189                .check(matches(isDisplayed()));
190        onData(anything()).atPosition(5).check(matches(isDisplayed()));
191        onView(withText(mResources.getString(R.string.popup_menu_print)))
192                .inRoot(withDecorView(not(is(mMainDecorView))))
193                .check(matches(isDisplayed()));
194
195        // Share submenu items should not be visible
196        onView(withText(mResources.getString(R.string.popup_menu_share_email)))
197                .inRoot(withDecorView(not(is(mMainDecorView))))
198                .check(doesNotExist());
199        onView(withText(mResources.getString(R.string.popup_menu_share_circles)))
200                .inRoot(withDecorView(not(is(mMainDecorView))))
201                .check(doesNotExist());
202    }
203
204    /**
205     * Returns the location of our popup menu in its window.
206     */
207    private int[] getPopupLocationInWindow() {
208        final int[] location = new int[2];
209        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME)))
210                .inRoot(isPlatformPopup()).perform(new ViewAction() {
211            @Override
212            public Matcher<View> getConstraints() {
213                return isDisplayed();
214            }
215
216            @Override
217            public String getDescription() {
218                return "Popup matcher";
219            }
220
221            @Override
222            public void perform(UiController uiController, View view) {
223                view.getLocationInWindow(location);
224            }
225        });
226        return location;
227    }
228
229    /**
230     * Returns the location of our popup menu on the screen.
231     */
232    private int[] getPopupLocationOnScreen() {
233        final int[] location = new int[2];
234        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME)))
235                .inRoot(isPlatformPopup()).perform(new ViewAction() {
236            @Override
237            public Matcher<View> getConstraints() {
238                return isDisplayed();
239            }
240
241            @Override
242            public String getDescription() {
243                return "Popup matcher";
244            }
245
246            @Override
247            public void perform(UiController uiController, View view) {
248                view.getLocationOnScreen(location);
249            }
250        });
251        return location;
252    }
253
254    /**
255     * Returns the combined padding around the content of our popup menu.
256     */
257    private Rect getPopupPadding() {
258        final Rect result = new Rect();
259        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME)))
260                .inRoot(isPlatformPopup()).perform(new ViewAction() {
261            @Override
262            public Matcher<View> getConstraints() {
263                return isDisplayed();
264            }
265
266            @Override
267            public String getDescription() {
268                return "Popup matcher";
269            }
270
271            @Override
272            public void perform(UiController uiController, View view) {
273                // Traverse the parent hierarchy and combine all their paddings
274                result.setEmpty();
275                final Rect current = new Rect();
276                while (true) {
277                    ViewParent parent = view.getParent();
278                    if (parent == null || !(parent instanceof View)) {
279                        return;
280                    }
281
282                    view = (View) parent;
283                    Drawable currentBackground = view.getBackground();
284                    if (currentBackground != null) {
285                        currentBackground.getPadding(current);
286                        result.left += current.left;
287                        result.right += current.right;
288                        result.top += current.top;
289                        result.bottom += current.bottom;
290                    }
291                }
292            }
293        });
294        return result;
295    }
296
297    /**
298     * Returns a root matcher that matches roots that have window focus on their decor view.
299     */
300    private static Matcher<Root> hasWindowFocus() {
301        return new TypeSafeMatcher<Root>() {
302            @Override
303            public void describeTo(Description description) {
304                description.appendText("has window focus");
305            }
306
307            @Override
308            public boolean matchesSafely(Root root) {
309                View rootView = root.getDecorView();
310                return rootView.hasWindowFocus();
311            }
312        };
313    }
314
315    @FlakyTest(bugId = 33669575)
316    @Test
317    @LargeTest
318    public void testAnchoring() {
319        Builder menuBuilder = new Builder();
320        menuBuilder.wireToActionButton();
321
322        onView(withId(R.id.test_button)).perform(click());
323
324        final int[] anchorOnScreenXY = new int[2];
325        final int[] popupOnScreenXY = getPopupLocationOnScreen();
326        final int[] popupInWindowXY = getPopupLocationInWindow();
327        final Rect popupPadding = getPopupPadding();
328
329        mButton.getLocationOnScreen(anchorOnScreenXY);
330
331        // Allow for off-by-one mismatch in anchoring
332
333        // See if Anchoring Y gets animated to the expected value.
334        PollingCheck.waitFor(1000, new PollingCheck.PollingCheckCondition() {
335            @Override
336            public boolean canProceed() {
337                final int y = anchorOnScreenXY[1] + popupInWindowXY[1] + mButton.getHeight()
338                        - popupPadding.top;
339                return Math.abs(y - popupOnScreenXY[1]) <= 1;
340            }
341        });
342
343        assertEquals("Anchoring X", anchorOnScreenXY[0] + popupInWindowXY[0],
344                popupOnScreenXY[0], 1);
345        assertEquals("Anchoring Y",
346                anchorOnScreenXY[1] + popupInWindowXY[1] + mButton.getHeight() - popupPadding.top,
347                popupOnScreenXY[1], 1);
348    }
349
350    @Test
351    @MediumTest
352    public void testDismissalViaAPI() throws Throwable {
353        Builder menuBuilder = new Builder().withDismissListener();
354        menuBuilder.wireToActionButton();
355
356        onView(withId(R.id.test_button)).perform(click());
357
358        // Since PopupMenu is not a View, we can't use Espresso's view actions to invoke
359        // the dismiss() API
360        mActivityTestRule.runOnUiThread(new Runnable() {
361            @Override
362            public void run() {
363                mPopupMenu.dismiss();
364            }
365        });
366
367        verify(menuBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu);
368
369        // Unlike ListPopupWindow, PopupMenu doesn't have an API to check whether it is showing.
370        // Use a custom matcher to check the visibility of the drop down list view instead.
371        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME))).check(doesNotExist());
372    }
373
374    @Test
375    @MediumTest
376    public void testDismissalViaTouch() throws Throwable {
377        Builder menuBuilder = new Builder().withDismissListener();
378        menuBuilder.wireToActionButton();
379
380        onView(withId(R.id.test_button)).perform(click());
381
382        // Determine the location of the popup on the screen so that we can emulate
383        // a tap outside of its bounds to dismiss it
384        final int[] popupOnScreenXY = getPopupLocationOnScreen();
385        final Rect popupPadding = getPopupPadding();
386
387
388        int emulatedTapX = popupOnScreenXY[0] - popupPadding.left - 20;
389        int emulatedTapY = popupOnScreenXY[1] + popupPadding.top + 20;
390
391        // The logic below uses Instrumentation to emulate a tap outside the bounds of the
392        // displayed popup menu. This tap is then treated by the framework to be "split" as
393        // the ACTION_OUTSIDE for the popup itself, as well as DOWN / MOVE / UP for the underlying
394        // view root if the popup is not modal.
395        // It is not correct to emulate these two sequences separately in the test, as it
396        // wouldn't emulate the user-facing interaction for this test. Note that usage
397        // of Instrumentation is necessary here since Espresso's actions operate at the level
398        // of view or data. Also, we don't want to use View.dispatchTouchEvent directly as
399        // that would require emulation of two separate sequences as well.
400
401        Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
402
403        // Inject DOWN event
404        long downTime = SystemClock.uptimeMillis();
405        MotionEvent eventDown = MotionEvent.obtain(
406                downTime, downTime, MotionEvent.ACTION_DOWN, emulatedTapX, emulatedTapY, 1);
407        instrumentation.sendPointerSync(eventDown);
408
409        // Inject MOVE event
410        long moveTime = SystemClock.uptimeMillis();
411        MotionEvent eventMove = MotionEvent.obtain(
412                moveTime, moveTime, MotionEvent.ACTION_MOVE, emulatedTapX, emulatedTapY, 1);
413        instrumentation.sendPointerSync(eventMove);
414
415        // Inject UP event
416        long upTime = SystemClock.uptimeMillis();
417        MotionEvent eventUp = MotionEvent.obtain(
418                upTime, upTime, MotionEvent.ACTION_UP, emulatedTapX, emulatedTapY, 1);
419        instrumentation.sendPointerSync(eventUp);
420
421        // Wait for the system to process all events in the queue
422        instrumentation.waitForIdleSync();
423
424        // At this point our popup should not be showing and should have notified its
425        // dismiss listener
426        verify(menuBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu);
427        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME))).check(doesNotExist());
428    }
429
430    @Test
431    @MediumTest
432    public void testSimpleMenuItemClickViaEvent() {
433        Builder menuBuilder = new Builder().withMenuItemClickListener();
434        menuBuilder.wireToActionButton();
435
436        onView(withId(R.id.test_button)).perform(click());
437
438        // Verify that our menu item click listener hasn't been called yet
439        verify(menuBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class));
440
441        onView(withText(mResources.getString(R.string.popup_menu_delete)))
442                .inRoot(withDecorView(not(is(mMainDecorView))))
443                .perform(click());
444
445        // Verify that out menu item click listener has been called with the expected menu item
446        verify(menuBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
447                mPopupMenu.getMenu().findItem(R.id.action_delete));
448
449        // Popup menu should be automatically dismissed on selecting an item
450        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME))).check(doesNotExist());
451    }
452
453    @Test
454    @MediumTest
455    public void testSimpleMenuItemClickViaAPI() throws Throwable {
456        Builder menuBuilder = new Builder().withMenuItemClickListener();
457        menuBuilder.wireToActionButton();
458
459        onView(withId(R.id.test_button)).perform(click());
460
461        // Verify that our menu item click listener hasn't been called yet
462        verify(menuBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class));
463
464        mActivityTestRule.runOnUiThread(new Runnable() {
465            @Override
466            public void run() {
467                mPopupMenu.getMenu().performIdentifierAction(R.id.action_highlight, 0);
468            }
469        });
470
471        // Verify that out menu item click listener has been called with the expected menu item
472        verify(menuBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
473                mPopupMenu.getMenu().findItem(R.id.action_highlight));
474
475        // Popup menu should be automatically dismissed on selecting an item
476        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME))).check(doesNotExist());
477    }
478
479    @Test
480    @MediumTest
481    public void testSubMenuClicksViaEvent() throws Throwable {
482        Builder menuBuilder = new Builder().withMenuItemClickListener();
483        menuBuilder.wireToActionButton();
484
485        onView(withId(R.id.test_button)).perform(click());
486
487        // Verify that our menu item click listener hasn't been called yet
488        verify(menuBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class));
489
490        onView(withText(mResources.getString(R.string.popup_menu_share)))
491                .inRoot(withDecorView(not(is(mMainDecorView))))
492                .perform(click());
493
494        // Verify that out menu item click listener has been called with the expected menu item
495        verify(menuBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
496                mPopupMenu.getMenu().findItem(R.id.action_share));
497
498        // Sleep for a bit to allow the menu -> submenu transition to complete
499        Thread.sleep(1000);
500
501        // At this point we should now have our sub-menu displayed. At this point on newer
502        // platform versions (L+) we have two view roots on the screen - one for the main popup
503        // menu and one for the submenu that has just been activated. If we only use the
504        // logic based on decor view, Espresso will time out on waiting for the first root
505        // to acquire window focus. This is why from this point on in this test we are using
506        // two root conditions to detect the submenu - one with decor view not being the same
507        // as the decor view of our main activity window, and the other that checks for window
508        // focus.
509
510        // Unlike ListPopupWindow, PopupMenu doesn't have an API to check whether it is showing.
511        // Use a custom matcher to check the visibility of the drop down list view instead.
512        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME)))
513                .inRoot(allOf(withDecorView(not(is(mMainDecorView))), hasWindowFocus()))
514                .check(matches(isDisplayed()));
515
516        // Note that MenuItem.isVisible() refers to the current "data" visibility state
517        // and not the "on screen" visibility state. This is why we're testing the display
518        // visibility of our main and sub menu items.
519
520        // Share submenu items should now be visible
521        onView(withText(mResources.getString(R.string.popup_menu_share_email)))
522                .inRoot(allOf(withDecorView(not(is(mMainDecorView))), hasWindowFocus()))
523                .check(matches(isDisplayed()));
524        onView(withText(mResources.getString(R.string.popup_menu_share_circles)))
525                .inRoot(allOf(withDecorView(not(is(mMainDecorView))), hasWindowFocus()))
526                .check(matches(isDisplayed()));
527
528        // Now click an item in the sub-menu
529        onView(withText(mResources.getString(R.string.popup_menu_share_circles)))
530                .inRoot(allOf(withDecorView(not(is(mMainDecorView))), hasWindowFocus()))
531                .perform(click());
532
533        // Verify that out menu item click listener has been called with the expected menu item
534        verify(menuBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
535                mPopupMenu.getMenu().findItem(R.id.action_share_circles));
536
537        // Popup menu should be automatically dismissed on selecting an item in the submenu
538        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME))).check(doesNotExist());
539    }
540
541    @Test
542    @MediumTest
543    public void testSubMenuClicksViaAPI() throws Throwable {
544        Builder menuBuilder = new Builder().withMenuItemClickListener();
545        menuBuilder.wireToActionButton();
546
547        onView(withId(R.id.test_button)).perform(click());
548
549        // Verify that our menu item click listener hasn't been called yet
550        verify(menuBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class));
551
552        mActivityTestRule.runOnUiThread(new Runnable() {
553            @Override
554            public void run() {
555                mPopupMenu.getMenu().performIdentifierAction(R.id.action_share, 0);
556            }
557        });
558
559        // Verify that out menu item click listener has been called with the expected menu item
560        verify(menuBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
561                mPopupMenu.getMenu().findItem(R.id.action_share));
562
563        // Sleep for a bit to allow the menu -> submenu transition to complete
564        Thread.sleep(1000);
565
566        // At this point we should now have our sub-menu displayed. At this point on newer
567        // platform versions (L+) we have two view roots on the screen - one for the main popup
568        // menu and one for the submenu that has just been activated. If we only use the
569        // logic based on decor view, Espresso will time out on waiting for the first root
570        // to acquire window focus. This is why from this point on in this test we are using
571        // two root conditions to detect the submenu - one with decor view not being the same
572        // as the decor view of our main activity window, and the other that checks for window
573        // focus.
574
575        // Unlike ListPopupWindow, PopupMenu doesn't have an API to check whether it is showing.
576        // Use a custom matcher to check the visibility of the drop down list view instead.
577        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME)))
578                .inRoot(allOf(withDecorView(not(is(mMainDecorView))), hasWindowFocus()))
579                .check(matches(isDisplayed()));
580
581        // Note that MenuItem.isVisible() refers to the current "data" visibility state
582        // and not the "on screen" visibility state. This is why we're testing the display
583        // visibility of our main and sub menu items.
584
585        // Share submenu items should now be visible
586        onView(withText(mResources.getString(R.string.popup_menu_share_email)))
587                .inRoot(allOf(withDecorView(not(is(mMainDecorView))), hasWindowFocus()))
588                .check(matches(isDisplayed()));
589        onView(withText(mResources.getString(R.string.popup_menu_share_circles)))
590                .inRoot(allOf(withDecorView(not(is(mMainDecorView))), hasWindowFocus()))
591                .check(matches(isDisplayed()));
592
593        // Now ask the share submenu to perform an action on its specific menu item
594        mActivityTestRule.runOnUiThread(new Runnable() {
595            @Override
596            public void run() {
597                mPopupMenu.getMenu().findItem(R.id.action_share).getSubMenu().
598                        performIdentifierAction(R.id.action_share_email, 0);
599            }
600        });
601
602        // Verify that out menu item click listener has been called with the expected menu item
603        verify(menuBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
604                mPopupMenu.getMenu().findItem(R.id.action_share_email));
605
606        // Popup menu should be automatically dismissed on selecting an item in the submenu
607        onView(withClassName(Matchers.is(DROP_DOWN_CLASS_NAME))).check(doesNotExist());
608    }
609
610    @Test
611    @MediumTest
612    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
613    public void testHoverSelectsMenuItem() throws Throwable {
614        Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
615
616        // Make menu long enough to be scrollable.
617        final Builder menuBuilder = new Builder().withExtraItems(100);
618        menuBuilder.wireToActionButton();
619
620        onView(withId(R.id.test_button)).perform(
621                click(InputDevice.SOURCE_MOUSE, MotionEvent.BUTTON_PRIMARY));
622
623        final ListView menuListView = mPopupMenu.getMenuListView();
624        assertEquals(0, menuListView.getFirstVisiblePosition());
625        emulateHoverOverVisibleItems(instrumentation, menuListView);
626
627        // Select the last item to force menu scrolling and emulate hover again.
628        mActivityTestRule.runOnUiThread(new Runnable() {
629            @Override
630            public void run() {
631                menuListView.setSelectionFromTop(mPopupMenu.getMenu().size() - 1, 0);
632            }
633        });
634        instrumentation.waitForIdleSync();
635
636        assertNotEquals("Too few menu items to test for scrolling",
637                0, menuListView.getFirstVisiblePosition());
638        emulateHoverOverVisibleItems(instrumentation, menuListView);
639    }
640
641    private void emulateHoverOverVisibleItems(Instrumentation instrumentation, ListView listView) {
642        final int childCount = listView.getChildCount();
643        for (int i = 0; i < childCount; i++) {
644            View itemView = listView.getChildAt(i);
645            injectMouseEvent(instrumentation, itemView, MotionEvent.ACTION_HOVER_MOVE);
646
647            // Wait for the system to process all events in the queue.
648            instrumentation.waitForIdleSync();
649
650            // Hovered menu item should be selected.
651            assertEquals(listView.getFirstVisiblePosition() + i,
652                    listView.getSelectedItemPosition());
653        }
654    }
655
656    public static void injectMouseEvent(Instrumentation instrumentation, View view, int action) {
657        final int[] xy = new int[2];
658        view.getLocationOnScreen(xy);
659        long eventTime = SystemClock.uptimeMillis();
660        MotionEvent event = MotionEvent.obtain(eventTime, eventTime, action, xy[0], xy[1], 0);
661        event.setSource(InputDevice.SOURCE_MOUSE);
662        instrumentation.sendPointerSync(event);
663        event.recycle();
664    }
665
666    /**
667     * Inner helper class to configure an instance of <code>PopupMenu</code> for the
668     * specific test. The main reason for its existence is that once a popup menu is shown
669     * with the show() method, most of its configuration APIs are no-ops. This means that
670     * we can't add logic that is specific to a certain test once it's shown and we have a
671     * reference to a displayed PopupMenu.
672     */
673    public class Builder {
674        private boolean mHasDismissListener;
675        private boolean mHasMenuItemClickListener;
676        private int mExtraItemCount;
677
678        private PopupMenu.OnMenuItemClickListener mOnMenuItemClickListener;
679        private PopupMenu.OnDismissListener mOnDismissListener;
680
681        public Builder withMenuItemClickListener() {
682            mHasMenuItemClickListener = true;
683            return this;
684        }
685
686        public Builder withDismissListener() {
687            mHasDismissListener = true;
688            return this;
689        }
690
691        public Builder withExtraItems(int count) {
692            mExtraItemCount = count;
693            return this;
694        }
695
696        private void show() {
697            mPopupMenu = new PopupMenu(mContainer.getContext(), mButton);
698            final MenuInflater menuInflater = mPopupMenu.getMenuInflater();
699            menuInflater.inflate(R.menu.popup_menu, mPopupMenu.getMenu());
700
701            if (mHasMenuItemClickListener) {
702                // Register a mock listener to be notified when a menu item in our popup menu has
703                // been clicked.
704                mOnMenuItemClickListener = mock(PopupMenu.OnMenuItemClickListener.class);
705                mPopupMenu.setOnMenuItemClickListener(mOnMenuItemClickListener);
706            }
707
708            if (mHasDismissListener) {
709                // Register a mock listener to be notified when our popup menu is dismissed.
710                mOnDismissListener = mock(PopupMenu.OnDismissListener.class);
711                mPopupMenu.setOnDismissListener(mOnDismissListener);
712            }
713
714            // Add extra items.
715            for (int i = 0; i < mExtraItemCount; i++) {
716                mPopupMenu.getMenu().add("Extra item " + i);
717            }
718
719            // Show the popup menu
720            mPopupMenu.show();
721        }
722
723        public void wireToActionButton() {
724            mButton.setOnClickListener(new View.OnClickListener() {
725                @Override
726                public void onClick(View v) {
727                    show();
728                }
729            });
730        }
731    }
732
733    private static Matcher<View> selfOrParentWithContentDescription(final CharSequence expected) {
734        return new TypeSafeMatcher<View>() {
735            @Override
736            public void describeTo(Description description) {
737                description.appendText("self of parent has content description: " + expected);
738            }
739
740            @Override
741            public boolean matchesSafely(View view) {
742                return TextUtils.equals(expected, getSelfOrParentContentDescription(view));
743            }
744
745            private CharSequence getSelfOrParentContentDescription(View view) {
746                while (view != null) {
747                    final CharSequence contentDescription = view.getContentDescription();
748                    if (contentDescription != null) {
749                        return contentDescription;
750                    }
751                    final ViewParent parent = view.getParent();
752                    if (!(parent instanceof View)) {
753                        break;
754                    }
755                    view = (View) parent;
756                }
757                return null;
758            }
759        };
760    }
761}
762