Skip to content

Commit

Permalink
Merge pull request #6745 from robolectric/piper_399115715
Browse files Browse the repository at this point in the history
Check Window flags when selecting root view in LocalUiController. #6741
  • Loading branch information
hoisie committed Nov 28, 2021
2 parents 9d5fc12 + 9f99c08 commit 71ca9ac
Show file tree
Hide file tree
Showing 3 changed files with 229 additions and 17 deletions.
Expand Up @@ -2,9 +2,6 @@

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import org.robolectric.integration.axt.R;

/** Fixture activity for {@link EspressoTest} */
Expand All @@ -18,13 +15,6 @@ public void onCreate(Bundle savedInstanceState) {

setContentView(R.layout.espresso_activity);

Button button = findViewById(R.id.button);
button.setOnClickListener(
new OnClickListener() {
@Override
public void onClick(View view) {
buttonClicked = true;
}
});
findViewById(R.id.button).setOnClickListener(view -> buttonClicked = true);
}
}
@@ -0,0 +1,216 @@
package org.robolectric.integrationtests.axt;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static com.google.common.truth.Truth.assertThat;

import android.view.View;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.PopupWindow;
import android.widget.TextView;
import androidx.test.core.app.ActivityScenario;
import androidx.test.espresso.Root;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.annotation.LooperMode;
import org.robolectric.integration.axt.R;

/** Test Espresso on Robolectric interoperability for toolbar menus. */
@RunWith(AndroidJUnit4.class)
@LooperMode(LooperMode.Mode.PAUSED)
public class EspressoWithWindowLayersTest {
private static final String TEXT = "Hello World";

/** The touchable popup window gets the event and the button is *not* clicked. */
@Test
public void click_interactivePopupWindow_isNotClicked() {
try (ActivityScenario<EspressoActivity> scenario =
ActivityScenario.launch(EspressoActivity.class)) {
showInteractivePopupAsButtonDropdown(scenario);

onView(withId(R.id.button)).inRoot(new IsBaseApplication()).perform(click());

scenario.onActivity(activity -> assertThat(activity.buttonClicked).isFalse());
}
}

/** The touchable popup window gets the event and the button is *not* clicked. */
@Test
public void click_occludingInteractivePopupWindow_isNotClicked() {
try (ActivityScenario<EspressoActivity> scenario =
ActivityScenario.launch(EspressoActivity.class)) {
showOccludingInteractivePopupAsButtonDropdown(scenario);

onView(withId(R.id.button)).inRoot(new IsBaseApplication()).perform(click());

scenario.onActivity(activity -> assertThat(activity.buttonClicked).isFalse());
}
}

/** The non-touchable popup window does *not* get the event and the button is clicked. */
@Test
public void click_nonInteractivePopupWindow_isClicked() {
try (ActivityScenario<EspressoActivity> scenario =
ActivityScenario.launch(EspressoActivity.class)) {
showNonInteractivePopupAsButtonDropdown(scenario);

onView(withId(R.id.button)).perform(click());

scenario.onActivity(activity -> assertThat(activity.buttonClicked).isTrue());
}
}

/** The focusable popup window gets the event and the text is *not* typed. */
@Test
public void typeText_interactivePopupWindow_textIsNotTyped() {
try (ActivityScenario<EspressoActivity> scenario =
ActivityScenario.launch(EspressoActivity.class)) {
showOccludingInteractivePopupAsButtonDropdown(scenario);

onView(withId(R.id.edit_text)).inRoot(new IsBaseApplication()).perform(typeText(TEXT));

scenario.onActivity(
activity -> {
TextView tv = activity.findViewById(R.id.edit_text);
assertThat(tv.getText().toString()).isNotEqualTo(TEXT);
});
}
}

/** The non-focusable popup window does *not* get the event and the text is typed. */
@Test
public void typeText_nonInteractivePopupWindow_textIsTyped() {
try (ActivityScenario<EspressoActivity> scenario =
ActivityScenario.launch(EspressoActivity.class)) {
showNonInteractivePopupAsButtonDropdown(scenario);

onView(withId(R.id.edit_text)).inRoot(new IsBaseApplication()).perform(typeText(TEXT));

scenario.onActivity(
activity -> {
TextView tv = activity.findViewById(R.id.edit_text);
assertThat(tv.getText().toString()).isEqualTo(TEXT);
});
}
}

/** Replacing text does not depend on events, so the focusable window does not interfere. */
@Test
public void replaceText_interactivePopupWindow_textIsReplaced() {
try (ActivityScenario<EspressoActivity> scenario =
ActivityScenario.launch(EspressoActivity.class)) {
showOccludingInteractivePopupAsButtonDropdown(scenario);

onView(withId(R.id.edit_text)).inRoot(new IsBaseApplication()).perform(replaceText(TEXT));

scenario.onActivity(
activity -> {
TextView tv = activity.findViewById(R.id.edit_text);
assertThat(tv.getText().toString()).isEqualTo(TEXT);
});
}
}

/** Replacing text does not depend on events, so the non-focusable window does not interfere. */
@Test
public void replaceText_nonInteractivePopupWindow_textIsReplaced() {
try (ActivityScenario<EspressoActivity> scenario =
ActivityScenario.launch(EspressoActivity.class)) {
showNonInteractivePopupAsButtonDropdown(scenario);

onView(withId(R.id.edit_text)).perform(replaceText(TEXT));

scenario.onActivity(
activity -> {
TextView tv = activity.findViewById(R.id.edit_text);
assertThat(tv.getText().toString()).isEqualTo(TEXT);
});
}
}

/**
* Shows an occluding touchable and focusable popup window as a drop-down on the button.
*
* <p>The drop-down is shown *over* the button by adjusting the x and y offsets. The position of
* the popup is *not* yet accounted for in the window selection heuristic. If it were, we should
* see different behavior when attempting to click the button underneath.
*/
private static void showOccludingInteractivePopupAsButtonDropdown(
ActivityScenario<EspressoActivity> scenario) {
scenario.onActivity(
activity -> {
View anchor = activity.findViewById(R.id.button);
new PopupWindow(
/* contentView= */ new FrameLayout(activity),
/* width= */ anchor.getWidth() * 2,
/* height= */ anchor.getHeight() * 2,
/* focusable= */ true)
.showAsDropDown(anchor, -anchor.getWidth(), -anchor.getHeight());
});
}

/** Shows a non-occluding touchable and focusable popup window as a drop-down on the button. */
private static void showInteractivePopupAsButtonDropdown(
ActivityScenario<EspressoActivity> scenario) {
scenario.onActivity(
activity -> {
View anchor = activity.findViewById(R.id.button);
new PopupWindow(
/* contentView= */ new FrameLayout(activity),
/* width= */ 10,
/* height= */ 10,
/* focusable= */ true)
.showAsDropDown(anchor);
});
}

/**
* Shows an occluding non-touchable and non-focusable popup window as a drop-down on the button.
*
* <p>The drop-down is shown *over* the button by adjusting the x and y offsets. The position of
* the popup is *not* yet accounted for in the window selection heuristic. If it were, we should
* see different behavior when attempting to click the button underneath.
*/
private static void showNonInteractivePopupAsButtonDropdown(
ActivityScenario<EspressoActivity> scenario) {
scenario.onActivity(
activity -> {
View anchor = activity.findViewById(R.id.button);
PopupWindow popup =
new PopupWindow(
/* contentView= */ new FrameLayout(activity),
/* width= */ anchor.getWidth() * 2,
/* height= */ anchor.getHeight() * 2,
/* focusable= */ false);
popup.setTouchable(false);
popup.showAsDropDown(anchor, -anchor.getWidth(), -anchor.getHeight());
});
}

/**
* Espresso Root matcher for only windows with the base application window type.
*
* <p>This matcher is required as Espresso will dutifully default to finding views in the focused
* window. However, the events are *not* guaranteed to be dispatched in this window. Robolectric
* uses a different heuristic, so forcing this window to be used is good for testing.
*/
static final class IsBaseApplication extends TypeSafeMatcher<Root> {
@Override
public void describeTo(Description description) {
description.appendText("is the base application window");
}

@Override
public boolean matchesSafely(Root root) {
return root.getWindowLayoutParams().get().type
== WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
}
}
}
Expand Up @@ -38,7 +38,8 @@ public boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityEx
checkState(Looper.myLooper() == Looper.getMainLooper(), "Expecting to be on main thread!");
loopMainThreadUntilIdle();

getViewRoot().dispatchTouchEvent(event);
// FLAG_NOT_TOUCHABLE: "this window can never receive touch events"
getTopMostViewRootExcluding(LayoutParams.FLAG_NOT_TOUCHABLE).dispatchTouchEvent(event);

loopMainThreadUntilIdle();

Expand All @@ -49,9 +50,10 @@ public boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityEx
public boolean injectKeyEvent(KeyEvent event) throws InjectEventSecurityException {
checkNotNull(event);
checkState(Looper.myLooper() == Looper.getMainLooper(), "Expecting to be on main thread!");

loopMainThreadUntilIdle();
getViewRoot().dispatchKeyEvent(event);

// FLAG_NOT_FOCUSABLE: "this window won't ever get key input focus"
getTopMostViewRootExcluding(LayoutParams.FLAG_NOT_FOCUSABLE).dispatchKeyEvent(event);

loopMainThreadUntilIdle();
return true;
Expand Down Expand Up @@ -146,7 +148,7 @@ public void loopMainThreadForAtLeast(long millisDelay) {
shadowMainLooper().idleFor(Duration.ofMillis(millisDelay));
}

private View getViewRoot() {
private View getTopMostViewRootExcluding(int prohibitedFlags) {
List<ViewRootImpl> viewRoots = getViewRoots();
if (viewRoots.isEmpty()) {
throw new IllegalStateException("no view roots!");
Expand All @@ -159,9 +161,13 @@ private View getViewRoot() {
int topMostRootIndex = 0;
for (int i = 0; i < params.size(); i++) {
LayoutParams param = params.get(i);
if (param.type > params.get(topMostRootIndex).type) {
topMostRootIndex = i;
if ((param.flags & prohibitedFlags) != 0) {
continue;
}
if (param.type <= params.get(topMostRootIndex).type) {
continue;
}
topMostRootIndex = i;
}
return viewRoots.get(topMostRootIndex).getView();
}
Expand Down

0 comments on commit 71ca9ac

Please sign in to comment.