droidrun 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- droidrun/__init__.py +19 -0
- droidrun/__main__.py +8 -0
- droidrun/adb/__init__.py +13 -0
- droidrun/adb/device.py +315 -0
- droidrun/adb/manager.py +93 -0
- droidrun/adb/wrapper.py +226 -0
- droidrun/agent/__init__.py +16 -0
- droidrun/agent/llm_reasoning.py +567 -0
- droidrun/agent/react_agent.py +556 -0
- droidrun/cli/__init__.py +9 -0
- droidrun/cli/main.py +265 -0
- droidrun/llm/__init__.py +24 -0
- droidrun/tools/__init__.py +35 -0
- droidrun/tools/actions.py +854 -0
- droidrun/tools/device.py +29 -0
- droidrun-0.1.0.dist-info/METADATA +276 -0
- droidrun-0.1.0.dist-info/RECORD +20 -0
- droidrun-0.1.0.dist-info/WHEEL +4 -0
- droidrun-0.1.0.dist-info/entry_points.txt +2 -0
- droidrun-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,854 @@
|
|
1
|
+
"""
|
2
|
+
UI Actions - Core UI interaction tools for Android device control.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import os
|
6
|
+
import re
|
7
|
+
import json
|
8
|
+
import time
|
9
|
+
import tempfile
|
10
|
+
import asyncio
|
11
|
+
import aiofiles
|
12
|
+
import contextlib
|
13
|
+
from typing import Optional, Dict, Tuple, List, Any
|
14
|
+
from droidrun.adb import Device, DeviceManager
|
15
|
+
|
16
|
+
# Global variable to store clickable elements for index-based tapping
|
17
|
+
CLICKABLE_ELEMENTS_CACHE = []
|
18
|
+
|
19
|
+
# Default device serial will be read from environment variable
|
20
|
+
def get_device_serial() -> str:
|
21
|
+
"""Get the device serial from environment variable.
|
22
|
+
|
23
|
+
Returns:
|
24
|
+
Device serial from environment or None
|
25
|
+
"""
|
26
|
+
return os.environ.get("DROIDRUN_DEVICE_SERIAL", "")
|
27
|
+
|
28
|
+
async def get_device() -> Optional[Device]:
|
29
|
+
"""Get the device instance using the serial from environment variable.
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
Device instance or None if not found
|
33
|
+
"""
|
34
|
+
serial = get_device_serial()
|
35
|
+
if not serial:
|
36
|
+
raise ValueError("DROIDRUN_DEVICE_SERIAL environment variable not set")
|
37
|
+
|
38
|
+
device_manager = DeviceManager()
|
39
|
+
device = await device_manager.get_device(serial)
|
40
|
+
if not device:
|
41
|
+
raise ValueError(f"Device {serial} not found")
|
42
|
+
|
43
|
+
return device
|
44
|
+
|
45
|
+
def parse_package_list(output: str) -> List[Dict[str, str]]:
|
46
|
+
"""Parse the output of 'pm list packages -f' command.
|
47
|
+
|
48
|
+
Args:
|
49
|
+
output: Raw command output from 'pm list packages -f'
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
List of dictionaries containing package info with 'package' and 'path' keys
|
53
|
+
"""
|
54
|
+
apps = []
|
55
|
+
for line in output.splitlines():
|
56
|
+
if line.startswith("package:"):
|
57
|
+
# Format is: "package:/path/to/base.apk=com.package.name"
|
58
|
+
path_and_pkg = line[8:] # Strip "package:"
|
59
|
+
if "=" in path_and_pkg:
|
60
|
+
path, package = path_and_pkg.rsplit("=", 1)
|
61
|
+
apps.append({"package": package.strip(), "path": path.strip()})
|
62
|
+
return apps
|
63
|
+
|
64
|
+
async def get_clickables(serial: Optional[str] = None) -> Dict[str, Any]:
|
65
|
+
"""
|
66
|
+
Get all clickable UI elements from the device using the custom TopViewService.
|
67
|
+
|
68
|
+
This function interacts with the TopViewService app installed on the device
|
69
|
+
to capture only the clickable UI elements. The service writes UI data
|
70
|
+
to a JSON file on the device, which is then pulled to the host.
|
71
|
+
|
72
|
+
Args:
|
73
|
+
serial: Optional device serial number
|
74
|
+
|
75
|
+
Returns:
|
76
|
+
Dictionary containing clickable UI elements extracted from the device screen
|
77
|
+
"""
|
78
|
+
global CLICKABLE_ELEMENTS_CACHE
|
79
|
+
|
80
|
+
try:
|
81
|
+
# Get the device
|
82
|
+
if serial:
|
83
|
+
device_manager = DeviceManager()
|
84
|
+
device = await device_manager.get_device(serial)
|
85
|
+
if not device:
|
86
|
+
raise ValueError(f"Device {serial} not found")
|
87
|
+
else:
|
88
|
+
device = await get_device()
|
89
|
+
|
90
|
+
# Create a temporary file for the JSON
|
91
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as temp:
|
92
|
+
local_path = temp.name
|
93
|
+
|
94
|
+
try:
|
95
|
+
# Clear logcat to make it easier to find our output
|
96
|
+
await device._adb.shell(device._serial, "logcat -c")
|
97
|
+
|
98
|
+
# Trigger the custom service via broadcast to get only interactive elements
|
99
|
+
await device._adb.shell(device._serial, "am broadcast -a com.droidrun.portal.GET_ELEMENTS")
|
100
|
+
|
101
|
+
# Poll for the JSON file path
|
102
|
+
start_time = asyncio.get_event_loop().time()
|
103
|
+
max_wait_time = 10 # Maximum wait time in seconds
|
104
|
+
poll_interval = 0.2 # Check every 200ms
|
105
|
+
|
106
|
+
device_path = None
|
107
|
+
while asyncio.get_event_loop().time() - start_time < max_wait_time:
|
108
|
+
# Check logcat for the file path
|
109
|
+
logcat_output = await device._adb.shell(device._serial, "logcat -d | grep \"DROIDRUN_FILE\" | grep \"JSON data written to\" | tail -1")
|
110
|
+
|
111
|
+
# Parse the file path if present
|
112
|
+
match = re.search(r"JSON data written to: (.*)", logcat_output)
|
113
|
+
if match:
|
114
|
+
device_path = match.group(1).strip()
|
115
|
+
break
|
116
|
+
|
117
|
+
# Wait before polling again
|
118
|
+
await asyncio.sleep(poll_interval)
|
119
|
+
|
120
|
+
# Check if we found the file path
|
121
|
+
if not device_path:
|
122
|
+
raise ValueError(f"Failed to find the JSON file path in logcat after {max_wait_time} seconds")
|
123
|
+
|
124
|
+
# Pull the JSON file from the device
|
125
|
+
await device._adb.pull_file(device._serial, device_path, local_path)
|
126
|
+
|
127
|
+
# Read the JSON file
|
128
|
+
async with aiofiles.open(local_path, "r", encoding="utf-8") as f:
|
129
|
+
json_content = await f.read()
|
130
|
+
|
131
|
+
# Clean up the temporary file
|
132
|
+
with contextlib.suppress(OSError):
|
133
|
+
os.unlink(local_path)
|
134
|
+
|
135
|
+
# Try to parse the JSON
|
136
|
+
import json
|
137
|
+
try:
|
138
|
+
ui_data = json.loads(json_content)
|
139
|
+
|
140
|
+
# Process the JSON to extract elements
|
141
|
+
flattened_elements = []
|
142
|
+
|
143
|
+
# Process the nested elements structure
|
144
|
+
if isinstance(ui_data, list):
|
145
|
+
# For each parent element in the list
|
146
|
+
for parent in ui_data:
|
147
|
+
# Add the parent if it's clickable (type should be 'clickable')
|
148
|
+
if parent.get('type') == 'clickable' and parent.get('index', -1) != -1:
|
149
|
+
parent_copy = {k: v for k, v in parent.items() if k != 'children'}
|
150
|
+
parent_copy['isParent'] = True
|
151
|
+
flattened_elements.append(parent_copy)
|
152
|
+
|
153
|
+
# Process children
|
154
|
+
children = parent.get('children', [])
|
155
|
+
for child in children:
|
156
|
+
# Add all children that have valid indices, regardless of type
|
157
|
+
# Include text elements as well, not just clickable ones
|
158
|
+
if child.get('index', -1) != -1:
|
159
|
+
child_copy = child.copy()
|
160
|
+
child_copy['isParent'] = False
|
161
|
+
child_copy['parentIndex'] = parent.get('index')
|
162
|
+
flattened_elements.append(child_copy)
|
163
|
+
|
164
|
+
# Also process nested children if present
|
165
|
+
nested_children = child.get('children', [])
|
166
|
+
for nested_child in nested_children:
|
167
|
+
if nested_child.get('index', -1) != -1:
|
168
|
+
nested_copy = nested_child.copy()
|
169
|
+
nested_copy['isParent'] = False
|
170
|
+
nested_copy['parentIndex'] = child.get('index')
|
171
|
+
nested_copy['grandparentIndex'] = parent.get('index')
|
172
|
+
flattened_elements.append(nested_copy)
|
173
|
+
else:
|
174
|
+
# Old format handling (dictionary with clickable_elements)
|
175
|
+
clickable_elements = ui_data.get("clickable_elements", [])
|
176
|
+
for element in clickable_elements:
|
177
|
+
if element.get('index', -1) != -1:
|
178
|
+
element_copy = {k: v for k, v in element.items() if k != 'isClickable'}
|
179
|
+
flattened_elements.append(element_copy)
|
180
|
+
|
181
|
+
# Update the global cache with the processed elements
|
182
|
+
CLICKABLE_ELEMENTS_CACHE = flattened_elements
|
183
|
+
|
184
|
+
# Sort by index
|
185
|
+
flattened_elements.sort(key=lambda x: x.get('index', 0))
|
186
|
+
|
187
|
+
# Create a summary of important text elements for each clickable parent
|
188
|
+
text_summary = []
|
189
|
+
parent_texts = {}
|
190
|
+
tappable_elements = []
|
191
|
+
|
192
|
+
# Group text elements by their parent and identify tappable elements
|
193
|
+
for elem in flattened_elements:
|
194
|
+
# Track elements that are actually tappable (have bounds and either type clickable or are parents)
|
195
|
+
if elem.get('bounds') and (elem.get('type') == 'clickable' or elem.get('isParent')):
|
196
|
+
tappable_elements.append(elem.get('index'))
|
197
|
+
|
198
|
+
if elem.get('type') == 'text' and elem.get('text'):
|
199
|
+
parent_id = elem.get('parentIndex')
|
200
|
+
if parent_id is not None:
|
201
|
+
if parent_id not in parent_texts:
|
202
|
+
parent_texts[parent_id] = []
|
203
|
+
parent_texts[parent_id].append(elem.get('text'))
|
204
|
+
|
205
|
+
# Create a text summary for parents with text children
|
206
|
+
for parent_id, texts in parent_texts.items():
|
207
|
+
# Find the parent element
|
208
|
+
parent = None
|
209
|
+
for elem in flattened_elements:
|
210
|
+
if elem.get('index') == parent_id:
|
211
|
+
parent = elem
|
212
|
+
break
|
213
|
+
|
214
|
+
if parent:
|
215
|
+
# Mark if this element is directly tappable
|
216
|
+
tappable_marker = "🔘" if parent_id in tappable_elements else "📄"
|
217
|
+
summary = f"{tappable_marker} Element {parent_id} ({parent.get('className', 'Unknown')}): " + " | ".join(texts)
|
218
|
+
text_summary.append(summary)
|
219
|
+
|
220
|
+
# Sort the text summary for better readability
|
221
|
+
text_summary.sort()
|
222
|
+
|
223
|
+
# Count how many elements are actually tappable
|
224
|
+
tappable_count = len(tappable_elements)
|
225
|
+
|
226
|
+
# Add a short sleep to ensure UI is fully loaded/processed
|
227
|
+
await asyncio.sleep(0.5) # 500ms sleep
|
228
|
+
|
229
|
+
return {
|
230
|
+
"clickable_elements": flattened_elements,
|
231
|
+
"count": len(flattened_elements),
|
232
|
+
"tappable_count": tappable_count,
|
233
|
+
"tappable_indices": sorted(tappable_elements),
|
234
|
+
"text_summary": text_summary,
|
235
|
+
"message": f"Found {tappable_count} tappable elements out of {len(flattened_elements)} total elements"
|
236
|
+
}
|
237
|
+
except json.JSONDecodeError:
|
238
|
+
raise ValueError("Failed to parse UI elements JSON data")
|
239
|
+
|
240
|
+
except Exception as e:
|
241
|
+
# Clean up in case of error
|
242
|
+
with contextlib.suppress(OSError):
|
243
|
+
os.unlink(local_path)
|
244
|
+
raise ValueError(f"Error retrieving clickable elements: {e}")
|
245
|
+
|
246
|
+
except Exception as e:
|
247
|
+
raise ValueError(f"Error getting clickable elements: {e}")
|
248
|
+
|
249
|
+
async def tap_by_index(index: int, serial: Optional[str] = None) -> str:
|
250
|
+
"""
|
251
|
+
Tap on a UI element by its index.
|
252
|
+
|
253
|
+
This function uses the cached clickable elements from the last get_clickables call
|
254
|
+
to find the element with the given index and tap on its center coordinates.
|
255
|
+
|
256
|
+
Args:
|
257
|
+
index: Index of the element to tap
|
258
|
+
serial: Optional device serial (for backward compatibility)
|
259
|
+
|
260
|
+
Returns:
|
261
|
+
Result message
|
262
|
+
"""
|
263
|
+
global CLICKABLE_ELEMENTS_CACHE
|
264
|
+
|
265
|
+
try:
|
266
|
+
# Check if we have cached elements
|
267
|
+
if not CLICKABLE_ELEMENTS_CACHE:
|
268
|
+
return "Error: No UI elements cached. Call get_clickables first."
|
269
|
+
|
270
|
+
# Find the element with the given index
|
271
|
+
element = None
|
272
|
+
for item in CLICKABLE_ELEMENTS_CACHE:
|
273
|
+
if item.get('index') == index:
|
274
|
+
element = item
|
275
|
+
break
|
276
|
+
|
277
|
+
if not element:
|
278
|
+
# List available indices to help the user
|
279
|
+
indices = sorted([item.get('index') for item in CLICKABLE_ELEMENTS_CACHE if item.get('index') is not None])
|
280
|
+
indices_str = ", ".join(str(idx) for idx in indices[:20])
|
281
|
+
if len(indices) > 20:
|
282
|
+
indices_str += f"... and {len(indices) - 20} more"
|
283
|
+
|
284
|
+
return f"Error: No element found with index {index}. Available indices: {indices_str}"
|
285
|
+
|
286
|
+
# Get the bounds of the element
|
287
|
+
bounds_str = element.get('bounds')
|
288
|
+
if not bounds_str:
|
289
|
+
element_text = element.get('text', 'No text')
|
290
|
+
element_type = element.get('type', 'unknown')
|
291
|
+
element_class = element.get('className', 'Unknown class')
|
292
|
+
|
293
|
+
# Check if this is a child element with a parent that can be tapped instead
|
294
|
+
parent_suggestion = ""
|
295
|
+
if 'parentIndex' in element:
|
296
|
+
parent_idx = element.get('parentIndex')
|
297
|
+
parent_suggestion = f" You might want to tap its parent element with index {parent_idx} instead."
|
298
|
+
|
299
|
+
return f"Error: Element with index {index} ('{element_text}', {element_class}, type: {element_type}) has no bounds and cannot be tapped directly.{parent_suggestion}"
|
300
|
+
|
301
|
+
# Parse the bounds (format: "left,top,right,bottom")
|
302
|
+
try:
|
303
|
+
left, top, right, bottom = map(int, bounds_str.split(','))
|
304
|
+
except ValueError:
|
305
|
+
return f"Error: Invalid bounds format for element with index {index}: {bounds_str}"
|
306
|
+
|
307
|
+
# Calculate the center of the element
|
308
|
+
x = (left + right) // 2
|
309
|
+
y = (top + bottom) // 2
|
310
|
+
|
311
|
+
# Get the device and tap at the coordinates
|
312
|
+
if serial:
|
313
|
+
device_manager = DeviceManager()
|
314
|
+
device = await device_manager.get_device(serial)
|
315
|
+
if not device:
|
316
|
+
return f"Error: Device {serial} not found"
|
317
|
+
else:
|
318
|
+
device = await get_device()
|
319
|
+
|
320
|
+
await device.tap(x, y)
|
321
|
+
|
322
|
+
# Gather element details for the response
|
323
|
+
element_text = element.get('text', 'No text')
|
324
|
+
element_class = element.get('className', 'Unknown class')
|
325
|
+
element_type = element.get('type', 'unknown')
|
326
|
+
is_parent = element.get('isParent', False)
|
327
|
+
|
328
|
+
# Create a descriptive response
|
329
|
+
response_parts = []
|
330
|
+
response_parts.append(f"Tapped element with index {index}")
|
331
|
+
response_parts.append(f"Text: '{element_text}'")
|
332
|
+
response_parts.append(f"Class: {element_class}")
|
333
|
+
response_parts.append(f"Type: {element_type}")
|
334
|
+
response_parts.append(f"Role: {'parent' if is_parent else 'child'}")
|
335
|
+
|
336
|
+
# If it's a parent element, include information about its text children
|
337
|
+
if is_parent:
|
338
|
+
# Find all child elements that are text elements
|
339
|
+
text_children = []
|
340
|
+
for item in CLICKABLE_ELEMENTS_CACHE:
|
341
|
+
if (item.get('parentIndex') == index and
|
342
|
+
item.get('type') == 'text' and
|
343
|
+
item.get('text')):
|
344
|
+
text_children.append(item.get('text'))
|
345
|
+
|
346
|
+
if text_children:
|
347
|
+
response_parts.append(f"Contains text: {' | '.join(text_children)}")
|
348
|
+
|
349
|
+
# If it's a child element, include parent information
|
350
|
+
if not is_parent and 'parentIndex' in element:
|
351
|
+
parent_index = element.get('parentIndex')
|
352
|
+
# Find the parent element
|
353
|
+
parent = None
|
354
|
+
for item in CLICKABLE_ELEMENTS_CACHE:
|
355
|
+
if item.get('index') == parent_index:
|
356
|
+
parent = item
|
357
|
+
break
|
358
|
+
|
359
|
+
if parent:
|
360
|
+
parent_text = parent.get('text', 'No text')
|
361
|
+
response_parts.append(f"Parent: {parent_index} ('{parent_text}')")
|
362
|
+
|
363
|
+
# Find sibling text elements (other children of the same parent)
|
364
|
+
sibling_texts = []
|
365
|
+
for item in CLICKABLE_ELEMENTS_CACHE:
|
366
|
+
if (item.get('parentIndex') == parent_index and
|
367
|
+
item.get('index') != index and
|
368
|
+
item.get('type') == 'text' and
|
369
|
+
item.get('text')):
|
370
|
+
sibling_texts.append(item.get('text'))
|
371
|
+
|
372
|
+
if sibling_texts:
|
373
|
+
response_parts.append(f"Related text: {' | '.join(sibling_texts)}")
|
374
|
+
|
375
|
+
response_parts.append(f"Coordinates: ({x}, {y})")
|
376
|
+
|
377
|
+
return " | ".join(response_parts)
|
378
|
+
except ValueError as e:
|
379
|
+
return f"Error: {str(e)}"
|
380
|
+
|
381
|
+
# Rename the old tap function to tap_by_coordinates for backward compatibility
|
382
|
+
async def tap_by_coordinates(x: int, y: int, serial: Optional[str] = None) -> str:
|
383
|
+
"""
|
384
|
+
Tap on the device screen at specific coordinates.
|
385
|
+
|
386
|
+
Args:
|
387
|
+
x: X coordinate
|
388
|
+
y: Y coordinate
|
389
|
+
serial: Optional device serial (for backward compatibility)
|
390
|
+
"""
|
391
|
+
try:
|
392
|
+
if serial:
|
393
|
+
device_manager = DeviceManager()
|
394
|
+
device = await device_manager.get_device(serial)
|
395
|
+
if not device:
|
396
|
+
return f"Error: Device {serial} not found"
|
397
|
+
else:
|
398
|
+
device = await get_device()
|
399
|
+
|
400
|
+
await device.tap(x, y)
|
401
|
+
return f"Tapped at ({x}, {y})"
|
402
|
+
except ValueError as e:
|
403
|
+
return f"Error: {str(e)}"
|
404
|
+
|
405
|
+
# Replace the old tap function with the new one
|
406
|
+
async def tap(index: int, serial: Optional[str] = None) -> str:
|
407
|
+
"""
|
408
|
+
Tap on a UI element by its index.
|
409
|
+
|
410
|
+
This function uses the cached clickable elements from the last get_clickables call
|
411
|
+
to find the element with the given index and tap on its center coordinates.
|
412
|
+
|
413
|
+
Args:
|
414
|
+
index: Index of the element to tap
|
415
|
+
serial: Optional device serial (for backward compatibility)
|
416
|
+
|
417
|
+
Returns:
|
418
|
+
Result message
|
419
|
+
"""
|
420
|
+
return await tap_by_index(index, serial)
|
421
|
+
|
422
|
+
async def swipe(
|
423
|
+
start_x: int,
|
424
|
+
start_y: int,
|
425
|
+
end_x: int,
|
426
|
+
end_y: int,
|
427
|
+
duration_ms: int = 300,
|
428
|
+
serial: Optional[str] = None
|
429
|
+
) -> str:
|
430
|
+
"""
|
431
|
+
Perform a swipe gesture on the device screen.
|
432
|
+
|
433
|
+
Args:
|
434
|
+
start_x: Starting X coordinate
|
435
|
+
start_y: Starting Y coordinate
|
436
|
+
end_x: Ending X coordinate
|
437
|
+
end_y: Ending Y coordinate
|
438
|
+
duration_ms: Duration of swipe in milliseconds
|
439
|
+
serial: Optional device serial (for backward compatibility)
|
440
|
+
"""
|
441
|
+
try:
|
442
|
+
if serial:
|
443
|
+
device_manager = DeviceManager()
|
444
|
+
device = await device_manager.get_device(serial)
|
445
|
+
if not device:
|
446
|
+
return f"Error: Device {serial} not found"
|
447
|
+
else:
|
448
|
+
device = await get_device()
|
449
|
+
|
450
|
+
await device.swipe(start_x, start_y, end_x, end_y, duration_ms)
|
451
|
+
return f"Swiped from ({start_x}, {start_y}) to ({end_x}, {end_y})"
|
452
|
+
except ValueError as e:
|
453
|
+
return f"Error: {str(e)}"
|
454
|
+
|
455
|
+
async def input_text(text: str, serial: Optional[str] = None) -> str:
|
456
|
+
"""
|
457
|
+
Input text on the device.
|
458
|
+
|
459
|
+
Args:
|
460
|
+
text: Text to input. Can contain spaces and special characters.
|
461
|
+
serial: Optional device serial (for backward compatibility)
|
462
|
+
"""
|
463
|
+
try:
|
464
|
+
if serial:
|
465
|
+
device_manager = DeviceManager()
|
466
|
+
device = await device_manager.get_device(serial)
|
467
|
+
if not device:
|
468
|
+
return f"Error: Device {serial} not found"
|
469
|
+
else:
|
470
|
+
device = await get_device()
|
471
|
+
|
472
|
+
# Function to escape special characters
|
473
|
+
def escape_text(s: str) -> str:
|
474
|
+
# Escape special characters that need shell escaping, excluding space
|
475
|
+
special_chars = '[]()|&;$<>\\`"\'{}#!?^~' # Removed space from special chars
|
476
|
+
escaped = ''
|
477
|
+
for c in s:
|
478
|
+
if c == ' ':
|
479
|
+
escaped += ' ' # Just add space without escaping
|
480
|
+
elif c in special_chars:
|
481
|
+
escaped += '\\' + c
|
482
|
+
else:
|
483
|
+
escaped += c
|
484
|
+
return escaped
|
485
|
+
|
486
|
+
# Split text into smaller chunks (max 500 chars)
|
487
|
+
chunk_size = 500
|
488
|
+
chunks = [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)]
|
489
|
+
|
490
|
+
for chunk in chunks:
|
491
|
+
# Escape the text chunk
|
492
|
+
escaped_chunk = escape_text(chunk)
|
493
|
+
|
494
|
+
# Try different input methods if one fails
|
495
|
+
methods = [
|
496
|
+
f'input text "{escaped_chunk}"', # Standard method
|
497
|
+
f'am broadcast -a ADB_INPUT_TEXT --es msg "{escaped_chunk}"', # Broadcast intent method
|
498
|
+
f'input keyboard text "{escaped_chunk}"' # Keyboard method
|
499
|
+
]
|
500
|
+
|
501
|
+
success = False
|
502
|
+
last_error = None
|
503
|
+
|
504
|
+
for method in methods:
|
505
|
+
try:
|
506
|
+
await device._adb.shell(device._serial, method)
|
507
|
+
success = True
|
508
|
+
break
|
509
|
+
except Exception as e:
|
510
|
+
last_error = str(e)
|
511
|
+
continue
|
512
|
+
|
513
|
+
if not success:
|
514
|
+
return f"Error: Failed to input text chunk. Last error: {last_error}"
|
515
|
+
|
516
|
+
# Small delay between chunks
|
517
|
+
await asyncio.sleep(0.1)
|
518
|
+
|
519
|
+
return f"Text input completed: {text}"
|
520
|
+
except ValueError as e:
|
521
|
+
return f"Error: {str(e)}"
|
522
|
+
|
523
|
+
async def press_key(keycode: int, serial: Optional[str] = None) -> str:
|
524
|
+
"""
|
525
|
+
Press a key on the device.
|
526
|
+
|
527
|
+
Common keycodes:
|
528
|
+
- 3: HOME
|
529
|
+
- 4: BACK
|
530
|
+
- 24: VOLUME UP
|
531
|
+
- 25: VOLUME DOWN
|
532
|
+
- 26: POWER
|
533
|
+
- 82: MENU
|
534
|
+
|
535
|
+
Args:
|
536
|
+
keycode: Android keycode to press
|
537
|
+
serial: Optional device serial (for backward compatibility)
|
538
|
+
"""
|
539
|
+
try:
|
540
|
+
if serial:
|
541
|
+
device_manager = DeviceManager()
|
542
|
+
device = await device_manager.get_device(serial)
|
543
|
+
if not device:
|
544
|
+
return f"Error: Device {serial} not found"
|
545
|
+
else:
|
546
|
+
device = await get_device()
|
547
|
+
|
548
|
+
key_names = {
|
549
|
+
3: "HOME",
|
550
|
+
4: "BACK",
|
551
|
+
24: "VOLUME UP",
|
552
|
+
25: "VOLUME DOWN",
|
553
|
+
26: "POWER",
|
554
|
+
82: "MENU",
|
555
|
+
}
|
556
|
+
key_name = key_names.get(keycode, str(keycode))
|
557
|
+
|
558
|
+
await device.press_key(keycode)
|
559
|
+
return f"Pressed key {key_name}"
|
560
|
+
except ValueError as e:
|
561
|
+
return f"Error: {str(e)}"
|
562
|
+
|
563
|
+
async def start_app(
|
564
|
+
package: str,
|
565
|
+
activity: str = "",
|
566
|
+
serial: Optional[str] = None
|
567
|
+
) -> str:
|
568
|
+
"""
|
569
|
+
Start an app on the device.
|
570
|
+
|
571
|
+
Args:
|
572
|
+
package: Package name (e.g., "com.android.settings")
|
573
|
+
activity: Optional activity name
|
574
|
+
serial: Optional device serial (for backward compatibility)
|
575
|
+
"""
|
576
|
+
try:
|
577
|
+
if serial:
|
578
|
+
device_manager = DeviceManager()
|
579
|
+
device = await device_manager.get_device(serial)
|
580
|
+
if not device:
|
581
|
+
return f"Error: Device {serial} not found"
|
582
|
+
else:
|
583
|
+
device = await get_device()
|
584
|
+
|
585
|
+
result = await device.start_app(package, activity)
|
586
|
+
return result
|
587
|
+
except ValueError as e:
|
588
|
+
return f"Error: {str(e)}"
|
589
|
+
|
590
|
+
async def install_app(
|
591
|
+
apk_path: str,
|
592
|
+
reinstall: bool = False,
|
593
|
+
grant_permissions: bool = True,
|
594
|
+
serial: Optional[str] = None
|
595
|
+
) -> str:
|
596
|
+
"""
|
597
|
+
Install an app on the device.
|
598
|
+
|
599
|
+
Args:
|
600
|
+
apk_path: Path to the APK file
|
601
|
+
reinstall: Whether to reinstall if app exists
|
602
|
+
grant_permissions: Whether to grant all permissions
|
603
|
+
serial: Optional device serial (for backward compatibility)
|
604
|
+
"""
|
605
|
+
try:
|
606
|
+
if serial:
|
607
|
+
device_manager = DeviceManager()
|
608
|
+
device = await device_manager.get_device(serial)
|
609
|
+
if not device:
|
610
|
+
return f"Error: Device {serial} not found"
|
611
|
+
else:
|
612
|
+
device = await get_device()
|
613
|
+
|
614
|
+
if not os.path.exists(apk_path):
|
615
|
+
return f"Error: APK file not found at {apk_path}"
|
616
|
+
|
617
|
+
result = await device.install_app(apk_path, reinstall, grant_permissions)
|
618
|
+
return result
|
619
|
+
except ValueError as e:
|
620
|
+
return f"Error: {str(e)}"
|
621
|
+
|
622
|
+
async def uninstall_app(
|
623
|
+
package: str,
|
624
|
+
keep_data: bool = False,
|
625
|
+
serial: Optional[str] = None
|
626
|
+
) -> str:
|
627
|
+
"""
|
628
|
+
Uninstall an app from the device.
|
629
|
+
|
630
|
+
Args:
|
631
|
+
package: Package name to uninstall
|
632
|
+
keep_data: Whether to keep app data and cache
|
633
|
+
serial: Optional device serial (for backward compatibility)
|
634
|
+
"""
|
635
|
+
try:
|
636
|
+
if serial:
|
637
|
+
device_manager = DeviceManager()
|
638
|
+
device = await device_manager.get_device(serial)
|
639
|
+
if not device:
|
640
|
+
return f"Error: Device {serial} not found"
|
641
|
+
else:
|
642
|
+
device = await get_device()
|
643
|
+
|
644
|
+
result = await device.uninstall_app(package, keep_data)
|
645
|
+
return result
|
646
|
+
except ValueError as e:
|
647
|
+
return f"Error: {str(e)}"
|
648
|
+
|
649
|
+
async def take_screenshot(serial: Optional[str] = None) -> Tuple[str, bytes]:
|
650
|
+
"""
|
651
|
+
Take a screenshot of the device.
|
652
|
+
|
653
|
+
Args:
|
654
|
+
serial: Optional device serial (for backward compatibility)
|
655
|
+
|
656
|
+
Returns:
|
657
|
+
Tuple of (local file path, screenshot data as bytes)
|
658
|
+
"""
|
659
|
+
try:
|
660
|
+
if serial:
|
661
|
+
device_manager = DeviceManager()
|
662
|
+
device = await device_manager.get_device(serial)
|
663
|
+
if not device:
|
664
|
+
raise ValueError(f"Device {serial} not found")
|
665
|
+
else:
|
666
|
+
device = await get_device()
|
667
|
+
|
668
|
+
return await device.take_screenshot()
|
669
|
+
except ValueError as e:
|
670
|
+
raise ValueError(f"Error taking screenshot: {str(e)}")
|
671
|
+
|
672
|
+
async def list_packages(
|
673
|
+
include_system_apps: bool = False,
|
674
|
+
serial: Optional[str] = None
|
675
|
+
) -> Dict[str, Any]:
|
676
|
+
"""
|
677
|
+
List installed packages on the device.
|
678
|
+
|
679
|
+
Args:
|
680
|
+
include_system_apps: Whether to include system apps (default: False)
|
681
|
+
serial: Optional device serial (for backward compatibility)
|
682
|
+
|
683
|
+
Returns:
|
684
|
+
Dictionary containing:
|
685
|
+
- packages: List of dictionaries with 'package' and 'path' keys
|
686
|
+
- count: Number of packages found
|
687
|
+
- type: Type of packages listed ("all" or "non-system")
|
688
|
+
"""
|
689
|
+
try:
|
690
|
+
if serial:
|
691
|
+
device_manager = DeviceManager()
|
692
|
+
device = await device_manager.get_device(serial)
|
693
|
+
if not device:
|
694
|
+
raise ValueError(f"Device {serial} not found")
|
695
|
+
else:
|
696
|
+
device = await get_device()
|
697
|
+
|
698
|
+
# Use the direct ADB command to get packages with paths
|
699
|
+
cmd = ["pm", "list", "packages", "-f"]
|
700
|
+
if not include_system_apps:
|
701
|
+
cmd.append("-3")
|
702
|
+
|
703
|
+
output = await device._adb.shell(device._serial, " ".join(cmd))
|
704
|
+
|
705
|
+
# Parse the package list using the function
|
706
|
+
packages = parse_package_list(output)
|
707
|
+
package_type = "all" if include_system_apps else "non-system"
|
708
|
+
|
709
|
+
return {
|
710
|
+
"packages": packages,
|
711
|
+
"count": len(packages),
|
712
|
+
"type": package_type,
|
713
|
+
"message": f"Found {len(packages)} {package_type} packages on the device"
|
714
|
+
}
|
715
|
+
except ValueError as e:
|
716
|
+
raise ValueError(f"Error listing packages: {str(e)}")
|
717
|
+
|
718
|
+
async def complete(result: str) -> str:
|
719
|
+
"""Complete the task with a result message.
|
720
|
+
|
721
|
+
Args:
|
722
|
+
result: The result message
|
723
|
+
|
724
|
+
Returns:
|
725
|
+
Success message
|
726
|
+
"""
|
727
|
+
return f"Task completed: {result}"
|
728
|
+
|
729
|
+
async def extract(filename: Optional[str] = None, serial: Optional[str] = None) -> str:
|
730
|
+
"""Extract and save the current UI state to a JSON file.
|
731
|
+
|
732
|
+
This function captures the current UI state including all UI elements
|
733
|
+
and saves it to a JSON file for later analysis or reference.
|
734
|
+
|
735
|
+
Args:
|
736
|
+
filename: Optional filename to save the UI state (defaults to ui_state_TIMESTAMP.json)
|
737
|
+
serial: Optional device serial number
|
738
|
+
|
739
|
+
Returns:
|
740
|
+
Path to the saved JSON file
|
741
|
+
"""
|
742
|
+
try:
|
743
|
+
# Generate default filename if not provided
|
744
|
+
if not filename:
|
745
|
+
timestamp = int(time.time())
|
746
|
+
filename = f"ui_state_{timestamp}.json"
|
747
|
+
|
748
|
+
# Ensure the filename ends with .json
|
749
|
+
if not filename.endswith(".json"):
|
750
|
+
filename += ".json"
|
751
|
+
|
752
|
+
# Get the UI elements
|
753
|
+
ui_elements = await get_all_elements(serial)
|
754
|
+
|
755
|
+
# Save to file
|
756
|
+
save_path = os.path.abspath(filename)
|
757
|
+
async with aiofiles.open(save_path, "w", encoding="utf-8") as f:
|
758
|
+
await f.write(json.dumps(ui_elements, indent=2))
|
759
|
+
|
760
|
+
return f"UI state extracted and saved to {save_path}"
|
761
|
+
|
762
|
+
except Exception as e:
|
763
|
+
return f"Error extracting UI state: {e}"
|
764
|
+
|
765
|
+
async def get_all_elements(serial: Optional[str] = None) -> Dict[str, Any]:
|
766
|
+
"""
|
767
|
+
Get all UI elements from the device, including non-interactive elements.
|
768
|
+
|
769
|
+
This function interacts with the TopViewService app installed on the device
|
770
|
+
to capture all UI elements, even those that are not interactive. This provides
|
771
|
+
a complete view of the UI hierarchy for analysis or debugging purposes.
|
772
|
+
|
773
|
+
Args:
|
774
|
+
serial: Optional device serial number
|
775
|
+
|
776
|
+
Returns:
|
777
|
+
Dictionary containing all UI elements extracted from the device screen
|
778
|
+
"""
|
779
|
+
try:
|
780
|
+
# Get the device
|
781
|
+
if serial:
|
782
|
+
device_manager = DeviceManager()
|
783
|
+
device = await device_manager.get_device(serial)
|
784
|
+
if not device:
|
785
|
+
raise ValueError(f"Device {serial} not found")
|
786
|
+
else:
|
787
|
+
device = await get_device()
|
788
|
+
|
789
|
+
# Create a temporary file for the JSON
|
790
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as temp:
|
791
|
+
local_path = temp.name
|
792
|
+
|
793
|
+
try:
|
794
|
+
# Clear logcat to make it easier to find our output
|
795
|
+
await device._adb.shell(device._serial, "logcat -c")
|
796
|
+
|
797
|
+
# Trigger the custom service via broadcast to get ALL elements
|
798
|
+
await device._adb.shell(device._serial, "am broadcast -a com.droidrun.portal.GET_ALL_ELEMENTS")
|
799
|
+
|
800
|
+
# Poll for the JSON file path
|
801
|
+
start_time = asyncio.get_event_loop().time()
|
802
|
+
max_wait_time = 10 # Maximum wait time in seconds
|
803
|
+
poll_interval = 0.2 # Check every 200ms
|
804
|
+
|
805
|
+
device_path = None
|
806
|
+
while asyncio.get_event_loop().time() - start_time < max_wait_time:
|
807
|
+
# Check logcat for the file path
|
808
|
+
logcat_output = await device._adb.shell(device._serial, "logcat -d | grep \"DROIDRUN_FILE\" | grep \"JSON data written to\" | tail -1")
|
809
|
+
|
810
|
+
# Parse the file path if present
|
811
|
+
match = re.search(r"JSON data written to: (.*)", logcat_output)
|
812
|
+
if match:
|
813
|
+
device_path = match.group(1).strip()
|
814
|
+
break
|
815
|
+
|
816
|
+
# Wait before polling again
|
817
|
+
await asyncio.sleep(poll_interval)
|
818
|
+
|
819
|
+
# Check if we found the file path
|
820
|
+
if not device_path:
|
821
|
+
raise ValueError(f"Failed to find the JSON file path in logcat after {max_wait_time} seconds")
|
822
|
+
|
823
|
+
# Pull the JSON file from the device
|
824
|
+
await device._adb.pull_file(device._serial, device_path, local_path)
|
825
|
+
|
826
|
+
# Read the JSON file
|
827
|
+
async with aiofiles.open(local_path, "r", encoding="utf-8") as f:
|
828
|
+
json_content = await f.read()
|
829
|
+
|
830
|
+
# Clean up the temporary file
|
831
|
+
with contextlib.suppress(OSError):
|
832
|
+
os.unlink(local_path)
|
833
|
+
|
834
|
+
# Try to parse the JSON
|
835
|
+
import json
|
836
|
+
try:
|
837
|
+
ui_data = json.loads(json_content)
|
838
|
+
|
839
|
+
return {
|
840
|
+
"all_elements": ui_data,
|
841
|
+
"count": len(ui_data) if isinstance(ui_data, list) else sum(1 for _ in ui_data.get("elements", [])),
|
842
|
+
"message": "Retrieved all UI elements from the device screen"
|
843
|
+
}
|
844
|
+
except json.JSONDecodeError:
|
845
|
+
raise ValueError("Failed to parse UI elements JSON data")
|
846
|
+
|
847
|
+
except Exception as e:
|
848
|
+
# Clean up in case of error
|
849
|
+
with contextlib.suppress(OSError):
|
850
|
+
os.unlink(local_path)
|
851
|
+
raise ValueError(f"Error retrieving all UI elements: {e}")
|
852
|
+
|
853
|
+
except Exception as e:
|
854
|
+
raise ValueError(f"Error getting all UI elements: {e}")
|