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