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 +57 -0
- u2/app.py +124 -0
- u2/device.py +136 -0
- u2/element.py +220 -0
- u2/interact.py +99 -0
- u2/screen.py +196 -0
- u2/watch.py +96 -0
- u2_cli-0.1.0.dist-info/METADATA +12 -0
- u2_cli-0.1.0.dist-info/RECORD +12 -0
- u2_cli-0.1.0.dist-info/WHEEL +4 -0
- u2_cli-0.1.0.dist-info/entry_points.txt +2 -0
- u2_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|