Skip to content

Commit

Permalink
Refactor Interrogator MessageQueue access to a TestLooperManagerCompa…
Browse files Browse the repository at this point in the history
…t class.

An upcoming TestLooperManager change will add the ability for Espresso's Interrogator class
to use it directly, replacing the use of reflection to access MessageQueue internals.

This commit prepares for this change by refactoring MessageQueue access to a
TestLooperManagerCompat container class. A future change will enhance
TestLooperManagerCompat to use the public TestLooperManager API when the Android
runtime environment supports it.

PiperOrigin-RevId: 706812503
  • Loading branch information
brettchabot authored and copybara-androidxtest committed Dec 18, 2024
1 parent eb37e0e commit 9c5afd7
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 90 deletions.
1 change: 1 addition & 0 deletions espresso/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The following artifacts were released:
**Bug Fixes**

* Fix deadlock in espresso in Robolectric INSTRUMENTATION_TEST + paused looper.
* Refactor espresso's MessageQueue access into a TestLooperManagerCompat class

**New Features**

Expand Down
2 changes: 2 additions & 0 deletions espresso/core/java/androidx/test/espresso/base/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ android_library(
"IdlingResourceRegistry.java",
"Interrogator.java",
"LooperIdlingResourceInterrogationHandler.java",
"TestLooperManagerCompat.java",
"ViewHierarchyExceptionHandler.java",
],
),
Expand Down Expand Up @@ -64,6 +65,7 @@ android_library(
"IdlingResourceRegistry.java",
"Interrogator.java",
"LooperIdlingResourceInterrogationHandler.java",
"TestLooperManagerCompat.java",
],
deps = [
"//espresso/core/java/androidx/test/espresso:interface",
Expand Down
114 changes: 28 additions & 86 deletions espresso/core/java/androidx/test/espresso/base/Interrogator.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,19 @@

package androidx.test.espresso.base;

import static androidx.test.espresso.util.Throwables.throwIfUnchecked;
import static androidx.test.internal.util.Checks.checkNotNull;
import static androidx.test.internal.util.Checks.checkState;

import android.os.Binder;
import android.os.Looper;
import android.os.Message;
import android.os.MessageQueue;
import android.os.SystemClock;
import android.util.Log;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/** Isolates the nasty details of touching the message queue. */
final class Interrogator {

private static final String TAG = "Interrogator";
private static final Method messageQueueNextMethod;
private static final Field messageQueueHeadField;
private static final Method recycleUncheckedMethod;

private static final int LOOKAHEAD_MILLIS = 15;
private static final ThreadLocal<Boolean> interrogating =
Expand All @@ -47,30 +39,7 @@ public Boolean initialValue() {
}
};

static {
try {
// TODO(b/112000181): remove the hidden api access here
messageQueueNextMethod = MessageQueue.class.getDeclaredMethod("next");
messageQueueNextMethod.setAccessible(true);

messageQueueHeadField = MessageQueue.class.getDeclaredField("mMessages");
messageQueueHeadField.setAccessible(true);
} catch (IllegalArgumentException
| NoSuchFieldException
| SecurityException
| NoSuchMethodException e) {
Log.e(TAG, "Could not initialize interrogator!", e);
throw new RuntimeException("Could not initialize interrogator!", e);
}

Method recycleUnchecked = null;
try {
recycleUnchecked = Message.class.getDeclaredMethod("recycleUnchecked");
recycleUnchecked.setAccessible(true);
} catch (NoSuchMethodException expectedOnLowerApiLevels) {
}
recycleUncheckedMethod = recycleUnchecked;
}

/** Informed of the state of the queue and controls whether to continue interrogation or quit. */
interface QueueInterrogationHandler<R> {
Expand Down Expand Up @@ -133,17 +102,18 @@ static <R> R loopAndInterrogate(InterrogationHandler<R> handler) {
checkSanity();
interrogating.set(Boolean.TRUE);
boolean stillInterested = true;
MessageQueue q = Looper.myQueue();
TestLooperManagerCompat testLooperManager = TestLooperManagerCompat.acquire(Looper.myLooper());

// We may have an identity when we're called - we want to restore it at the end of the fn.
final long entryIdentity = Binder.clearCallingIdentity();
try {
// this identity should not get changed by dispatching the loop until the observer is happy.
final long threadIdentity = Binder.clearCallingIdentity();
while (stillInterested) {
// run until the observer is no longer interested.
stillInterested = interrogateQueueState(q, handler);
stillInterested = interrogateQueueState(testLooperManager, handler);
if (stillInterested) {
Message m = getNextMessage();
Message m = testLooperManager.next();

// the observer cannot stop us from dispatching this message - but we need to let it know
// that we're about to dispatch.
Expand All @@ -153,7 +123,7 @@ static <R> R loopAndInterrogate(InterrogationHandler<R> handler) {
}
stillInterested = handler.beforeTaskDispatch();
handler.setMessage(m);
m.getTarget().dispatchMessage(m);
testLooperManager.execute(m);

// ensure looper invariants
final long newIdentity = Binder.clearCallingIdentity();
Expand All @@ -172,48 +142,17 @@ static <R> R loopAndInterrogate(InterrogationHandler<R> handler) {
+ " what="
+ m.what);
}
recycle(m);
testLooperManager.recycle(m);
}
}
} finally {
Binder.restoreCallingIdentity(entryIdentity);
interrogating.set(Boolean.FALSE);
testLooperManager.release();
}
return handler.get();
}

private static void recycle(Message m) {
if (recycleUncheckedMethod != null) {
try {
recycleUncheckedMethod.invoke(m);
} catch (IllegalAccessException | IllegalArgumentException | SecurityException e) {
throwIfUnchecked(e);
throw new RuntimeException(e);
} catch (InvocationTargetException ite) {
if (ite.getCause() != null) {
throwIfUnchecked(ite.getCause());
throw new RuntimeException(ite.getCause());
} else {
throw new RuntimeException(ite);
}
}
} else {
m.recycle();
}
}

private static Message getNextMessage() {
try {
return (Message) messageQueueNextMethod.invoke(Looper.myQueue());
} catch (IllegalAccessException
| IllegalArgumentException
| InvocationTargetException
| SecurityException e) {
throwIfUnchecked(e);
throw new RuntimeException(e);
}
}

/**
* Allows caller to see if the message queue is empty, has a task due soon / long, or has a
* barrier.
Expand All @@ -228,36 +167,40 @@ private static Message getNextMessage() {
* queueEmpty(), taskDueSoon(), taskDueLong() or barrierUp(). once and only once.
* @return the result of handler.get()
*/
static <R> R peekAtQueueState(MessageQueue q, QueueInterrogationHandler<R> handler) {
checkNotNull(q);
static <R> R peekAtQueueState(
TestLooperManagerCompat testLooperManager, QueueInterrogationHandler<R> handler) {
checkNotNull(testLooperManager);
checkNotNull(handler);
checkState(
!interrogateQueueState(q, handler),
!interrogateQueueState(testLooperManager, handler),
"It is expected that %s would stop interrogation after a single peak at the queue.",
handler);
return handler.get();
}

static <R> R peekAtQueueState(Looper looper, QueueInterrogationHandler<R> handler) {
TestLooperManagerCompat testLooperManager = TestLooperManagerCompat.acquire(looper);
try {
return peekAtQueueState(testLooperManager, handler);
} finally {
testLooperManager.release();
}
}

private static boolean interrogateQueueState(
MessageQueue q, QueueInterrogationHandler<?> handler) {
synchronized (q) {
final Message head;
try {
head = (Message) messageQueueHeadField.get(q);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
if (null == head) {
// no messages pending - AT ALL!
return handler.queueEmpty();
} else if (null == head.getTarget()) {
// null target is a sync barrier token.
TestLooperManagerCompat testLooperManager, QueueInterrogationHandler<?> handler) {

Long headWhen = testLooperManager.peekWhen();
if (null == headWhen) {
if (testLooperManager.isBlockedOnSyncBarrier()) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "barrier is up");
}
return handler.barrierUp();
}
long headWhen = head.getWhen();
return handler.queueEmpty();
}

long nowFuz = SystemClock.uptimeMillis() + LOOKAHEAD_MILLIS;
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(
Expand All @@ -268,7 +211,6 @@ private static boolean interrogateQueueState(
return handler.taskDueSoon();
}
return handler.taskDueLong();
}
}

private static void checkSanity() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.MessageQueue;
import androidx.test.espresso.IdlingResource;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -69,7 +68,7 @@ public boolean barrierUp() {

// read on main - written on looper
private volatile boolean started = false;
private volatile MessageQueue queue = null;
private volatile Looper looper = null;
private volatile boolean idle = true;

// written on main - read on looper
Expand Down Expand Up @@ -97,7 +96,7 @@ static LooperIdlingResourceInterrogationHandler forLooper(Looper l) {
new Runnable() {
@Override
public void run() {
ir.queue = Looper.myQueue();
ir.looper = Looper.myLooper();
ir.started = true;
Interrogator.loopAndInterrogate(ir);
}
Expand Down Expand Up @@ -163,7 +162,7 @@ public boolean isIdleNow() {
// make sure nothing has arrived in the queue while the looper thread is waiting to pull a
// new task out of it. There can be some delay between a new message entering the queue and
// the looper thread pulling it out and processing it.
return Boolean.FALSE.equals(Interrogator.peekAtQueueState(queue, queueHasNewTasks));
return Boolean.FALSE.equals(Interrogator.peekAtQueueState(looper, queueHasNewTasks));
}
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package androidx.test.espresso.base;

import android.os.Looper;
import android.os.Message;
import android.os.MessageQueue;
import androidx.annotation.Nullable;
import androidx.test.internal.platform.reflect.ReflectiveField;
import androidx.test.internal.platform.reflect.ReflectiveMethod;

/**
* Compat class that supports the {@link android.os.TestLooperManager} Baklava+ functionality on
* older Android SDKs.
*
* <p>Unlike the real TestLooperManager this only supports being used from the Looper's thread.
*/
class TestLooperManagerCompat {

private static final ReflectiveMethod<Message> messageQueueNextMethod =
new ReflectiveMethod<>(MessageQueue.class, "next");

private static final ReflectiveField<Message> messageQueueHeadField =
new ReflectiveField<>(MessageQueue.class, "mMessages");

private static final ReflectiveMethod<Void> recycleUncheckedMethod =
new ReflectiveMethod<>(Message.class, "recycleUnchecked");
private final MessageQueue queue;

private TestLooperManagerCompat(MessageQueue queue) {
this.queue = queue;
}

static TestLooperManagerCompat acquire(Looper looper) {
return new TestLooperManagerCompat(looper.getQueue());
}

@Nullable
Long peekWhen() {
Message msg = legacyPeek();
if (msg != null && msg.getTarget() == null) {
return null;
}
return msg == null ? null : msg.getWhen();
}

private @Nullable Message legacyPeek() {
synchronized (queue) {
return messageQueueHeadField.get(queue);
}
}

void execute(Message message) {
message.getTarget().dispatchMessage(message);
}

void release() {
// ignore for now
}

boolean isBlockedOnSyncBarrier() {
Message msg = legacyPeek();
return msg != null && msg.getTarget() == null;
}

Message next() {
return messageQueueNextMethod.invoke(queue);
}

void recycle(Message m) {
recycleUncheckedMethod.invoke(m);
}
}
12 changes: 12 additions & 0 deletions espresso/core/javatests/androidx/test/espresso/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,15 @@ axt_android_library_test(
"@maven//:org_mockito_mockito_core",
],
)

axt_android_library_test(
name = "EspressoIdleTest",
srcs = ["EspressoIdleTest.java"],
deps = [
"//espresso/core",
"//ext/junit",
"//runner/android_junit_runner",
"@maven//:com_google_truth_truth",
"@maven//:junit_junit",
],
)
Loading

0 comments on commit 9c5afd7

Please sign in to comment.