droidrun 0.3.1__py3-none-any.whl → 0.3.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.
- droidrun/__init__.py +7 -12
- droidrun/agent/codeact/codeact_agent.py +9 -7
- droidrun/agent/common/events.py +44 -1
- droidrun/agent/context/personas/__init__.py +2 -2
- droidrun/agent/context/personas/big_agent.py +96 -0
- droidrun/agent/context/personas/ui_expert.py +1 -0
- droidrun/agent/droid/droid_agent.py +63 -11
- droidrun/agent/droid/events.py +4 -0
- droidrun/agent/planner/planner_agent.py +2 -2
- droidrun/agent/utils/executer.py +10 -2
- droidrun/agent/utils/llm_picker.py +1 -0
- droidrun/agent/utils/trajectory.py +258 -11
- droidrun/cli/main.py +179 -86
- droidrun/macro/__init__.py +14 -0
- droidrun/macro/__main__.py +10 -0
- droidrun/macro/cli.py +228 -0
- droidrun/macro/replay.py +309 -0
- droidrun/portal.py +138 -0
- droidrun/telemetry/__init__.py +4 -0
- droidrun/telemetry/events.py +27 -0
- droidrun/telemetry/tracker.py +84 -0
- droidrun/tools/adb.py +704 -372
- droidrun/tools/ios.py +169 -166
- droidrun/tools/tools.py +70 -17
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/METADATA +31 -29
- droidrun-0.3.3.dist-info/RECORD +54 -0
- droidrun/adb/__init__.py +0 -13
- droidrun/adb/device.py +0 -315
- droidrun/adb/manager.py +0 -93
- droidrun/adb/wrapper.py +0 -226
- droidrun/agent/context/personas/extractor.py +0 -52
- droidrun-0.3.1.dist-info/RECORD +0 -50
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/WHEEL +0 -0
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/entry_points.txt +0 -0
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/licenses/LICENSE +0 -0
droidrun/tools/adb.py
CHANGED
@@ -3,29 +3,50 @@ UI Actions - Core UI interaction tools for Android device control.
|
|
3
3
|
"""
|
4
4
|
|
5
5
|
import os
|
6
|
-
import
|
6
|
+
import io
|
7
7
|
import json
|
8
8
|
import time
|
9
|
-
import tempfile
|
10
|
-
import asyncio
|
11
|
-
import aiofiles
|
12
|
-
import contextlib
|
13
9
|
import logging
|
14
|
-
from
|
15
|
-
from
|
16
|
-
from droidrun.
|
10
|
+
from llama_index.core.workflow import Context
|
11
|
+
from typing_extensions import Optional, Dict, Tuple, List, Any, Type, Self
|
12
|
+
from droidrun.agent.common.events import (
|
13
|
+
InputTextActionEvent,
|
14
|
+
KeyPressActionEvent,
|
15
|
+
StartAppEvent,
|
16
|
+
SwipeActionEvent,
|
17
|
+
TapActionEvent,
|
18
|
+
DragActionEvent,
|
19
|
+
)
|
17
20
|
from droidrun.tools.tools import Tools
|
21
|
+
from adbutils import adb
|
22
|
+
import requests
|
23
|
+
import base64
|
24
|
+
|
25
|
+
logger = logging.getLogger("droidrun-tools")
|
18
26
|
|
19
|
-
logger = logging.getLogger("droidrun-adb-tools")
|
20
27
|
|
21
28
|
class AdbTools(Tools):
|
22
29
|
"""Core UI interaction tools for Android device control."""
|
23
30
|
|
24
|
-
def __init__(
|
31
|
+
def __init__(
|
32
|
+
self, serial: str | None = None, use_tcp: bool = False, tcp_port: int = 8080
|
33
|
+
) -> None:
|
34
|
+
"""Initialize the AdbTools instance.
|
35
|
+
|
36
|
+
Args:
|
37
|
+
serial: Device serial number
|
38
|
+
use_tcp: Whether to use TCP communication (default: False)
|
39
|
+
tcp_port: TCP port for communication (default: 8080)
|
40
|
+
"""
|
41
|
+
self.device = adb.device(serial=serial)
|
42
|
+
self.use_tcp = use_tcp
|
43
|
+
self.tcp_port = tcp_port
|
44
|
+
self.tcp_base_url = f"http://localhost:{tcp_port}"
|
45
|
+
self.tcp_forwarded = False
|
46
|
+
|
47
|
+
self._ctx = None
|
25
48
|
# Instance‐level cache for clickable elements (index-based tapping)
|
26
49
|
self.clickable_elements_cache: List[Dict[str, Any]] = []
|
27
|
-
self.serial = serial
|
28
|
-
self.device_manager = DeviceManager()
|
29
50
|
self.last_screenshot = None
|
30
51
|
self.reason = None
|
31
52
|
self.success = None
|
@@ -35,85 +56,113 @@ class AdbTools(Tools):
|
|
35
56
|
# Store all screenshots with timestamps
|
36
57
|
self.screenshots: List[Dict[str, Any]] = []
|
37
58
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
if self.serial:
|
42
|
-
return self.serial
|
59
|
+
# Set up TCP forwarding if requested
|
60
|
+
if self.use_tcp:
|
61
|
+
self.setup_tcp_forward()
|
43
62
|
|
44
|
-
|
45
|
-
"""
|
63
|
+
def setup_tcp_forward(self) -> bool:
|
64
|
+
"""
|
65
|
+
Set up ADB TCP port forwarding for communication with the portal app.
|
46
66
|
|
47
67
|
Returns:
|
48
|
-
|
68
|
+
bool: True if forwarding was set up successfully, False otherwise
|
49
69
|
"""
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
70
|
+
try:
|
71
|
+
logger.debug(
|
72
|
+
f"Setting up TCP port forwarding: tcp:{self.tcp_port} tcp:{self.tcp_port}"
|
73
|
+
)
|
74
|
+
# Use adb forward command to set up port forwarding
|
75
|
+
result = self.device.forward(f"tcp:{self.tcp_port}", f"tcp:{self.tcp_port}")
|
76
|
+
self.tcp_forwarded = True
|
77
|
+
logger.debug(f"TCP port forwarding set up successfully: {result}")
|
57
78
|
|
58
|
-
|
79
|
+
# Test the connection with a ping
|
80
|
+
try:
|
81
|
+
response = requests.get(f"{self.tcp_base_url}/ping", timeout=5)
|
82
|
+
if response.status_code == 200:
|
83
|
+
logger.debug("TCP connection test successful")
|
84
|
+
return True
|
85
|
+
else:
|
86
|
+
logger.warning(
|
87
|
+
f"TCP connection test failed with status: {response.status_code}"
|
88
|
+
)
|
89
|
+
return False
|
90
|
+
except requests.exceptions.RequestException as e:
|
91
|
+
logger.warning(f"TCP connection test failed: {e}")
|
92
|
+
return False
|
59
93
|
|
60
|
-
|
61
|
-
|
94
|
+
except Exception as e:
|
95
|
+
logger.error(f"Failed to set up TCP port forwarding: {e}")
|
96
|
+
self.tcp_forwarded = False
|
97
|
+
return False
|
62
98
|
|
63
|
-
|
64
|
-
|
99
|
+
def teardown_tcp_forward(self) -> bool:
|
100
|
+
"""
|
101
|
+
Remove ADB TCP port forwarding.
|
65
102
|
|
66
103
|
Returns:
|
67
|
-
|
104
|
+
bool: True if forwarding was removed successfully, False otherwise
|
68
105
|
"""
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
106
|
+
try:
|
107
|
+
if self.tcp_forwarded:
|
108
|
+
logger.debug(f"Removing TCP port forwarding for port {self.tcp_port}")
|
109
|
+
result = self.device.forward_remove(f"tcp:{self.tcp_port}")
|
110
|
+
self.tcp_forwarded = False
|
111
|
+
logger.debug(f"TCP port forwarding removed: {result}")
|
112
|
+
return True
|
113
|
+
return True
|
114
|
+
except Exception as e:
|
115
|
+
logger.error(f"Failed to remove TCP port forwarding: {e}")
|
116
|
+
return False
|
117
|
+
|
118
|
+
def __del__(self):
|
119
|
+
"""Cleanup when the object is destroyed."""
|
120
|
+
if hasattr(self, "tcp_forwarded") and self.tcp_forwarded:
|
121
|
+
self.teardown_tcp_forward()
|
78
122
|
|
79
|
-
def
|
123
|
+
def _set_context(self, ctx: Context):
|
124
|
+
self._ctx = ctx
|
125
|
+
|
126
|
+
def _parse_content_provider_output(
|
127
|
+
self, raw_output: str
|
128
|
+
) -> Optional[Dict[str, Any]]:
|
80
129
|
"""
|
81
130
|
Parse the raw ADB content provider output and extract JSON data.
|
82
|
-
|
131
|
+
|
83
132
|
Args:
|
84
133
|
raw_output (str): Raw output from ADB content query command
|
85
|
-
|
134
|
+
|
86
135
|
Returns:
|
87
136
|
dict: Parsed JSON data or None if parsing failed
|
88
137
|
"""
|
89
138
|
# The ADB content query output format is: "Row: 0 result={json_data}"
|
90
139
|
# We need to extract the JSON part after "result="
|
91
|
-
lines = raw_output.strip().split(
|
92
|
-
|
140
|
+
lines = raw_output.strip().split("\n")
|
141
|
+
|
93
142
|
for line in lines:
|
94
143
|
line = line.strip()
|
95
|
-
|
144
|
+
|
96
145
|
# Look for lines that contain "result=" pattern
|
97
146
|
if "result=" in line:
|
98
147
|
# Extract everything after "result="
|
99
148
|
result_start = line.find("result=") + 7
|
100
149
|
json_str = line[result_start:]
|
101
|
-
|
150
|
+
|
102
151
|
try:
|
103
152
|
# Parse the JSON string
|
104
153
|
json_data = json.loads(json_str)
|
105
154
|
return json_data
|
106
155
|
except json.JSONDecodeError:
|
107
156
|
continue
|
108
|
-
|
157
|
+
|
109
158
|
# Fallback: try to parse lines that start with { or [
|
110
|
-
elif line.startswith(
|
159
|
+
elif line.startswith("{") or line.startswith("["):
|
111
160
|
try:
|
112
161
|
json_data = json.loads(line)
|
113
162
|
return json_data
|
114
163
|
except json.JSONDecodeError:
|
115
164
|
continue
|
116
|
-
|
165
|
+
|
117
166
|
# If no valid JSON found in individual lines, try the entire output
|
118
167
|
try:
|
119
168
|
json_data = json.loads(raw_output.strip())
|
@@ -121,7 +170,7 @@ class AdbTools(Tools):
|
|
121
170
|
except json.JSONDecodeError:
|
122
171
|
return None
|
123
172
|
|
124
|
-
|
173
|
+
def tap_by_index(self, index: int) -> str:
|
125
174
|
"""
|
126
175
|
Tap on a UI element by its index.
|
127
176
|
|
@@ -193,18 +242,32 @@ class AdbTools(Tools):
|
|
193
242
|
x = (left + right) // 2
|
194
243
|
y = (top + bottom) // 2
|
195
244
|
|
245
|
+
logger.debug(
|
246
|
+
f"Tapping element with index {index} at coordinates ({x}, {y})"
|
247
|
+
)
|
196
248
|
# Get the device and tap at the coordinates
|
197
|
-
|
198
|
-
|
199
|
-
if not device:
|
200
|
-
return f"Error: Device {serial} not found"
|
201
|
-
else:
|
202
|
-
device = await self.get_device()
|
249
|
+
self.device.click(x, y)
|
250
|
+
logger.debug(f"Tapped element with index {index} at coordinates ({x}, {y})")
|
203
251
|
|
204
|
-
|
252
|
+
# Emit coordinate action event for trajectory recording
|
253
|
+
|
254
|
+
if self._ctx:
|
255
|
+
element_text = element.get("text", "No text")
|
256
|
+
element_class = element.get("className", "Unknown class")
|
257
|
+
|
258
|
+
tap_event = TapActionEvent(
|
259
|
+
action_type="tap",
|
260
|
+
description=f"Tap element at index {index}: '{element_text}' ({element_class}) at coordinates ({x}, {y})",
|
261
|
+
x=x,
|
262
|
+
y=y,
|
263
|
+
element_index=index,
|
264
|
+
element_text=element_text,
|
265
|
+
element_bounds=bounds_str,
|
266
|
+
)
|
267
|
+
self._ctx.write_event_to_stream(tap_event)
|
205
268
|
|
206
269
|
# Add a small delay to allow UI to update
|
207
|
-
|
270
|
+
time.sleep(0.5)
|
208
271
|
|
209
272
|
# Create a descriptive response
|
210
273
|
response_parts = []
|
@@ -229,7 +292,7 @@ class AdbTools(Tools):
|
|
229
292
|
return f"Error: {str(e)}"
|
230
293
|
|
231
294
|
# Rename the old tap function to tap_by_coordinates for backward compatibility
|
232
|
-
|
295
|
+
def tap_by_coordinates(self, x: int, y: int) -> bool:
|
233
296
|
"""
|
234
297
|
Tap on the device screen at specific coordinates.
|
235
298
|
|
@@ -241,22 +304,16 @@ class AdbTools(Tools):
|
|
241
304
|
Bool indicating success or failure
|
242
305
|
"""
|
243
306
|
try:
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
return f"Error: Device {self.serial} not found"
|
248
|
-
else:
|
249
|
-
device = await self.get_device()
|
250
|
-
|
251
|
-
await device.tap(x, y)
|
252
|
-
print(f"Tapped at coordinates ({x}, {y})")
|
307
|
+
logger.debug(f"Tapping at coordinates ({x}, {y})")
|
308
|
+
self.device.click(x, y)
|
309
|
+
logger.debug(f"Tapped at coordinates ({x}, {y})")
|
253
310
|
return True
|
254
311
|
except ValueError as e:
|
255
|
-
|
312
|
+
logger.debug(f"Error: {str(e)}")
|
256
313
|
return False
|
257
314
|
|
258
315
|
# Replace the old tap function with the new one
|
259
|
-
|
316
|
+
def tap(self, index: int) -> str:
|
260
317
|
"""
|
261
318
|
Tap on a UI element by its index.
|
262
319
|
|
@@ -269,10 +326,15 @@ class AdbTools(Tools):
|
|
269
326
|
Returns:
|
270
327
|
Result message
|
271
328
|
"""
|
272
|
-
return
|
273
|
-
|
274
|
-
|
275
|
-
self,
|
329
|
+
return self.tap_by_index(index)
|
330
|
+
|
331
|
+
def swipe(
|
332
|
+
self,
|
333
|
+
start_x: int,
|
334
|
+
start_y: int,
|
335
|
+
end_x: int,
|
336
|
+
end_y: int,
|
337
|
+
duration_ms: float = 300,
|
276
338
|
) -> bool:
|
277
339
|
"""
|
278
340
|
Performs a straight-line swipe gesture on the device screen.
|
@@ -282,27 +344,76 @@ class AdbTools(Tools):
|
|
282
344
|
start_y: Starting Y coordinate
|
283
345
|
end_x: Ending X coordinate
|
284
346
|
end_y: Ending Y coordinate
|
285
|
-
|
347
|
+
duration: Duration of swipe in seconds
|
286
348
|
Returns:
|
287
349
|
Bool indicating success or failure
|
288
350
|
"""
|
289
351
|
try:
|
290
|
-
if self.serial:
|
291
|
-
device = await self.device_manager.get_device(self.serial)
|
292
|
-
if not device:
|
293
|
-
return f"Error: Device {self.serial} not found"
|
294
|
-
else:
|
295
|
-
device = await self.get_device()
|
296
352
|
|
297
|
-
|
298
|
-
|
299
|
-
|
353
|
+
if self._ctx:
|
354
|
+
swipe_event = SwipeActionEvent(
|
355
|
+
action_type="swipe",
|
356
|
+
description=f"Swipe from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms} milliseconds",
|
357
|
+
start_x=start_x,
|
358
|
+
start_y=start_y,
|
359
|
+
end_x=end_x,
|
360
|
+
end_y=end_y,
|
361
|
+
duration_ms=duration_ms,
|
362
|
+
)
|
363
|
+
self._ctx.write_event_to_stream(swipe_event)
|
364
|
+
|
365
|
+
self.device.swipe(start_x, start_y, end_x, end_y, float(duration_ms / 1000))
|
366
|
+
time.sleep(duration_ms / 1000)
|
367
|
+
logger.debug(
|
368
|
+
f"Swiped from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms} milliseconds"
|
369
|
+
)
|
370
|
+
return True
|
371
|
+
except ValueError as e:
|
372
|
+
print(f"Error: {str(e)}")
|
373
|
+
return False
|
374
|
+
|
375
|
+
def drag(
|
376
|
+
self, start_x: int, start_y: int, end_x: int, end_y: int, duration: float = 3
|
377
|
+
) -> bool:
|
378
|
+
"""
|
379
|
+
Performs a straight-line drag and drop gesture on the device screen.
|
380
|
+
Args:
|
381
|
+
start_x: Starting X coordinate
|
382
|
+
start_y: Starting Y coordinate
|
383
|
+
end_x: Ending X coordinate
|
384
|
+
end_y: Ending Y coordinate
|
385
|
+
duration: Duration of swipe in seconds
|
386
|
+
Returns:
|
387
|
+
Bool indicating success or failure
|
388
|
+
"""
|
389
|
+
try:
|
390
|
+
logger.debug(
|
391
|
+
f"Dragging from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration} seconds"
|
392
|
+
)
|
393
|
+
self.device.drag(start_x, start_y, end_x, end_y, duration)
|
394
|
+
|
395
|
+
if self._ctx:
|
396
|
+
drag_event = DragActionEvent(
|
397
|
+
action_type="drag",
|
398
|
+
description=f"Drag from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration} seconds",
|
399
|
+
start_x=start_x,
|
400
|
+
start_y=start_y,
|
401
|
+
end_x=end_x,
|
402
|
+
end_y=end_y,
|
403
|
+
duration=duration,
|
404
|
+
)
|
405
|
+
self._ctx.write_event_to_stream(drag_event)
|
406
|
+
|
407
|
+
time.sleep(duration)
|
408
|
+
logger.debug(
|
409
|
+
f"Dragged from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration} seconds"
|
410
|
+
)
|
300
411
|
return True
|
301
412
|
except ValueError as e:
|
302
413
|
print(f"Error: {str(e)}")
|
303
414
|
return False
|
304
415
|
|
305
|
-
|
416
|
+
def input_text(self, text: str) -> str:
|
306
417
|
"""
|
307
418
|
Input text on the device.
|
308
419
|
Always make sure that the Focused Element is not None before inputting text.
|
@@ -314,72 +425,105 @@ class AdbTools(Tools):
|
|
314
425
|
Result message
|
315
426
|
"""
|
316
427
|
try:
|
317
|
-
|
318
|
-
device = await self.device_manager.get_device(serial)
|
319
|
-
if not device:
|
320
|
-
return f"Error: Device {serial} not found"
|
321
|
-
else:
|
322
|
-
device = await self.get_device()
|
323
|
-
|
324
|
-
# Save the current keyboard
|
325
|
-
original_ime = await device._adb.shell(
|
326
|
-
device._serial, "settings get secure default_input_method"
|
327
|
-
)
|
328
|
-
original_ime = original_ime.strip()
|
329
|
-
|
330
|
-
# Enable the Droidrun keyboard
|
331
|
-
await device._adb.shell(
|
332
|
-
device._serial, "ime enable com.droidrun.portal/.DroidrunKeyboardIME"
|
333
|
-
)
|
334
|
-
|
335
|
-
# Set the Droidrun keyboard as the default
|
336
|
-
await device._adb.shell(
|
337
|
-
device._serial, "ime set com.droidrun.portal/.DroidrunKeyboardIME"
|
338
|
-
)
|
428
|
+
logger.debug(f"Inputting text: {text}")
|
339
429
|
|
340
|
-
|
341
|
-
|
430
|
+
if self.use_tcp and self.tcp_forwarded:
|
431
|
+
# Use TCP communication
|
432
|
+
encoded_text = base64.b64encode(text.encode()).decode()
|
342
433
|
|
343
|
-
|
344
|
-
|
434
|
+
payload = {"base64_text": encoded_text}
|
435
|
+
response = requests.post(
|
436
|
+
f"{self.tcp_base_url}/keyboard/input",
|
437
|
+
json=payload,
|
438
|
+
headers={"Content-Type": "application/json"},
|
439
|
+
timeout=10,
|
440
|
+
)
|
345
441
|
|
346
|
-
|
442
|
+
logger.debug(
|
443
|
+
f"Keyboard input TCP response: {response.status_code}, {response.text}"
|
444
|
+
)
|
347
445
|
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
# Wait for text input to complete
|
352
|
-
await asyncio.sleep(0.5)
|
353
|
-
|
354
|
-
# Restore the original keyboard
|
355
|
-
if original_ime and "com.droidrun.portal" not in original_ime:
|
356
|
-
await device._adb.shell(device._serial, f"ime set {original_ime}")
|
446
|
+
if response.status_code != 200:
|
447
|
+
return f"Error: HTTP request failed with status {response.status_code}: {response.text}"
|
357
448
|
|
449
|
+
else:
|
450
|
+
# Fallback to content provider method
|
451
|
+
# Save the current keyboard
|
452
|
+
original_ime = self.device.shell(
|
453
|
+
"settings get secure default_input_method"
|
454
|
+
)
|
455
|
+
original_ime = original_ime.strip()
|
456
|
+
|
457
|
+
# Enable the Droidrun keyboard
|
458
|
+
self.device.shell("ime enable com.droidrun.portal/.DroidrunKeyboardIME")
|
459
|
+
|
460
|
+
# Set the Droidrun keyboard as the default
|
461
|
+
self.device.shell("ime set com.droidrun.portal/.DroidrunKeyboardIME")
|
462
|
+
|
463
|
+
# Wait for keyboard to change
|
464
|
+
time.sleep(1)
|
465
|
+
|
466
|
+
# Encode the text to Base64
|
467
|
+
encoded_text = base64.b64encode(text.encode()).decode()
|
468
|
+
|
469
|
+
cmd = f'content insert --uri "content://com.droidrun.portal/keyboard/input" --bind base64_text:s:"{encoded_text}"'
|
470
|
+
self.device.shell(cmd)
|
471
|
+
|
472
|
+
# Wait for text input to complete
|
473
|
+
time.sleep(0.5)
|
474
|
+
|
475
|
+
# Restore the original keyboard
|
476
|
+
if original_ime and "com.droidrun.portal" not in original_ime:
|
477
|
+
self.device.shell(f"ime set {original_ime}")
|
478
|
+
|
479
|
+
logger.debug(
|
480
|
+
f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
|
481
|
+
)
|
482
|
+
return f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
|
483
|
+
|
484
|
+
if self._ctx:
|
485
|
+
input_event = InputTextActionEvent(
|
486
|
+
action_type="input_text",
|
487
|
+
description=f"Input text: '{text[:50]}{'...' if len(text) > 50 else ''}'",
|
488
|
+
text=text,
|
489
|
+
)
|
490
|
+
self._ctx.write_event_to_stream(input_event)
|
491
|
+
|
492
|
+
logger.debug(
|
493
|
+
f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
|
494
|
+
)
|
358
495
|
return f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
|
496
|
+
|
497
|
+
except requests.exceptions.RequestException as e:
|
498
|
+
return f"Error: TCP request failed: {str(e)}"
|
359
499
|
except ValueError as e:
|
360
500
|
return f"Error: {str(e)}"
|
361
501
|
except Exception as e:
|
362
502
|
return f"Error sending text input: {str(e)}"
|
363
503
|
|
364
|
-
|
504
|
+
def back(self) -> str:
|
365
505
|
"""
|
366
506
|
Go back on the current view.
|
367
507
|
This presses the Android back button.
|
368
508
|
"""
|
369
509
|
try:
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
510
|
+
logger.debug("Pressing key BACK")
|
511
|
+
self.device.keyevent(3)
|
512
|
+
|
513
|
+
if self._ctx:
|
514
|
+
key_event = KeyPressActionEvent(
|
515
|
+
action_type="key_press",
|
516
|
+
description=f"Pressed key BACK",
|
517
|
+
keycode=3,
|
518
|
+
key_name="BACK",
|
519
|
+
)
|
520
|
+
self._ctx.write_event_to_stream(key_event)
|
376
521
|
|
377
|
-
await device.press_key(3)
|
378
522
|
return f"Pressed key BACK"
|
379
523
|
except ValueError as e:
|
380
524
|
return f"Error: {str(e)}"
|
381
525
|
|
382
|
-
|
526
|
+
def press_key(self, keycode: int) -> str:
|
383
527
|
"""
|
384
528
|
Press a key on the Android device.
|
385
529
|
|
@@ -393,13 +537,6 @@ class AdbTools(Tools):
|
|
393
537
|
keycode: Android keycode to press
|
394
538
|
"""
|
395
539
|
try:
|
396
|
-
if self.serial:
|
397
|
-
device = await self.device_manager.get_device(self.serial)
|
398
|
-
if not device:
|
399
|
-
return f"Error: Device {self.serial} not found"
|
400
|
-
else:
|
401
|
-
device = await self.get_device()
|
402
|
-
|
403
540
|
key_names = {
|
404
541
|
66: "ENTER",
|
405
542
|
4: "BACK",
|
@@ -408,12 +545,23 @@ class AdbTools(Tools):
|
|
408
545
|
}
|
409
546
|
key_name = key_names.get(keycode, str(keycode))
|
410
547
|
|
411
|
-
|
548
|
+
if self._ctx:
|
549
|
+
key_event = KeyPressActionEvent(
|
550
|
+
action_type="key_press",
|
551
|
+
description=f"Pressed key {key_name}",
|
552
|
+
keycode=keycode,
|
553
|
+
key_name=key_name,
|
554
|
+
)
|
555
|
+
self._ctx.write_event_to_stream(key_event)
|
556
|
+
|
557
|
+
logger.debug(f"Pressing key {key_name}")
|
558
|
+
self.device.keyevent(keycode)
|
559
|
+
logger.debug(f"Pressed key {key_name}")
|
412
560
|
return f"Pressed key {key_name}"
|
413
561
|
except ValueError as e:
|
414
562
|
return f"Error: {str(e)}"
|
415
563
|
|
416
|
-
|
564
|
+
def start_app(self, package: str, activity: str | None = None) -> str:
|
417
565
|
"""
|
418
566
|
Start an app on the device.
|
419
567
|
|
@@ -422,19 +570,32 @@ class AdbTools(Tools):
|
|
422
570
|
activity: Optional activity name
|
423
571
|
"""
|
424
572
|
try:
|
425
|
-
if self.serial:
|
426
|
-
device = await self.device_manager.get_device(self.serial)
|
427
|
-
if not device:
|
428
|
-
return f"Error: Device {self.serial} not found"
|
429
|
-
else:
|
430
|
-
device = await self.get_device()
|
431
573
|
|
432
|
-
|
433
|
-
|
434
|
-
|
574
|
+
logger.debug(f"Starting app {package} with activity {activity}")
|
575
|
+
if not activity:
|
576
|
+
dumpsys_output = self.device.shell(
|
577
|
+
f"cmd package resolve-activity --brief {package}"
|
578
|
+
)
|
579
|
+
activity = dumpsys_output.splitlines()[1].split("/")[1]
|
580
|
+
|
581
|
+
if self._ctx:
|
582
|
+
start_app_event = StartAppEvent(
|
583
|
+
action_type="start_app",
|
584
|
+
description=f"Start app {package}",
|
585
|
+
package=package,
|
586
|
+
activity=activity,
|
587
|
+
)
|
588
|
+
self._ctx.write_event_to_stream(start_app_event)
|
589
|
+
|
590
|
+
print(f"Activity: {activity}")
|
591
|
+
|
592
|
+
self.device.app_start(package, activity)
|
593
|
+
logger.debug(f"App started: {package} with activity {activity}")
|
594
|
+
return f"App started: {package} with activity {activity}"
|
595
|
+
except Exception as e:
|
435
596
|
return f"Error: {str(e)}"
|
436
597
|
|
437
|
-
|
598
|
+
def install_app(
|
438
599
|
self, apk_path: str, reinstall: bool = False, grant_permissions: bool = True
|
439
600
|
) -> str:
|
440
601
|
"""
|
@@ -446,50 +607,94 @@ class AdbTools(Tools):
|
|
446
607
|
grant_permissions: Whether to grant all permissions
|
447
608
|
"""
|
448
609
|
try:
|
449
|
-
if self.serial:
|
450
|
-
device = await self.device_manager.get_device(self.serial)
|
451
|
-
if not device:
|
452
|
-
return f"Error: Device {self.serial} not found"
|
453
|
-
else:
|
454
|
-
device = await self.get_device()
|
455
|
-
|
456
610
|
if not os.path.exists(apk_path):
|
457
611
|
return f"Error: APK file not found at {apk_path}"
|
458
612
|
|
459
|
-
|
613
|
+
logger.debug(
|
614
|
+
f"Installing app: {apk_path} with reinstall: {reinstall} and grant_permissions: {grant_permissions}"
|
615
|
+
)
|
616
|
+
result = self.device.install(
|
617
|
+
apk_path,
|
618
|
+
nolaunch=True,
|
619
|
+
uninstall=reinstall,
|
620
|
+
flags=["-g"] if grant_permissions else [],
|
621
|
+
silent=True,
|
622
|
+
)
|
623
|
+
logger.debug(f"Installed app: {apk_path} with result: {result}")
|
460
624
|
return result
|
461
625
|
except ValueError as e:
|
462
626
|
return f"Error: {str(e)}"
|
463
627
|
|
464
|
-
|
628
|
+
def take_screenshot(self) -> Tuple[str, bytes]:
|
465
629
|
"""
|
466
630
|
Take a screenshot of the device.
|
467
631
|
This function captures the current screen and adds the screenshot to context in the next message.
|
468
632
|
Also stores the screenshot in the screenshots list with timestamp for later GIF creation.
|
469
633
|
"""
|
470
634
|
try:
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
635
|
+
logger.debug("Taking screenshot")
|
636
|
+
|
637
|
+
if self.use_tcp and self.tcp_forwarded:
|
638
|
+
# Use TCP communication
|
639
|
+
response = requests.get(f"{self.tcp_base_url}/screenshot", timeout=15)
|
640
|
+
|
641
|
+
if response.status_code == 200:
|
642
|
+
tcp_response = response.json()
|
643
|
+
|
644
|
+
# Check if response has the expected format with data field
|
645
|
+
if isinstance(tcp_response, dict) and "data" in tcp_response:
|
646
|
+
base64_data = tcp_response["data"]
|
647
|
+
try:
|
648
|
+
# Decode base64 to get image bytes
|
649
|
+
image_bytes = base64.b64decode(base64_data)
|
650
|
+
img_format = "PNG" # Assuming PNG format from TCP endpoint
|
651
|
+
logger.debug("Screenshot taken via TCP")
|
652
|
+
except Exception as e:
|
653
|
+
raise ValueError(
|
654
|
+
f"Failed to decode base64 screenshot data: {str(e)}"
|
655
|
+
)
|
656
|
+
else:
|
657
|
+
# Fallback: assume direct base64 format
|
658
|
+
try:
|
659
|
+
image_bytes = base64.b64decode(tcp_response)
|
660
|
+
img_format = "PNG"
|
661
|
+
logger.debug("Screenshot taken via TCP (direct base64)")
|
662
|
+
except Exception as e:
|
663
|
+
raise ValueError(
|
664
|
+
f"Failed to decode screenshot response: {str(e)}"
|
665
|
+
)
|
666
|
+
else:
|
667
|
+
raise ValueError(
|
668
|
+
f"HTTP request failed with status {response.status_code}: {response.text}"
|
669
|
+
)
|
670
|
+
|
475
671
|
else:
|
476
|
-
|
477
|
-
|
478
|
-
|
672
|
+
# Fallback to ADB screenshot method
|
673
|
+
img = self.device.screenshot()
|
674
|
+
img_buf = io.BytesIO()
|
675
|
+
img_format = "PNG"
|
676
|
+
img.save(img_buf, format=img_format)
|
677
|
+
image_bytes = img_buf.getvalue()
|
678
|
+
logger.debug("Screenshot taken via ADB")
|
479
679
|
|
480
680
|
# Store screenshot with timestamp
|
481
681
|
self.screenshots.append(
|
482
682
|
{
|
483
683
|
"timestamp": time.time(),
|
484
|
-
"image_data":
|
485
|
-
"format":
|
684
|
+
"image_data": image_bytes,
|
685
|
+
"format": img_format,
|
486
686
|
}
|
487
687
|
)
|
488
|
-
return
|
688
|
+
return img_format, image_bytes
|
689
|
+
|
690
|
+
except requests.exceptions.RequestException as e:
|
691
|
+
raise ValueError(f"Error taking screenshot via TCP: {str(e)}")
|
489
692
|
except ValueError as e:
|
490
693
|
raise ValueError(f"Error taking screenshot: {str(e)}")
|
694
|
+
except Exception as e:
|
695
|
+
raise ValueError(f"Unexpected error taking screenshot: {str(e)}")
|
491
696
|
|
492
|
-
|
697
|
+
def list_packages(self, include_system_apps: bool = False) -> List[str]:
|
493
698
|
"""
|
494
699
|
List installed packages on the device.
|
495
700
|
|
@@ -500,163 +705,11 @@ class AdbTools(Tools):
|
|
500
705
|
List of package names
|
501
706
|
"""
|
502
707
|
try:
|
503
|
-
|
504
|
-
|
505
|
-
if not device:
|
506
|
-
raise ValueError(f"Device {self.serial} not found")
|
507
|
-
else:
|
508
|
-
device = await self.get_device()
|
509
|
-
|
510
|
-
# Use the direct ADB command to get packages with paths
|
511
|
-
cmd = ["pm", "list", "packages", "-f"]
|
512
|
-
if not include_system_apps:
|
513
|
-
cmd.append("-3")
|
514
|
-
|
515
|
-
output = await device._adb.shell(device._serial, " ".join(cmd))
|
516
|
-
|
517
|
-
# Parse the package list using the function
|
518
|
-
packages = self.parse_package_list(output)
|
519
|
-
# Format package list for better readability
|
520
|
-
package_list = [pack["package"] for pack in packages]
|
521
|
-
for package in package_list:
|
522
|
-
print(package)
|
523
|
-
return package_list
|
708
|
+
logger.debug("Listing packages")
|
709
|
+
return self.device.list_packages(["-3"] if not include_system_apps else [])
|
524
710
|
except ValueError as e:
|
525
711
|
raise ValueError(f"Error listing packages: {str(e)}")
|
526
712
|
|
527
|
-
async def extract(self, filename: Optional[str] = None) -> str:
|
528
|
-
"""Extract and save the current UI state to a JSON file.
|
529
|
-
|
530
|
-
This function captures the current UI state including all UI elements
|
531
|
-
and saves it to a JSON file for later analysis or reference.
|
532
|
-
|
533
|
-
Args:
|
534
|
-
filename: Optional filename to save the UI state (defaults to ui_state_TIMESTAMP.json)
|
535
|
-
|
536
|
-
Returns:
|
537
|
-
Path to the saved JSON file
|
538
|
-
"""
|
539
|
-
try:
|
540
|
-
# Generate default filename if not provided
|
541
|
-
if not filename:
|
542
|
-
timestamp = int(time.time())
|
543
|
-
filename = f"ui_state_{timestamp}.json"
|
544
|
-
|
545
|
-
# Ensure the filename ends with .json
|
546
|
-
if not filename.endswith(".json"):
|
547
|
-
filename += ".json"
|
548
|
-
|
549
|
-
# Get the UI elements
|
550
|
-
ui_elements = await self.get_all_elements(self.serial)
|
551
|
-
|
552
|
-
# Save to file
|
553
|
-
save_path = os.path.abspath(filename)
|
554
|
-
async with aiofiles.open(save_path, "w", encoding="utf-8") as f:
|
555
|
-
await f.write(json.dumps(ui_elements, indent=2))
|
556
|
-
|
557
|
-
return f"UI state extracted and saved to {save_path}"
|
558
|
-
|
559
|
-
except Exception as e:
|
560
|
-
return f"Error extracting UI state: {e}"
|
561
|
-
|
562
|
-
async def get_all_elements(self) -> Dict[str, Any]:
|
563
|
-
"""
|
564
|
-
Get all UI elements from the device, including non-interactive elements.
|
565
|
-
|
566
|
-
This function interacts with the TopViewService app installed on the device
|
567
|
-
to capture all UI elements, even those that are not interactive. This provides
|
568
|
-
a complete view of the UI hierarchy for analysis or debugging purposes.
|
569
|
-
|
570
|
-
Returns:
|
571
|
-
Dictionary containing all UI elements extracted from the device screen
|
572
|
-
"""
|
573
|
-
try:
|
574
|
-
# Get the device
|
575
|
-
device = await self.device_manager.get_device(self.serial)
|
576
|
-
if not device:
|
577
|
-
raise ValueError(f"Device {self.serial} not found")
|
578
|
-
|
579
|
-
# Create a temporary file for the JSON
|
580
|
-
with tempfile.NamedTemporaryFile(suffix=".json") as temp:
|
581
|
-
local_path = temp.name
|
582
|
-
|
583
|
-
try:
|
584
|
-
# Clear logcat to make it easier to find our output
|
585
|
-
await device._adb.shell(device._serial, "logcat -c")
|
586
|
-
|
587
|
-
# Trigger the custom service via broadcast to get ALL elements
|
588
|
-
await device._adb.shell(
|
589
|
-
device._serial,
|
590
|
-
"am broadcast -a com.droidrun.portal.GET_ALL_ELEMENTS",
|
591
|
-
)
|
592
|
-
|
593
|
-
# Poll for the JSON file path
|
594
|
-
start_time = asyncio.get_event_loop().time()
|
595
|
-
max_wait_time = 10 # Maximum wait time in seconds
|
596
|
-
poll_interval = 0.2 # Check every 200ms
|
597
|
-
|
598
|
-
device_path = None
|
599
|
-
while asyncio.get_event_loop().time() - start_time < max_wait_time:
|
600
|
-
# Check logcat for the file path
|
601
|
-
logcat_output = await device._adb.shell(
|
602
|
-
device._serial,
|
603
|
-
'logcat -d | grep "DROIDRUN_FILE" | grep "JSON data written to" | tail -1',
|
604
|
-
)
|
605
|
-
|
606
|
-
# Parse the file path if present
|
607
|
-
match = re.search(r"JSON data written to: (.*)", logcat_output)
|
608
|
-
if match:
|
609
|
-
device_path = match.group(1).strip()
|
610
|
-
break
|
611
|
-
|
612
|
-
# Wait before polling again
|
613
|
-
await asyncio.sleep(poll_interval)
|
614
|
-
|
615
|
-
# Check if we found the file path
|
616
|
-
if not device_path:
|
617
|
-
raise ValueError(
|
618
|
-
f"Failed to find the JSON file path in logcat after {max_wait_time} seconds"
|
619
|
-
)
|
620
|
-
|
621
|
-
logger.debug(f"Pulling file from {device_path} to {local_path}")
|
622
|
-
# Pull the JSON file from the device
|
623
|
-
await device._adb.pull_file(device._serial, device_path, local_path)
|
624
|
-
|
625
|
-
# Read the JSON file
|
626
|
-
async with aiofiles.open(local_path, "r", encoding="utf-8") as f:
|
627
|
-
json_content = await f.read()
|
628
|
-
|
629
|
-
# Clean up the temporary file
|
630
|
-
with contextlib.suppress(OSError):
|
631
|
-
os.unlink(local_path)
|
632
|
-
|
633
|
-
# Try to parse the JSON
|
634
|
-
import json
|
635
|
-
|
636
|
-
try:
|
637
|
-
ui_data = json.loads(json_content)
|
638
|
-
|
639
|
-
return {
|
640
|
-
"all_elements": ui_data,
|
641
|
-
"count": (
|
642
|
-
len(ui_data)
|
643
|
-
if isinstance(ui_data, list)
|
644
|
-
else sum(1 for _ in ui_data.get("elements", []))
|
645
|
-
),
|
646
|
-
"message": "Retrieved all UI elements from the device screen",
|
647
|
-
}
|
648
|
-
except json.JSONDecodeError:
|
649
|
-
raise ValueError("Failed to parse UI elements JSON data")
|
650
|
-
|
651
|
-
except Exception as e:
|
652
|
-
# Clean up in case of error
|
653
|
-
with contextlib.suppress(OSError):
|
654
|
-
os.unlink(local_path)
|
655
|
-
raise ValueError(f"Error retrieving all UI elements: {e}")
|
656
|
-
|
657
|
-
except Exception as e:
|
658
|
-
raise ValueError(f"Error getting all UI elements: {e}")
|
659
|
-
|
660
713
|
def complete(self, success: bool, reason: str = ""):
|
661
714
|
"""
|
662
715
|
Mark the task as finished.
|
@@ -676,7 +729,7 @@ class AdbTools(Tools):
|
|
676
729
|
self.reason = reason
|
677
730
|
self.finished = True
|
678
731
|
|
679
|
-
|
732
|
+
def remember(self, information: str) -> str:
|
680
733
|
"""
|
681
734
|
Store important information to remember for future context.
|
682
735
|
|
@@ -712,7 +765,7 @@ class AdbTools(Tools):
|
|
712
765
|
"""
|
713
766
|
return self.memory.copy()
|
714
767
|
|
715
|
-
|
768
|
+
def get_state(self, serial: Optional[str] = None) -> Dict[str, Any]:
|
716
769
|
"""
|
717
770
|
Get both the a11y tree and phone state in a single call using the combined /state endpoint.
|
718
771
|
|
@@ -722,54 +775,75 @@ class AdbTools(Tools):
|
|
722
775
|
Returns:
|
723
776
|
Dictionary containing both 'a11y_tree' and 'phone_state' data
|
724
777
|
"""
|
725
|
-
|
778
|
+
|
726
779
|
try:
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
780
|
+
logger.debug("Getting state")
|
781
|
+
|
782
|
+
if self.use_tcp and self.tcp_forwarded:
|
783
|
+
# Use TCP communication
|
784
|
+
response = requests.get(f"{self.tcp_base_url}/state", timeout=10)
|
785
|
+
|
786
|
+
if response.status_code == 200:
|
787
|
+
tcp_response = response.json()
|
788
|
+
|
789
|
+
# Check if response has the expected format
|
790
|
+
if isinstance(tcp_response, dict) and "data" in tcp_response:
|
791
|
+
data_str = tcp_response["data"]
|
792
|
+
try:
|
793
|
+
combined_data = json.loads(data_str)
|
794
|
+
except json.JSONDecodeError:
|
795
|
+
return {
|
796
|
+
"error": "Parse Error",
|
797
|
+
"message": "Failed to parse JSON data from TCP response data field",
|
798
|
+
}
|
799
|
+
else:
|
800
|
+
# Fallback: assume direct JSON format
|
801
|
+
combined_data = tcp_response
|
802
|
+
else:
|
803
|
+
return {
|
804
|
+
"error": "HTTP Error",
|
805
|
+
"message": f"HTTP request failed with status {response.status_code}",
|
806
|
+
}
|
731
807
|
else:
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
'content query --uri content://com.droidrun.portal/state'
|
737
|
-
)
|
808
|
+
# Fallback to content provider method
|
809
|
+
adb_output = self.device.shell(
|
810
|
+
"content query --uri content://com.droidrun.portal/state",
|
811
|
+
)
|
738
812
|
|
739
|
-
|
740
|
-
|
741
|
-
if state_data is None:
|
742
|
-
return {
|
743
|
-
"error": "Parse Error",
|
744
|
-
"message": "Failed to parse state data from ContentProvider response"
|
745
|
-
}
|
813
|
+
state_data = self._parse_content_provider_output(adb_output)
|
746
814
|
|
747
|
-
|
748
|
-
data_str = state_data["data"]
|
749
|
-
try:
|
750
|
-
combined_data = json.loads(data_str)
|
751
|
-
except json.JSONDecodeError:
|
815
|
+
if state_data is None:
|
752
816
|
return {
|
753
817
|
"error": "Parse Error",
|
754
|
-
"message": "Failed to parse
|
818
|
+
"message": "Failed to parse state data from ContentProvider response",
|
819
|
+
}
|
820
|
+
|
821
|
+
if isinstance(state_data, dict) and "data" in state_data:
|
822
|
+
data_str = state_data["data"]
|
823
|
+
try:
|
824
|
+
combined_data = json.loads(data_str)
|
825
|
+
except json.JSONDecodeError:
|
826
|
+
return {
|
827
|
+
"error": "Parse Error",
|
828
|
+
"message": "Failed to parse JSON data from ContentProvider data field",
|
829
|
+
}
|
830
|
+
else:
|
831
|
+
return {
|
832
|
+
"error": "Format Error",
|
833
|
+
"message": f"Unexpected state data format: {type(state_data)}",
|
755
834
|
}
|
756
|
-
else:
|
757
|
-
return {
|
758
|
-
"error": "Format Error",
|
759
|
-
"message": f"Unexpected state data format: {type(state_data)}"
|
760
|
-
}
|
761
835
|
|
762
836
|
# Validate that both a11y_tree and phone_state are present
|
763
837
|
if "a11y_tree" not in combined_data:
|
764
838
|
return {
|
765
839
|
"error": "Missing Data",
|
766
|
-
"message": "a11y_tree not found in combined state data"
|
840
|
+
"message": "a11y_tree not found in combined state data",
|
767
841
|
}
|
768
|
-
|
842
|
+
|
769
843
|
if "phone_state" not in combined_data:
|
770
844
|
return {
|
771
|
-
"error": "Missing Data",
|
772
|
-
"message": "phone_state not found in combined state data"
|
845
|
+
"error": "Missing Data",
|
846
|
+
"message": "phone_state not found in combined state data",
|
773
847
|
}
|
774
848
|
|
775
849
|
# Filter out the "type" attribute from all a11y_tree elements
|
@@ -777,9 +851,7 @@ class AdbTools(Tools):
|
|
777
851
|
filtered_elements = []
|
778
852
|
for element in elements:
|
779
853
|
# Create a copy of the element without the "type" attribute
|
780
|
-
filtered_element = {
|
781
|
-
k: v for k, v in element.items() if k != "type"
|
782
|
-
}
|
854
|
+
filtered_element = {k: v for k, v in element.items() if k != "type"}
|
783
855
|
|
784
856
|
# Also filter children if present
|
785
857
|
if "children" in filtered_element:
|
@@ -789,19 +861,279 @@ class AdbTools(Tools):
|
|
789
861
|
]
|
790
862
|
|
791
863
|
filtered_elements.append(filtered_element)
|
792
|
-
|
864
|
+
|
793
865
|
self.clickable_elements_cache = filtered_elements
|
794
|
-
|
866
|
+
|
795
867
|
return {
|
796
868
|
"a11y_tree": filtered_elements,
|
797
|
-
"phone_state": combined_data["phone_state"]
|
869
|
+
"phone_state": combined_data["phone_state"],
|
798
870
|
}
|
799
871
|
|
872
|
+
except requests.exceptions.RequestException as e:
|
873
|
+
return {
|
874
|
+
"error": "TCP Error",
|
875
|
+
"message": f"TCP request failed: {str(e)}",
|
876
|
+
}
|
800
877
|
except Exception as e:
|
801
|
-
return {
|
878
|
+
return {
|
879
|
+
"error": str(e),
|
880
|
+
"message": f"Error getting combined state: {str(e)}",
|
881
|
+
}
|
802
882
|
|
803
|
-
|
804
|
-
|
805
|
-
|
883
|
+
def get_a11y_tree(self) -> Dict[str, Any]:
|
884
|
+
"""
|
885
|
+
Get just the accessibility tree using the /a11y_tree endpoint.
|
886
|
+
|
887
|
+
Returns:
|
888
|
+
Dictionary containing accessibility tree data
|
889
|
+
"""
|
890
|
+
try:
|
891
|
+
if self.use_tcp and self.tcp_forwarded:
|
892
|
+
response = requests.get(f"{self.tcp_base_url}/a11y_tree", timeout=10)
|
893
|
+
|
894
|
+
if response.status_code == 200:
|
895
|
+
tcp_response = response.json()
|
896
|
+
|
897
|
+
# Check if response has the expected format with data field
|
898
|
+
if isinstance(tcp_response, dict) and "data" in tcp_response:
|
899
|
+
data_str = tcp_response["data"]
|
900
|
+
try:
|
901
|
+
return json.loads(data_str)
|
902
|
+
except json.JSONDecodeError:
|
903
|
+
return {
|
904
|
+
"error": "Parse Error",
|
905
|
+
"message": "Failed to parse JSON data from TCP response data field",
|
906
|
+
}
|
907
|
+
else:
|
908
|
+
# Fallback: assume direct JSON format
|
909
|
+
return tcp_response
|
910
|
+
else:
|
911
|
+
return {
|
912
|
+
"error": "HTTP Error",
|
913
|
+
"message": f"HTTP request failed with status {response.status_code}",
|
914
|
+
}
|
915
|
+
else:
|
916
|
+
# Fallback: use get_state and extract a11y_tree
|
917
|
+
state = self.get_state()
|
918
|
+
if "error" in state:
|
919
|
+
return state
|
920
|
+
return {"a11y_tree": state.get("a11y_tree", [])}
|
921
|
+
|
922
|
+
except requests.exceptions.RequestException as e:
|
923
|
+
return {
|
924
|
+
"error": "TCP Error",
|
925
|
+
"message": f"TCP request failed: {str(e)}",
|
926
|
+
}
|
927
|
+
except Exception as e:
|
928
|
+
return {
|
929
|
+
"error": str(e),
|
930
|
+
"message": f"Error getting a11y tree: {str(e)}",
|
931
|
+
}
|
932
|
+
|
933
|
+
def get_phone_state(self) -> Dict[str, Any]:
|
934
|
+
"""
|
935
|
+
Get just the phone state using the /phone_state endpoint.
|
936
|
+
|
937
|
+
Returns:
|
938
|
+
Dictionary containing phone state data
|
939
|
+
"""
|
940
|
+
try:
|
941
|
+
if self.use_tcp and self.tcp_forwarded:
|
942
|
+
response = requests.get(f"{self.tcp_base_url}/phone_state", timeout=10)
|
943
|
+
|
944
|
+
if response.status_code == 200:
|
945
|
+
tcp_response = response.json()
|
946
|
+
|
947
|
+
# Check if response has the expected format with data field
|
948
|
+
if isinstance(tcp_response, dict) and "data" in tcp_response:
|
949
|
+
data_str = tcp_response["data"]
|
950
|
+
try:
|
951
|
+
return json.loads(data_str)
|
952
|
+
except json.JSONDecodeError:
|
953
|
+
return {
|
954
|
+
"error": "Parse Error",
|
955
|
+
"message": "Failed to parse JSON data from TCP response data field",
|
956
|
+
}
|
957
|
+
else:
|
958
|
+
# Fallback: assume direct JSON format
|
959
|
+
return tcp_response
|
960
|
+
else:
|
961
|
+
return {
|
962
|
+
"error": "HTTP Error",
|
963
|
+
"message": f"HTTP request failed with status {response.status_code}",
|
964
|
+
}
|
965
|
+
else:
|
966
|
+
# Fallback: use get_state and extract phone_state
|
967
|
+
state = self.get_state()
|
968
|
+
if "error" in state:
|
969
|
+
return state
|
970
|
+
return {"phone_state": state.get("phone_state", {})}
|
971
|
+
|
972
|
+
except requests.exceptions.RequestException as e:
|
973
|
+
return {
|
974
|
+
"error": "TCP Error",
|
975
|
+
"message": f"TCP request failed: {str(e)}",
|
976
|
+
}
|
977
|
+
except Exception as e:
|
978
|
+
return {
|
979
|
+
"error": str(e),
|
980
|
+
"message": f"Error getting phone state: {str(e)}",
|
981
|
+
}
|
982
|
+
|
983
|
+
def ping(self) -> Dict[str, Any]:
|
984
|
+
"""
|
985
|
+
Test the TCP connection using the /ping endpoint.
|
806
986
|
|
807
|
-
|
987
|
+
Returns:
|
988
|
+
Dictionary with ping result
|
989
|
+
"""
|
990
|
+
try:
|
991
|
+
if self.use_tcp and self.tcp_forwarded:
|
992
|
+
response = requests.get(f"{self.tcp_base_url}/ping", timeout=5)
|
993
|
+
|
994
|
+
if response.status_code == 200:
|
995
|
+
try:
|
996
|
+
tcp_response = response.json() if response.content else {}
|
997
|
+
logger.debug(f"Ping TCP response: {tcp_response}")
|
998
|
+
return {
|
999
|
+
"status": "success",
|
1000
|
+
"message": "Ping successful",
|
1001
|
+
"response": tcp_response,
|
1002
|
+
}
|
1003
|
+
except json.JSONDecodeError:
|
1004
|
+
return {
|
1005
|
+
"status": "success",
|
1006
|
+
"message": "Ping successful (non-JSON response)",
|
1007
|
+
"response": response.text,
|
1008
|
+
}
|
1009
|
+
else:
|
1010
|
+
return {
|
1011
|
+
"status": "error",
|
1012
|
+
"message": f"Ping failed with status {response.status_code}: {response.text}",
|
1013
|
+
}
|
1014
|
+
else:
|
1015
|
+
return {
|
1016
|
+
"status": "error",
|
1017
|
+
"message": "TCP communication is not enabled",
|
1018
|
+
}
|
1019
|
+
|
1020
|
+
except requests.exceptions.RequestException as e:
|
1021
|
+
return {
|
1022
|
+
"status": "error",
|
1023
|
+
"message": f"Ping failed: {str(e)}",
|
1024
|
+
}
|
1025
|
+
except Exception as e:
|
1026
|
+
return {
|
1027
|
+
"status": "error",
|
1028
|
+
"message": f"Error during ping: {str(e)}",
|
1029
|
+
}
|
1030
|
+
|
1031
|
+
|
1032
|
+
def _shell_test_cli(serial: str, command: str) -> tuple[str, float]:
|
1033
|
+
"""
|
1034
|
+
Run an adb shell command using the adb CLI and measure execution time.
|
1035
|
+
Args:
|
1036
|
+
serial: Device serial number
|
1037
|
+
command: Shell command to run
|
1038
|
+
Returns:
|
1039
|
+
Tuple of (output, elapsed_time)
|
1040
|
+
"""
|
1041
|
+
import time
|
1042
|
+
import subprocess
|
1043
|
+
|
1044
|
+
adb_cmd = ["adb", "-s", serial, "shell", command]
|
1045
|
+
start = time.perf_counter()
|
1046
|
+
result = subprocess.run(adb_cmd, capture_output=True, text=True)
|
1047
|
+
elapsed = time.perf_counter() - start
|
1048
|
+
output = result.stdout.strip() if result.returncode == 0 else result.stderr.strip()
|
1049
|
+
return output, elapsed
|
1050
|
+
|
1051
|
+
|
1052
|
+
def _shell_test():
|
1053
|
+
device = adb.device("emulator-5554")
|
1054
|
+
# Native Python adb client
|
1055
|
+
start = time.time()
|
1056
|
+
res = device.shell("echo 'Hello, World!'")
|
1057
|
+
end = time.time()
|
1058
|
+
print(f"[Native] Shell execution took {end - start:.3f} seconds: {res}")
|
1059
|
+
|
1060
|
+
start = time.time()
|
1061
|
+
res = device.shell("content query --uri content://com.droidrun.portal/state")
|
1062
|
+
end = time.time()
|
1063
|
+
print(f"[Native] Shell execution took {end - start:.3f} seconds: phone_state")
|
1064
|
+
|
1065
|
+
# CLI version
|
1066
|
+
output, elapsed = _shell_test_cli("emulator-5554", "echo 'Hello, World!'")
|
1067
|
+
print(f"[CLI] Shell execution took {elapsed:.3f} seconds: {output}")
|
1068
|
+
|
1069
|
+
output, elapsed = _shell_test_cli(
|
1070
|
+
"emulator-5554", "content query --uri content://com.droidrun.portal/state"
|
1071
|
+
)
|
1072
|
+
print(f"[CLI] Shell execution took {elapsed:.3f} seconds: phone_state")
|
1073
|
+
|
1074
|
+
|
1075
|
+
def _list_packages():
|
1076
|
+
tools = AdbTools()
|
1077
|
+
print(tools.list_packages())
|
1078
|
+
|
1079
|
+
|
1080
|
+
def _start_app():
|
1081
|
+
tools = AdbTools()
|
1082
|
+
tools.start_app("com.android.settings", ".Settings")
|
1083
|
+
|
1084
|
+
|
1085
|
+
def _shell_test_cli(serial: str, command: str) -> tuple[str, float]:
|
1086
|
+
"""
|
1087
|
+
Run an adb shell command using the adb CLI and measure execution time.
|
1088
|
+
Args:
|
1089
|
+
serial: Device serial number
|
1090
|
+
command: Shell command to run
|
1091
|
+
Returns:
|
1092
|
+
Tuple of (output, elapsed_time)
|
1093
|
+
"""
|
1094
|
+
import time
|
1095
|
+
import subprocess
|
1096
|
+
|
1097
|
+
adb_cmd = ["adb", "-s", serial, "shell", command]
|
1098
|
+
start = time.perf_counter()
|
1099
|
+
result = subprocess.run(adb_cmd, capture_output=True, text=True)
|
1100
|
+
elapsed = time.perf_counter() - start
|
1101
|
+
output = result.stdout.strip() if result.returncode == 0 else result.stderr.strip()
|
1102
|
+
return output, elapsed
|
1103
|
+
|
1104
|
+
|
1105
|
+
def _shell_test():
|
1106
|
+
device = adb.device("emulator-5554")
|
1107
|
+
# Native Python adb client
|
1108
|
+
start = time.time()
|
1109
|
+
res = device.shell("echo 'Hello, World!'")
|
1110
|
+
end = time.time()
|
1111
|
+
print(f"[Native] Shell execution took {end - start:.3f} seconds: {res}")
|
1112
|
+
|
1113
|
+
start = time.time()
|
1114
|
+
res = device.shell("content query --uri content://com.droidrun.portal/state")
|
1115
|
+
end = time.time()
|
1116
|
+
print(f"[Native] Shell execution took {end - start:.3f} seconds: phone_state")
|
1117
|
+
|
1118
|
+
# CLI version
|
1119
|
+
output, elapsed = _shell_test_cli("emulator-5554", "echo 'Hello, World!'")
|
1120
|
+
print(f"[CLI] Shell execution took {elapsed:.3f} seconds: {output}")
|
1121
|
+
|
1122
|
+
output, elapsed = _shell_test_cli(
|
1123
|
+
"emulator-5554", "content query --uri content://com.droidrun.portal/state"
|
1124
|
+
)
|
1125
|
+
print(f"[CLI] Shell execution took {elapsed:.3f} seconds: phone_state")
|
1126
|
+
|
1127
|
+
|
1128
|
+
def _list_packages():
|
1129
|
+
tools = AdbTools()
|
1130
|
+
print(tools.list_packages())
|
1131
|
+
|
1132
|
+
|
1133
|
+
def _start_app():
|
1134
|
+
tools = AdbTools()
|
1135
|
+
tools.start_app("com.android.settings", ".Settings")
|
1136
|
+
|
1137
|
+
|
1138
|
+
if __name__ == "__main__":
|
1139
|
+
_start_app()
|