u2-cli 0.1.0__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.
u2/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+ import typer
8
+ import uiautomator2 as u2
9
+
10
+ cli = typer.Typer(no_args_is_help=True)
11
+
12
+
13
+ @dataclass
14
+ class State:
15
+ serial: str = ""
16
+
17
+
18
+ state = State()
19
+
20
+
21
+ def get_device() -> u2.Device:
22
+ return u2.connect(state.serial) if state.serial else u2.connect()
23
+
24
+
25
+ def out(success: bool, output: str, code: str = "") -> None: # noqa: FBT001
26
+ typer.echo(json.dumps({"success": success, "output": output, "code": code}, ensure_ascii=False))
27
+
28
+
29
+ @cli.callback()
30
+ def main_callback(
31
+ serial: Optional[str] = typer.Option(None, "-s", "--serial", help="Android device serial"),
32
+ ) -> None:
33
+ if serial:
34
+ state.serial = serial
35
+
36
+
37
+ from u2.device import device_app # noqa: E402
38
+ from u2.app import app_app # noqa: E402
39
+ from u2.screen import screen_app # noqa: E402
40
+ from u2.interact import interact_app # noqa: E402
41
+ from u2.element import element_app # noqa: E402
42
+ from u2.watch import watch_app # noqa: E402
43
+
44
+ cli.add_typer(device_app, name="device")
45
+ cli.add_typer(app_app, name="app")
46
+ cli.add_typer(screen_app, name="screen")
47
+ cli.add_typer(interact_app, name="interact")
48
+ cli.add_typer(element_app, name="element")
49
+ cli.add_typer(watch_app, name="watch")
50
+
51
+
52
+ def main() -> None:
53
+ cli()
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
u2/app.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from u2 import get_device, out
9
+
10
+ app_app = typer.Typer(no_args_is_help=True)
11
+
12
+
13
+ @app_app.command()
14
+ def start(
15
+ package: str = typer.Argument(..., help="Package name"),
16
+ activity: Optional[str] = typer.Option(None, "--activity", "-a", help="Activity to launch"),
17
+ wait: bool = typer.Option(False, "--wait", help="Wait for app to start"), # noqa: FBT001, FBT002
18
+ stop: bool = typer.Option(False, "--stop", help="Stop before starting"), # noqa: FBT001, FBT002
19
+ ) -> None:
20
+ """Start an app by package name."""
21
+ try:
22
+ d = get_device()
23
+ d.app_start(package, activity=activity, wait=wait, stop=stop)
24
+ code = f"d.app_start({package!r}"
25
+ if activity:
26
+ code += f", activity={activity!r}"
27
+ if wait:
28
+ code += ", wait=True"
29
+ if stop:
30
+ code += ", stop=True"
31
+ code += ")"
32
+ out(True, f"Started: {package}", code)
33
+ except Exception as e:
34
+ out(False, str(e))
35
+
36
+
37
+ @app_app.command()
38
+ def stop(package: str = typer.Argument(..., help="Package name")) -> None:
39
+ """Stop an app by package name."""
40
+ try:
41
+ d = get_device()
42
+ d.app_stop(package)
43
+ out(True, f"Stopped: {package}", f"d.app_stop({package!r})")
44
+ except Exception as e:
45
+ out(False, str(e))
46
+
47
+
48
+ @app_app.command()
49
+ def install(path: str = typer.Argument(..., help="APK path or URL")) -> None:
50
+ """Install an APK from path or URL."""
51
+ try:
52
+ d = get_device()
53
+ d.app_install(path)
54
+ out(True, f"Installed: {path}", f"d.app_install({path!r})")
55
+ except Exception as e:
56
+ out(False, str(e))
57
+
58
+
59
+ @app_app.command()
60
+ def uninstall(package: str = typer.Argument(..., help="Package name")) -> None:
61
+ """Uninstall an app by package name."""
62
+ try:
63
+ d = get_device()
64
+ success = d.app_uninstall(package)
65
+ msg = f"Uninstalled: {package}" if success else f"Failed to uninstall: {package}"
66
+ out(success, msg, f"d.app_uninstall({package!r})")
67
+ except Exception as e:
68
+ out(False, str(e))
69
+
70
+
71
+ @app_app.command()
72
+ def clear(package: str = typer.Argument(..., help="Package name")) -> None:
73
+ """Clear app data."""
74
+ try:
75
+ d = get_device()
76
+ d.app_clear(package)
77
+ out(True, f"Cleared: {package}", f"d.app_clear({package!r})")
78
+ except Exception as e:
79
+ out(False, str(e))
80
+
81
+
82
+ @app_app.command(name="list")
83
+ def list_apps(
84
+ filter: Optional[str] = typer.Option( # noqa: A002
85
+ None,
86
+ "-f",
87
+ "--filter",
88
+ help="Filter: -3 (third-party), -s (system), -d (disabled), -e (enabled)",
89
+ ),
90
+ ) -> None:
91
+ """List installed apps."""
92
+ try:
93
+ d = get_device()
94
+ packages = d.app_list(filter) if filter else d.app_list()
95
+ code = f"d.app_list({filter!r})" if filter else "d.app_list()"
96
+ out(True, json.dumps(packages), code)
97
+ except Exception as e:
98
+ out(False, str(e))
99
+
100
+
101
+ @app_app.command()
102
+ def current() -> None:
103
+ """Show the current foreground app."""
104
+ try:
105
+ d = get_device()
106
+ info = d.app_current()
107
+ out(True, json.dumps(info), "d.app_current()")
108
+ except Exception as e:
109
+ out(False, str(e))
110
+
111
+
112
+ @app_app.command(name="wait-activity")
113
+ def wait_activity(
114
+ activity: str = typer.Argument(..., help="Activity name to wait for"),
115
+ timeout: float = typer.Option(10.0, "--timeout", "-t", help="Timeout in seconds"),
116
+ ) -> None:
117
+ """Wait for a specific activity to appear."""
118
+ try:
119
+ d = get_device()
120
+ found = d.wait_activity(activity, timeout=timeout)
121
+ code = f"d.wait_activity({activity!r}, timeout={timeout})"
122
+ out(found, f"Activity {'found' if found else 'not found'}: {activity}", code)
123
+ except Exception as e:
124
+ out(False, str(e))
u2/device.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from u2 import get_device, out
10
+
11
+ device_app = typer.Typer(no_args_is_help=True)
12
+
13
+
14
+ @device_app.command()
15
+ def info() -> None:
16
+ """Show device info."""
17
+ try:
18
+ d = get_device()
19
+ result = d.device_info
20
+ out(True, json.dumps(result), "d.device_info")
21
+ except Exception as e:
22
+ out(False, str(e))
23
+
24
+
25
+ @device_app.command()
26
+ def battery() -> None:
27
+ """Show battery info."""
28
+ try:
29
+ d = get_device()
30
+ b = d.adb_device.battery()
31
+ result = dataclasses.asdict(b)
32
+ out(True, json.dumps(result), "d.adb_device.battery()")
33
+ except Exception as e:
34
+ out(False, str(e))
35
+
36
+
37
+ @device_app.command(name="wlan-ip")
38
+ def wlan_ip() -> None:
39
+ """Show WLAN IP address."""
40
+ try:
41
+ d = get_device()
42
+ ip = d.wlan_ip
43
+ out(True, ip or "", "d.wlan_ip")
44
+ except Exception as e:
45
+ out(False, str(e))
46
+
47
+
48
+ @device_app.command()
49
+ def shell(cmd: str = typer.Argument(..., help="Shell command to run")) -> None:
50
+ """Run an adb shell command."""
51
+ try:
52
+ d = get_device()
53
+ resp = d.shell(cmd)
54
+ out(True, resp.output, f"d.shell({cmd!r})")
55
+ except Exception as e:
56
+ out(False, str(e))
57
+
58
+
59
+ @device_app.command()
60
+ def keyevent(key: str = typer.Argument(..., help="Key name or keycode")) -> None:
61
+ """Send a key event (e.g. home, back, power)."""
62
+ try:
63
+ d = get_device()
64
+ d.press(key)
65
+ out(True, f"Sent key: {key}", f"d.press({key!r})")
66
+ except Exception as e:
67
+ out(False, str(e))
68
+
69
+
70
+ @device_app.command()
71
+ def push(
72
+ src: str = typer.Argument(..., help="Local source path"),
73
+ dst: str = typer.Argument(..., help="Remote destination path"),
74
+ ) -> None:
75
+ """Push a file to the device."""
76
+ try:
77
+ d = get_device()
78
+ d.push(src, dst)
79
+ out(True, f"Pushed {src} -> {dst}", f"d.push({src!r}, {dst!r})")
80
+ except Exception as e:
81
+ out(False, str(e))
82
+
83
+
84
+ @device_app.command()
85
+ def pull(
86
+ src: str = typer.Argument(..., help="Remote source path"),
87
+ dst: str = typer.Argument(..., help="Local destination path"),
88
+ ) -> None:
89
+ """Pull a file from the device."""
90
+ try:
91
+ d = get_device()
92
+ d.pull(src, dst)
93
+ out(True, f"Pulled {src} -> {dst}", f"d.pull({src!r}, {dst!r})")
94
+ except Exception as e:
95
+ out(False, str(e))
96
+
97
+
98
+ @device_app.command(name="screen-on")
99
+ def screen_on() -> None:
100
+ """Turn the screen on."""
101
+ try:
102
+ d = get_device()
103
+ d.screen_on()
104
+ out(True, "Screen on", "d.screen_on()")
105
+ except Exception as e:
106
+ out(False, str(e))
107
+
108
+
109
+ @device_app.command(name="screen-off")
110
+ def screen_off() -> None:
111
+ """Turn the screen off."""
112
+ try:
113
+ d = get_device()
114
+ d.screen_off()
115
+ out(True, "Screen off", "d.screen_off()")
116
+ except Exception as e:
117
+ out(False, str(e))
118
+
119
+
120
+ @device_app.command()
121
+ def orientation(
122
+ value: Optional[str] = typer.Argument(
123
+ None, help="Set orientation: natural, left, right, upsidedown (omit to get)"
124
+ ),
125
+ ) -> None:
126
+ """Get or set screen orientation."""
127
+ try:
128
+ d = get_device()
129
+ if value is None:
130
+ current = d.orientation
131
+ out(True, current, "d.orientation")
132
+ else:
133
+ d.orientation = value # type: ignore[assignment]
134
+ out(True, f"Orientation set to: {value}", f"d.orientation = {value!r}")
135
+ except Exception as e:
136
+ out(False, str(e))
u2/element.py ADDED
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from u2 import get_device, out
9
+
10
+ element_app = typer.Typer(no_args_is_help=True)
11
+
12
+ # Shared selector options reused across commands
13
+ _TEXT = typer.Option(None, "--text", "-t", help="Element text")
14
+ _RESOURCE_ID = typer.Option(None, "--resource-id", "-r", help="Resource ID")
15
+ _CLASS_NAME = typer.Option(None, "--class-name", "-c", help="Class name")
16
+ _XPATH = typer.Option(None, "--xpath", "-x", help="XPath expression")
17
+ _DESCRIPTION = typer.Option(None, "--description", "-d", help="Content description")
18
+
19
+
20
+ def _build_selector(
21
+ text: Optional[str],
22
+ resource_id: Optional[str],
23
+ class_name: Optional[str],
24
+ description: Optional[str],
25
+ ) -> dict:
26
+ sel: dict = {}
27
+ if text is not None:
28
+ sel["text"] = text
29
+ if resource_id is not None:
30
+ sel["resourceId"] = resource_id
31
+ if class_name is not None:
32
+ sel["className"] = class_name
33
+ if description is not None:
34
+ sel["description"] = description
35
+ return sel
36
+
37
+
38
+ @element_app.command()
39
+ def find(
40
+ text: Optional[str] = _TEXT,
41
+ resource_id: Optional[str] = _RESOURCE_ID,
42
+ class_name: Optional[str] = _CLASS_NAME,
43
+ xpath: Optional[str] = _XPATH,
44
+ description: Optional[str] = _DESCRIPTION,
45
+ timeout: float = typer.Option(0.0, "--timeout", "-T", help="Wait up to N seconds for elements"),
46
+ ) -> None:
47
+ """Find elements matching the given selector."""
48
+ try:
49
+ d = get_device()
50
+ if xpath:
51
+ el = d.xpath(xpath)
52
+ if timeout > 0:
53
+ el.wait(timeout=timeout)
54
+ elements = el.all()
55
+ results = [e.elem.attrib for e in elements]
56
+ code = f"d.xpath({xpath!r}).all()"
57
+ else:
58
+ sel = _build_selector(text, resource_id, class_name, description)
59
+ el = d(**sel)
60
+ count = el.count
61
+ results = [el[i].info for i in range(count)]
62
+ code = f"d({', '.join(f'{k}={v!r}' for k, v in sel.items())}).info"
63
+ out(True, json.dumps(results), code)
64
+ except Exception as e:
65
+ out(False, str(e))
66
+
67
+
68
+ @element_app.command()
69
+ def wait(
70
+ text: Optional[str] = _TEXT,
71
+ resource_id: Optional[str] = _RESOURCE_ID,
72
+ class_name: Optional[str] = _CLASS_NAME,
73
+ xpath: Optional[str] = _XPATH,
74
+ description: Optional[str] = _DESCRIPTION,
75
+ timeout: float = typer.Option(10.0, "--timeout", "-T", help="Timeout in seconds"),
76
+ ) -> None:
77
+ """Wait for an element to appear."""
78
+ try:
79
+ d = get_device()
80
+ if xpath:
81
+ found = d.xpath(xpath).wait(timeout=timeout)
82
+ code = f"d.xpath({xpath!r}).wait(timeout={timeout})"
83
+ else:
84
+ sel = _build_selector(text, resource_id, class_name, description)
85
+ found = d(**sel).wait(exists=True, timeout=timeout)
86
+ sel_str = ", ".join(f"{k}={v!r}" for k, v in sel.items())
87
+ code = f"d({sel_str}).wait(exists=True, timeout={timeout})"
88
+ out(bool(found), "Element found" if found else "Element not found", code)
89
+ except Exception as e:
90
+ out(False, str(e))
91
+
92
+
93
+ @element_app.command()
94
+ def exists(
95
+ text: Optional[str] = _TEXT,
96
+ resource_id: Optional[str] = _RESOURCE_ID,
97
+ class_name: Optional[str] = _CLASS_NAME,
98
+ xpath: Optional[str] = _XPATH,
99
+ description: Optional[str] = _DESCRIPTION,
100
+ ) -> None:
101
+ """Check if an element exists."""
102
+ try:
103
+ d = get_device()
104
+ if xpath:
105
+ found = d.xpath(xpath).exists
106
+ code = f"d.xpath({xpath!r}).exists"
107
+ else:
108
+ sel = _build_selector(text, resource_id, class_name, description)
109
+ found = bool(d(**sel).exists)
110
+ code = f"d({', '.join(f'{k}={v!r}' for k, v in sel.items())}).exists"
111
+ out(True, str(found).lower(), code)
112
+ except Exception as e:
113
+ out(False, str(e))
114
+
115
+
116
+ @element_app.command(name="get-text")
117
+ def get_text(
118
+ text: Optional[str] = _TEXT,
119
+ resource_id: Optional[str] = _RESOURCE_ID,
120
+ class_name: Optional[str] = _CLASS_NAME,
121
+ xpath: Optional[str] = _XPATH,
122
+ description: Optional[str] = _DESCRIPTION,
123
+ timeout: float = typer.Option(10.0, "--timeout", "-T", help="Timeout in seconds"),
124
+ ) -> None:
125
+ """Get text content of a matching element."""
126
+ try:
127
+ d = get_device()
128
+ if xpath:
129
+ el = d.xpath(xpath)
130
+ el.wait(timeout=timeout)
131
+ value = el.get_text()
132
+ code = f"d.xpath({xpath!r}).get_text()"
133
+ else:
134
+ sel = _build_selector(text, resource_id, class_name, description)
135
+ value = d(**sel).get_text(timeout=timeout)
136
+ code = f"d({', '.join(f'{k}={v!r}' for k, v in sel.items())}).get_text()"
137
+ out(True, value or "", code)
138
+ except Exception as e:
139
+ out(False, str(e))
140
+
141
+
142
+ @element_app.command(name="set-text")
143
+ def set_text(
144
+ value: str = typer.Argument(..., help="Text to set"),
145
+ text: Optional[str] = _TEXT,
146
+ resource_id: Optional[str] = _RESOURCE_ID,
147
+ class_name: Optional[str] = _CLASS_NAME,
148
+ xpath: Optional[str] = _XPATH,
149
+ description: Optional[str] = _DESCRIPTION,
150
+ timeout: float = typer.Option(10.0, "--timeout", "-T", help="Timeout in seconds"),
151
+ ) -> None:
152
+ """Set text on a matching element."""
153
+ try:
154
+ d = get_device()
155
+ if xpath:
156
+ el = d.xpath(xpath)
157
+ el.wait(timeout=timeout)
158
+ el.set_text(value)
159
+ code = f"d.xpath({xpath!r}).set_text({value!r})"
160
+ else:
161
+ sel = _build_selector(text, resource_id, class_name, description)
162
+ d(**sel).set_text(value, timeout=timeout)
163
+ code = f"d({', '.join(f'{k}={v!r}' for k, v in sel.items())}).set_text({value!r})"
164
+ out(True, f"Set text to: {value!r}", code)
165
+ except Exception as e:
166
+ out(False, str(e))
167
+
168
+
169
+ @element_app.command()
170
+ def tap(
171
+ text: Optional[str] = _TEXT,
172
+ resource_id: Optional[str] = _RESOURCE_ID,
173
+ class_name: Optional[str] = _CLASS_NAME,
174
+ xpath: Optional[str] = _XPATH,
175
+ description: Optional[str] = _DESCRIPTION,
176
+ timeout: float = typer.Option(10.0, "--timeout", "-T", help="Timeout in seconds"),
177
+ ) -> None:
178
+ """Tap a matching element."""
179
+ try:
180
+ d = get_device()
181
+ if xpath:
182
+ el = d.xpath(xpath)
183
+ el.wait(timeout=timeout)
184
+ el.click()
185
+ code = f"d.xpath({xpath!r}).click()"
186
+ else:
187
+ sel = _build_selector(text, resource_id, class_name, description)
188
+ d(**sel).click(timeout=timeout)
189
+ code = f"d({', '.join(f'{k}={v!r}' for k, v in sel.items())}).click()"
190
+ out(True, "Tapped element", code)
191
+ except Exception as e:
192
+ out(False, str(e))
193
+
194
+
195
+ @element_app.command(name="long-tap")
196
+ def long_tap(
197
+ text: Optional[str] = _TEXT,
198
+ resource_id: Optional[str] = _RESOURCE_ID,
199
+ class_name: Optional[str] = _CLASS_NAME,
200
+ xpath: Optional[str] = _XPATH,
201
+ description: Optional[str] = _DESCRIPTION,
202
+ duration: float = typer.Option(0.5, "--duration", "-D", help="Hold duration in seconds"),
203
+ timeout: float = typer.Option(10.0, "--timeout", "-T", help="Timeout in seconds"),
204
+ ) -> None:
205
+ """Long-tap a matching element."""
206
+ try:
207
+ d = get_device()
208
+ if xpath:
209
+ el = d.xpath(xpath)
210
+ el.wait(timeout=timeout)
211
+ el.long_click()
212
+ code = f"d.xpath({xpath!r}).long_click()"
213
+ else:
214
+ sel = _build_selector(text, resource_id, class_name, description)
215
+ d(**sel).long_click(duration=duration, timeout=timeout)
216
+ sel_str = ", ".join(f"{k}={v!r}" for k, v in sel.items())
217
+ code = f"d({sel_str}).long_click(duration={duration})"
218
+ out(True, "Long-tapped element", code)
219
+ except Exception as e:
220
+ out(False, str(e))
u2/interact.py ADDED
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from u2 import get_device, out
6
+
7
+ interact_app = typer.Typer(no_args_is_help=True)
8
+
9
+
10
+ @interact_app.command()
11
+ def tap(
12
+ x: int = typer.Argument(..., help="X coordinate"),
13
+ y: int = typer.Argument(..., help="Y coordinate"),
14
+ ) -> None:
15
+ """Tap at screen coordinates."""
16
+ try:
17
+ d = get_device()
18
+ d.click(x, y)
19
+ out(True, f"Tapped at ({x}, {y})", f"d.click({x}, {y})")
20
+ except Exception as e:
21
+ out(False, str(e))
22
+
23
+
24
+ @interact_app.command(name="long-tap")
25
+ def long_tap(
26
+ x: int = typer.Argument(..., help="X coordinate"),
27
+ y: int = typer.Argument(..., help="Y coordinate"),
28
+ duration: float = typer.Option(0.5, "--duration", "-d", help="Press duration in seconds"),
29
+ ) -> None:
30
+ """Long tap at screen coordinates."""
31
+ try:
32
+ d = get_device()
33
+ d.long_click(x, y, duration=duration)
34
+ out(True, f"Long-tapped at ({x}, {y})", f"d.long_click({x}, {y}, duration={duration})")
35
+ except Exception as e:
36
+ out(False, str(e))
37
+
38
+
39
+ @interact_app.command()
40
+ def swipe(
41
+ fx: int = typer.Argument(..., help="From X coordinate"),
42
+ fy: int = typer.Argument(..., help="From Y coordinate"),
43
+ tx: int = typer.Argument(..., help="To X coordinate"),
44
+ ty: int = typer.Argument(..., help="To Y coordinate"),
45
+ duration: float = typer.Option(0.5, "--duration", "-d", help="Swipe duration in seconds"),
46
+ ) -> None:
47
+ """Swipe from one point to another."""
48
+ try:
49
+ d = get_device()
50
+ d.swipe(fx, fy, tx, ty, duration=duration)
51
+ code = f"d.swipe({fx}, {fy}, {tx}, {ty}, duration={duration})"
52
+ out(True, f"Swiped ({fx},{fy}) -> ({tx},{ty})", code)
53
+ except Exception as e:
54
+ out(False, str(e))
55
+
56
+
57
+ @interact_app.command()
58
+ def drag(
59
+ sx: int = typer.Argument(..., help="Start X coordinate"),
60
+ sy: int = typer.Argument(..., help="Start Y coordinate"),
61
+ ex: int = typer.Argument(..., help="End X coordinate"),
62
+ ey: int = typer.Argument(..., help="End Y coordinate"),
63
+ duration: float = typer.Option(0.5, "--duration", "-d", help="Drag duration in seconds"),
64
+ ) -> None:
65
+ """Drag from one point to another."""
66
+ try:
67
+ d = get_device()
68
+ d.drag(sx, sy, ex, ey, duration=duration)
69
+ code = f"d.drag({sx}, {sy}, {ex}, {ey}, duration={duration})"
70
+ out(True, f"Dragged ({sx},{sy}) -> ({ex},{ey})", code)
71
+ except Exception as e:
72
+ out(False, str(e))
73
+
74
+
75
+ @interact_app.command(name="type")
76
+ def type_text(
77
+ text: str = typer.Argument(..., help="Text to type into the focused element"),
78
+ ) -> None:
79
+ """Type text into the focused element (supports Unicode/Chinese via clipboard paste)."""
80
+ try:
81
+ d = get_device()
82
+ d.set_clipboard(text)
83
+ d.send_action("paste")
84
+ out(True, f"Typed: {text!r}", f"d.set_clipboard('{text}'); d.send_action('paste')")
85
+ except Exception as e:
86
+ out(False, str(e))
87
+ except Exception as e:
88
+ out(False, str(e))
89
+
90
+
91
+ @interact_app.command()
92
+ def clear() -> None:
93
+ """Clear text in the focused element."""
94
+ try:
95
+ d = get_device()
96
+ d.clear_text()
97
+ out(True, "Cleared focused element", "d.clear_text()")
98
+ except Exception as e:
99
+ out(False, str(e))
u2/screen.py ADDED
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import sys
5
+ import xml.etree.ElementTree as ET
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from u2 import get_device, out
11
+
12
+ # Attributes kept in simplified output (all others dropped)
13
+ _KEEP_ATTRS = {"text", "resource-id", "content-desc", "bounds", "class"}
14
+ # Boolean attributes included only when "true"
15
+ _BOOL_ATTRS = {
16
+ "clickable", "scrollable", "checkable", "checked", "selected", "long-clickable", "focused"
17
+ }
18
+ # Class name suffixes used as short labels
19
+ _CLASS_SHORT = {
20
+ "TextView": "text",
21
+ "EditText": "input",
22
+ "Button": "button",
23
+ "ImageView": "image",
24
+ "ImageButton": "image-button",
25
+ "CheckBox": "checkbox",
26
+ "RadioButton": "radio",
27
+ "Switch": "switch",
28
+ "ToggleButton": "toggle",
29
+ "SeekBar": "seekbar",
30
+ "ProgressBar": "progress",
31
+ "ListView": "list",
32
+ "RecyclerView": "list",
33
+ "ScrollView": "scroll",
34
+ "HorizontalScrollView": "scroll-h",
35
+ "ViewPager": "pager",
36
+ "WebView": "web",
37
+ "FrameLayout": "frame",
38
+ "LinearLayout": "linear",
39
+ "RelativeLayout": "relative",
40
+ "ConstraintLayout": "constraint",
41
+ "CoordinatorLayout": "coordinator",
42
+ "Toolbar": "toolbar",
43
+ "TabLayout": "tabs",
44
+ "BottomNavigationView": "bottom-nav",
45
+ "NavigationView": "nav",
46
+ }
47
+
48
+
49
+ def _short_class(class_name: str) -> str:
50
+ suffix = class_name.rsplit(".", 1)[-1]
51
+ return _CLASS_SHORT.get(suffix, suffix)
52
+
53
+
54
+ def _simplify_node(node: ET.Element) -> dict | None:
55
+ """Convert an XML node to a simplified dict, or None if the node should be pruned."""
56
+ attrib = node.attrib
57
+
58
+ # Drop invisible nodes and their subtrees
59
+ if attrib.get("visible-to-user") == "false":
60
+ return None
61
+
62
+ children = []
63
+ for child in node:
64
+ simplified = _simplify_node(child)
65
+ if simplified is not None:
66
+ children.append(simplified)
67
+
68
+ # Build the simplified representation
69
+ entry: dict = {}
70
+
71
+ class_name = attrib.get("class", "")
72
+ short = _short_class(class_name)
73
+ if short != class_name:
74
+ entry["type"] = short
75
+ else:
76
+ entry["type"] = class_name
77
+
78
+ text = attrib.get("text", "").strip()
79
+ if text:
80
+ entry["text"] = text
81
+
82
+ desc = attrib.get("content-desc", "").strip()
83
+ if desc and desc != text:
84
+ entry["desc"] = desc
85
+
86
+ rid = attrib.get("resource-id", "")
87
+ if rid:
88
+ # Strip package prefix for brevity: "com.example:id/foo" -> "foo"
89
+ entry["id"] = rid.split("/", 1)[-1] if "/" in rid else rid
90
+
91
+ bounds = attrib.get("bounds", "")
92
+ if bounds:
93
+ entry["bounds"] = bounds
94
+
95
+ for attr in _BOOL_ATTRS:
96
+ if attrib.get(attr) == "true":
97
+ entry[attr] = True
98
+
99
+ # Prune container nodes that carry no info and have exactly one child
100
+ no_info = not any(k in entry for k in ("text", "desc", "id"))
101
+ no_flags = not any(entry.get(a) for a in _BOOL_ATTRS)
102
+ if no_info and no_flags and len(children) == 1:
103
+ return children[0]
104
+
105
+ if children:
106
+ entry["children"] = children
107
+
108
+ return entry
109
+
110
+
111
+ def _simplify_xml(xml: str) -> str:
112
+ import json
113
+
114
+ root = ET.fromstring(xml)
115
+ # The root is <hierarchy>, its child is the top-level node
116
+ nodes = []
117
+ for child in root:
118
+ result = _simplify_node(child)
119
+ if result is not None:
120
+ nodes.append(result)
121
+
122
+ top = nodes[0] if len(nodes) == 1 else nodes
123
+ return json.dumps(top, ensure_ascii=False)
124
+
125
+ screen_app = typer.Typer(no_args_is_help=True)
126
+
127
+
128
+ @screen_app.command()
129
+ def screenshot(
130
+ path: Optional[str] = typer.Argument(
131
+ None, help="Output file path (omit to write PNG to stdout)"
132
+ ),
133
+ ) -> None:
134
+ """Take a screenshot. Saves to file or writes PNG bytes to stdout."""
135
+ try:
136
+ d = get_device()
137
+ if path:
138
+ d.screenshot(path)
139
+ out(True, f"Screenshot saved to: {path}", f"d.screenshot({path!r})")
140
+ else:
141
+ img = d.screenshot()
142
+ buf = io.BytesIO()
143
+ img.save(buf, format="PNG")
144
+ sys.stdout.buffer.write(buf.getvalue())
145
+ except Exception as e:
146
+ out(False, str(e))
147
+
148
+
149
+ @screen_app.command()
150
+ def dump(
151
+ compressed: bool = typer.Option(False, "--compressed", help="Use compressed hierarchy"), # noqa: FBT001, FBT002
152
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print XML"), # noqa: FBT001, FBT002
153
+ simplify: bool = typer.Option( # noqa: FBT001, FBT002
154
+ False, "--simplify", "-s", help="Strip noise, return compact JSON"
155
+ ),
156
+ ) -> None:
157
+ """Dump UI hierarchy as XML, or simplified JSON with --simplify."""
158
+ try:
159
+ d = get_device()
160
+ xml = d.dump_hierarchy(compressed=compressed)
161
+ if simplify:
162
+ result = _simplify_xml(xml)
163
+ out(True, result, "d.dump_hierarchy() [simplified]")
164
+ else:
165
+ out(True, xml, f"d.dump_hierarchy(compressed={compressed}, pretty={pretty})")
166
+ except Exception as e:
167
+ out(False, str(e))
168
+
169
+
170
+ @screen_app.command()
171
+ def size() -> None:
172
+ """Show screen size."""
173
+ try:
174
+ d = get_device()
175
+ w, h = d.window_size()
176
+ out(True, f"{w}x{h}", "d.window_size()")
177
+ except Exception as e:
178
+ out(False, str(e))
179
+
180
+
181
+ @screen_app.command()
182
+ def brightness(
183
+ value: Optional[int] = typer.Argument(None, help="Brightness level 0-255 (omit to get)"),
184
+ ) -> None:
185
+ """Get or set screen brightness (0-255)."""
186
+ try:
187
+ d = get_device()
188
+ if value is None:
189
+ resp = d.shell("settings get system screen_brightness")
190
+ out(True, resp.output.strip(), "d.shell('settings get system screen_brightness')")
191
+ else:
192
+ cmd = f"settings put system screen_brightness {value}"
193
+ d.shell(cmd)
194
+ out(True, f"Brightness set to: {value}", f"d.shell({cmd!r})")
195
+ except Exception as e:
196
+ out(False, str(e))
u2/watch.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ import typer
6
+
7
+ from u2 import get_device, out
8
+
9
+ watch_app = typer.Typer(no_args_is_help=True)
10
+
11
+
12
+ @watch_app.command()
13
+ def add(
14
+ name: str = typer.Argument(..., help="Watcher name"),
15
+ xpath: str = typer.Option(..., "--xpath", "-x", help="XPath expression to watch for"),
16
+ action: str = typer.Option("click", "--action", "-a", help="Action: click, back, home"),
17
+ timeout: float = typer.Option(30.0, "--timeout", "-t", help="Watch duration in seconds"),
18
+ ) -> None:
19
+ """Register a watcher and run it for a duration."""
20
+ try:
21
+ d = get_device()
22
+ w = d.watcher(name).when(xpath)
23
+ code_action: str
24
+ if action == "click":
25
+ w.click()
26
+ code_action = ".click()"
27
+ elif action in ("back", "home", "recent"):
28
+ w.press(action)
29
+ code_action = f".press({action!r})"
30
+ else:
31
+ out(False, f"Unknown action: {action!r}. Use: click, back, home, recent")
32
+ return
33
+
34
+ code = f"d.watcher({name!r}).when({xpath!r}){code_action}"
35
+ d.watcher.start()
36
+ time.sleep(timeout)
37
+ d.watcher.stop()
38
+ out(True, f"Watcher '{name}' ran for {timeout}s", code)
39
+ except Exception as e:
40
+ out(False, str(e))
41
+
42
+
43
+ @watch_app.command()
44
+ def remove(
45
+ name: str = typer.Argument(..., help="Watcher name (use '__all__' to remove all)"),
46
+ ) -> None:
47
+ """Remove a named watcher (note: only meaningful within same process)."""
48
+ try:
49
+ d = get_device()
50
+ if name == "__all__":
51
+ d.watcher.remove()
52
+ out(True, "Removed all watchers", "d.watcher.remove()")
53
+ else:
54
+ d.watcher.remove(name)
55
+ out(True, f"Removed watcher: {name}", f"d.watcher.remove({name!r})")
56
+ except Exception as e:
57
+ out(False, str(e))
58
+
59
+
60
+ @watch_app.command(name="list")
61
+ def list_watchers() -> None:
62
+ """List registered watchers (note: only meaningful within same process)."""
63
+ try:
64
+ d = get_device()
65
+ running = d.watcher.running()
66
+ out(True, f"Watcher running: {running}", "d.watcher.running()")
67
+ except Exception as e:
68
+ out(False, str(e))
69
+
70
+
71
+ @watch_app.command()
72
+ def run(
73
+ xpath: str = typer.Option(..., "--xpath", "-x", help="XPath expression to watch for"),
74
+ action: str = typer.Option("click", "--action", "-a", help="Action: click, back, home"),
75
+ ) -> None:
76
+ """Run watcher once and report if it triggered."""
77
+ try:
78
+ d = get_device()
79
+ w = d.watcher("__run__").when(xpath)
80
+ code_action: str
81
+ if action == "click":
82
+ w.click()
83
+ code_action = ".click()"
84
+ elif action in ("back", "home", "recent"):
85
+ w.press(action)
86
+ code_action = f".press({action!r})"
87
+ else:
88
+ out(False, f"Unknown action: {action!r}. Use: click, back, home, recent")
89
+ return
90
+
91
+ triggered = d.watcher.run()
92
+ d.watcher.remove("__run__")
93
+ code = f"d.watcher('__run__').when({xpath!r}){code_action}; d.watcher.run()"
94
+ out(triggered, "Watcher triggered" if triggered else "Watcher did not trigger", code)
95
+ except Exception as e:
96
+ out(False, str(e))
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: u2-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool for executing uiautomator2 commands to control Android devices from the AI Agent
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: typer>=0.24.1
8
+ Requires-Dist: uiautomator2>=3.5.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # u2-cli
12
+ CLI tool for executing uiautomator2 commands to control Android devices from the AI Agent
@@ -0,0 +1,12 @@
1
+ u2/__init__.py,sha256=j0NAPdAW0NtZx2BKtqISllrTXYZOfq2wW1bw15t5KRA,1327
2
+ u2/app.py,sha256=9BWBp3yJMN5lMeVyFSHNIH6UAyAa-vKQVN3pLpidGeQ,3827
3
+ u2/device.py,sha256=6Y28bPUNuWOagei_Px1POT2xC-PKh95iiw7DzzqXtJM,3513
4
+ u2/element.py,sha256=sA-XU5Dglg5B1QgG9ojqGKrrM-HEBcUvepqCxb0D8L0,7793
5
+ u2/interact.py,sha256=7Ct_ATZgpCq_1wKDM2Ev3zUDFRlSJCFCGI2lJuVKPZQ,3238
6
+ u2/screen.py,sha256=96ZEhfwOhf4YMLM9zMByHY9H33b_d727FGbzGxBGvak,5834
7
+ u2/watch.py,sha256=JLp3lMZgK6aSv36xFulfVgZ4kKlWjBGdbHt2G6oEYG0,3202
8
+ u2_cli-0.1.0.dist-info/METADATA,sha256=HjMC_gID75sZsqZ06sSIhoQPdiPKkpISUfpOcHVDoFU,399
9
+ u2_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ u2_cli-0.1.0.dist-info/entry_points.txt,sha256=e9l8ereIB7Lp2gG2bc0a3R2kqsxkcfKq_-M447haFqU,31
11
+ u2_cli-0.1.0.dist-info/licenses/LICENSE,sha256=hYRaJ2ed2IMo9RmO96UxUSTt9iwL49leBY5kVtDymRo,1063
12
+ u2_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ u2 = u2:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lanbao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.