From 7921b1ac16e57dfb0d5fa2b165bd39a524a8f8d2 Mon Sep 17 00:00:00 2001 From: Googler Date: Thu, 17 Feb 2022 23:19:50 -0800 Subject: [PATCH] Block `UiController#loopMainThreadUntilIdle` on registered idling resources being idle. When using the paused looper collect the registered idling resources and wait for them to become idle before returning from `loopMainThreadUntilIdle`. Because Robolectric runs on the same thread as the main looper we need to continually loop the main looper until all idling resources transition to idle state. To do this we'll first drain the looper of currently scheduled tasks and then collect all of the idling resources that are not reporting idle. While this list is not empty (we need to loop as one idling resource becoming idle may cause another idling resource to become not idle, we need to observe them all idle at once) wait on the message queue to receive new messages up to the error timeout. Issue: #4807 PiperOrigin-RevId: 429489649 --- .github/workflows/build_native_runtime.yml | 4 +- .github/workflows/check_aggregateDocs.yml | 2 +- .github/workflows/check_code_formatting.yml | 2 +- .../workflows/gradle_wrapper_validation.yml | 2 +- .github/workflows/tests.yml | 2 +- gradle.properties | 2 +- .../axt/EspressoIdlingResourceTest.java | 181 ++++++++++++++ robolectric/build.gradle | 1 + .../IdlingResourceTimeoutException.java | 24 ++ .../android/internal/LocalUiController.java | 225 +++++++++++++++++- .../shadows/ShadowPausedLooperTest.java | 21 ++ .../shadows/ShadowSpeechRecognizerTest.java | 18 +- .../shadows/ShadowPausedLooper.java | 38 ++- .../shadows/ShadowPausedMessageQueue.java | 45 +++- .../shadows/ShadowSpeechRecognizer.java | 4 + 15 files changed, 560 insertions(+), 11 deletions(-) create mode 100644 integration_tests/androidx_test/src/test/java/org/robolectric/integrationtests/axt/EspressoIdlingResourceTest.java create mode 100644 robolectric/src/main/java/org/robolectric/android/internal/IdlingResourceTimeoutException.java diff --git a/.github/workflows/build_native_runtime.yml b/.github/workflows/build_native_runtime.yml index b47c34b3200..cbe051e680e 100644 --- a/.github/workflows/build_native_runtime.yml +++ b/.github/workflows/build_native_runtime.yml @@ -5,7 +5,7 @@ on: branches: [ master ] pull_request: - branches: [ master ] + branches: [ master, google ] jobs: build_native_runtime: @@ -138,4 +138,4 @@ jobs: with: name: robolectric-nativeruntime-windows-x86_64.dll path: | - build/robolectric-nativeruntime-windows-x86_64.dll \ No newline at end of file + build/robolectric-nativeruntime-windows-x86_64.dll diff --git a/.github/workflows/check_aggregateDocs.yml b/.github/workflows/check_aggregateDocs.yml index ee23ae704df..75364e2327d 100644 --- a/.github/workflows/check_aggregateDocs.yml +++ b/.github/workflows/check_aggregateDocs.yml @@ -5,7 +5,7 @@ on: branches: [ master ] pull_request: - branches: [ master ] + branches: [ master, google ] jobs: check_aggregateDocs: diff --git a/.github/workflows/check_code_formatting.yml b/.github/workflows/check_code_formatting.yml index 38f71f40467..05bd8e28d50 100644 --- a/.github/workflows/check_code_formatting.yml +++ b/.github/workflows/check_code_formatting.yml @@ -5,7 +5,7 @@ on: branches: [ master ] pull_request: - branches: [ master ] + branches: [ master, google ] jobs: check_code_formatting: diff --git a/.github/workflows/gradle_wrapper_validation.yml b/.github/workflows/gradle_wrapper_validation.yml index e6a43b4a96a..f8296e5a8ab 100644 --- a/.github/workflows/gradle_wrapper_validation.yml +++ b/.github/workflows/gradle_wrapper_validation.yml @@ -5,7 +5,7 @@ on: branches: [ master ] pull_request: - branches: [ master ] + branches: [ master, google ] jobs: validation: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a42fc4996d..4f8276acc31 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,7 +5,7 @@ on: branches: [ master ] pull_request: - branches: [ master ] + branches: [ master, google ] jobs: build: 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 resourceNames) { + super( + String.format( + Locale.ROOT, "Wait for %s to become idle timed out", checkNotNull(resourceNames))); + } +} 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 f73b0057e3d..f20e3e50842 100644 --- a/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java +++ b/robolectric/src/main/java/org/robolectric/android/internal/LocalUiController.java @@ -7,12 +7,16 @@ import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static java.util.Comparator.comparingInt; +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.toList; +import static org.robolectric.Shadows.shadowOf; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; import android.annotation.SuppressLint; import android.os.Build; import android.os.Build.VERSION_CODES; +import android.os.Handler; import android.os.Looper; import android.os.SystemClock; import android.util.Log; @@ -23,15 +27,31 @@ import android.view.WindowManager.LayoutParams; import android.view.WindowManagerGlobal; import android.view.WindowManagerImpl; +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.IdlingResource; import androidx.test.platform.ui.InjectEventSecurityException; import androidx.test.platform.ui.UiController; +import com.google.common.annotations.Beta; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableSet; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.LooperMode; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowLooper; +import org.robolectric.shadows.ShadowPausedLooper; import org.robolectric.util.ReflectionHelpers; /** A {@link UiController} that runs on a local JVM with Robolectric. */ @@ -46,6 +66,22 @@ public class LocalUiController implements UiController { private static final Predicate WATCH_TOUCH_OUTSIDE = IS_TOUCH_MODAL.negate().and(hasLayoutFlag(FLAG_WATCH_OUTSIDE_TOUCH)); + private static long idlingResourceErrorTimeoutMs = SECONDS.toMillis(26); + private final HashSet syncedIdlingResources = new HashSet<>(); + private final ExecutorService looperIdlingExecutor = Executors.newCachedThreadPool(); + + /** + * Sets the error timeout for idling resources. + * + *

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 idlingResources = syncIdlingResources(); + if (idlingResources.isEmpty()) { + shadowMainLooper().idle(); + } else { + loopMainThreadUntilIdlingResourcesIdle(idlingResources); + } + } + } + + private void loopMainThreadUntilIdlingResourcesIdle( + ImmutableSet idlingResources) { + Looper mainLooper = Looper.myLooper(); + ShadowPausedLooper shadowMainLooper = Shadow.extract(mainLooper); + Handler handler = new Handler(mainLooper); + Set activeResources = new HashSet<>(); + long startTimeNanos = System.nanoTime(); + + shadowMainLooper.idle(); + while (true) { + // Gather the list of resources that are not idling. + for (IdlingResourceProxy resource : idlingResources) { + // Add the resource as active and check if it's idle, if it is already is will be removed + // synchronously. The idle callback is synchronized in the resource which avoids a race + // between registering the idle callback and checking the idle state. + activeResources.add(resource); + resource.notifyOnIdle( + () -> { + if (Looper.myLooper() == mainLooper) { + activeResources.remove(resource); + } else { + // Post to restart the main thread. + handler.post(() -> activeResources.remove(resource)); + } + }); + } + // If all are idle then just return, we're done. + if (activeResources.isEmpty()) { + break; + } + // While the resources that weren't idle haven't transitioned to idle continue to loop the + // main looper waiting for any new messages. Once all resources have transitioned to idle loop + // around again to make sure all resources are idle at the same time. + while (!activeResources.isEmpty()) { + long elapsedTimeMs = NANOSECONDS.toMillis(System.nanoTime() - startTimeNanos); + if (elapsedTimeMs >= idlingResourceErrorTimeoutMs) { + throw new IdlingResourceTimeoutException(idlingResourceNames(activeResources)); + } + // Poll the queue and suspend the thread until we get new messages or the idle transition. + shadowMainLooper.poll(idlingResourceErrorTimeoutMs - elapsedTimeMs); + shadowMainLooper.idle(); + } + } + } + + private ImmutableSet syncIdlingResources() { + // Collect unique registered idling resources. + HashMap registeredResourceByName = new HashMap<>(); + for (IdlingResource resource : IdlingRegistry.getInstance().getResources()) { + String name = resource.getName(); + if (registeredResourceByName.containsKey(name)) { + logDuplicate(name, registeredResourceByName.get(name), resource); + } else { + registeredResourceByName.put(name, resource); + } + } + Iterator iterator = syncedIdlingResources.iterator(); + while (iterator.hasNext()) { + IdlingResourceProxyImpl proxy = iterator.next(); + if (registeredResourceByName.get(proxy.name) == proxy.resource) { + // Already registered, don't need to add. + registeredResourceByName.remove(proxy.name); + } else { + // Previously registered, but no longer registered, remove. + iterator.remove(); + } + } + // Add new idling resources that weren't previously registered. + for (Map.Entry entry : registeredResourceByName.entrySet()) { + syncedIdlingResources.add(new IdlingResourceProxyImpl(entry.getKey(), entry.getValue())); + } + + return ImmutableSet.builder() + .addAll(syncedIdlingResources) + .addAll( + IdlingRegistry.getInstance().getLoopers().stream() + .map(LooperIdlingResource::new) + .iterator()) + .build(); + } + + private static void logDuplicate(String name, IdlingResource a, IdlingResource b) { + Log.e( + TAG, + String.format( + "Attempted to register resource with same names:" + + " %s. R1: %s R2: %s.\nDuplicate resource registration will be ignored.", + name, a, b)); + } + + private static List idlingResourceNames(Set idlingResources) { + return idlingResources.stream().map(IdlingResourceProxy::getName).collect(toList()); } @Override @@ -275,4 +414,88 @@ boolean watchTouchOutside() { return WATCH_TOUCH_OUTSIDE.test(this); } } + + private interface IdlingResourceProxy { + String getName(); + + void notifyOnIdle(Runnable idleCallback); + } + + private static final class IdlingResourceProxyImpl implements IdlingResourceProxy { + private final String name; + private final IdlingResource resource; + + private Runnable idleCallback; + + IdlingResourceProxyImpl(String name, IdlingResource resource) { + this.name = name; + this.resource = resource; + resource.registerIdleTransitionCallback(this::onIdle); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public synchronized void notifyOnIdle(Runnable idleCallback) { + if (resource.isIdleNow()) { + this.idleCallback = null; + idleCallback.run(); + } else { + this.idleCallback = idleCallback; + } + } + + private synchronized void onIdle() { + if (idleCallback != null) { + idleCallback.run(); + idleCallback = null; + } + } + } + + private final class LooperIdlingResource implements IdlingResourceProxy { + private final Looper looper; + private final ShadowLooper shadowLooper; + private Runnable idleCallback; + + LooperIdlingResource(Looper looper) { + this.looper = looper; + this.shadowLooper = shadowOf(looper); + } + + @Override + public String getName() { + return looper.toString(); + } + + @Override + public synchronized void notifyOnIdle(Runnable idleCallback) { + if (shadowLooper.isIdle()) { + this.idleCallback = null; + idleCallback.run(); + } else { + this.idleCallback = idleCallback; + // Note idle() doesn't throw an exception if called from another thread, the looper would + // die with an unhandled exception. + // TODO(paulsowden): It's not technically necessary to idle the looper from another thread, + // it can be idled from its own thread, however we'll need API access to do this and + // observe the idle state--the idle() api blocks the calling thread by default. Perhaps a + // ListenableFuture idleAsync() variant? + looperIdlingExecutor.execute(this::idleLooper); + } + } + + private void idleLooper() { + shadowLooper.idle(); + synchronized (this) { + if (idleCallback != null) { + idleCallback.run(); + idleCallback = null; + } + } + } + } } diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java index 1e23ef7aa23..cadc3e96952 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowPausedLooperTest.java @@ -25,6 +25,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -544,6 +545,26 @@ public void testIdleNotStuck_whenThreadCrashes() throws Exception { assertThat(thread.getState()).isEqualTo(Thread.State.TERMINATED); } + @Test + public void poll() { + ShadowPausedLooper shadowPausedLooper = Shadow.extract(Looper.getMainLooper()); + AtomicBoolean backgroundThreadPosted = new AtomicBoolean(); + AtomicBoolean foregroundThreadReceived = new AtomicBoolean(); + shadowPausedLooper.idle(); + + new Handler(handlerThread.getLooper()) + .post( + () -> { + backgroundThreadPosted.set(true); + new Handler(Looper.getMainLooper()).post(() -> foregroundThreadReceived.set(true)); + }); + shadowPausedLooper.poll(0); + shadowPausedLooper.idle(); + + assertThat(backgroundThreadPosted.get()).isTrue(); + assertThat(foregroundThreadReceived.get()).isTrue(); + } + private static class BlockingRunnable implements Runnable { CountDownLatch latch = new CountDownLatch(1); diff --git a/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java index 8419a5e1480..a20f3940b62 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowSpeechRecognizerTest.java @@ -42,6 +42,20 @@ public void onErrorCalled() { assertThat(listener.errorReceived).isEqualTo(-1); } + @Test + public void onReadyForSpeechCalled() { + startListening(); + Bundle expectedBundle = new Bundle(); + ArrayList results = new ArrayList<>(); + String result = "onReadyForSpeech"; + results.add(result); + expectedBundle.putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, results); + + shadowOf(speechRecognizer).triggerOnReadyForSpeech(expectedBundle); + + assertThat(listener.bundleReceived).isEqualTo(expectedBundle); + } + @Test public void onPartialResultsCalled() { startListening(); @@ -183,7 +197,9 @@ public void onPartialResults(Bundle bundle) { } @Override - public void onReadyForSpeech(Bundle params) {} + public void onReadyForSpeech(Bundle bundle) { + bundleReceived = bundle; + } @Override public void onResults(Bundle bundle) { diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java index b2bf2322f02..df4e15b583f 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowPausedLooper.java @@ -1,5 +1,6 @@ package org.robolectric.shadows; +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; @@ -206,6 +207,41 @@ public void runPaused(Runnable runnable) { } } + /** + * 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. + * + *

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

{@code
+   * while (!condition) {
+   *   shadowMainLooper.poll(timeout);
+   *   shadowMainLooper.idle();
+   * }
+   * }
+ * + *

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) { diff --git a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java index 29d8bcdc171..3bccfa8ddf4 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowSpeechRecognizer.java @@ -97,6 +97,10 @@ public void triggerOnError(int error) { recognitionListener.onError(error); } + public void triggerOnReadyForSpeech(Bundle bundle) { + recognitionListener.onReadyForSpeech(bundle); + } + public void triggerOnPartialResults(Bundle bundle) { recognitionListener.onPartialResults(bundle); }