diff --git a/gradle.properties b/gradle.properties index 7ed60456a8b..0bed8c8b057 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ errorproneJavacVersion=9+181-r4173-1 # AndroidX test versions axtVersion=1.4.0 -espressoVersion=3.5.0-alpha04 +espressoVersion=3.5.0-alpha05 axtJunitVersion=1.1.3 # AndroidX versions diff --git a/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoIdlingResourceTest.java b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoIdlingResourceTest.java new file mode 100644 index 00000000000..36565a24a75 --- /dev/null +++ b/integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoIdlingResourceTest.java @@ -0,0 +1,181 @@ +package org.robolectric.integrationtests.axt; + +import static androidx.test.espresso.Espresso.onIdle; +import static com.google.common.truth.Truth.assertThat; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.IdlingResource; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Test Espresso IdlingResource support. */ +@RunWith(AndroidJUnit4.class) +public final class EspressoIdlingResourceTest { + private final IdlingRegistry idlingRegistry = IdlingRegistry.getInstance(); + + private ExecutorService executor; + + @Before + public void setup() { + executor = Executors.newSingleThreadExecutor(); + } + + @After + public void teardown() { + for (IdlingResource resource : idlingRegistry.getResources()) { + idlingRegistry.unregister(resource); + } + for (Looper looper : idlingRegistry.getLoopers()) { + idlingRegistry.unregisterLooperAsIdlingResource(looper); + } + executor.shutdown(); + } + + @Test + public void onIdle_idlingResourceIsIdle_doesntBlock() { + AtomicBoolean didCheckIdle = new AtomicBoolean(); + idlingRegistry.register( + new NamedIdleResource("Test", /* isIdle= */ true) { + @Override + public boolean isIdleNow() { + didCheckIdle.set(true); + return super.isIdleNow(); + } + }); + + onIdle(); + + assertThat(didCheckIdle.get()).isTrue(); + } + + @SuppressWarnings("FutureReturnValueIgnored") + @Test + public void onIdle_postToMainThread() { + idlingRegistry.register( + new NamedIdleResource("Test", /* isIdle= */ false) { + boolean submitted; + + @Override + public boolean isIdleNow() { + if (!submitted) { + submitted = true; + executor.submit(this::postToMainLooper); + } + return super.isIdleNow(); + } + + void postToMainLooper() { + new Handler(Looper.getMainLooper()).post(() -> setIdle(true)); + } + }); + + onIdle(); + } + + @SuppressWarnings("FutureReturnValueIgnored") + @Test + public void onIdle_cooperativeResources() { + NamedIdleResource a = new NamedIdleResource("A", /* isIdle= */ true); + NamedIdleResource b = new NamedIdleResource("B", /* isIdle= */ false); + NamedIdleResource c = new NamedIdleResource("C", /* isIdle= */ false); + idlingRegistry.register(a, b); + executor.submit( + () -> { + a.setIdle(false); + b.setIdle(true); + c.setIdle(false); + executor.submit( + () -> { + a.setIdle(true); + b.setIdle(false); + c.setIdle(false); + executor.submit( + () -> { + a.setIdle(true); + b.setIdle(true); + c.setIdle(true); + }); + }); + }); + + onIdle(); + + assertThat(a.isIdleNow()).isTrue(); + assertThat(b.isIdleNow()).isTrue(); + assertThat(c.isIdleNow()).isTrue(); + } + + @SuppressWarnings("FutureReturnValueIgnored") + @Test + public void onIdle_looperIsIdle() throws Exception { + HandlerThread handlerThread = new HandlerThread("Test"); + try { + handlerThread.start(); + Handler handler = new Handler(handlerThread.getLooper()); + CountDownLatch handlerStarted = new CountDownLatch(1); + CountDownLatch releaseHandler = new CountDownLatch(1); + handler.post( + () -> { + handlerStarted.countDown(); + try { + releaseHandler.await(); + } catch (InterruptedException e) { + // ignore + } + }); + handlerStarted.await(); + idlingRegistry.registerLooperAsIdlingResource(handlerThread.getLooper()); + + executor.submit(releaseHandler::countDown); + onIdle(); + + // onIdle should have blocked on the looper waiting on the release latch + assertThat(releaseHandler.getCount()).isEqualTo(0); + } finally { + handlerThread.quit(); + } + } + + private static class NamedIdleResource implements IdlingResource { + final String name; + final AtomicBoolean isIdle; + ResourceCallback callback; + + NamedIdleResource(String name, boolean isIdle) { + this.name = name; + this.isIdle = new AtomicBoolean(isIdle); + } + + @Override + public String getName() { + return name; + } + + @Override + public boolean isIdleNow() { + return isIdle.get(); + } + + void setIdle(boolean isIdle) { + this.isIdle.set(isIdle); + if (isIdle && callback != null) { + callback.onTransitionToIdle(); + } + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + this.callback = callback; + } + } +} diff --git a/robolectric/build.gradle b/robolectric/build.gradle index 13355388b24..96484372cb7 100755 --- a/robolectric/build.gradle +++ b/robolectric/build.gradle @@ -44,6 +44,7 @@ dependencies { compileOnly AndroidSdk.MAX_SDK.coordinates compileOnly "junit:junit:${junitVersion}" implementation "androidx.test:monitor:$axtVersion" + implementation "androidx.test.espresso:espresso-idling-resource:3.4.0" testImplementation "junit:junit:${junitVersion}" testImplementation "com.google.truth:truth:${truthVersion}" diff --git a/robolectric/src/main/java/org/robolectric/android/internal/IdlingResourceTimeoutException.java b/robolectric/src/main/java/org/robolectric/android/internal/IdlingResourceTimeoutException.java new file mode 100644 index 00000000000..29de83dec2d --- /dev/null +++ b/robolectric/src/main/java/org/robolectric/android/internal/IdlingResourceTimeoutException.java @@ -0,0 +1,24 @@ +package org.robolectric.android.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.Beta; +import java.util.List; +import java.util.Locale; + +/** + * Timeout exception thrown when idling resources are not idle for longer than the configured + * timeout. + * + *
See {@link androidx.test.espresso.IdlingResourceTimeoutException}. + * + *
Note: This API may be removed in the future in favor of using espresso's exception directly.
+ */
+@Beta
+public final class IdlingResourceTimeoutException extends RuntimeException {
+ public IdlingResourceTimeoutException(List See {@link androidx.test.espresso.IdlingPolicies#setIdlingResourceTimeout(long, TimeUnit)}.
+ *
+ * Note: This API may be removed in the future in favor of using IdlingPolicies directly.
+ */
+ @Beta
+ public static void setIdlingResourceTimeout(long timeout, TimeUnit unit) {
+ idlingResourceErrorTimeoutMs = unit.toMillis(timeout);
+ }
+
@Override
public boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityException {
checkNotNull(event);
@@ -174,7 +210,110 @@ static KeyCharacterMap getKeyCharacterMap() {
@Override
public void loopMainThreadUntilIdle() {
- shadowMainLooper().idle();
+ if (!ShadowLooper.looperMode().equals(LooperMode.Mode.PAUSED)) {
+ shadowMainLooper().idle();
+ } else {
+ ImmutableSet This method is only applicable for the main looper's queue when called on the main thread,
+ * as the main looper in Robolectric is processed manually (it doesn't loop)--looper threads are
+ * using the native polling of their loopers. Throws an exception if called for another looper's
+ * queue. Non-main thread loopers should use {@link #unPause()}.
+ *
+ * This should be used with care, it can be used to suspend the main (i.e. test) thread while
+ * worker threads perform some work, and then resumed by posting to the main looper. Used in a
+ * loop to wait on some condition it can process messages on the main looper, simulating the
+ * behavior of the real looper, for example:
+ *
+ * Beware though that a message must be posted to the main thread after the condition is
+ * satisfied, or the condition satisfied while idling the main thread, otherwise the main thread
+ * will continue to be suspended until the timeout.
+ *
+ * @param timeout Timeout in milliseconds, the maximum time to wait before returning, or 0 to wait
+ * indefinitely,
+ */
+ public void poll(long timeout) {
+ checkState(Looper.myLooper() == Looper.getMainLooper() && Looper.myLooper() == realLooper);
+ shadowQueue().poll(timeout);
+ }
+
@Override
public Duration getNextScheduledTaskTime() {
return shadowQueue().getNextScheduledTaskTime();
@@ -344,7 +380,7 @@ private class RunOneRunnable extends ControlRunnable {
@Override
public void run() {
try {
- Message msg = shadowQueue().poll();
+ Message msg = shadowQueue().getNextIgnoringWhen();
if (msg != null) {
SystemClock.setCurrentTimeMillis(shadowMsg(msg).getWhen());
msg.getTarget().dispatchMessage(msg);
diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java
index 2c378663f70..8ddcc7bc229 100644
--- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java
+++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedMessageQueue.java
@@ -6,10 +6,12 @@
import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
import static android.os.Build.VERSION_CODES.LOLLIPOP_MR1;
import static android.os.Build.VERSION_CODES.M;
+import static com.google.common.base.Preconditions.checkState;
import static org.robolectric.shadow.api.Shadow.invokeConstructor;
import static org.robolectric.util.ReflectionHelpers.ClassParameter.from;
import static org.robolectric.util.reflector.Reflector.reflector;
+import android.os.Looper;
import android.os.Message;
import android.os.MessageQueue;
import android.os.MessageQueue.IdleHandler;
@@ -23,6 +25,7 @@
import org.robolectric.annotation.RealObject;
import org.robolectric.res.android.NativeObjRegistry;
import org.robolectric.shadow.api.Shadow;
+import org.robolectric.util.ReflectionHelpers;
import org.robolectric.util.Scheduler;
import org.robolectric.util.reflector.Accessor;
import org.robolectric.util.reflector.Direct;
@@ -33,6 +36,7 @@
*
* This class should not be referenced directly. Use {@link ShadowMessageQueue} instead.
*/
+@SuppressWarnings("SynchronizeOnNonFinalField")
@Implements(value = MessageQueue.class, isInAndroidSdk = false, looseSignatures = true)
public class ShadowPausedMessageQueue extends ShadowMessageQueue {
@@ -104,6 +108,40 @@ protected void nativePollOnce(long ptr, int timeoutMillis) {
}
}
+ /**
+ * Polls the message queue waiting until a message is posted to the head of the queue. This will
+ * suspend the thread until a new message becomes available. Returns immediately if the queue is
+ * not idle. There's no guarantee that the message queue will not still be idle when returning,
+ * but if the message queue becomes not idle it will return immediately.
+ *
+ * See {@link ShadowPausedLooper#poll(long)} for more information.
+ *
+ * @param timeout Timeout in milliseconds, the maximum time to wait before returning, or 0 to wait
+ * indefinitely,
+ */
+ void poll(long timeout) {
+ checkState(Looper.myLooper() == Looper.getMainLooper() && Looper.myQueue() == realQueue);
+ // Message queue typically expects the looper to loop calling next() which returns current
+ // messages from the head of the queue. If no messages are current it will mark itself blocked
+ // and call nativePollOnce (see above) which suspends the thread until the next message's time.
+ // When messages are posted to the queue, if a new message is posted to the head and the queue
+ // is marked as blocked, then the enqueue function will notify and resume next(), allowing it
+ // return the next message. To simulate this behavior check if the queue is idle and if it is
+ // mark the queue as blocked and wait on a new message.
+ synchronized (realQueue) {
+ if (isIdle()) {
+ ReflectionHelpers.setField(realQueue, "mBlocked", true);
+ try {
+ realQueue.wait(timeout);
+ } catch (InterruptedException ignored) {
+ // Fall through and unblock with no messages.
+ } finally {
+ ReflectionHelpers.setField(realQueue, "mBlocked", false);
+ }
+ }
+ }
+ }
+
@Implementation(maxSdk = JELLY_BEAN_MR1)
protected void nativeWake(int ptr) {
synchronized (realQueue) {
@@ -272,7 +310,12 @@ public int internalGetSize() {
return count;
}
- Message poll() {
+ /**
+ * Returns the message at the head of the queue immediately, regardless of its scheduled time.
+ * Compare to {@link #getNext()} which will only return the next message if the system clock is
+ * advanced to its scheduled time.
+ */
+ Message getNextIgnoringWhen() {
synchronized (realQueue) {
Message head = getMessages();
if (head != null) {
{@code
+ * while (!condition) {
+ * shadowMainLooper.poll(timeout);
+ * shadowMainLooper.idle();
+ * }
+ * }
+ *
+ *