diff --git a/espresso/core/java/androidx/test/espresso/base/BUILD b/espresso/core/java/androidx/test/espresso/base/BUILD index 7b2aa881c..9ba887cff 100644 --- a/espresso/core/java/androidx/test/espresso/base/BUILD +++ b/espresso/core/java/androidx/test/espresso/base/BUILD @@ -25,8 +25,11 @@ android_library( "IdlingUiController.java", "IdlingResourceRegistry.java", "Interrogator.java", + "LegacyLookaheadDetector.java", "LooperIdlingResourceInterrogationHandler.java", + "QueueIdleDetector.java", "TestLooperManagerCompat.java", + "UnifiedRecentCountDetector.java", "ViewHierarchyExceptionHandler.java", ], ), @@ -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", diff --git a/espresso/core/java/androidx/test/espresso/base/Interrogator.java b/espresso/core/java/androidx/test/espresso/base/Interrogator.java index e091ac5ed..1457a6517 100644 --- a/espresso/core/java/androidx/test/espresso/base/Interrogator.java +++ b/espresso/core/java/androidx/test/espresso/base/Interrogator.java @@ -25,6 +25,7 @@ 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 { @@ -32,6 +33,79 @@ 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 detectorThreadLocal = + new ThreadLocal() { + @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 interrogating = new ThreadLocal() { @Override @@ -92,7 +166,6 @@ interface InterrogationHandler extends QueueInterrogationHandler { public String getMessage(); } - Interrogator() {} /** * Loops the main thread and informs the interrogation handler at interesting points in the exec @@ -125,6 +198,8 @@ T loopAndInterrogate( } stillInterested = handler.beforeTaskDispatch(); handler.setMessage(m); + logDispatch(m); + detector.recordDispatch(SystemClock.uptimeMillis(), m); testLooperManager.execute(m); // ensure looper invariants @@ -181,27 +256,30 @@ 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(); } } diff --git a/espresso/core/java/androidx/test/espresso/base/LegacyLookaheadDetector.java b/espresso/core/java/androidx/test/espresso/base/LegacyLookaheadDetector.java new file mode 100644 index 000000000..c112c2edb --- /dev/null +++ b/espresso/core/java/androidx/test/espresso/base/LegacyLookaheadDetector.java @@ -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; + } +} diff --git a/espresso/core/java/androidx/test/espresso/base/QueueIdleDetector.java b/espresso/core/java/androidx/test/espresso/base/QueueIdleDetector.java new file mode 100644 index 000000000..1401e0f8f --- /dev/null +++ b/espresso/core/java/androidx/test/espresso/base/QueueIdleDetector.java @@ -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); +} diff --git a/espresso/core/java/androidx/test/espresso/base/UnifiedRecentCountDetector.java b/espresso/core/java/androidx/test/espresso/base/UnifiedRecentCountDetector.java new file mode 100644 index 000000000..d40323e83 --- /dev/null +++ b/espresso/core/java/androidx/test/espresso/base/UnifiedRecentCountDetector.java @@ -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 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(); + } + } +} diff --git a/espresso/core/javatests/androidx/test/espresso/base/BUILD b/espresso/core/javatests/androidx/test/espresso/base/BUILD index f43944c30..964b272e1 100644 --- a/espresso/core/javatests/androidx/test/espresso/base/BUILD +++ b/espresso/core/javatests/androidx/test/espresso/base/BUILD @@ -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", + ], +) diff --git a/espresso/core/javatests/androidx/test/espresso/base/QueueIdleDetectorTest.java b/espresso/core/javatests/androidx/test/espresso/base/QueueIdleDetectorTest.java new file mode 100644 index 000000000..d19836576 --- /dev/null +++ b/espresso/core/javatests/androidx/test/espresso/base/QueueIdleDetectorTest.java @@ -0,0 +1,127 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import android.os.Message; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QueueIdleDetectorTest { + + @Test + public void legacyLookahead_emptyQueue_isIdle() { + QueueIdleDetector detector = new LegacyLookaheadDetector(15); + assertThat(detector.isIdle(1000, null)).isTrue(); + } + + @Test + public void legacyLookahead_soonDueTask_isBusy() { + QueueIdleDetector detector = new LegacyLookaheadDetector(15); + // Task due at 1010 (10ms from now) + assertThat(detector.isIdle(1000, 1010L)).isFalse(); + } + + @Test + public void legacyLookahead_farFutureTask_isIdle() { + QueueIdleDetector detector = new LegacyLookaheadDetector(15); + // Task due at 1020 (20ms from now) + assertThat(detector.isIdle(1000, 1020L)).isTrue(); + } + + @Test + public void unifiedRecentCount_emptyQueue_noHistory_isIdle() { + QueueIdleDetector detector = new UnifiedRecentCountDetector(50, 4); + assertThat(detector.isIdle(1000, null)).isTrue(); + } + + @Test + public void unifiedRecentCount_emptyQueue_highHistory_isBusy() { + QueueIdleDetector detector = new UnifiedRecentCountDetector(50, 4); + Message m = new Message(); + // Record 5 dispatches in the last 50ms + detector.recordDispatch(960, m); + detector.recordDispatch(970, m); + detector.recordDispatch(980, m); + detector.recordDispatch(990, m); + detector.recordDispatch(995, m); + + // Queue is empty, but we had 5 dispatches -> should be busy (not idle) + assertThat(detector.isIdle(1000, null)).isFalse(); + } + + @Test + public void unifiedRecentCount_emptyQueue_prunedHistory_isIdle() { + QueueIdleDetector detector = new UnifiedRecentCountDetector(50, 4); + Message m = new Message(); + // Record dispatches older than 50ms + detector.recordDispatch(940, m); + detector.recordDispatch(945, m); + detector.recordDispatch(948, m); + detector.recordDispatch(949, m); + detector.recordDispatch(950, m); // exactly 50ms ago + + // Queue is empty, old history is pruned -> should be idle + assertThat(detector.isIdle(1000, null)).isTrue(); + } + + @Test + public void unifiedRecentCount_soonDueTask_isBusy() { + QueueIdleDetector detector = new UnifiedRecentCountDetector(50, 4); + // Next task at 1040 (40ms away, less than 50ms) -> should be busy regardless of history + assertThat(detector.isIdle(1000, 1040L)).isFalse(); + } + + @Test + public void unifiedRecentCount_farFutureTask_lowHistory_isIdle() { + QueueIdleDetector detector = new UnifiedRecentCountDetector(50, 4); + Message m = new Message(); + detector.recordDispatch(980, m); + detector.recordDispatch(990, m); + + // Next task at 1060 (60ms away) and 2 dispatches -> should be idle + assertThat(detector.isIdle(1000, 1060L)).isTrue(); + } + + @Test + public void unifiedRecentCount_farFutureTask_highHistory_isBusy() { + QueueIdleDetector detector = new UnifiedRecentCountDetector(50, 4); + Message m = new Message(); + detector.recordDispatch(960, m); + detector.recordDispatch(970, m); + detector.recordDispatch(980, m); + detector.recordDispatch(990, m); + detector.recordDispatch(995, m); + + // Next task at 1060 (60ms away) but 5 dispatches -> should be busy + assertThat(detector.isIdle(1000, 1060L)).isFalse(); + } + + @Test + public void unifiedRecentCount_boundaryConditions_evaluatedCorrectly() { + QueueIdleDetector detector = new UnifiedRecentCountDetector(50, 4); + + // Exactly 50ms away -> should be busy (headDelay must be strictly > 50ms to be idle) + assertThat(detector.isIdle(1000, 1050L)).isFalse(); + + // Exactly 51ms away -> should be idle (headDelay is 51ms, which is > 50ms) + assertThat(detector.isIdle(1000, 1051L)).isTrue(); + } +}