Skip to content

feat: headless UI tree dump via app_process (no instrumentation)#28

Merged
gmegidish merged 3 commits into
mainfrom
feat/headless-uiautomation
Jun 16, 2026
Merged

feat: headless UI tree dump via app_process (no instrumentation)#28
gmegidish merged 3 commits into
mainfrom
feat/headless-uiautomation

Conversation

@gmegidish

@gmegidish gmegidish commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

Introduces a standalone app_process path for dumping the UI hierarchy, so the tree dump can run headless — without am instrument and without an Instrumentation host. This is the first step toward running the whole toolkit under app_process.

What's new

  • UiAutomationFactory — builds and connects a UiAutomation without instrumentation, by reflecting the @hide UiAutomation(Looper, IUiAutomationConnection) constructor and connect() method. This is the same approach AOSP's legacy uiautomator shell tool (UiAutomationShellWrapper) uses. It also exposes configureForWindowRetrieval(), which is now shared by the existing instrumentation entry points instead of being duplicated in each.
  • UiDump — a standalone main() that prints the hierarchy JSON to stdout:
    adb shell CLASSPATH=/data/local/tmp/classes.dex app_process / \
      com.mobilenext.devicekit.UiDump [waitUntilIdleMs]
    
    (@file:JvmName("UiDump") keeps the class name clean for the command line.)

Refactors (behavior-preserving)

  • UiTreeSerializer.dump no longer takes an Instrumentation. Idle-waiting now uses UiAutomation.waitForIdle(...) directly, so the serializer is fully decoupled from the instrumentation framework.
  • DeviceKitServer and ViewTreeDump now reuse UiAutomationFactory.configureForWindowRetrieval() and the new dump signature. No behavior change.
  • Removed the androidx.test.uiautomator dependencyUiDevice was its only consumer. Fully decouples from instrumentation and shrinks the dex (0 uiautomator refs remain).

Requirements / caveats

  • Must run as shell (uid 2000) or root. UiAutomationConnection.connect() registers with AccessibilityManagerService / WindowManager, which require those permissions. adb shell app_process … satisfies this; it would not work from inside a normal installed app.
  • Relies on @hide reflection. The constructor/connect() signatures aren't SDK-stable and can shift across Android versions. minSdk is 29, which is within the supported range for this pattern.
  • Hidden-API enforcement does not apply to bare app_process invocations (same reason scrcpy's server can call hidden APIs).

Verification

  • ./gradlew assembleDebug and ./gradlew detekt both pass.
  • Dex confirmed to contain Lcom/mobilenext/devicekit/UiDump; with main([Ljava/lang/String;)V and UiAutomationFactory; 0 uiautomator references remain.
  • Device run not yet executed (no device/emulator was attached in this environment).

Test plan

  • Push the dex/APK and run app_process / com.mobilenext.devicekit.UiDump 2000 against a foreground app; confirm the JSON hierarchy matches the instrumentation ViewTreeDump output.
  • Confirm DeviceKitServer (device.dump.ui) and instrumentation ViewTreeDump still work unchanged.
  • Test on multiple API levels (29 → 35) to shake out @hide reflection differences.

Summary by cubic

Adds a headless UI tree dump that runs via app_process without am instrument. Drops use of androidx.test.uiautomator in code, decouples the serializer from Instrumentation, and fixes a headless startup crash by preparing the main looper.

  • New Features

    • UiAutomationFactory: builds and connects UiAutomation without instrumentation; exposes configureForWindowRetrieval().
    • UiDump: standalone main() that prints hierarchy JSON; optional waitUntilIdleMs; prepares the main looper and runs on a worker thread; requires shell or root.
  • Refactors

    • UiTreeSerializer.dump(uiAutomation, waitUntilIdle) uses UiAutomation.waitForIdle; no Instrumentation.
    • DeviceKitServer and ViewTreeDump now reuse UiAutomationFactory.configureForWindowRetrieval(); behavior unchanged.

Written for commit eb7d1f3. Summary will update on new commits.

Review in cubic

Adds a standalone app_process entry point that dumps the UI hierarchy
without an Instrumentation host, plus the bootstrap that makes it possible.

- UiAutomationFactory: builds and connects a UiAutomation by reflecting the
  @hide UiAutomation(Looper, IUiAutomationConnection) constructor and
  connect() method (the same path AOSP's uiautomator shell tool uses). Must
  run as shell/root. Also exposes configureForWindowRetrieval(), now shared
  by the Instrumentation entry points instead of duplicated in each.
- UiDump: app_process main() that prints the hierarchy JSON to stdout:
    app_process / com.mobilenext.devicekit.UiDump [waitUntilIdleMs]
- UiTreeSerializer: dropped the Instrumentation/UiDevice parameter; idle
  waiting now uses UiAutomation.waitForIdle directly, so the dump no longer
  depends on the instrumentation framework.
- Removed the androidx.test.uiautomator dependency (UiDevice was its only
  use), further decoupling from instrumentation and shrinking the dex.

DeviceKitServer and ViewTreeDump are unchanged in behavior; they now reuse
the shared a11y configuration helper.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/com/mobilenext/devicekit/UiTreeSerializer.kt">

<violation number="1" location="app/src/main/java/com/mobilenext/devicekit/UiTreeSerializer.kt:18">
P1: uiAutomation.waitForIdle() throws TimeoutException on timeout; the old UiDevice.waitForIdle() wrapper swallowed it, allowing dump to proceed with current state. Now an uncaught TimeoutException propagates to callers, causing dump to fail entirely instead of returning best-effort hierarchy.</violation>
</file>

<file name="app/src/main/java/com/mobilenext/devicekit/UiAutomationFactory.kt">

<violation number="1" location="app/src/main/java/com/mobilenext/devicekit/UiAutomationFactory.kt:35">
P2: No disconnect/cleanup path — HandlerThread and UiAutomation connection are never torn down. The thread leaks and the accessibility registration persists unless the process exits. Adding a disconnect() (or making the factory AutoCloseable) would let callers clean up.</violation>

<violation number="2" location="app/src/main/java/com/mobilenext/devicekit/UiAutomationFactory.kt:58">
P2: latch.await() return value ignored — timeout is treated as success. If the accessibility connection isn't live within 2 s, callers will proceed with a UiAutomation that may return empty windows or throw.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


fun dump(uiAutomation: UiAutomation, waitUntilIdle: Long = 0L): String {
if (waitUntilIdle > 0) {
uiAutomation.waitForIdle(IDLE_WINDOW_MS, waitUntilIdle)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: uiAutomation.waitForIdle() throws TimeoutException on timeout; the old UiDevice.waitForIdle() wrapper swallowed it, allowing dump to proceed with current state. Now an uncaught TimeoutException propagates to callers, causing dump to fail entirely instead of returning best-effort hierarchy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/mobilenext/devicekit/UiTreeSerializer.kt, line 18:

<comment>uiAutomation.waitForIdle() throws TimeoutException on timeout; the old UiDevice.waitForIdle() wrapper swallowed it, allowing dump to proceed with current state. Now an uncaught TimeoutException propagates to callers, causing dump to fail entirely instead of returning best-effort hierarchy.</comment>

<file context>
@@ -1,19 +1,21 @@
+
+    fun dump(uiAutomation: UiAutomation, waitUntilIdle: Long = 0L): String {
+        if (waitUntilIdle > 0) {
+            uiAutomation.waitForIdle(IDLE_WINDOW_MS, waitUntilIdle)
         }
 
</file context>
Suggested change
uiAutomation.waitForIdle(IDLE_WINDOW_MS, waitUntilIdle)
try {
uiAutomation.waitForIdle(IDLE_WINDOW_MS, waitUntilIdle)
} catch (_: java.util.concurrent.TimeoutException) {
// Proceed with current UI state, mirroring UiDevice.waitForIdle behavior
}

* standalone app_process entry points.
*/
fun createAndConnect(): UiAutomation {
val thread = HandlerThread("devicekit-uiautomation").apply { start() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: No disconnect/cleanup path — HandlerThread and UiAutomation connection are never torn down. The thread leaks and the accessibility registration persists unless the process exits. Adding a disconnect() (or making the factory AutoCloseable) would let callers clean up.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/mobilenext/devicekit/UiAutomationFactory.kt, line 35:

<comment>No disconnect/cleanup path — HandlerThread and UiAutomation connection are never torn down. The thread leaks and the accessibility registration persists unless the process exits. Adding a disconnect() (or making the factory AutoCloseable) would let callers clean up.</comment>

<file context>
@@ -0,0 +1,81 @@
+     * standalone app_process entry points.
+     */
+    fun createAndConnect(): UiAutomation {
+        val thread = HandlerThread("devicekit-uiautomation").apply { start() }
+        val uiAutomation = construct(thread.looper)
+        connect(uiAutomation)
</file context>

AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
}
latch.await(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: latch.await() return value ignored — timeout is treated as success. If the accessibility connection isn't live within 2 s, callers will proceed with a UiAutomation that may return empty windows or throw.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/mobilenext/devicekit/UiAutomationFactory.kt, line 58:

<comment>latch.await() return value ignored — timeout is treated as success. If the accessibility connection isn't live within 2 s, callers will proceed with a UiAutomation that may return empty windows or throw.</comment>

<file context>
@@ -0,0 +1,81 @@
+                AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or
+                AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
+        }
+        latch.await(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+        uiAutomation.setOnAccessibilityEventListener(null)
+    }
</file context>

A bare app_process has no main looper (a normal app gets one from
ActivityThread). The accessibility framework builds a Handler on the main
looper during connect, so without one the headless dump crashed with:

  NullPointerException: ... 'android.os.Looper.mQueue' on a null object
  reference  (AccessibilityInteractionClient.<init>)

Prepare the main looper in UiDump.main and run the dump on a worker thread
while the main thread services looper callbacks. Verified end to end on an
emulator (API 37): UiDump now emits the full UI hierarchy as JSON.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@gmegidish, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 20 minutes and 55 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f92a73b4-9d7f-4fe6-a1eb-b0945ca26c84

📥 Commits

Reviewing files that changed from the base of the PR and between 067dc04 and eb7d1f3.

📒 Files selected for processing (6)
  • app/build.gradle.kts
  • app/src/main/java/com/mobilenext/devicekit/DeviceKitServer.kt
  • app/src/main/java/com/mobilenext/devicekit/UiAutomationFactory.kt
  • app/src/main/java/com/mobilenext/devicekit/UiDump.kt
  • app/src/main/java/com/mobilenext/devicekit/UiTreeSerializer.kt
  • app/src/main/java/com/mobilenext/devicekit/ViewTreeDump.kt
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/headless-uiautomation

Comment @coderabbitai help to get the list of available commands and usage tips.

@gmegidish

Copy link
Copy Markdown
Member Author

✅ Verified on emulator (API 37)

Tested both paths end to end on a running emulator.

Headless UiDump via app_process

adb shell CLASSPATH=/data/local/tmp/classes.dex app_process / \
  com.mobilenext.devicekit.UiDump 2000

Emits valid JSON: 2 root windows, 93 nodes, with resource-id, text ("11:30"), content-desc ("Wifi signal full.", "Battery 100 percent."), and bounds — same shape as the instrumentation dump.

Instrumentation ViewTreeDump (regression)

adb shell am instrument -w -e waitUntilIdle 2000 com.mobilenext.devicekit/.ViewTreeDump

Returns the hierarchy and INSTRUMENTATION_CODE: -1 (RESULT_OK). The shared configureForWindowRetrieval refactor is intact.

One fix landed during testing (commit a6d82c4)

The first headless run crashed:

NullPointerException: ... 'android.os.Looper.mQueue' on a null object reference
    at android.view.accessibility.AccessibilityInteractionClient.<init>

Root cause: a bare app_process has no main looper (a normal app gets one from ActivityThread), and the accessibility framework builds a Handler on it during connect(). Fixed by calling Looper.prepareMainLooper() in UiDump.main and running the dump on a worker thread while main services looper callbacks. The instrumentation entry points are unaffected (the framework already provides their main looper).

Notes

  • Confirmed it runs as shell (uid 2000) — no root needed.
  • The @hide reflection worked on API 37 (logged UiAutomation: Created with deprecated constructor, assumes DEFAULT_DISPLAY — harmless; only the legacy single-display ctor is used).
  • Still worth testing across API 29 → 35 before relying on it broadly.

@gmegidish gmegidish merged commit b1c2c33 into main Jun 16, 2026
7 checks passed
@gmegidish gmegidish deleted the feat/headless-uiautomation branch June 16, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant