android-emu-agent 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- android_emu_agent/__init__.py +3 -0
- android_emu_agent/actions/__init__.py +1 -0
- android_emu_agent/actions/executor.py +288 -0
- android_emu_agent/actions/selector.py +122 -0
- android_emu_agent/actions/wait.py +193 -0
- android_emu_agent/artifacts/__init__.py +1 -0
- android_emu_agent/artifacts/manager.py +125 -0
- android_emu_agent/artifacts/py.typed +0 -0
- android_emu_agent/cli/__init__.py +1 -0
- android_emu_agent/cli/commands/__init__.py +1 -0
- android_emu_agent/cli/commands/action.py +158 -0
- android_emu_agent/cli/commands/app_cmd.py +95 -0
- android_emu_agent/cli/commands/artifact.py +81 -0
- android_emu_agent/cli/commands/daemon.py +62 -0
- android_emu_agent/cli/commands/device.py +122 -0
- android_emu_agent/cli/commands/emulator.py +46 -0
- android_emu_agent/cli/commands/file.py +139 -0
- android_emu_agent/cli/commands/reliability.py +310 -0
- android_emu_agent/cli/commands/session.py +65 -0
- android_emu_agent/cli/commands/ui.py +112 -0
- android_emu_agent/cli/commands/wait.py +132 -0
- android_emu_agent/cli/daemon_client.py +185 -0
- android_emu_agent/cli/main.py +52 -0
- android_emu_agent/cli/utils.py +171 -0
- android_emu_agent/daemon/__init__.py +1 -0
- android_emu_agent/daemon/core.py +62 -0
- android_emu_agent/daemon/health.py +177 -0
- android_emu_agent/daemon/models.py +244 -0
- android_emu_agent/daemon/server.py +1644 -0
- android_emu_agent/db/__init__.py +1 -0
- android_emu_agent/db/models.py +229 -0
- android_emu_agent/device/__init__.py +1 -0
- android_emu_agent/device/manager.py +522 -0
- android_emu_agent/device/session.py +129 -0
- android_emu_agent/errors.py +232 -0
- android_emu_agent/files/__init__.py +1 -0
- android_emu_agent/files/manager.py +311 -0
- android_emu_agent/py.typed +0 -0
- android_emu_agent/reliability/__init__.py +1 -0
- android_emu_agent/reliability/manager.py +244 -0
- android_emu_agent/ui/__init__.py +1 -0
- android_emu_agent/ui/context.py +169 -0
- android_emu_agent/ui/ref_resolver.py +149 -0
- android_emu_agent/ui/snapshotter.py +236 -0
- android_emu_agent/validation.py +59 -0
- android_emu_agent-0.1.3.dist-info/METADATA +375 -0
- android_emu_agent-0.1.3.dist-info/RECORD +50 -0
- android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
- android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
- android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""UI Snapshotter - Multi-source extraction, filtering, scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from lxml import etree
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger()
|
|
16
|
+
|
|
17
|
+
# Warning threshold for snapshot size (20KB)
|
|
18
|
+
SNAPSHOT_SIZE_WARNING_BYTES = 20480
|
|
19
|
+
|
|
20
|
+
# XPath for interactive elements only
|
|
21
|
+
INTERACTIVE_XPATH = (
|
|
22
|
+
"//*["
|
|
23
|
+
"@clickable='true' or "
|
|
24
|
+
"@focusable='true' or "
|
|
25
|
+
"@scrollable='true' or "
|
|
26
|
+
"@checkable='true' or "
|
|
27
|
+
"@editable='true' or "
|
|
28
|
+
"string-length(@text)>0"
|
|
29
|
+
"]"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ElementNode:
|
|
35
|
+
"""Represents an interactive UI element."""
|
|
36
|
+
|
|
37
|
+
ref: str
|
|
38
|
+
role: str
|
|
39
|
+
label: str | None
|
|
40
|
+
resource_id: str | None
|
|
41
|
+
class_name: str
|
|
42
|
+
bounds: list[int]
|
|
43
|
+
state: dict[str, bool]
|
|
44
|
+
content_desc: str | None = None
|
|
45
|
+
text: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Snapshot:
|
|
50
|
+
"""Complete UI snapshot with context and elements."""
|
|
51
|
+
|
|
52
|
+
schema_version: int
|
|
53
|
+
session_id: str
|
|
54
|
+
generation: int
|
|
55
|
+
timestamp_ms: int
|
|
56
|
+
device: dict[str, Any]
|
|
57
|
+
context: dict[str, Any]
|
|
58
|
+
elements: list[ElementNode]
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
"""Convert to JSON-serializable dict."""
|
|
62
|
+
result: dict[str, Any] = {
|
|
63
|
+
"schema_version": self.schema_version,
|
|
64
|
+
"session_id": self.session_id,
|
|
65
|
+
"generation": self.generation,
|
|
66
|
+
"timestamp_ms": self.timestamp_ms,
|
|
67
|
+
"device": self.device,
|
|
68
|
+
"context": self.context,
|
|
69
|
+
"elements": [
|
|
70
|
+
{
|
|
71
|
+
"ref": e.ref,
|
|
72
|
+
"role": e.role,
|
|
73
|
+
"label": e.label,
|
|
74
|
+
"resource_id": e.resource_id,
|
|
75
|
+
"class": e.class_name,
|
|
76
|
+
"bounds": e.bounds,
|
|
77
|
+
"state": e.state,
|
|
78
|
+
"content_desc": e.content_desc,
|
|
79
|
+
"text": e.text,
|
|
80
|
+
}
|
|
81
|
+
for e in self.elements
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Check size and add warning if needed
|
|
86
|
+
size_bytes = len(json.dumps(result).encode())
|
|
87
|
+
if size_bytes > SNAPSHOT_SIZE_WARNING_BYTES:
|
|
88
|
+
result["warnings"] = [
|
|
89
|
+
f"Snapshot size {size_bytes // 1024}KB exceeds 20KB target. "
|
|
90
|
+
"Consider filtering or using a simpler screen."
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UISnapshotter:
|
|
97
|
+
"""Generates compact UI snapshots from device hierarchy."""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._ref_counter = 0
|
|
101
|
+
|
|
102
|
+
def parse_hierarchy(
|
|
103
|
+
self,
|
|
104
|
+
xml_content: bytes,
|
|
105
|
+
session_id: str,
|
|
106
|
+
generation: int,
|
|
107
|
+
device_info: dict[str, Any],
|
|
108
|
+
context_info: dict[str, Any],
|
|
109
|
+
interactive_only: bool = True,
|
|
110
|
+
) -> Snapshot:
|
|
111
|
+
"""Parse XML hierarchy and extract elements."""
|
|
112
|
+
import time
|
|
113
|
+
|
|
114
|
+
start = time.time()
|
|
115
|
+
tree = etree.fromstring(xml_content)
|
|
116
|
+
elements = self._extract_elements(tree, interactive_only=interactive_only)
|
|
117
|
+
elapsed = (time.time() - start) * 1000
|
|
118
|
+
|
|
119
|
+
logger.info(
|
|
120
|
+
"hierarchy_parsed",
|
|
121
|
+
element_count=len(elements),
|
|
122
|
+
elapsed_ms=round(elapsed, 2),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return Snapshot(
|
|
126
|
+
schema_version=1,
|
|
127
|
+
session_id=session_id,
|
|
128
|
+
generation=generation,
|
|
129
|
+
timestamp_ms=int(time.time() * 1000),
|
|
130
|
+
device=device_info,
|
|
131
|
+
context=context_info,
|
|
132
|
+
elements=elements,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _extract_elements(
|
|
136
|
+
self,
|
|
137
|
+
tree: etree._Element,
|
|
138
|
+
*,
|
|
139
|
+
interactive_only: bool,
|
|
140
|
+
) -> list[ElementNode]:
|
|
141
|
+
"""Extract elements using XPath."""
|
|
142
|
+
elements: list[ElementNode] = []
|
|
143
|
+
self._ref_counter = 0
|
|
144
|
+
|
|
145
|
+
xpath = INTERACTIVE_XPATH if interactive_only else "//*"
|
|
146
|
+
nodes = tree.xpath(xpath)
|
|
147
|
+
if not isinstance(nodes, list):
|
|
148
|
+
return elements
|
|
149
|
+
for node in nodes:
|
|
150
|
+
if not isinstance(node, etree._Element):
|
|
151
|
+
continue
|
|
152
|
+
element = self._node_to_element(node)
|
|
153
|
+
if element:
|
|
154
|
+
elements.append(element)
|
|
155
|
+
|
|
156
|
+
return elements
|
|
157
|
+
|
|
158
|
+
def _node_to_element(self, node: etree._Element) -> ElementNode | None:
|
|
159
|
+
"""Convert XML node to ElementNode."""
|
|
160
|
+
self._ref_counter += 1
|
|
161
|
+
ref = f"@a{self._ref_counter}"
|
|
162
|
+
|
|
163
|
+
# Parse bounds "[left,top][right,bottom]"
|
|
164
|
+
bounds_str = node.get("bounds", "[0,0][0,0]")
|
|
165
|
+
bounds = self._parse_bounds(bounds_str)
|
|
166
|
+
|
|
167
|
+
# Determine role from class name
|
|
168
|
+
class_name = node.get("class", "")
|
|
169
|
+
role = self._infer_role(class_name, node)
|
|
170
|
+
|
|
171
|
+
# Get label (prefer content-desc, then text)
|
|
172
|
+
content_desc = node.get("content-desc")
|
|
173
|
+
text = node.get("text")
|
|
174
|
+
label = content_desc or text or None
|
|
175
|
+
|
|
176
|
+
return ElementNode(
|
|
177
|
+
ref=ref,
|
|
178
|
+
role=role,
|
|
179
|
+
label=label,
|
|
180
|
+
resource_id=node.get("resource-id"),
|
|
181
|
+
class_name=class_name,
|
|
182
|
+
bounds=bounds,
|
|
183
|
+
content_desc=content_desc,
|
|
184
|
+
text=text,
|
|
185
|
+
state={
|
|
186
|
+
"clickable": node.get("clickable") == "true",
|
|
187
|
+
"focusable": node.get("focusable") == "true",
|
|
188
|
+
"focused": node.get("focused") == "true",
|
|
189
|
+
"enabled": node.get("enabled") == "true",
|
|
190
|
+
"checked": node.get("checked") == "true",
|
|
191
|
+
"selected": node.get("selected") == "true",
|
|
192
|
+
"scrollable": node.get("scrollable") == "true",
|
|
193
|
+
"editable": node.get("editable") == "true",
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _parse_bounds(self, bounds_str: str) -> list[int]:
|
|
198
|
+
"""Parse bounds string '[left,top][right,bottom]' to list."""
|
|
199
|
+
try:
|
|
200
|
+
# Remove brackets and split
|
|
201
|
+
clean = bounds_str.replace("][", ",").strip("[]")
|
|
202
|
+
parts = clean.split(",")
|
|
203
|
+
return [int(p) for p in parts[:4]]
|
|
204
|
+
except (ValueError, IndexError):
|
|
205
|
+
return [0, 0, 0, 0]
|
|
206
|
+
|
|
207
|
+
def _infer_role(self, class_name: str, node: etree._Element) -> str:
|
|
208
|
+
"""Infer semantic role from class name."""
|
|
209
|
+
class_lower = class_name.lower()
|
|
210
|
+
|
|
211
|
+
if "button" in class_lower:
|
|
212
|
+
return "button"
|
|
213
|
+
if "edittext" in class_lower:
|
|
214
|
+
return "textfield"
|
|
215
|
+
if "textview" in class_lower:
|
|
216
|
+
return "text"
|
|
217
|
+
if "imageview" in class_lower:
|
|
218
|
+
return "image"
|
|
219
|
+
if "checkbox" in class_lower:
|
|
220
|
+
return "checkbox"
|
|
221
|
+
if "switch" in class_lower:
|
|
222
|
+
return "switch"
|
|
223
|
+
if "radiobutton" in class_lower:
|
|
224
|
+
return "radio"
|
|
225
|
+
if "recyclerview" in class_lower or "listview" in class_lower:
|
|
226
|
+
return "list"
|
|
227
|
+
if "scrollview" in class_lower:
|
|
228
|
+
return "scrollable"
|
|
229
|
+
|
|
230
|
+
# Fallback based on state
|
|
231
|
+
if node.get("clickable") == "true":
|
|
232
|
+
return "clickable"
|
|
233
|
+
if node.get("editable") == "true":
|
|
234
|
+
return "textfield"
|
|
235
|
+
|
|
236
|
+
return "element"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Validation helpers for user input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from android_emu_agent.errors import invalid_package_error, invalid_uri_error, not_emulator_error
|
|
8
|
+
|
|
9
|
+
# Package name: starts with letter, segments separated by dots, each segment alphanumeric/underscore
|
|
10
|
+
PACKAGE_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_package(package: str) -> None:
|
|
14
|
+
"""Validate Android package name format.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
package: Package name to validate
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
AgentError: If package name is invalid
|
|
21
|
+
"""
|
|
22
|
+
if not PACKAGE_PATTERN.match(package):
|
|
23
|
+
raise invalid_package_error(package)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_uri(uri: str) -> None:
|
|
27
|
+
"""Validate URI has a scheme.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
uri: URI to validate
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
AgentError: If URI is invalid
|
|
34
|
+
"""
|
|
35
|
+
if not uri or "://" not in uri:
|
|
36
|
+
raise invalid_uri_error(uri)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_console_port(serial: str) -> int:
|
|
40
|
+
"""Extract emulator console port from serial.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
serial: Device serial (e.g., 'emulator-5554')
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Console port number
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
AgentError: If serial is not an emulator
|
|
50
|
+
"""
|
|
51
|
+
if not serial.startswith("emulator-"):
|
|
52
|
+
raise not_emulator_error(serial)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
port = int(serial.split("-")[1])
|
|
56
|
+
except (IndexError, ValueError) as err:
|
|
57
|
+
raise not_emulator_error(serial) from err
|
|
58
|
+
|
|
59
|
+
return port
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: android-emu-agent
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: CLI + daemon for LLM-driven Android UI control on emulators and rooted devices
|
|
5
|
+
Project-URL: Homepage, https://github.com/alehkot/android-emu-agent
|
|
6
|
+
Author-email: Oleg Kot <kot.oleg@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: android,automation,llm,ui-testing
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: adbutils>=2.0.0
|
|
12
|
+
Requires-Dist: aiosqlite>=0.19.0
|
|
13
|
+
Requires-Dist: fastapi>=0.128.1
|
|
14
|
+
Requires-Dist: httpx>=0.27.0
|
|
15
|
+
Requires-Dist: lxml>=5.0.0
|
|
16
|
+
Requires-Dist: pydantic>=2.5.0
|
|
17
|
+
Requires-Dist: structlog>=24.0.0
|
|
18
|
+
Requires-Dist: typer>=0.9.0
|
|
19
|
+
Requires-Dist: uiautomator2>=3.0.0
|
|
20
|
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: lxml-stubs>=0.5.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pyright>=1.1.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.2.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Android Emu Agent
|
|
32
|
+
|
|
33
|
+
A CLI + daemon system for LLM-driven Android UI control on emulators and rooted devices.
|
|
34
|
+
|
|
35
|
+
## Overview
|
|
36
|
+
|
|
37
|
+
Android Emu Agent automates Android apps using a fast observe-act-verify loop:
|
|
38
|
+
|
|
39
|
+
1. Observe: capture a compact UI snapshot (interactive elements only)
|
|
40
|
+
2. Act: issue commands using ephemeral element refs like `@a1`
|
|
41
|
+
3. Verify: re-snapshot when needed
|
|
42
|
+
|
|
43
|
+
The CLI is a thin client. A long-running daemon handles all device I/O.
|
|
44
|
+
|
|
45
|
+
## Inspiration
|
|
46
|
+
|
|
47
|
+
Inspired by [agent-browser](https://github.com/vercel-labs/agent-browser), a fast headless browser
|
|
48
|
+
automation CLI for AI agents.
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
|
|
52
|
+
- Python 3.11+
|
|
53
|
+
- `uv` package manager
|
|
54
|
+
- Android SDK with `adb` on PATH
|
|
55
|
+
- Android emulator or rooted device (primary target)
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Clone the repository
|
|
61
|
+
git clone https://github.com/alehkot/android-emu-agent.git
|
|
62
|
+
cd android-emu-agent
|
|
63
|
+
|
|
64
|
+
# Install dependencies
|
|
65
|
+
uv sync
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Inside this repo, prefer `uv run android-emu-agent <command>`.
|
|
69
|
+
|
|
70
|
+
## Quick Start (2 minutes)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# 1. Start the daemon (optional, CLI will auto-start by default)
|
|
74
|
+
uv run android-emu-agent daemon start
|
|
75
|
+
|
|
76
|
+
# 2. List connected devices
|
|
77
|
+
uv run android-emu-agent device list
|
|
78
|
+
|
|
79
|
+
# 3. Start a session (prints the session id)
|
|
80
|
+
uv run android-emu-agent session start --device emulator-5554
|
|
81
|
+
|
|
82
|
+
# 4. Take a compact snapshot and read refs
|
|
83
|
+
uv run android-emu-agent ui snapshot s-abc123 --format text
|
|
84
|
+
|
|
85
|
+
# 5. Tap an element using a ref
|
|
86
|
+
uv run android-emu-agent action tap s-abc123 @a1
|
|
87
|
+
|
|
88
|
+
# 6. Stop the session
|
|
89
|
+
uv run android-emu-agent session stop s-abc123
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Most commands accept `--json` for machine-readable output.
|
|
93
|
+
|
|
94
|
+
Example `--json` output for `session start`:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"status": "done",
|
|
99
|
+
"session_id": "s-abc123",
|
|
100
|
+
"device_serial": "emulator-5554",
|
|
101
|
+
"generation": 0
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Core Concepts
|
|
106
|
+
|
|
107
|
+
Sessions
|
|
108
|
+
|
|
109
|
+
- Sessions tie actions to a specific device. Most commands accept a session id.
|
|
110
|
+
- `session start` returns a session id. `session stop` releases it.
|
|
111
|
+
|
|
112
|
+
Snapshots and refs
|
|
113
|
+
|
|
114
|
+
- `ui snapshot` returns `@refs` that are stable only for that snapshot.
|
|
115
|
+
- If you get `ERR_STALE_REF`, take a new snapshot and use fresh refs.
|
|
116
|
+
|
|
117
|
+
Selectors
|
|
118
|
+
|
|
119
|
+
- `action tap` accepts an `@ref` or a selector.
|
|
120
|
+
- `long-tap`, `set-text`, and `clear` require an `@ref`.
|
|
121
|
+
|
|
122
|
+
Selector examples:
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
@a1
|
|
126
|
+
text:"Sign in"
|
|
127
|
+
id:com.example:id/login_btn
|
|
128
|
+
desc:"Open navigation"
|
|
129
|
+
coords:120,450
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Snapshot Format
|
|
133
|
+
|
|
134
|
+
Compact snapshot output includes context and interactive elements only:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"schema_version": 1,
|
|
139
|
+
"session_id": "s-abc123",
|
|
140
|
+
"generation": 42,
|
|
141
|
+
"context": {
|
|
142
|
+
"package": "com.example.app",
|
|
143
|
+
"activity": ".MainActivity",
|
|
144
|
+
"orientation": "PORTRAIT",
|
|
145
|
+
"ime_visible": false
|
|
146
|
+
},
|
|
147
|
+
"elements": [
|
|
148
|
+
{
|
|
149
|
+
"ref": "@a1",
|
|
150
|
+
"role": "button",
|
|
151
|
+
"label": "Sign in",
|
|
152
|
+
"resource_id": "com.example:id/login_btn",
|
|
153
|
+
"bounds": [100, 200, 300, 250],
|
|
154
|
+
"state": { "clickable": true, "enabled": true }
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Daemon and State
|
|
161
|
+
|
|
162
|
+
- Socket: `/tmp/android-emu-agent.sock`
|
|
163
|
+
- Logs: `~/.android-emu-agent/daemon.log`
|
|
164
|
+
- PID file: `~/.android-emu-agent/daemon.pid`
|
|
165
|
+
|
|
166
|
+
The CLI auto-starts the daemon on first request. Use these to debug:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
uv run android-emu-agent daemon status --json
|
|
170
|
+
uv run android-emu-agent daemon stop
|
|
171
|
+
uv run android-emu-agent daemon start
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Common Workflows
|
|
175
|
+
|
|
176
|
+
Login flow
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
uv run android-emu-agent app launch s-abc123 com.example.app
|
|
180
|
+
uv run android-emu-agent wait idle s-abc123 --timeout-ms 5000
|
|
181
|
+
uv run android-emu-agent ui snapshot s-abc123 --format text
|
|
182
|
+
uv run android-emu-agent action set-text s-abc123 @a3 "user@example.com"
|
|
183
|
+
uv run android-emu-agent action set-text s-abc123 @a4 "hunter2"
|
|
184
|
+
uv run android-emu-agent action tap s-abc123 @a5
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
When elements are missing
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
uv run android-emu-agent ui snapshot s-abc123 --full
|
|
191
|
+
uv run android-emu-agent wait idle s-abc123 --timeout-ms 3000
|
|
192
|
+
uv run android-emu-agent ui snapshot s-abc123
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Visual debug
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv run android-emu-agent ui screenshot --device emulator-5554 --pull --output ./screen.png
|
|
199
|
+
uv run android-emu-agent artifact bundle s-abc123
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Emulator snapshots
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
uv run android-emu-agent emulator snapshot save emulator-5554 clean
|
|
206
|
+
uv run android-emu-agent emulator snapshot restore emulator-5554 clean
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
These commands require an emulator serial (`emulator-5554`). If you pass a non-emulator serial, you
|
|
210
|
+
will see `ERR_NOT_EMULATOR`.
|
|
211
|
+
|
|
212
|
+
## Real Devices (Non-Root)
|
|
213
|
+
|
|
214
|
+
The project targets emulators and rooted devices, but many commands do not enforce root and can work
|
|
215
|
+
on real devices as long as `adb` is connected and uiautomator2 can attach (ATX server on port 7912).
|
|
216
|
+
In practice, these are usually safe on non-root devices:
|
|
217
|
+
|
|
218
|
+
- UI snapshots and screenshots
|
|
219
|
+
- Actions (tap, set-text, swipe, scroll, back/home/recents)
|
|
220
|
+
- Wait commands
|
|
221
|
+
- App list/launch/force-stop/reset/deeplink
|
|
222
|
+
- File `push` and `pull` to shared storage
|
|
223
|
+
|
|
224
|
+
Emulator-only commands are `emulator snapshot save|restore`. Root-required commands are listed
|
|
225
|
+
below.
|
|
226
|
+
|
|
227
|
+
## Root-Required Operations
|
|
228
|
+
|
|
229
|
+
The following require a rooted device or emulator with root access:
|
|
230
|
+
|
|
231
|
+
- `reliability oom-adj`
|
|
232
|
+
- `reliability pull anr`
|
|
233
|
+
- `reliability pull tombstones`
|
|
234
|
+
- `reliability pull dropbox`
|
|
235
|
+
- `file find`
|
|
236
|
+
- `file list`
|
|
237
|
+
- `file app push`
|
|
238
|
+
- `file app pull`
|
|
239
|
+
|
|
240
|
+
If root is missing, you will see `ERR_PERMISSION`.
|
|
241
|
+
|
|
242
|
+
## Artifacts
|
|
243
|
+
|
|
244
|
+
Artifacts are written to `~/.android-emu-agent/artifacts` by default.
|
|
245
|
+
|
|
246
|
+
- Reliability outputs: `~/.android-emu-agent/artifacts/reliability`
|
|
247
|
+
- File transfers: `~/.android-emu-agent/artifacts/files`
|
|
248
|
+
|
|
249
|
+
## Troubleshooting
|
|
250
|
+
|
|
251
|
+
Quick checks
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
uv run android-emu-agent device list
|
|
255
|
+
adb devices
|
|
256
|
+
uv run android-emu-agent daemon status --json
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Common errors
|
|
260
|
+
|
|
261
|
+
| Error Code | Meaning | Fix |
|
|
262
|
+
| --------------------- | ------------------------ | ----------------------------------------------- |
|
|
263
|
+
| `ERR_STALE_REF` | Ref from an old snapshot | Take a new snapshot |
|
|
264
|
+
| `ERR_NOT_FOUND` | Element not found | Verify screen, use `--full` or a selector |
|
|
265
|
+
| `ERR_BLOCKED_INPUT` | Dialog/IME blocking | `wait idle` or `back` |
|
|
266
|
+
| `ERR_TIMEOUT` | Wait condition not met | Increase `--timeout-ms` or check condition |
|
|
267
|
+
| `ERR_DEVICE_OFFLINE` | Device disconnected | Reconnect and re-run `device list` |
|
|
268
|
+
| `ERR_SESSION_EXPIRED` | Session is gone | Start a new session |
|
|
269
|
+
| `ERR_PERMISSION` | Root required | Use a rooted device/emulator |
|
|
270
|
+
| `ERR_ADB_NOT_FOUND` | `adb` not on PATH | Install Android SDK and ensure `adb` is on PATH |
|
|
271
|
+
| `ERR_ADB_COMMAND` | ADB command failed | Check device connectivity and retry |
|
|
272
|
+
|
|
273
|
+
For deeper guidance, see `skills/android-emu-agent/references/troubleshooting.md`.
|
|
274
|
+
|
|
275
|
+
## CLI Reference
|
|
276
|
+
|
|
277
|
+
A concise command list lives at `skills/android-emu-agent/references/command-reference.md`.
|
|
278
|
+
|
|
279
|
+
If you prefer an interactive guide:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
uv run android-emu-agent --help
|
|
283
|
+
uv run android-emu-agent <group> --help
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Architecture
|
|
287
|
+
|
|
288
|
+
```text
|
|
289
|
+
┌─────────────────┐ Unix Socket ┌──────────────────────────────────┐
|
|
290
|
+
│ │ ◄────────────────► │ Daemon │
|
|
291
|
+
│ CLI Client │ │ ┌────────────────────────────┐ │
|
|
292
|
+
│ │ │ │ Device Manager │ │
|
|
293
|
+
└─────────────────┘ │ │ (adbutils, uiautomator2) │ │
|
|
294
|
+
│ └────────────────────────────┘ │
|
|
295
|
+
│ ┌────────────────────────────┐ │
|
|
296
|
+
│ │ Session Manager │ │
|
|
297
|
+
│ │ (refs, state, SQLite) │ │
|
|
298
|
+
│ └────────────────────────────┘ │
|
|
299
|
+
│ ┌────────────────────────────┐ │
|
|
300
|
+
│ │ UI Snapshotter │ │
|
|
301
|
+
│ │ (lxml, filtering) │ │
|
|
302
|
+
│ └────────────────────────────┘ │
|
|
303
|
+
└──────────────────────────────────┘
|
|
304
|
+
│
|
|
305
|
+
▼
|
|
306
|
+
┌──────────────────────────────────┐
|
|
307
|
+
│ Android Device/Emulator │
|
|
308
|
+
│ (ATX Server on port 7912) │
|
|
309
|
+
└──────────────────────────────────┘
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Development
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
# Install with dev dependencies
|
|
316
|
+
uv sync --all-extras
|
|
317
|
+
|
|
318
|
+
# Run tests
|
|
319
|
+
uv run pytest tests/unit -v
|
|
320
|
+
|
|
321
|
+
# Run linter
|
|
322
|
+
uv run ruff check .
|
|
323
|
+
|
|
324
|
+
# Format code
|
|
325
|
+
uv run ruff format .
|
|
326
|
+
|
|
327
|
+
# Type check
|
|
328
|
+
uv run mypy src/
|
|
329
|
+
|
|
330
|
+
# Run all checks
|
|
331
|
+
./scripts/dev.sh check
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Agent Skills
|
|
335
|
+
|
|
336
|
+
This repo ships an `android-emu-agent` skill in `skills/android-emu-agent/` for agent environments
|
|
337
|
+
that support skills. Use the install steps below.
|
|
338
|
+
|
|
339
|
+
### Install for Codex
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
export CODEX_HOME="$HOME/.codex"
|
|
343
|
+
mkdir -p "$CODEX_HOME/skills"
|
|
344
|
+
ln -sfn "$(pwd)/skills/android-emu-agent" "$CODEX_HOME/skills/android-emu-agent"
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Install for Claude Code
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
mkdir -p ~/.claude/skills
|
|
351
|
+
ln -sfn "$(pwd)/skills/android-emu-agent" ~/.claude/skills/android-emu-agent
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
If symlinks are not an option, copy the directory instead.
|
|
355
|
+
|
|
356
|
+
### Using the Skill
|
|
357
|
+
|
|
358
|
+
After installation, the agent should be primed to start a session with your connected device before
|
|
359
|
+
you ask for specific actions. Begin with a direct initialization request, for example:
|
|
360
|
+
|
|
361
|
+
`I want to interact with my connected Android emulator.`
|
|
362
|
+
|
|
363
|
+
Once the agent has initialized the session, you can proceed with normal requests (snapshots,
|
|
364
|
+
tap/type actions, app launches, waits, etc.).
|
|
365
|
+
|
|
366
|
+
Prerequisites you may need (if the agent reports it cannot connect):
|
|
367
|
+
|
|
368
|
+
1. Start the daemon: `uv run android-emu-agent daemon start`
|
|
369
|
+
2. Confirm a device is visible: `uv run android-emu-agent device list`
|
|
370
|
+
3. If needed, start a session explicitly:
|
|
371
|
+
`uv run android-emu-agent session start --device emulator-5554`
|
|
372
|
+
|
|
373
|
+
## License
|
|
374
|
+
|
|
375
|
+
MIT. See `LICENSE`.
|