Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions espresso/core/java/androidx/test/espresso/base/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ android_library(
"IdlingUiController.java",
"IdlingResourceRegistry.java",
"Interrogator.java",
"LegacyLookaheadDetector.java",
"LooperIdlingResourceInterrogationHandler.java",
"QueueIdleDetector.java",
"TestLooperManagerCompat.java",
"UnifiedRecentCountDetector.java",
"ViewHierarchyExceptionHandler.java",
],
),
Expand Down Expand Up @@ -65,8 +68,11 @@ android_library(
"IdleNotifier.java",
"IdlingResourceRegistry.java",
"Interrogator.java",
"LegacyLookaheadDetector.java",
"LooperIdlingResourceInterrogationHandler.java",
"QueueIdleDetector.java",
"TestLooperManagerCompat.java",
"UnifiedRecentCountDetector.java",
],
deps = [
"//espresso/core/java/androidx/test/espresso:interface",
Expand Down
102 changes: 90 additions & 12 deletions espresso/core/java/androidx/test/espresso/base/Interrogator.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,87 @@
import android.os.SystemClock;
import android.util.Log;
import androidx.annotation.VisibleForTesting;
import java.util.Locale;

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

private static final String TAG = "Interrogator";

@VisibleForTesting static final int LOOKAHEAD_MILLIS = 15;

private static boolean useNewSync() {
return Boolean.parseBoolean(System.getProperty("espresso.use_new_sync", "true"));
}

private static boolean enableMlLogging() {
return Boolean.parseBoolean(System.getProperty("espresso.enable_ml_logging", "true"));
}

private static void logEvent(
String event, long now, Long headWhen, boolean barrier, String decision) {
if (!enableMlLogging()) {
return;
}
String headWhenStr = headWhen == null ? "null" : String.valueOf(headWhen);
Log.i(
"ESPRESSO_ML",
String.format(
Locale.ROOT,
"{\"event\":\"%s\",\"time\":%d,\"headWhen\":%s,\"barrier\":%b,\"decision\":\"%s\"}",
event,
now,
headWhenStr,
barrier,
decision));
}

private static void logDispatch(Message m) {
if (!enableMlLogging()) {
return;
}
long now = SystemClock.uptimeMillis();
String target = m.getTarget() == null ? "null" : m.getTarget().getClass().getName();
String callback = m.getCallback() == null ? "null" : m.getCallback().getClass().getName();
Log.i(
"ESPRESSO_ML",
String.format(
Locale.ROOT,
"{\"event\":\"dispatch\",\"time\":%d,\"msgWhen\":%d,\"what\":%d,\"target\":\"%s\",\"callback\":\"%s\"}",
now,
m.getWhen(),
m.what,
target,
callback));
}

private static final ThreadLocal<QueueIdleDetector> detectorThreadLocal =
new ThreadLocal<QueueIdleDetector>() {
@Override
protected QueueIdleDetector initialValue() {
return createDefaultDetector();
}
};

private final QueueIdleDetector detector;

Interrogator() {
this(detectorThreadLocal.get());
}

@VisibleForTesting
Interrogator(QueueIdleDetector detector) {
this.detector = checkNotNull(detector);
}

private static QueueIdleDetector createDefaultDetector() {
if (useNewSync()) {
return new UnifiedRecentCountDetector(50, 4);
} else {
return new LegacyLookaheadDetector(LOOKAHEAD_MILLIS);
}
}

private static final ThreadLocal<Boolean> interrogating =
new ThreadLocal<Boolean>() {
@Override
Expand Down Expand Up @@ -92,7 +166,6 @@ interface InterrogationHandler<R> extends QueueInterrogationHandler<R> {
public String getMessage();
}

Interrogator() {}

/**
* Loops the main thread and informs the interrogation handler at interesting points in the exec
Expand Down Expand Up @@ -125,6 +198,8 @@ <T> T loopAndInterrogate(
}
stillInterested = handler.beforeTaskDispatch();
handler.setMessage(m);
logDispatch(m);
detector.recordDispatch(SystemClock.uptimeMillis(), m);
testLooperManager.execute(m);

// ensure looper invariants
Expand Down Expand Up @@ -181,27 +256,30 @@ <T> T peekAtQueueState(
private boolean interrogateQueueState(
TestLooperManagerCompat testLooperManager, QueueInterrogationHandler<?> handler) {
synchronized (testLooperManager.getQueue()) {
long now = SystemClock.uptimeMillis();
if (testLooperManager.isBlockedOnSyncBarrier()) {
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "barrier is up");
}
logEvent("interrogate", now, null, true, "barrier");
return handler.barrierUp();
}
Long headWhen = testLooperManager.peekWhen();
if (headWhen == null) {
return handler.queueEmpty();
}

long nowFuz = SystemClock.uptimeMillis() + LOOKAHEAD_MILLIS;
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(
TAG,
"headWhen: " + headWhen + " nowFuz: " + nowFuz + " due long: " + (nowFuz < headWhen));
}
if (nowFuz > headWhen) {
boolean isUnifiedIdle = detector.isIdle(now, headWhen);

if (isUnifiedIdle) {
if (headWhen == null) {
logEvent("interrogate", now, null, false, "empty");
return handler.queueEmpty();
} else {
logEvent("interrogate", now, headWhen, false, "long");
return handler.taskDueLong();
}
} else {
logEvent("interrogate", now, headWhen, false, "soon");
return handler.taskDueSoon();
}
return handler.taskDueLong();
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.test.espresso.base;

import android.os.Message;

/** Legacy lookahead-based idle detector. */
final class LegacyLookaheadDetector implements QueueIdleDetector {

private final int lookaheadMillis;

LegacyLookaheadDetector(int lookaheadMillis) {
this.lookaheadMillis = lookaheadMillis;
}

@Override
public void recordDispatch(long now, Message m) {
// No-op. Legacy detector does not track dispatch history.
}

@Override
public boolean isIdle(long now, Long headWhen) {
if (headWhen == null) {
return true;
}
long nowFuz = now + lookaheadMillis;
return nowFuz <= headWhen;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.test.espresso.base;

import android.os.Message;

/** Interface for deciding if a MessageQueue under interrogation is idle. */
interface QueueIdleDetector {

/** Records that a message has been dispatched at the given time. */
void recordDispatch(long now, Message m);

/**
* Evaluates whether the queue is idle.
*
* @param now the current uptime millis
* @param headWhen the execution uptime millis of the next message in the queue, or null if empty
* @return true if the queue should be considered idle, false otherwise.
*/
boolean isIdle(long now, Long headWhen);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.test.espresso.base;

import android.os.Message;
import java.util.ArrayDeque;

/** Unified Recent Count heuristic detector (T=50ms, C=4). */
final class UnifiedRecentCountDetector implements QueueIdleDetector {

private final long windowMs;
private final int maxDispatches;
private final ArrayDeque<Long> dispatchHistory = new ArrayDeque<>();

UnifiedRecentCountDetector(long windowMs, int maxDispatches) {
this.windowMs = windowMs;
this.maxDispatches = maxDispatches;
}

@Override
public synchronized void recordDispatch(long now, Message m) {
dispatchHistory.addLast(now);
pruneHistory(now);
}

@Override
public synchronized boolean isIdle(long now, Long headWhen) {
pruneHistory(now);
long recentDispatches = dispatchHistory.size();

boolean isIdleHeuristic = false;
if (headWhen == null) {
isIdleHeuristic = true;
} else {
long headDelay = headWhen - now;
if (headDelay > windowMs) {
isIdleHeuristic = true;
}
}

return isIdleHeuristic && recentDispatches <= maxDispatches;
}

private void pruneHistory(long now) {
long threshold = now - windowMs;
while (!dispatchHistory.isEmpty() && dispatchHistory.peekFirst() < threshold) {
dispatchHistory.removeFirst();
}
}
}
12 changes: 12 additions & 0 deletions espresso/core/javatests/androidx/test/espresso/base/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,15 @@ axt_android_library_test(
"@maven//:junit_junit",
],
)

axt_android_library_test(
name = "QueueIdleDetectorTest",
srcs = ["QueueIdleDetectorTest.java"],
deps = [
"//core",
"//espresso/core/java/androidx/test/espresso/base:idling_resource_registry",
"//ext/junit",
"@maven//:com_google_truth_truth",
"@maven//:junit_junit",
],
)
Loading
Loading