blob: 275fc4e4b760747b60badaabe49dabcb2d2ad752 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package android.widget.cts;
import static com.android.compatibility.common.util.CtsMockitoUtils.within;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import android.app.Activity;
import android.app.Instrumentation;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.SystemClock;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.PopupMenu;
import androidx.test.InstrumentationRegistry;
import androidx.test.annotation.UiThreadTest;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
import androidx.test.runner.AndroidJUnit4;
import com.android.compatibility.common.util.CtsMouseUtil;
import com.android.compatibility.common.util.CtsTouchUtils;
import com.android.compatibility.common.util.WidgetTestUtils;
import junit.framework.Assert;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class PopupMenuTest {
private Instrumentation mInstrumentation;
private Activity mActivity;
private Builder mBuilder;
private PopupMenu mPopupMenu;
@Rule
public ActivityTestRule<PopupMenuCtsActivity> mActivityRule =
new ActivityTestRule<>(PopupMenuCtsActivity.class);
@UiThreadTest
@Before
public void setup() {
mInstrumentation = InstrumentationRegistry.getInstrumentation();
mActivity = mActivityRule.getActivity();
// Disable and remove focusability on the first child of our activity so that
// it doesn't bring in the soft keyboard that can mess up with some of the tests
// (such as menu dismissal when we emulate a tap outside the menu bounds).
final EditText editText = (EditText) mActivity.findViewById(R.id.anchor_upper_left);
editText.setEnabled(false);
editText.setFocusable(false);
}
@After
public void teardown() throws Throwable {
if (mPopupMenu != null) {
final CountDownLatch latch = new CountDownLatch(1);
try {
mActivityRule.runOnUiThread(() -> {
mPopupMenu.setOnDismissListener((PopupMenu menu) -> {
latch.countDown();
});
mPopupMenu.dismiss();
});
Assert.assertTrue("Expected dismissal occurred within 5 seconds",
latch.await(5, TimeUnit.SECONDS));
} catch (Throwable t) {
throw new RuntimeException(t);
} finally {
mPopupMenu = null;
}
}
}
private void verifyMenuContent() {
final Menu menu = mPopupMenu.getMenu();
assertEquals(6, menu.size());
assertEquals(R.id.action_highlight, menu.getItem(0).getItemId());
assertEquals(R.id.action_edit, menu.getItem(1).getItemId());
assertEquals(R.id.action_delete, menu.getItem(2).getItemId());
assertEquals(R.id.action_ignore, menu.getItem(3).getItemId());
assertEquals(R.id.action_share, menu.getItem(4).getItemId());
assertEquals(R.id.action_print, menu.getItem(5).getItemId());
final SubMenu shareSubMenu = menu.getItem(4).getSubMenu();
assertNotNull(shareSubMenu);
assertEquals(2, shareSubMenu.size());
assertEquals(R.id.action_share_email, shareSubMenu.getItem(0).getItemId());
assertEquals(R.id.action_share_circles, shareSubMenu.getItem(1).getItemId());
}
@Test
public void testPopulateViaInflater() throws Throwable {
mBuilder = new Builder().inflateWithInflater(true).withDismissListener();
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
// Verify that nothing has dismissed the popup menu since it was shown
verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu);
verifyMenuContent();
}
@Test
public void testDirectPopulate() throws Throwable {
mBuilder = new Builder().inflateWithInflater(false).withDismissListener();
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
// Verify that nothing has dismissed the popup menu since it was shown
verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu);
verifyMenuContent();
}
@Test
public void testAccessGravity() throws Throwable {
mBuilder = new Builder();
mActivityRule.runOnUiThread(mBuilder::show);
assertEquals(Gravity.NO_GRAVITY, mPopupMenu.getGravity());
mPopupMenu.setGravity(Gravity.TOP);
assertEquals(Gravity.TOP, mPopupMenu.getGravity());
}
@Test
public void testConstructorWithGravity() throws Throwable {
mBuilder = new Builder().withGravity(Gravity.TOP);
mActivityRule.runOnUiThread(mBuilder::show);
assertEquals(Gravity.TOP, mPopupMenu.getGravity());
}
@Test
public void testDismissalViaAPI() throws Throwable {
mBuilder = new Builder().withDismissListener();
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
// Verify that nothing has dismissed the popup menu since it was shown
verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu);
mActivityRule.runOnUiThread(mPopupMenu::dismiss);
// Verify that the popup menu has been dismissed exactly once
verify(mBuilder.mOnDismissListener, within(1000)).onDismiss(mPopupMenu);
verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu);
mActivityRule.runOnUiThread(mPopupMenu::dismiss);
SystemClock.sleep(2000);
// Shouldn't have any more interactions with our dismiss listener since the menu was
// already dismissed when we called dismiss()
verifyNoMoreInteractions(mBuilder.mOnDismissListener);
mPopupMenu = null;
}
@Test
public void testNestedDismissalViaAPI() throws Throwable {
// Use empty popup style to remove all transitions from the popup. That way we don't
// need to synchronize with the popup window enter transition before proceeding to
// "click" a submenu item.
mBuilder = new Builder().withDismissListener()
.withPopupStyleResource(R.style.PopupWindow_NullTransitions);
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
// Verify that nothing has dismissed the popup menu since it was shown
verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu);
mActivityRule.runOnUiThread(
() -> mPopupMenu.getMenu().performIdentifierAction(R.id.action_share, 0));
SystemClock.sleep(500);
mActivityRule.runOnUiThread(
() -> mPopupMenu.getMenu().findItem(R.id.action_share).getSubMenu().
performIdentifierAction(R.id.action_share_email, 0));
SystemClock.sleep(500);
mActivityRule.runOnUiThread(mPopupMenu::dismiss);
// Verify that the popup menu has been dismissed exactly once
verify(mBuilder.mOnDismissListener, within(1000)).onDismiss(mPopupMenu);
verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu);
mActivityRule.runOnUiThread(mPopupMenu::dismiss);
SystemClock.sleep(2000);
// Shouldn't have any more interactions with our dismiss listener since the menu was
// already dismissed when we called dismiss()
verifyNoMoreInteractions(mBuilder.mOnDismissListener);
mPopupMenu = null;
}
@Test
public void testDismissalViaTouch() throws Throwable {
// Use empty popup style to remove all transitions from the popup. That way we don't
// need to synchronize with the popup window enter transition before proceeding to
// emulate a click outside the popup window bounds.
mBuilder = new Builder().withDismissListener()
.withPopupMenuContent(R.menu.popup_menu_single)
.withPopupStyleResource(R.style.PopupWindow_NullTransitions);
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
// Verify that nothing has dismissed the popup menu since it was shown
verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu);
// The call below uses Instrumentation to emulate a tap outside the bounds of the
// displayed popup menu. This tap is then treated by the framework to be "split" as
// the ACTION_OUTSIDE for the popup itself, as well as DOWN / MOVE / UP for the underlying
// view root if the popup is not modal.
// It is not correct to emulate these two sequences separately in the test, as it
// wouldn't emulate the user-facing interaction for this test. Also, we don't want to use
// View.dispatchTouchEvent directly as that would require emulation of two separate
// sequences as well.
CtsTouchUtils.emulateTapOnView(mInstrumentation, mActivityRule, mBuilder.mAnchor, 10, -20);
// Verify that the popup menu has been dismissed exactly once
verify(mBuilder.mOnDismissListener, within(1000)).onDismiss(mPopupMenu);
verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu);
mPopupMenu = null;
}
@Test
public void testSimpleMenuItemClickViaAPI() throws Throwable {
mBuilder = new Builder().withMenuItemClickListener().withDismissListener();
mActivityRule.runOnUiThread(mBuilder::show);
// Verify that our menu item click listener hasn't been called yet
verify(mBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class));
mActivityRule.runOnUiThread(
() -> mPopupMenu.getMenu().performIdentifierAction(R.id.action_highlight, 0));
// Verify that our menu item click listener has been called with the expected menu item
verify(mBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
mPopupMenu.getMenu().findItem(R.id.action_highlight));
// Popup menu should be automatically dismissed on selecting an item
verify(mBuilder.mOnDismissListener, within(1000)).onDismiss(mPopupMenu);
verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu);
verifyNoMoreInteractions(mBuilder.mOnDismissListener);
mPopupMenu = null;
}
@Test
public void testSubMenuClickViaAPI() throws Throwable {
// Use empty popup style to remove all transitions from the popup. That way we don't
// need to synchronize with the popup window enter transition before proceeding to
// "click" a submenu item.
mBuilder = new Builder().withDismissListener().withMenuItemClickListener()
.withPopupStyleResource(R.style.PopupWindow_NullTransitions);
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
// Verify that our menu item click listener hasn't been called yet
verify(mBuilder.mOnMenuItemClickListener, never()).onMenuItemClick(any(MenuItem.class));
mActivityRule.runOnUiThread(
() -> mPopupMenu.getMenu().performIdentifierAction(R.id.action_share, 0));
// Verify that our menu item click listener has been called on "share" action
// and that the dismiss listener hasn't been called just as a result of opening the submenu.
verify(mBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
mPopupMenu.getMenu().findItem(R.id.action_share));
verify(mBuilder.mOnDismissListener, never()).onDismiss(mPopupMenu);
mActivityRule.runOnUiThread(
() -> mPopupMenu.getMenu().findItem(R.id.action_share).getSubMenu().
performIdentifierAction(R.id.action_share_email, 0));
// Verify that out menu item click listener has been called with the expected menu item
verify(mBuilder.mOnMenuItemClickListener, times(1)).onMenuItemClick(
mPopupMenu.getMenu().findItem(R.id.action_share).getSubMenu()
.findItem(R.id.action_share_email));
verifyNoMoreInteractions(mBuilder.mOnMenuItemClickListener);
// Popup menu should be automatically dismissed on selecting an item
verify(mBuilder.mOnDismissListener, times(1)).onDismiss(mPopupMenu);
verifyNoMoreInteractions(mBuilder.mOnDismissListener);
mPopupMenu = null;
}
@Test
public void testItemViewAttributes() throws Throwable {
mBuilder = new Builder().withDismissListener().withAnchorId(R.id.anchor_upper_left);
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
Menu menu = mPopupMenu.getMenu();
ListView menuItemList = mPopupMenu.getMenuListView();
for (int i = 0; i != menu.size(); i++) {
MenuItem item = menu.getItem(i);
View itemView = null;
// On smaller screens, not all menu items will be visible.
if (i < menuItemList.getChildCount()) {
itemView = menuItemList.getChildAt(i);
assertNotNull(itemView);
}
if (i < 2) {
assertNotNull(item.getContentDescription());
assertNotNull(item.getTooltipText());
} else {
assertNull(item.getContentDescription());
assertNull(item.getTooltipText());
}
if (itemView != null) {
// Tooltips are not set on list-based menus.
assertNull(itemView.getTooltipText());
assertEquals(item.getContentDescription(), itemView.getContentDescription());
}
}
}
@Test
public void testGroupDividerEnabledAPI() throws Throwable {
testGroupDivider(false);
testGroupDivider(true);
}
private void testGroupDivider(boolean groupDividerEnabled) throws Throwable {
mBuilder = new Builder().withGroupDivider(groupDividerEnabled)
.withAnchorId(R.id.anchor_upper_left);
mActivityRule.runOnUiThread(mBuilder::show);
SystemClock.sleep(2000);
Menu menu = mPopupMenu.getMenu();
ListView menuItemList = mPopupMenu.getMenuListView();
for (int i = 0; i < menuItemList.getChildCount(); i++) {
final int currGroupId = menu.getItem(i).getGroupId();
final int prevGroupId =
i - 1 >= 0 ? menu.getItem(i - 1).getGroupId() : currGroupId;
View itemView = menuItemList.getChildAt(i);
// Find com.android.internal.R.id.group_divider
ImageView groupDivider = itemView.findViewById(
Resources.getSystem().getIdentifier("group_divider", "id", "android"));
assertNotNull(groupDivider);
if (!groupDividerEnabled || currGroupId == prevGroupId) {
assertEquals(groupDivider.getVisibility(), View.GONE);
} else {
assertEquals(groupDivider.getVisibility(), View.VISIBLE);
}
}
teardown();
}
@Test
public void testForceShowIcon_enabled() throws Throwable {
testForceShowIcon(true);
}
@Test
public void testForceShowIcon_disabled() throws Throwable {
testForceShowIcon(false);
}
private void testForceShowIcon(boolean forceShowIcon) throws Throwable {
mBuilder = new Builder().withForceShowIcon(forceShowIcon);
mActivityRule.runOnUiThread(mBuilder::configure);
final TestColorDrawable drawable = new TestColorDrawable(Color.BLUE);
mPopupMenu.getMenu().getItem(0).setIcon(drawable);
WidgetTestUtils.runOnMainAndDrawSync(
mActivityRule,
mActivity.findViewById(R.id.anchor_middle_left),
mBuilder::show
);
assertEquals(forceShowIcon, drawable.mWasDrawn);
}
private class TestColorDrawable extends ColorDrawable {
boolean mWasDrawn = false;
TestColorDrawable(final int color) {
super(color);
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
mWasDrawn = true;
}
}
@Test
public void testHoverSelectsMenuItem() throws Throwable {
mBuilder = new Builder().withExtraItems(100).withAnchorId(R.id.anchor_upper_left);
mActivityRule.runOnUiThread(mBuilder::show);
mInstrumentation.waitForIdleSync();
ListView menuItemList = mPopupMenu.getMenuListView();
assertEquals(0, menuItemList.getFirstVisiblePosition());
emulateHoverOverVisibleItems(menuItemList);
// Select the last item to force menu scrolling and emulate hover again.
mActivityRule.runOnUiThread(
() -> menuItemList.setSelectionFromTop(mPopupMenu.getMenu().size() - 1, 0));
mInstrumentation.waitForIdleSync();
assertNotEquals("Too few menu items to test for scrolling",
0, menuItemList.getFirstVisiblePosition());
emulateHoverOverVisibleItems(menuItemList);
mPopupMenu = null;
}
private void emulateHoverOverVisibleItems(ListView listView) {
final int childCount = listView.getChildCount();
// The first/last child may present partially on the app, we should ignore them when inject
// mouse events to prevent the event send to the wrong target.
for (int i = 1; i < childCount - 1; i++) {
View itemView = listView.getChildAt(i);
CtsMouseUtil.emulateHoverOnView(mInstrumentation, itemView, itemView.getWidth() / 2,
itemView.getHeight() / 2);
// Wait for the system to process all events in the queue.
mInstrumentation.waitForIdleSync();
// Hovered menu item should be selected.
assertEquals(listView.getFirstVisiblePosition() + i,
listView.getSelectedItemPosition());
}
}
/**
* Inner helper class to configure an instance of {@link PopupMenu} for the specific test.
* The main reason for its existence is that once a popup menu is shown with the show() method,
* most of its configuration APIs are no-ops. This means that we can't add logic that is
* specific to a certain test once it's shown and we have a reference to a displayed
* {@link PopupMenu}.
*/
public class Builder {
private boolean mHasDismissListener;
private boolean mHasMenuItemClickListener;
private boolean mInflateWithInflater;
private int mExtraItemCount;
private int mAnchorId = R.id.anchor_middle_left;
private int mPopupMenuContent = R.menu.popup_menu;
private boolean mUseCustomPopupResource;
private int mPopupStyleResource = 0;
private boolean mUseCustomGravity;
private int mGravity = Gravity.NO_GRAVITY;
private PopupMenu.OnMenuItemClickListener mOnMenuItemClickListener;
private PopupMenu.OnDismissListener mOnDismissListener;
private View mAnchor;
private boolean mGroupDividerEnabled = false;
private boolean mForceShowIcon = false;
public Builder withMenuItemClickListener() {
mHasMenuItemClickListener = true;
return this;
}
public Builder withDismissListener() {
mHasDismissListener = true;
return this;
}
public Builder inflateWithInflater(boolean inflateWithInflater) {
mInflateWithInflater = inflateWithInflater;
return this;
}
public Builder withPopupStyleResource(int popupStyleResource) {
mUseCustomPopupResource = true;
mPopupStyleResource = popupStyleResource;
return this;
}
public Builder withPopupMenuContent(int popupMenuContent) {
mPopupMenuContent = popupMenuContent;
return this;
}
public Builder withGravity(int gravity) {
mUseCustomGravity = true;
mGravity = gravity;
return this;
}
public Builder withGroupDivider(boolean groupDividerEnabled) {
mGroupDividerEnabled = groupDividerEnabled;
return this;
}
public Builder withAnchorId(int anchorId) {
mAnchorId = anchorId;
return this;
}
public Builder withForceShowIcon(boolean forceShowIcon) {
mForceShowIcon = forceShowIcon;
return this;
}
public Builder withExtraItems(int count) {
mExtraItemCount = count;
return this;
}
public void configure() {
mAnchor = mActivity.findViewById(mAnchorId);
if (!mUseCustomGravity && !mUseCustomPopupResource) {
mPopupMenu = new PopupMenu(mActivity, mAnchor);
} else if (!mUseCustomPopupResource) {
mPopupMenu = new PopupMenu(mActivity, mAnchor, mGravity);
} else {
mPopupMenu = new PopupMenu(mActivity, mAnchor, Gravity.NO_GRAVITY,
0, mPopupStyleResource);
}
if (mInflateWithInflater) {
final MenuInflater menuInflater = mPopupMenu.getMenuInflater();
menuInflater.inflate(mPopupMenuContent, mPopupMenu.getMenu());
} else {
mPopupMenu.inflate(mPopupMenuContent);
}
if (mHasMenuItemClickListener) {
// Register a mock listener to be notified when a menu item in our popup menu has
// been clicked.
mOnMenuItemClickListener = mock(PopupMenu.OnMenuItemClickListener.class);
mPopupMenu.setOnMenuItemClickListener(mOnMenuItemClickListener);
}
if (mHasDismissListener) {
// Register a mock listener to be notified when our popup menu is dismissed.
mOnDismissListener = mock(PopupMenu.OnDismissListener.class);
mPopupMenu.setOnDismissListener(mOnDismissListener);
}
if (mGroupDividerEnabled) {
mPopupMenu.getMenu().setGroupDividerEnabled(true);
}
mPopupMenu.setForceShowIcon(mForceShowIcon);
// Add extra items.
for (int i = 0; i < mExtraItemCount; i++) {
mPopupMenu.getMenu().add("Extra item " + i);
}
}
public void show() {
if (mPopupMenu == null) {
configure();
}
// Show the popup menu
mPopupMenu.show();
}
}
}