Skip to content
Merged
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
49 changes: 48 additions & 1 deletion agents/android/java/UiTreeSerializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.json.JSONObject;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeoutException;

Expand All @@ -19,6 +20,8 @@ class UiTreeSerializer {

private static final String TAG = "UiTreeSerializer";

private static final String SYSTEM_UI_PACKAGE = "com.android.systemui";

// The UI must be quiet for this long before waitForIdle returns, bounded by
// the caller-supplied global timeout. Mirrors UiDevice.waitForIdle semantics.
private static final long IDLE_WINDOW_MS = 500;
Expand All @@ -38,15 +41,27 @@ static JSONObject dump(UiAutomation automation, long waitUntilIdle, boolean full
clearNodeCache(automation);

List<AccessibilityNodeInfo> roots = new ArrayList<>();
boolean activeWindowIsSystemUi = false;
for (AccessibilityWindowInfo window : automation.getWindows()) {
// The soft keyboard is noise for most callers: skip IME windows unless asked for a full dump.
if (!full && window.getType() == AccessibilityWindowInfo.TYPE_INPUT_METHOD) {
window.recycle();
continue;
}
AccessibilityNodeInfo root = window.getRoot();
if (root != null) roots.add(root);
boolean active = window.isActive();
window.recycle();
if (root == null) {
continue;
}
if (active && isSystemUi(root)) {
activeWindowIsSystemUi = true;
}
roots.add(root);
}

if (!full && !activeWindowIsSystemUi) {
dropSystemUiBesideApps(roots);
}

// Windows can be present yet expose null roots (not queryable at the
Expand All @@ -67,6 +82,38 @@ static JSONObject dump(UiAutomation automation, long waitUntilIdle, boolean full
}
}

// The status bar clock ticks every second, so a dump with it never matches the
// one before. It is only noise next to an app: on the lock screen, the
// notification shade, or mid launch before the app has a window, System UI is
// the screen itself and stays.
@SuppressWarnings("deprecation") // recycle() is a no-op on API 33+, still frees pools below
private static void dropSystemUiBesideApps(List<AccessibilityNodeInfo> roots) {
boolean hasApp = false;
for (AccessibilityNodeInfo root : roots) {
if (!isSystemUi(root)) {
hasApp = true;
break;
}
}
if (!hasApp) {
return;
}
Iterator<AccessibilityNodeInfo> it = roots.iterator();
while (it.hasNext()) {
AccessibilityNodeInfo root = it.next();
if (isSystemUi(root)) {
root.recycle();
it.remove();
}
}
}

// Status bar, navigation bar and notification shade all belong to System UI.
private static boolean isSystemUi(AccessibilityNodeInfo root) {
CharSequence packageName = root.getPackageName();
return packageName != null && SYSTEM_UI_PACKAGE.contentEquals(packageName);
}

// This connection is long-lived, so node reads are served from the framework's
// accessibility cache. A walk that races a content change (a Compose LazyRow
// swapping placeholders for items) can re-cache the old subtree after the
Expand Down
4 changes: 2 additions & 2 deletions cli/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ func init() {
// dump ui command flags
dumpUICmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to dump UI tree from")
dumpUICmd.Flags().StringVar(&dumpUIFormat, "format", "", "Output format: 'text' for indented element lines, 'raw' for unprocessed tree from agent (Default: json)")
dumpUICmd.Flags().BoolVar(&dumpUIFull, "full", false, "Include additional elements normally left out, such as the on-screen keyboard")
dumpUICmd.Flags().BoolVar(&dumpUIFull, "full", false, "Include additional elements normally left out, such as the on-screen keyboard and the Android status bar")

snapshotCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to dump UI tree from")
snapshotCmd.Flags().BoolVar(&dumpUIFull, "full", false, "Include additional elements normally left out, such as the on-screen keyboard")
snapshotCmd.Flags().BoolVar(&dumpUIFull, "full", false, "Include additional elements normally left out, such as the on-screen keyboard and the Android status bar")
}
2 changes: 1 addition & 1 deletion docs/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,7 @@
},
{
"name": "full",
"description": "Include additional elements normally left out, such as the on-screen keyboard",
"description": "Include additional elements normally left out, such as the on-screen keyboard and the Android status bar",
"required": false,
"schema": {
"type": "boolean",
Expand Down
2 changes: 1 addition & 1 deletion docs/openrpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ Dumps the UI hierarchy of the device screen
|------|------|----------|-------------|
| `deviceId` | `string` | ✓ | ID of the target device |
| `format` | enum: `json, raw` | | Output format (json or raw) |
| `full` | `boolean` | | Include additional elements normally left out, such as the on-screen keyboard |
| `full` | `boolean` | | Include additional elements normally left out, such as the on-screen keyboard and the Android status bar |

#### Response

Expand Down
2 changes: 1 addition & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ type DeviceRebootParams struct {
type DumpUIParams struct {
DeviceID string `json:"deviceId"`
Format string `json:"format,omitempty"` // "json" or "raw"
Full bool `json:"full,omitempty"` // include the on-screen keyboard and other normally hidden windows
Full bool `json:"full,omitempty"` // include the on-screen keyboard, the Android status bar and other normally hidden windows
}

type AppsLaunchParams struct {
Expand Down
2 changes: 1 addition & 1 deletion skills/mobilecli/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ All commands support the global `--device <id>` flag to specify the target devic
# Raw XML/JSON source from agent
mobilecli dump ui --device <device-id> --format raw

# Include elements normally left out, such as the on-screen keyboard (Android)
# Include elements normally left out, such as the on-screen keyboard and status bar (Android)
mobilecli dump ui --device <device-id> --full
```
* **List Webviews**:
Expand Down
17 changes: 15 additions & 2 deletions test/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,24 @@ const POLL_OPTIONS = {
intervals: new Array(ATTEMPTS).fill(INTERVAL_MS),
};

// What a read that threw resolves to, so the poll keeps going and a timeout
// shows the last error as the received value.
type FailedRead = {readFailed: string};

function readOrFailure<T>(read: () => T): T | FailedRead {
try {
return read();
} catch (error) {
return {readFailed: String(error)};
}
}

// Reads the device repeatedly until the assertion chained onto it passes:
//
// await eventually(() => foregroundPackage(id), 'settings never came up').toBe(SETTINGS_PACKAGE);
//
// The returned value is a normal expect matcher, so any assertion works.
// The returned value is a normal expect matcher, so any assertion works. A read
// that throws, like a dump taken while an app is still starting, is retried too.
export function eventually<T>(read: () => T, message: string) {
return expect.poll(read, {...POLL_OPTIONS, message});
return expect.poll(() => readOrFailure(read), {...POLL_OPTIONS, message});
}
Loading