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