From f229c3536dc3690998993de0a5922021a707cfaa 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 --- .../axt/EspressoIdlingResourceTest.java | 181 ++++++++++++++ robolectric/build.gradle | 1 + .../IdlingResourceTimeoutException.java | 24 ++ .../android/internal/LocalUiController.java | 225 +++++++++++++++++- .../shadows/ShadowBitmapFactoryTest.java | 8 +- .../shadows/ShadowPausedLooperTest.java | 21 ++ .../shadows/ShadowBitmapFactory.java | 27 ++- .../shadows/ShadowPausedLooper.java | 38 ++- .../shadows/ShadowPausedMessageQueue.java | 45 +++- 9 files changed, 552 insertions(+), 18 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/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/ShadowBitmapFactoryTest.java b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapFactoryTest.java index 2d0e5e41e4d..c19b5d286b9 100644 --- a/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapFactoryTest.java +++ b/robolectric/src/test/java/org/robolectric/shadows/ShadowBitmapFactoryTest.java @@ -136,7 +136,7 @@ public void decodeBytes_shouldSetDescriptionAndCreatedFrom() throws Exception { byte[] yummyBites = "Hi!".getBytes("UTF-8"); Bitmap bitmap = BitmapFactory.decodeByteArray(yummyBites, 100, 100); ShadowBitmap shadowBitmap = shadowOf(bitmap); - assertEquals("Bitmap for Hi! bytes 100..100", shadowBitmap.getDescription()); + assertEquals("Bitmap for 3 bytes 100..100", shadowBitmap.getDescription()); assertEquals(yummyBites, shadowBitmap.getCreatedFromBytes()); assertEquals(100, bitmap.getWidth()); assertEquals(100, bitmap.getHeight()); @@ -148,7 +148,7 @@ public void decodeBytes_shouldSetDescriptionAndCreatedFromWithOptions() throws E BitmapFactory.Options options = new BitmapFactory.Options(); Bitmap bitmap = BitmapFactory.decodeByteArray(yummyBites, 100, 100, options); ShadowBitmap shadowBitmap = shadowOf(bitmap); - assertEquals("Bitmap for Hi! bytes 100..100", shadowBitmap.getDescription()); + assertEquals("Bitmap for 3 bytes 100..100", shadowBitmap.getDescription()); assertEquals(yummyBites, shadowBitmap.getCreatedFromBytes()); assertEquals(100, bitmap.getWidth()); assertEquals(100, bitmap.getHeight()); @@ -263,7 +263,7 @@ public void decodeByteArray_shouldGetWidthAndHeightFromHints() { byte[] bytes = data.getBytes(UTF_8); Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - assertEquals("Bitmap for " + data, shadowOf(bitmap).getDescription()); + assertEquals("Bitmap for " + bytes.length + " bytes", shadowOf(bitmap).getDescription()); assertEquals(123, bitmap.getWidth()); assertEquals(456, bitmap.getHeight()); } @@ -275,7 +275,7 @@ public void decodeByteArray_shouldIncludeOffsets() { byte[] bytes = data.getBytes(UTF_8); Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 1, bytes.length - 2); - assertEquals("Bitmap for " + data + " bytes 1..13", shadowOf(bitmap).getDescription()); + assertEquals("Bitmap for " + bytes.length + " bytes 1..13", shadowOf(bitmap).getDescription()); } @Test 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/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapFactory.java b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapFactory.java index b33e57ee300..a966ff5a651 100644 --- a/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapFactory.java +++ b/shadows/framework/src/main/java/org/robolectric/shadows/ShadowBitmapFactory.java @@ -142,7 +142,7 @@ protected static Bitmap decodeFileDescriptor( } return null; } - Bitmap bitmap = create("fd:" + fd, outPadding, opts, null, image); + Bitmap bitmap = create("fd:" + fd, null, outPadding, opts, null, image); ShadowBitmap shadowBitmap = Shadow.extract(bitmap); shadowBitmap.createdFromFileDescriptor = fd; return bitmap; @@ -186,7 +186,7 @@ protected static Bitmap decodeStream( } return null; } - Bitmap bitmap = create(name, outPadding, opts, null, image); + Bitmap bitmap = create(name, null, outPadding, opts, null, image); ReflectionHelpers.callInstanceMethod( bitmap, "setNinePatchChunk", ClassParameter.from(byte[].class, ninePatchChunk)); ShadowBitmap shadowBitmap = Shadow.extract(bitmap); @@ -207,10 +207,10 @@ protected static Bitmap decodeByteArray(byte[] data, int offset, int length) { @Implementation protected static Bitmap decodeByteArray( byte[] data, int offset, int length, BitmapFactory.Options opts) { - String desc = new String(data, UTF_8); + String desc = data.length + " bytes"; if (offset != 0 || length != data.length) { - desc += " bytes " + offset + ".." + length; + desc += " " + offset + ".." + length; } ByteArrayInputStream is = new ByteArrayInputStream(data, offset, length); @@ -222,7 +222,7 @@ protected static Bitmap decodeByteArray( } return null; } - Bitmap bitmap = create(desc, opts, image); + Bitmap bitmap = create(desc, data, null, opts, null, image); ShadowBitmap shadowBitmap = Shadow.extract(bitmap); shadowBitmap.createdFromBytes = data; return bitmap; @@ -248,18 +248,19 @@ public static Bitmap create(String name, BitmapFactory.Options options) { @Deprecated public static Bitmap create( final String name, final BitmapFactory.Options options, final Point widthAndHeight) { - return create(name, null, options, widthAndHeight, null); + return create(name, null, null, options, widthAndHeight, null); } private static Bitmap create( final String name, final BitmapFactory.Options options, final RobolectricBufferedImage image) { - return create(name, null, options, null, image); + return create(name, null, null, options, null, image); } private static Bitmap create( final String name, + byte[] bytes, final Rect outPadding, final BitmapFactory.Options options, final Point widthAndHeightOverride, @@ -282,7 +283,7 @@ private static Bitmap create( shadowBitmap.appendDescription(optionsString); } - Point p = new Point(selectWidthAndHeight(name, widthAndHeightOverride, image)); + Point p = new Point(selectWidthAndHeight(name, bytes, widthAndHeightOverride, image)); if (options != null && options.inSampleSize > 1) { p.x = p.x / options.inSampleSize; p.y = p.y / options.inSampleSize; @@ -373,11 +374,15 @@ public static void reset() { private static Point selectWidthAndHeight( final String name, + byte[] bytes, final Point widthAndHeightOverride, final RobolectricBufferedImage robolectricBufferedImage) { - final Point widthAndHeightFromMap = widthAndHeightMap.get(name); - if (widthAndHeightFromMap != null) { - return widthAndHeightFromMap; + if (!widthAndHeightMap.isEmpty()) { + String sizeKey = bytes == null ? name : new String(bytes, UTF_8); + final Point widthAndHeightFromMap = widthAndHeightMap.get(sizeKey); + if (widthAndHeightFromMap != null) { + return widthAndHeightFromMap; + } } if (robolectricBufferedImage != null) { return robolectricBufferedImage.getWidthAndHeight(); 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) {