From da56d6b59b46506aa62dc0f72555b7555cd6729e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 16:02:39 +0200 Subject: [PATCH 1/2] perf(android): batch helper node reads with uninterruptible prefetch Each accessibility read that misses the connection cache is served on the app's main thread after the frame it is drawing. An app with a looping animation draws back to back, so a 400-node traversal took 5-8 s. Reading window roots and children with uninterruptible depth-first prefetch (API 33+) measured about half the traversal time of the platform default. Refs #3558 --- .../AccessibilityNodeReads.java | 34 +++++++++++++++++++ .../AccessibilityTreeCapture.java | 2 +- .../snapshothelper/AccessibilityTreeXml.java | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java new file mode 100644 index 0000000000..432651cfae --- /dev/null +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java @@ -0,0 +1,34 @@ +package com.callstack.agentdevice.snapshothelper; + +import android.os.Build; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; + +/** + * Reads window roots and children from the app with uninterruptible descendant prefetch. + * + *

Every read that misses the connection cache is a round trip served on the app's main thread + * after the frame it is drawing, so on an app that never stops animating each miss costs about a + * frame. Of the prefetch strategies, uninterruptible depth-first traversed such an app fastest. + */ +final class AccessibilityNodeReads { + private static final int PREFETCH_STRATEGY = + AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS_DEPTH_FIRST + | AccessibilityNodeInfo.FLAG_PREFETCH_UNINTERRUPTIBLE; + + private AccessibilityNodeReads() {} + + static AccessibilityNodeInfo root(AccessibilityWindowInfo window) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return window.getRoot(PREFETCH_STRATEGY); + } + return window.getRoot(); + } + + static AccessibilityNodeInfo child(AccessibilityNodeInfo node, int index) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return node.getChild(index, PREFETCH_STRATEGY); + } + return node.getChild(index); + } +} diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java index 4ac7be3db5..46e7a41656 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java @@ -144,7 +144,7 @@ private static int appendInteractiveWindowRoots( AccessibilityTreeXml.WindowMetadata windowMetadata = null; try { windowMetadata = AccessibilityTreeXml.readWindowMetadata(window, windowCount); - root = window.getRoot(); + root = AccessibilityNodeReads.root(window); if (root == null) { missingRootWindowTypes.add(windowMetadata.type); stats.activeWindowRootMissing |= activeWindow; diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java index 8dd382c412..93491f8109 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java @@ -117,7 +117,7 @@ static void appendNode( stats.truncated = true; break; } - AccessibilityNodeInfo child = node.getChild(index); + AccessibilityNodeInfo child = AccessibilityNodeReads.child(node, index); if (child == null) { continue; } From 86eb2d978041ed2c9af4ece9197c5b1714cf2c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 16:02:39 +0200 Subject: [PATCH 2/2] fix(android): wait for the window list after enabling interactive windows A fresh UiAutomation connection turns on FLAG_RETRIEVE_INTERACTIVE_WINDOWS right before its first read, and the platform fills the window list after that. The first capture often listed only the status bar, so a new helper session or the one-shot fallback read as content-poor with no app window. When the helper turns the flag on, wait up to 1 s for the list to name an active window before capturing. Refs #3558 --- .../AccessibilityTreeCapture.java | 44 +++++++++++++++++-- .../snapshothelper/GestureViewportReader.java | 6 +-- .../SnapshotInstrumentation.java | 2 +- .../snapshothelper/TouchCommandHandler.java | 3 +- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java index 46e7a41656..532d47b094 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java @@ -10,6 +10,8 @@ /** Captures and serializes the Android accessibility window tree. */ final class AccessibilityTreeCapture { + private static final long WINDOW_LIST_SETTLE_TIMEOUT_MS = 1_000; + private AccessibilityTreeCapture() {} static Result capture( @@ -98,23 +100,59 @@ private static void clearAccessibilityCacheViaServiceInfo(UiAutomation automatio } } - static void enableInteractiveWindowRetrieval(UiAutomation automation) { + /** + * Turns on interactive-window retrieval and, when this call turned it on, waits a bounded time for + * the window list to name an active window. The platform fills the list after the flag is set, so + * a read straight after it can list only the status bar and none of the app's windows. + */ + static void enableInteractiveWindowRetrieval(UiAutomation automation) + throws InterruptedException { + if (!turnOnInteractiveWindowRetrieval(automation)) { + return; + } + try { + AccessibilityCaptureStabilizer.capture( + () -> () -> listsActiveWindow(automation), WINDOW_LIST_SETTLE_TIMEOUT_MS); + } catch (AccessibilityCaptureStabilizer.IncompleteCaptureException ignored) { + // Capture what the platform lists; the host judges whether it holds the app. + } + } + + private static boolean turnOnInteractiveWindowRetrieval(UiAutomation automation) { AccessibilityServiceInfo serviceInfo; try { serviceInfo = automation.getServiceInfo(); } catch (RuntimeException error) { - return; + return false; } if (serviceInfo == null || (serviceInfo.flags & AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS) != 0) { - return; + return false; } serviceInfo.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS; try { automation.setServiceInfo(serviceInfo); + return true; } catch (RuntimeException ignored) { // Fall back to active-window capture if the platform rejects dynamic service flags. + return false; + } + } + + @SuppressWarnings("deprecation") + private static boolean listsActiveWindow(UiAutomation automation) { + List windows; + try { + windows = automation.getWindows(); + } catch (RuntimeException error) { + return false; + } + boolean active = false; + for (AccessibilityWindowInfo window : windows) { + active |= isActiveWindow(window); + window.recycle(); } + return active; } @SuppressWarnings("deprecation") diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/GestureViewportReader.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/GestureViewportReader.java index 93cf14e16c..bccd801fa0 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/GestureViewportReader.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/GestureViewportReader.java @@ -58,7 +58,7 @@ Rect toRect() { } @SuppressWarnings("deprecation") - static Reading readReading(UiAutomation automation) { + static Reading readReading(UiAutomation automation) throws InterruptedException { try { automation.waitForIdle(100, 2_000); } catch (TimeoutException ignored) { @@ -138,10 +138,6 @@ static WindowEdges struckInputMethod(List inputMethodWindows, Windo return struck; } - static Rect read(UiAutomation automation) { - return readReading(automation).application; - } - private static Rect resolveApplication( UiAutomation automation, Rect activeBounds, Rect fallbackBounds) { if (activeBounds != null) return activeBounds; diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java index 1a66f0e139..e72bf364f4 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java @@ -147,7 +147,7 @@ private static void putCaptureMetadata( } } - private void runOneShotViewport(Bundle result) { + private void runOneShotViewport(Bundle result) throws InterruptedException { TouchCommandHandler.populateViewport(result, getConnectedUiAutomationUnchecked()); finishSafely(0, result); } diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/TouchCommandHandler.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/TouchCommandHandler.java index 93a6c82848..5a083611a5 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/TouchCommandHandler.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/TouchCommandHandler.java @@ -14,7 +14,8 @@ final class TouchCommandHandler { private TouchCommandHandler() {} - static void populateViewport(Bundle result, UiAutomation automation) { + static void populateViewport(Bundle result, UiAutomation automation) + throws InterruptedException { GestureViewportReader.Reading reading = GestureViewportReader.readReading(automation); result.putString("ok", "true"); result.putString("kind", "viewport");