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,232 @@
|
|
|
1
|
+
"""Error model - Actionable errors with remediation hints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class AgentError(Exception):
|
|
11
|
+
"""
|
|
12
|
+
Base error with context and remediation guidance.
|
|
13
|
+
|
|
14
|
+
All errors should be actionable - tell the caller what went wrong
|
|
15
|
+
and what they can do about it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
code: str
|
|
19
|
+
message: str
|
|
20
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
remediation: str = ""
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
return f"[{self.code}] {self.message}"
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
"""Convert to JSON-serializable dict."""
|
|
28
|
+
return {
|
|
29
|
+
"code": self.code,
|
|
30
|
+
"message": self.message,
|
|
31
|
+
"context": self.context,
|
|
32
|
+
"remediation": self.remediation,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Specific error constructors for common cases
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stale_ref_error(ref: str, ref_generation: int, current_generation: int) -> AgentError:
|
|
40
|
+
"""Create error for stale ref usage."""
|
|
41
|
+
return AgentError(
|
|
42
|
+
code="ERR_STALE_REF",
|
|
43
|
+
message=f"Ref {ref} is from generation {ref_generation}, current is {current_generation}",
|
|
44
|
+
context={
|
|
45
|
+
"ref": ref,
|
|
46
|
+
"ref_generation": ref_generation,
|
|
47
|
+
"current_generation": current_generation,
|
|
48
|
+
},
|
|
49
|
+
remediation="Take a new snapshot with 'ui snapshot' and use fresh @refs",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def not_found_error(ref_or_selector: str, locator: dict[str, Any] | None = None) -> AgentError:
|
|
54
|
+
"""Create error for element not found."""
|
|
55
|
+
return AgentError(
|
|
56
|
+
code="ERR_NOT_FOUND",
|
|
57
|
+
message=f"Element not found: {ref_or_selector}",
|
|
58
|
+
context={"target": ref_or_selector, "locator": locator},
|
|
59
|
+
remediation="Take a new snapshot with 'ui snapshot' and verify element exists",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def blocked_input_error(reason: str) -> AgentError:
|
|
64
|
+
"""Create error for blocked input (overlay, IME, security)."""
|
|
65
|
+
return AgentError(
|
|
66
|
+
code="ERR_BLOCKED_INPUT",
|
|
67
|
+
message=f"Input blocked: {reason}",
|
|
68
|
+
context={"reason": reason},
|
|
69
|
+
remediation="Try 'wait idle', 'back', or check for system dialogs",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def timeout_error(
|
|
74
|
+
operation: str, timeout_ms: float, last_context: dict[str, Any] | None = None
|
|
75
|
+
) -> AgentError:
|
|
76
|
+
"""Create error for timeout."""
|
|
77
|
+
return AgentError(
|
|
78
|
+
code="ERR_TIMEOUT",
|
|
79
|
+
message=f"Operation timed out: {operation}",
|
|
80
|
+
context={"operation": operation, "timeout_ms": timeout_ms, "last_context": last_context},
|
|
81
|
+
remediation="Increase timeout or check if condition can be met",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def device_offline_error(serial: str) -> AgentError:
|
|
86
|
+
"""Create error for offline device."""
|
|
87
|
+
return AgentError(
|
|
88
|
+
code="ERR_DEVICE_OFFLINE",
|
|
89
|
+
message=f"Device offline: {serial}",
|
|
90
|
+
context={"serial": serial},
|
|
91
|
+
remediation="Check device connection with 'devices list' and reconnect",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def permission_error(operation: str) -> AgentError:
|
|
96
|
+
"""Create error for permission denied."""
|
|
97
|
+
return AgentError(
|
|
98
|
+
code="ERR_PERMISSION",
|
|
99
|
+
message=f"Permission denied: {operation}",
|
|
100
|
+
context={"operation": operation},
|
|
101
|
+
remediation="Check device is rooted or emulator has required permissions",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def session_expired_error(session_id: str) -> AgentError:
|
|
106
|
+
"""Create error for expired session."""
|
|
107
|
+
return AgentError(
|
|
108
|
+
code="ERR_SESSION_EXPIRED",
|
|
109
|
+
message=f"Session expired: {session_id}",
|
|
110
|
+
context={"session_id": session_id},
|
|
111
|
+
remediation="Start a new session with 'session start --device <serial>'",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def not_emulator_error(serial: str) -> AgentError:
|
|
116
|
+
"""Create error for non-emulator device."""
|
|
117
|
+
return AgentError(
|
|
118
|
+
code="ERR_NOT_EMULATOR",
|
|
119
|
+
message=f"Not an emulator: {serial}",
|
|
120
|
+
context={"serial": serial},
|
|
121
|
+
remediation="Snapshots only work on emulators. Use a serial like 'emulator-5554'.",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def console_connect_error(port: int) -> AgentError:
|
|
126
|
+
"""Create error for emulator console connection failure."""
|
|
127
|
+
return AgentError(
|
|
128
|
+
code="ERR_CONSOLE_CONNECT",
|
|
129
|
+
message=f"Cannot connect to emulator console on port {port}",
|
|
130
|
+
context={"port": port},
|
|
131
|
+
remediation="Ensure emulator is running and console port is accessible.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def snapshot_failed_error(name: str, reason: str) -> AgentError:
|
|
136
|
+
"""Create error for snapshot operation failure."""
|
|
137
|
+
return AgentError(
|
|
138
|
+
code="ERR_SNAPSHOT_FAILED",
|
|
139
|
+
message=f"Snapshot '{name}' failed: {reason}",
|
|
140
|
+
context={"name": name, "reason": reason},
|
|
141
|
+
remediation="Check snapshot name exists with 'emulator -avd <name> -list-snapshots'.",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def invalid_package_error(package: str) -> AgentError:
|
|
146
|
+
"""Create error for invalid package name."""
|
|
147
|
+
return AgentError(
|
|
148
|
+
code="ERR_INVALID_PACKAGE",
|
|
149
|
+
message=f"Invalid package name: {package}",
|
|
150
|
+
context={"package": package},
|
|
151
|
+
remediation="Package names must be like 'com.example.app'.",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def package_not_found_error(package: str) -> AgentError:
|
|
156
|
+
"""Create error for package not installed."""
|
|
157
|
+
return AgentError(
|
|
158
|
+
code="ERR_PACKAGE_NOT_FOUND",
|
|
159
|
+
message=f"Package not found: {package}",
|
|
160
|
+
context={"package": package},
|
|
161
|
+
remediation="Check package is installed with 'adb shell pm list packages'.",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def launch_failed_error(package: str, reason: str) -> AgentError:
|
|
166
|
+
"""Create error for app launch failure."""
|
|
167
|
+
return AgentError(
|
|
168
|
+
code="ERR_LAUNCH_FAILED",
|
|
169
|
+
message=f"Failed to launch {package}: {reason}",
|
|
170
|
+
context={"package": package, "reason": reason},
|
|
171
|
+
remediation="Verify package is installed and has a launchable activity.",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def invalid_uri_error(uri: str) -> AgentError:
|
|
176
|
+
"""Create error for invalid URI."""
|
|
177
|
+
return AgentError(
|
|
178
|
+
code="ERR_INVALID_URI",
|
|
179
|
+
message=f"Invalid URI: {uri}",
|
|
180
|
+
context={"uri": uri},
|
|
181
|
+
remediation="URI must include scheme (e.g., https:// or myapp://).",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def invalid_selector_error(selector: str) -> AgentError:
|
|
186
|
+
"""Create error for invalid selector syntax."""
|
|
187
|
+
return AgentError(
|
|
188
|
+
code="ERR_INVALID_SELECTOR",
|
|
189
|
+
message=f"Invalid selector: {selector}",
|
|
190
|
+
context={"selector": selector},
|
|
191
|
+
remediation='Use @ref, text:"...", id:..., desc:"...", or coords:x,y',
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def adb_not_found_error() -> AgentError:
|
|
196
|
+
"""Create error for missing adb binary."""
|
|
197
|
+
return AgentError(
|
|
198
|
+
code="ERR_ADB_NOT_FOUND",
|
|
199
|
+
message="adb command not found",
|
|
200
|
+
context={},
|
|
201
|
+
remediation="Install Android platform-tools and ensure adb is in PATH.",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def adb_command_error(command: str, reason: str) -> AgentError:
|
|
206
|
+
"""Create error for adb command failure."""
|
|
207
|
+
return AgentError(
|
|
208
|
+
code="ERR_ADB_COMMAND",
|
|
209
|
+
message=f"adb command failed: {command}",
|
|
210
|
+
context={"command": command, "reason": reason},
|
|
211
|
+
remediation="Check adb connection and command arguments, then retry.",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def process_not_found_error(package: str) -> AgentError:
|
|
216
|
+
"""Create error for missing process."""
|
|
217
|
+
return AgentError(
|
|
218
|
+
code="ERR_PROCESS_NOT_FOUND",
|
|
219
|
+
message=f"Process not running for {package}",
|
|
220
|
+
context={"package": package},
|
|
221
|
+
remediation="Ensure the app is running and try again.",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def file_not_found_error(path: str) -> AgentError:
|
|
226
|
+
"""Create error for missing local file."""
|
|
227
|
+
return AgentError(
|
|
228
|
+
code="ERR_FILE_NOT_FOUND",
|
|
229
|
+
message=f"Local file not found: {path}",
|
|
230
|
+
context={"path": path},
|
|
231
|
+
remediation="Verify the local path and try again.",
|
|
232
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""File transfer module."""
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""File manager - push/pull files to device and app data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, TypedDict
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from android_emu_agent.errors import (
|
|
16
|
+
AgentError,
|
|
17
|
+
adb_command_error,
|
|
18
|
+
adb_not_found_error,
|
|
19
|
+
file_not_found_error,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from adbutils import AdbDevice
|
|
24
|
+
|
|
25
|
+
logger = structlog.get_logger()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FileMatch(TypedDict):
|
|
29
|
+
path: str
|
|
30
|
+
name: str
|
|
31
|
+
kind: str
|
|
32
|
+
type_raw: str
|
|
33
|
+
size_bytes: int
|
|
34
|
+
uid: int
|
|
35
|
+
gid: int
|
|
36
|
+
mode: str
|
|
37
|
+
mtime_epoch: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FileManager:
|
|
41
|
+
"""Push and pull files via adb."""
|
|
42
|
+
|
|
43
|
+
_FIND_DELIMITER = "|"
|
|
44
|
+
_FIND_FORMAT = "%n|%F|%s|%u|%g|%a|%Y"
|
|
45
|
+
|
|
46
|
+
def __init__(self, output_dir: Path | None = None) -> None:
|
|
47
|
+
default_dir = Path.home() / ".android-emu-agent" / "artifacts" / "files"
|
|
48
|
+
self.output_dir = output_dir or default_dir
|
|
49
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
async def push(self, serial: str, local_path: str, remote_path: str | None) -> str:
|
|
52
|
+
local = Path(local_path).expanduser()
|
|
53
|
+
if not local.exists():
|
|
54
|
+
raise file_not_found_error(str(local))
|
|
55
|
+
if remote_path is None:
|
|
56
|
+
remote_path = f"/sdcard/Download/{local.name}"
|
|
57
|
+
|
|
58
|
+
await self._run_adb(serial, ["push", str(local), remote_path])
|
|
59
|
+
logger.info("file_pushed", serial=serial, local=str(local), remote=remote_path)
|
|
60
|
+
return remote_path
|
|
61
|
+
|
|
62
|
+
async def pull(self, serial: str, remote_path: str, local_path: str | None) -> Path:
|
|
63
|
+
local = self._resolve_local_path(serial, remote_path, local_path)
|
|
64
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
await self._run_adb(serial, ["pull", remote_path, str(local)])
|
|
66
|
+
logger.info("file_pulled", serial=serial, remote=remote_path, local=str(local))
|
|
67
|
+
return local
|
|
68
|
+
|
|
69
|
+
async def app_pull(
|
|
70
|
+
self,
|
|
71
|
+
device: AdbDevice,
|
|
72
|
+
serial: str,
|
|
73
|
+
package: str,
|
|
74
|
+
remote_path: str,
|
|
75
|
+
local_path: str | None,
|
|
76
|
+
) -> Path:
|
|
77
|
+
remote_abs = self._resolve_app_path(package, remote_path)
|
|
78
|
+
stage_dir = self._stage_dir(package)
|
|
79
|
+
stage_path = f"{stage_dir}/{self._stage_name(package)}"
|
|
80
|
+
stage_cmd = (
|
|
81
|
+
f"rm -rf {stage_path} && mkdir -p {stage_dir} && cp -r {remote_abs} {stage_path}"
|
|
82
|
+
)
|
|
83
|
+
await self._shell_su(device, stage_cmd)
|
|
84
|
+
|
|
85
|
+
local = self._resolve_local_path(serial, remote_path, local_path, prefix=f"{package}_")
|
|
86
|
+
local.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
await self._run_adb(serial, ["pull", stage_path, str(local)])
|
|
88
|
+
await self._shell_su(device, f"rm -rf {stage_path}")
|
|
89
|
+
logger.info("app_file_pulled", serial=serial, remote=remote_abs, local=str(local))
|
|
90
|
+
return local
|
|
91
|
+
|
|
92
|
+
async def app_push(
|
|
93
|
+
self,
|
|
94
|
+
device: AdbDevice,
|
|
95
|
+
serial: str,
|
|
96
|
+
package: str,
|
|
97
|
+
local_path: str,
|
|
98
|
+
remote_path: str | None,
|
|
99
|
+
) -> str:
|
|
100
|
+
local = Path(local_path).expanduser()
|
|
101
|
+
if not local.exists():
|
|
102
|
+
raise file_not_found_error(str(local))
|
|
103
|
+
|
|
104
|
+
remote_abs = self._resolve_app_dest(package, remote_path, local.name)
|
|
105
|
+
stage_dir = self._stage_dir(package)
|
|
106
|
+
stage_path = f"{stage_dir}/{self._stage_name(package, local.name)}"
|
|
107
|
+
|
|
108
|
+
await self._run_adb(serial, ["push", str(local), stage_path])
|
|
109
|
+
dest_parent = str(Path(remote_abs).parent)
|
|
110
|
+
stage_cmd = (
|
|
111
|
+
f"mkdir -p {shlex.quote(dest_parent)} "
|
|
112
|
+
f"&& cp -r {shlex.quote(stage_path)} {shlex.quote(remote_abs)}"
|
|
113
|
+
)
|
|
114
|
+
await self._shell_su(device, stage_cmd)
|
|
115
|
+
await self._shell(device, f"rm -f {shlex.quote(stage_path)}")
|
|
116
|
+
logger.info("app_file_pushed", serial=serial, local=str(local), remote=remote_abs)
|
|
117
|
+
return remote_abs
|
|
118
|
+
|
|
119
|
+
async def find_metadata(
|
|
120
|
+
self,
|
|
121
|
+
device: AdbDevice,
|
|
122
|
+
path: str,
|
|
123
|
+
name: str,
|
|
124
|
+
kind: str,
|
|
125
|
+
max_depth: int,
|
|
126
|
+
) -> list[FileMatch]:
|
|
127
|
+
if max_depth < 0:
|
|
128
|
+
raise AgentError(
|
|
129
|
+
code="ERR_INVALID_DEPTH",
|
|
130
|
+
message=f"Invalid max depth: {max_depth}",
|
|
131
|
+
context={"max_depth": max_depth},
|
|
132
|
+
remediation="Provide --max-depth 0 or greater.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
type_flag = ""
|
|
136
|
+
if kind == "file":
|
|
137
|
+
type_flag = "-type f"
|
|
138
|
+
elif kind == "dir":
|
|
139
|
+
type_flag = "-type d"
|
|
140
|
+
|
|
141
|
+
safe_path = shlex.quote(path)
|
|
142
|
+
safe_name = shlex.quote(name)
|
|
143
|
+
safe_format = shlex.quote(self._FIND_FORMAT)
|
|
144
|
+
depth_flag = f"-maxdepth {max_depth}"
|
|
145
|
+
parts = [
|
|
146
|
+
"find",
|
|
147
|
+
safe_path,
|
|
148
|
+
depth_flag,
|
|
149
|
+
type_flag,
|
|
150
|
+
"-name",
|
|
151
|
+
safe_name,
|
|
152
|
+
"-exec",
|
|
153
|
+
"stat",
|
|
154
|
+
"-c",
|
|
155
|
+
safe_format,
|
|
156
|
+
"{}",
|
|
157
|
+
"+",
|
|
158
|
+
]
|
|
159
|
+
cmd = " ".join(part for part in parts if part)
|
|
160
|
+
output = await self._shell_su(device, cmd)
|
|
161
|
+
return self._parse_find_output(output)
|
|
162
|
+
|
|
163
|
+
async def list_metadata(self, device: AdbDevice, path: str, kind: str) -> list[FileMatch]:
|
|
164
|
+
type_flag = ""
|
|
165
|
+
if kind == "file":
|
|
166
|
+
type_flag = "-type f"
|
|
167
|
+
elif kind == "dir":
|
|
168
|
+
type_flag = "-type d"
|
|
169
|
+
|
|
170
|
+
safe_path = shlex.quote(path)
|
|
171
|
+
safe_format = shlex.quote(self._FIND_FORMAT)
|
|
172
|
+
parts = [
|
|
173
|
+
"find",
|
|
174
|
+
safe_path,
|
|
175
|
+
"-mindepth 1",
|
|
176
|
+
"-maxdepth 1",
|
|
177
|
+
type_flag,
|
|
178
|
+
"-exec",
|
|
179
|
+
"stat",
|
|
180
|
+
"-c",
|
|
181
|
+
safe_format,
|
|
182
|
+
"{}",
|
|
183
|
+
"+",
|
|
184
|
+
]
|
|
185
|
+
cmd = " ".join(part for part in parts if part)
|
|
186
|
+
output = await self._shell_su(device, cmd)
|
|
187
|
+
return self._parse_find_output(output)
|
|
188
|
+
|
|
189
|
+
def _resolve_local_path(
|
|
190
|
+
self,
|
|
191
|
+
serial: str,
|
|
192
|
+
remote_path: str,
|
|
193
|
+
local_path: str | None,
|
|
194
|
+
prefix: str | None = None,
|
|
195
|
+
) -> Path:
|
|
196
|
+
if local_path:
|
|
197
|
+
return Path(local_path).expanduser()
|
|
198
|
+
timestamp = self._timestamp()
|
|
199
|
+
base = Path(remote_path).name or "artifact"
|
|
200
|
+
prefix_value = prefix or ""
|
|
201
|
+
filename = f"{prefix_value}{serial}_{timestamp}_{base}"
|
|
202
|
+
return self.output_dir / filename
|
|
203
|
+
|
|
204
|
+
def _resolve_app_path(self, package: str, remote_path: str) -> str:
|
|
205
|
+
if remote_path.startswith("/"):
|
|
206
|
+
return remote_path
|
|
207
|
+
return f"/data/data/{package}/{remote_path}"
|
|
208
|
+
|
|
209
|
+
def _resolve_app_dest(self, package: str, remote_path: str | None, basename: str) -> str:
|
|
210
|
+
if not remote_path:
|
|
211
|
+
return f"/data/data/{package}/files/{basename}"
|
|
212
|
+
if remote_path.endswith("/"):
|
|
213
|
+
return f"{self._resolve_app_path(package, remote_path.rstrip('/'))}/{basename}"
|
|
214
|
+
return self._resolve_app_path(package, remote_path)
|
|
215
|
+
|
|
216
|
+
def _stage_dir(self, package: str) -> str:
|
|
217
|
+
safe_pkg = package.replace(".", "_")
|
|
218
|
+
return f"/data/local/tmp/android-emu-agent/{safe_pkg}"
|
|
219
|
+
|
|
220
|
+
def _stage_name(self, package: str, suffix: str | None = None) -> str:
|
|
221
|
+
safe_pkg = package.replace(".", "_")
|
|
222
|
+
suffix_part = f"_{suffix}" if suffix else ""
|
|
223
|
+
return f"stage_{safe_pkg}_{self._timestamp()}{suffix_part}"
|
|
224
|
+
|
|
225
|
+
def _parse_find_output(self, output: str) -> list[FileMatch]:
|
|
226
|
+
matches: list[FileMatch] = []
|
|
227
|
+
for line in output.splitlines():
|
|
228
|
+
if not line.strip():
|
|
229
|
+
continue
|
|
230
|
+
parts = line.split(self._FIND_DELIMITER)
|
|
231
|
+
if len(parts) != 7:
|
|
232
|
+
continue
|
|
233
|
+
path, type_raw, size, uid, gid, mode, mtime = parts
|
|
234
|
+
try:
|
|
235
|
+
size_bytes = int(size)
|
|
236
|
+
uid_value = int(uid)
|
|
237
|
+
gid_value = int(gid)
|
|
238
|
+
mtime_epoch = int(mtime)
|
|
239
|
+
except ValueError:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
matches.append(
|
|
243
|
+
{
|
|
244
|
+
"path": path,
|
|
245
|
+
"name": Path(path).name,
|
|
246
|
+
"kind": self._normalize_kind(type_raw),
|
|
247
|
+
"type_raw": type_raw,
|
|
248
|
+
"size_bytes": size_bytes,
|
|
249
|
+
"uid": uid_value,
|
|
250
|
+
"gid": gid_value,
|
|
251
|
+
"mode": mode,
|
|
252
|
+
"mtime_epoch": mtime_epoch,
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
return matches
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _normalize_kind(type_raw: str) -> str:
|
|
259
|
+
lowered = type_raw.lower()
|
|
260
|
+
if "directory" in lowered:
|
|
261
|
+
return "dir"
|
|
262
|
+
if "regular file" in lowered:
|
|
263
|
+
return "file"
|
|
264
|
+
if "symbolic link" in lowered:
|
|
265
|
+
return "link"
|
|
266
|
+
if "socket" in lowered:
|
|
267
|
+
return "socket"
|
|
268
|
+
if "fifo" in lowered or "named pipe" in lowered:
|
|
269
|
+
return "fifo"
|
|
270
|
+
if "block" in lowered:
|
|
271
|
+
return "block"
|
|
272
|
+
if "character" in lowered:
|
|
273
|
+
return "char"
|
|
274
|
+
return "other"
|
|
275
|
+
|
|
276
|
+
async def _shell(self, device: AdbDevice, command: str) -> str:
|
|
277
|
+
def _run() -> str:
|
|
278
|
+
result = device.shell(command)
|
|
279
|
+
output = getattr(result, "output", None)
|
|
280
|
+
return output if isinstance(output, str) else str(result)
|
|
281
|
+
|
|
282
|
+
return await asyncio.to_thread(_run)
|
|
283
|
+
|
|
284
|
+
async def _shell_su(self, device: AdbDevice, command: str) -> str:
|
|
285
|
+
return await self._shell(device, f"su -c {shlex.quote(command)}")
|
|
286
|
+
|
|
287
|
+
async def _run_adb(self, serial: str, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
288
|
+
def _run() -> subprocess.CompletedProcess[str]:
|
|
289
|
+
adb_path = shutil.which("adb")
|
|
290
|
+
if not adb_path:
|
|
291
|
+
raise adb_not_found_error()
|
|
292
|
+
return subprocess.run(
|
|
293
|
+
[adb_path, "-s", serial, *args],
|
|
294
|
+
check=True,
|
|
295
|
+
capture_output=True,
|
|
296
|
+
text=True,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
return await asyncio.to_thread(_run)
|
|
301
|
+
except AgentError:
|
|
302
|
+
raise
|
|
303
|
+
except FileNotFoundError as exc:
|
|
304
|
+
raise adb_not_found_error() from exc
|
|
305
|
+
except subprocess.CalledProcessError as exc:
|
|
306
|
+
reason = (exc.stderr or exc.stdout or str(exc)).strip()
|
|
307
|
+
raise adb_command_error(" ".join(args), reason) from exc
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _timestamp() -> str:
|
|
311
|
+
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reliability diagnostics module."""
|