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,125 @@
|
|
|
1
|
+
"""Artifact manager - Screenshots, logs, and debug bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import zipfile
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import uiautomator2 as u2
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ArtifactManager:
|
|
20
|
+
"""Manages artifact capture and storage."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, output_dir: Path | None = None) -> None:
|
|
23
|
+
default_dir = Path.home() / ".android-agent" / "artifacts"
|
|
24
|
+
self.output_dir = output_dir or default_dir
|
|
25
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
async def screenshot(
|
|
28
|
+
self,
|
|
29
|
+
device: u2.Device,
|
|
30
|
+
session_id: str,
|
|
31
|
+
filename: str | None = None,
|
|
32
|
+
) -> Path:
|
|
33
|
+
"""Capture a screenshot from device."""
|
|
34
|
+
if filename is None:
|
|
35
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
36
|
+
filename = f"{session_id}_{timestamp}.png"
|
|
37
|
+
|
|
38
|
+
output_path = self.output_dir / filename
|
|
39
|
+
|
|
40
|
+
# uiautomator2 returns PIL Image
|
|
41
|
+
image = await asyncio.to_thread(device.screenshot)
|
|
42
|
+
if image is None:
|
|
43
|
+
raise RuntimeError("Failed to capture screenshot from device")
|
|
44
|
+
await asyncio.to_thread(image.save, str(output_path))
|
|
45
|
+
|
|
46
|
+
logger.info("screenshot_captured", path=str(output_path))
|
|
47
|
+
return output_path
|
|
48
|
+
|
|
49
|
+
async def pull_logs(
|
|
50
|
+
self,
|
|
51
|
+
device: u2.Device,
|
|
52
|
+
session_id: str,
|
|
53
|
+
since: str | None = None,
|
|
54
|
+
filename: str | None = None,
|
|
55
|
+
) -> Path:
|
|
56
|
+
"""Pull logcat logs from device."""
|
|
57
|
+
if filename is None:
|
|
58
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
59
|
+
filename = f"{session_id}_{timestamp}_logcat.txt"
|
|
60
|
+
|
|
61
|
+
output_path = self.output_dir / filename
|
|
62
|
+
|
|
63
|
+
def _pull() -> str:
|
|
64
|
+
cmd = "logcat -d"
|
|
65
|
+
if since:
|
|
66
|
+
cmd += f" -t '{since}'"
|
|
67
|
+
result = device.shell(cmd)
|
|
68
|
+
output = getattr(result, "output", None)
|
|
69
|
+
return output if isinstance(output, str) else str(result)
|
|
70
|
+
|
|
71
|
+
logs = await asyncio.to_thread(_pull)
|
|
72
|
+
output_path.write_text(logs)
|
|
73
|
+
|
|
74
|
+
logger.info("logs_pulled", path=str(output_path), size=len(logs))
|
|
75
|
+
return output_path
|
|
76
|
+
|
|
77
|
+
async def save_snapshot(
|
|
78
|
+
self,
|
|
79
|
+
snapshot_json: str,
|
|
80
|
+
session_id: str,
|
|
81
|
+
generation: int,
|
|
82
|
+
) -> Path:
|
|
83
|
+
"""Save a snapshot to disk."""
|
|
84
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
85
|
+
filename = f"{session_id}_gen{generation}_{timestamp}.json"
|
|
86
|
+
output_path = self.output_dir / filename
|
|
87
|
+
|
|
88
|
+
output_path.write_text(snapshot_json)
|
|
89
|
+
logger.info("snapshot_saved", path=str(output_path))
|
|
90
|
+
return output_path
|
|
91
|
+
|
|
92
|
+
async def create_debug_bundle(
|
|
93
|
+
self,
|
|
94
|
+
device: u2.Device,
|
|
95
|
+
session_id: str,
|
|
96
|
+
snapshot_json: str | None = None,
|
|
97
|
+
) -> Path:
|
|
98
|
+
"""Create a debug bundle with screenshot, logs, and snapshot."""
|
|
99
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
100
|
+
bundle_name = f"{session_id}_{timestamp}_debug.zip"
|
|
101
|
+
bundle_path = self.output_dir / bundle_name
|
|
102
|
+
|
|
103
|
+
# Collect artifacts
|
|
104
|
+
screenshot_path = await self.screenshot(device, session_id)
|
|
105
|
+
logs_path = await self.pull_logs(device, session_id)
|
|
106
|
+
|
|
107
|
+
# Create zip bundle
|
|
108
|
+
def _create_zip() -> None:
|
|
109
|
+
with zipfile.ZipFile(bundle_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
110
|
+
zf.write(screenshot_path, screenshot_path.name)
|
|
111
|
+
zf.write(logs_path, logs_path.name)
|
|
112
|
+
if snapshot_json:
|
|
113
|
+
zf.writestr("snapshot.json", snapshot_json)
|
|
114
|
+
# Add metadata
|
|
115
|
+
metadata = f"session_id: {session_id}\ntimestamp: {timestamp}\n"
|
|
116
|
+
zf.writestr("metadata.txt", metadata)
|
|
117
|
+
|
|
118
|
+
await asyncio.to_thread(_create_zip)
|
|
119
|
+
|
|
120
|
+
# Cleanup temp files
|
|
121
|
+
screenshot_path.unlink(missing_ok=True)
|
|
122
|
+
logs_path.unlink(missing_ok=True)
|
|
123
|
+
|
|
124
|
+
logger.info("debug_bundle_created", path=str(bundle_path))
|
|
125
|
+
return bundle_path
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI - Thin client that communicates with the daemon."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command groups."""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Action execution CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from android_emu_agent.cli.daemon_client import DaemonClient
|
|
8
|
+
from android_emu_agent.cli.utils import handle_response
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Action execution commands")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("tap")
|
|
14
|
+
def action_tap(
|
|
15
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
16
|
+
ref: str = typer.Argument(..., help="Element ref (@a1)"),
|
|
17
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Tap an element."""
|
|
20
|
+
client = DaemonClient()
|
|
21
|
+
resp = client.request("POST", "/actions/tap", json_body={"session_id": session_id, "ref": ref})
|
|
22
|
+
client.close()
|
|
23
|
+
handle_response(resp, json_output=json_output)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("long-tap")
|
|
27
|
+
def action_long_tap(
|
|
28
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
29
|
+
ref: str = typer.Argument(..., help="Element ref (@a1)"),
|
|
30
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Long tap an element."""
|
|
33
|
+
client = DaemonClient()
|
|
34
|
+
resp = client.request(
|
|
35
|
+
"POST", "/actions/long_tap", json_body={"session_id": session_id, "ref": ref}
|
|
36
|
+
)
|
|
37
|
+
client.close()
|
|
38
|
+
handle_response(resp, json_output=json_output)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("set-text")
|
|
42
|
+
def action_set_text(
|
|
43
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
44
|
+
ref: str = typer.Argument(..., help="Element ref (@a1)"),
|
|
45
|
+
text: str = typer.Argument(..., help="Text to set"),
|
|
46
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Set text on an element."""
|
|
49
|
+
client = DaemonClient()
|
|
50
|
+
resp = client.request(
|
|
51
|
+
"POST",
|
|
52
|
+
"/actions/set_text",
|
|
53
|
+
json_body={"session_id": session_id, "ref": ref, "text": text},
|
|
54
|
+
)
|
|
55
|
+
client.close()
|
|
56
|
+
handle_response(resp, json_output=json_output)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("clear")
|
|
60
|
+
def action_clear(
|
|
61
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
62
|
+
ref: str = typer.Argument(..., help="Element ref (@a1)"),
|
|
63
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Clear text."""
|
|
66
|
+
client = DaemonClient()
|
|
67
|
+
resp = client.request(
|
|
68
|
+
"POST", "/actions/clear", json_body={"session_id": session_id, "ref": ref}
|
|
69
|
+
)
|
|
70
|
+
client.close()
|
|
71
|
+
handle_response(resp, json_output=json_output)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("back")
|
|
75
|
+
def action_back(
|
|
76
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
77
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Press back."""
|
|
80
|
+
client = DaemonClient()
|
|
81
|
+
resp = client.request("POST", "/actions/back", json_body={"session_id": session_id})
|
|
82
|
+
client.close()
|
|
83
|
+
handle_response(resp, json_output=json_output)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("home")
|
|
87
|
+
def action_home(
|
|
88
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
89
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Press home."""
|
|
92
|
+
client = DaemonClient()
|
|
93
|
+
resp = client.request("POST", "/actions/home", json_body={"session_id": session_id})
|
|
94
|
+
client.close()
|
|
95
|
+
handle_response(resp, json_output=json_output)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command("recents")
|
|
99
|
+
def action_recents(
|
|
100
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
101
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Press recents."""
|
|
104
|
+
client = DaemonClient()
|
|
105
|
+
resp = client.request("POST", "/actions/recents", json_body={"session_id": session_id})
|
|
106
|
+
client.close()
|
|
107
|
+
handle_response(resp, json_output=json_output)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("swipe")
|
|
111
|
+
def action_swipe(
|
|
112
|
+
direction: str = typer.Argument(..., help="Direction: up, down, left, right"),
|
|
113
|
+
session: str = typer.Option(..., "--session", "-s", help="Session ID"),
|
|
114
|
+
container: str | None = typer.Option(None, "--in", help="Container @ref or selector"),
|
|
115
|
+
distance: float = typer.Option(0.8, "--distance", "-d", help="Swipe distance (0.0-1.0)"),
|
|
116
|
+
duration: int = typer.Option(300, "--duration", help="Swipe duration in ms"),
|
|
117
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Perform swipe gesture."""
|
|
120
|
+
client = DaemonClient()
|
|
121
|
+
resp = client.request(
|
|
122
|
+
"POST",
|
|
123
|
+
"/actions/swipe",
|
|
124
|
+
json_body={
|
|
125
|
+
"session_id": session,
|
|
126
|
+
"direction": direction,
|
|
127
|
+
"container": container,
|
|
128
|
+
"distance": distance,
|
|
129
|
+
"duration_ms": duration,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
client.close()
|
|
133
|
+
handle_response(resp, json_output=json_output)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("scroll")
|
|
137
|
+
def action_scroll(
|
|
138
|
+
direction: str = typer.Argument(..., help="Direction: up, down, left, right"),
|
|
139
|
+
session: str = typer.Option(..., "--session", "-s", help="Session ID"),
|
|
140
|
+
container: str | None = typer.Option(None, "--in", help="Container @ref or selector"),
|
|
141
|
+
distance: float = typer.Option(0.8, "--distance", "-d", help="Scroll distance (0.0-1.0)"),
|
|
142
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Scroll in a direction (alias for swipe)."""
|
|
145
|
+
client = DaemonClient()
|
|
146
|
+
resp = client.request(
|
|
147
|
+
"POST",
|
|
148
|
+
"/actions/swipe",
|
|
149
|
+
json_body={
|
|
150
|
+
"session_id": session,
|
|
151
|
+
"direction": direction,
|
|
152
|
+
"container": container,
|
|
153
|
+
"distance": distance,
|
|
154
|
+
"duration_ms": 300,
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
client.close()
|
|
158
|
+
handle_response(resp, json_output=json_output)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""App management CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from android_emu_agent.cli.daemon_client import DaemonClient
|
|
8
|
+
from android_emu_agent.cli.utils import handle_output_response, handle_response, require_target
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="App management commands")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("reset")
|
|
14
|
+
def app_reset(
|
|
15
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
16
|
+
package: str = typer.Argument(..., help="Package name"),
|
|
17
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Clear app data for a package."""
|
|
20
|
+
client = DaemonClient()
|
|
21
|
+
resp = client.request(
|
|
22
|
+
"POST",
|
|
23
|
+
"/app/reset",
|
|
24
|
+
json_body={"session_id": session_id, "package": package},
|
|
25
|
+
)
|
|
26
|
+
client.close()
|
|
27
|
+
handle_response(resp, json_output=json_output)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("launch")
|
|
31
|
+
def app_launch(
|
|
32
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
33
|
+
package: str = typer.Argument(..., help="Package name"),
|
|
34
|
+
activity: str | None = typer.Option(None, "--activity", "-a", help="Activity name"),
|
|
35
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Launch an app."""
|
|
38
|
+
client = DaemonClient()
|
|
39
|
+
resp = client.request(
|
|
40
|
+
"POST",
|
|
41
|
+
"/app/launch",
|
|
42
|
+
json_body={"session_id": session_id, "package": package, "activity": activity},
|
|
43
|
+
)
|
|
44
|
+
client.close()
|
|
45
|
+
handle_response(resp, json_output=json_output)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command("force-stop")
|
|
49
|
+
def app_force_stop(
|
|
50
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
51
|
+
package: str = typer.Argument(..., help="Package name"),
|
|
52
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Force stop an app."""
|
|
55
|
+
client = DaemonClient()
|
|
56
|
+
resp = client.request(
|
|
57
|
+
"POST",
|
|
58
|
+
"/app/force_stop",
|
|
59
|
+
json_body={"session_id": session_id, "package": package},
|
|
60
|
+
)
|
|
61
|
+
client.close()
|
|
62
|
+
handle_response(resp, json_output=json_output)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command("deeplink")
|
|
66
|
+
def app_deeplink(
|
|
67
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
68
|
+
uri: str = typer.Argument(..., help="URI to open"),
|
|
69
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Open a deeplink URI."""
|
|
72
|
+
client = DaemonClient()
|
|
73
|
+
resp = client.request(
|
|
74
|
+
"POST",
|
|
75
|
+
"/app/deeplink",
|
|
76
|
+
json_body={"session_id": session_id, "uri": uri},
|
|
77
|
+
)
|
|
78
|
+
client.close()
|
|
79
|
+
handle_response(resp, json_output=json_output)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("list")
|
|
83
|
+
def app_list(
|
|
84
|
+
device: str | None = typer.Option(None, "--device", "-d", help="Device serial"),
|
|
85
|
+
session_id: str | None = typer.Option(None, "--session", "-s", help="Session ID"),
|
|
86
|
+
scope: str = typer.Option("all", "--scope", help="all|system|third-party"),
|
|
87
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""List installed packages."""
|
|
90
|
+
payload = require_target(device, session_id)
|
|
91
|
+
payload.update({"scope": scope})
|
|
92
|
+
client = DaemonClient()
|
|
93
|
+
resp = client.request("POST", "/app/list", json_body=payload)
|
|
94
|
+
client.close()
|
|
95
|
+
handle_output_response(resp, json_output=json_output)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Artifact and debugging CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from android_emu_agent.cli.daemon_client import DaemonClient
|
|
8
|
+
from android_emu_agent.cli.utils import (
|
|
9
|
+
handle_response,
|
|
10
|
+
handle_response_with_pull,
|
|
11
|
+
require_target,
|
|
12
|
+
resolve_session_id,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Artifact and debugging commands")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("save-snapshot")
|
|
19
|
+
def artifact_save_snapshot(
|
|
20
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
21
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Save the last snapshot to disk."""
|
|
24
|
+
client = DaemonClient()
|
|
25
|
+
resp = client.request("POST", "/artifacts/save_snapshot", json_body={"session_id": session_id})
|
|
26
|
+
client.close()
|
|
27
|
+
handle_response(resp, json_output=json_output)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("screenshot")
|
|
31
|
+
def artifact_screenshot(
|
|
32
|
+
session_id: str | None = typer.Argument(None, help="Session ID"),
|
|
33
|
+
device: str | None = typer.Option(None, "--device", "-d", help="Device serial"),
|
|
34
|
+
session: str | None = typer.Option(None, "--session", "-s", help="Session ID"),
|
|
35
|
+
pull: bool = typer.Option(False, "--pull", help="Copy screenshot to local path"),
|
|
36
|
+
output: str | None = typer.Option(
|
|
37
|
+
None, "--output", "-o", help="Output path (file or directory)"
|
|
38
|
+
),
|
|
39
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Capture a screenshot artifact."""
|
|
42
|
+
try:
|
|
43
|
+
resolved_session = resolve_session_id(session_id, session)
|
|
44
|
+
except typer.BadParameter as exc:
|
|
45
|
+
typer.echo(f"Error: {exc}")
|
|
46
|
+
raise typer.Exit(code=1) from None
|
|
47
|
+
|
|
48
|
+
payload = require_target(device, resolved_session)
|
|
49
|
+
client = DaemonClient()
|
|
50
|
+
resp = client.request("POST", "/ui/screenshot", json_body=payload)
|
|
51
|
+
client.close()
|
|
52
|
+
handle_response_with_pull(resp, json_output=json_output, pull=pull, output=output)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("logs")
|
|
56
|
+
def artifact_logs(
|
|
57
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
58
|
+
since: str | None = typer.Option(None, "--since", help="Logcat since timestamp"),
|
|
59
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Pull logcat logs."""
|
|
62
|
+
client = DaemonClient()
|
|
63
|
+
resp = client.request(
|
|
64
|
+
"POST",
|
|
65
|
+
"/artifacts/logs",
|
|
66
|
+
json_body={"session_id": session_id, "since": since},
|
|
67
|
+
)
|
|
68
|
+
client.close()
|
|
69
|
+
handle_response(resp, json_output=json_output)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("bundle")
|
|
73
|
+
def artifact_bundle(
|
|
74
|
+
session_id: str = typer.Argument(..., help="Session ID"),
|
|
75
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Create a debug bundle."""
|
|
78
|
+
client = DaemonClient()
|
|
79
|
+
resp = client.request("POST", "/artifacts/debug_bundle", json_body={"session_id": session_id})
|
|
80
|
+
client.close()
|
|
81
|
+
handle_response(resp, json_output=json_output)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Daemon lifecycle CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from android_emu_agent.cli.daemon_client import DaemonClient, DaemonController, format_json
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Daemon lifecycle commands")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("start")
|
|
15
|
+
def daemon_start() -> None:
|
|
16
|
+
"""Start the daemon process."""
|
|
17
|
+
controller = DaemonController()
|
|
18
|
+
status = controller.status()
|
|
19
|
+
if status["pid_running"]:
|
|
20
|
+
typer.echo(f"Daemon already running (pid {status['pid']})")
|
|
21
|
+
return
|
|
22
|
+
if controller.health():
|
|
23
|
+
typer.echo("Daemon already running (pid unknown)")
|
|
24
|
+
return
|
|
25
|
+
pid = controller.start()
|
|
26
|
+
if pid == -1:
|
|
27
|
+
typer.echo("Daemon already running (pid unknown)")
|
|
28
|
+
return
|
|
29
|
+
typer.echo(f"Daemon started (pid {pid})")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("stop")
|
|
33
|
+
def daemon_stop() -> None:
|
|
34
|
+
"""Stop the daemon process."""
|
|
35
|
+
controller = DaemonController()
|
|
36
|
+
stopped = controller.stop()
|
|
37
|
+
if stopped:
|
|
38
|
+
typer.echo("Daemon stopped")
|
|
39
|
+
else:
|
|
40
|
+
typer.echo("Daemon not running")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("status")
|
|
44
|
+
def daemon_status(json_output: bool = typer.Option(False, "--json", help="Output JSON")) -> None:
|
|
45
|
+
"""Show daemon status."""
|
|
46
|
+
controller = DaemonController()
|
|
47
|
+
status = controller.status()
|
|
48
|
+
|
|
49
|
+
health: dict[str, Any] | None = None
|
|
50
|
+
try:
|
|
51
|
+
client = DaemonClient(auto_start=False)
|
|
52
|
+
resp = client.request("GET", "/health")
|
|
53
|
+
health = resp.json()
|
|
54
|
+
client.close()
|
|
55
|
+
except Exception:
|
|
56
|
+
health = None
|
|
57
|
+
|
|
58
|
+
status["health"] = health
|
|
59
|
+
if json_output:
|
|
60
|
+
typer.echo(format_json(status))
|
|
61
|
+
else:
|
|
62
|
+
typer.echo(format_json(status))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Device management CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from android_emu_agent.cli.daemon_client import DaemonClient, format_json
|
|
8
|
+
from android_emu_agent.cli.utils import handle_response
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Device management commands")
|
|
11
|
+
device_set_app = typer.Typer(help="Determinism controls")
|
|
12
|
+
app.add_typer(device_set_app, name="set")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def device_list(json_output: bool = typer.Option(False, "--json", help="Output JSON")) -> None:
|
|
17
|
+
"""List connected devices."""
|
|
18
|
+
client = DaemonClient()
|
|
19
|
+
resp = client.request("GET", "/devices")
|
|
20
|
+
client.close()
|
|
21
|
+
|
|
22
|
+
data = resp.json()
|
|
23
|
+
if json_output:
|
|
24
|
+
typer.echo(format_json(data))
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
for device in data.get("devices", []):
|
|
28
|
+
typer.echo(
|
|
29
|
+
f"{device['serial']} model={device['model']} sdk={device['sdk_version']} "
|
|
30
|
+
f"root={device['is_rooted']} emulator={device['is_emulator']}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@device_set_app.command("animations")
|
|
35
|
+
def device_set_animations(
|
|
36
|
+
state: str = typer.Argument(..., help="on|off"),
|
|
37
|
+
device: str = typer.Option(..., "--device", "-d", help="Device serial"),
|
|
38
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Enable or disable system animations."""
|
|
41
|
+
client = DaemonClient()
|
|
42
|
+
resp = client.request(
|
|
43
|
+
"POST", "/devices/animations", json_body={"serial": device, "state": state}
|
|
44
|
+
)
|
|
45
|
+
client.close()
|
|
46
|
+
handle_response(resp, json_output=json_output)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@device_set_app.command("stay_awake")
|
|
50
|
+
def device_set_stay_awake(
|
|
51
|
+
state: str = typer.Argument(..., help="on|off"),
|
|
52
|
+
device: str = typer.Option(..., "--device", "-d", help="Device serial"),
|
|
53
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Enable or disable stay-awake."""
|
|
56
|
+
client = DaemonClient()
|
|
57
|
+
resp = client.request(
|
|
58
|
+
"POST", "/devices/stay_awake", json_body={"serial": device, "state": state}
|
|
59
|
+
)
|
|
60
|
+
client.close()
|
|
61
|
+
handle_response(resp, json_output=json_output)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@device_set_app.command("rotation")
|
|
65
|
+
def device_set_rotation(
|
|
66
|
+
orientation: str = typer.Argument(
|
|
67
|
+
..., help="portrait|landscape|reverse-portrait|reverse-landscape|auto"
|
|
68
|
+
),
|
|
69
|
+
device: str = typer.Option(..., "--device", "-d", help="Device serial"),
|
|
70
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Set screen rotation."""
|
|
73
|
+
client = DaemonClient()
|
|
74
|
+
resp = client.request(
|
|
75
|
+
"POST", "/devices/rotation", json_body={"serial": device, "orientation": orientation}
|
|
76
|
+
)
|
|
77
|
+
client.close()
|
|
78
|
+
handle_response(resp, json_output=json_output)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@device_set_app.command("wifi")
|
|
82
|
+
def device_set_wifi(
|
|
83
|
+
state: str = typer.Argument(..., help="on|off"),
|
|
84
|
+
device: str = typer.Option(..., "--device", "-d", help="Device serial"),
|
|
85
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Enable or disable WiFi."""
|
|
88
|
+
client = DaemonClient()
|
|
89
|
+
enabled = state.lower() == "on"
|
|
90
|
+
resp = client.request("POST", "/devices/wifi", json_body={"serial": device, "enabled": enabled})
|
|
91
|
+
client.close()
|
|
92
|
+
handle_response(resp, json_output=json_output)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@device_set_app.command("mobile")
|
|
96
|
+
def device_set_mobile(
|
|
97
|
+
state: str = typer.Argument(..., help="on|off"),
|
|
98
|
+
device: str = typer.Option(..., "--device", "-d", help="Device serial"),
|
|
99
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Enable or disable mobile data."""
|
|
102
|
+
client = DaemonClient()
|
|
103
|
+
enabled = state.lower() == "on"
|
|
104
|
+
resp = client.request(
|
|
105
|
+
"POST", "/devices/mobile", json_body={"serial": device, "enabled": enabled}
|
|
106
|
+
)
|
|
107
|
+
client.close()
|
|
108
|
+
handle_response(resp, json_output=json_output)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@device_set_app.command("doze")
|
|
112
|
+
def device_set_doze(
|
|
113
|
+
state: str = typer.Argument(..., help="on|off"),
|
|
114
|
+
device: str = typer.Option(..., "--device", "-d", help="Device serial"),
|
|
115
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Force device into or out of doze mode."""
|
|
118
|
+
client = DaemonClient()
|
|
119
|
+
enabled = state.lower() == "on"
|
|
120
|
+
resp = client.request("POST", "/devices/doze", json_body={"serial": device, "enabled": enabled})
|
|
121
|
+
client.close()
|
|
122
|
+
handle_response(resp, json_output=json_output)
|