diff --git a/integration_tests/androidx_test/src/main/AndroidManifest-NoTestPackageActivities.xml b/integration_tests/androidx_test/src/main/AndroidManifest-NoTestPackageActivities.xml new file mode 100644 index 00000000000..30c87ddbfaf --- /dev/null +++ b/integration_tests/androidx_test/src/main/AndroidManifest-NoTestPackageActivities.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoActivity.java b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoActivity.java index 8ecb53b6f51..cc5d74e0534 100644 --- a/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoActivity.java +++ b/integration_tests/androidx_test/src/main/java/org/robolectric/integrationtests/axt/EspressoActivity.java @@ -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} */ @@ -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); } } diff --git a/integration_tests/androidx_test/src/test/AndroidManifest-NoTestPackageActivities.xml b/integration_tests/androidx_test/src/test/AndroidManifest-NoTestPackageActivities.xml new file mode 100644 index 00000000000..c4f5e2b53a1 --- /dev/null +++ b/integration_tests/androidx_test/src/test/AndroidManifest-NoTestPackageActivities.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithWindowLayersTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithWindowLayersTest.java new file mode 100644 index 00000000000..8ca61c6ab02 --- /dev/null +++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoWithWindowLayersTest.java @@ -0,0 +1,215 @@ +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; + +/** 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 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 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 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 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 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 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 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. + * + *

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 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 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. + * + *

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 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. + * + *

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 { + @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; + } + } +} diff --git a/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java b/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java index 02d570a3b9a..cba47bc22a1 100644 --- a/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java +++ b/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java @@ -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(); @@ -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; @@ -146,7 +148,7 @@ public void loopMainThreadForAtLeast(long millisDelay) { shadowMainLooper().idleFor(Duration.ofMillis(millisDelay)); } - private View getViewRoot() { + private View getTopMostViewRootExcluding(int prohibitedFlags) { List viewRoots = getViewRoots(); if (viewRoots.isEmpty()) { throw new IllegalStateException("no view roots!"); @@ -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(); } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputMethodManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputMethodManager.java index e6ad1293b1d..ee25a81beb7 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputMethodManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowInputMethodManager.java @@ -267,9 +267,10 @@ public static void reset() { _InputMethodManager_ _reflector = reflector(_InputMethodManager_.class); if (apiLevel <= JELLY_BEAN_MR1) { _reflector.setMInstance(null); - } else if (apiLevel <= P) { - _reflector.setInstance(null); } else { + _reflector.setInstance(null); + } + if (apiLevel > P) { _reflector.getInstanceMap().clear(); } } diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java index 256df8bed3a..bed1c9e3c8c 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowServiceManager.java @@ -55,6 +55,7 @@ import android.net.INetworkScoreService; import android.net.ITetheringConnector; import android.net.nsd.INsdManager; +import android.net.vcn.IVcnManagementService; import android.net.wifi.IWifiManager; import android.net.wifi.aware.IWifiAwareManager; import android.net.wifi.p2p.IWifiP2pManager; @@ -192,6 +193,7 @@ public class ShadowServiceManager { addBinderService(Context.SPEECH_RECOGNITION_SERVICE, IRecognitionServiceManager.class); addBinderService(Context.LEGACY_PERMISSION_SERVICE, ILegacyPermissionManager.class); addBinderService(Context.UWB_SERVICE, IUwbAdapter.class); + addBinderService(Context.VCN_MANAGEMENT_SERVICE, IVcnManagementService.class); } }