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
droidrun/__init__.py
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
"""
|
2
|
+
DroidRun - A framework for controlling Android devices through LLM agents.
|
3
|
+
"""
|
4
|
+
|
5
|
+
__version__ = "0.1.0"
|
6
|
+
|
7
|
+
# Import main classes for easier access
|
8
|
+
from droidrun.agent.react_agent import ReActAgent as Agent
|
9
|
+
from droidrun.agent.react_agent import ReActStep, ReActStepType
|
10
|
+
from droidrun.llm import OpenAILLM, AnthropicLLM
|
11
|
+
|
12
|
+
# Make main components available at package level
|
13
|
+
__all__ = [
|
14
|
+
"Agent",
|
15
|
+
"ReActStep",
|
16
|
+
"ReActStepType",
|
17
|
+
"OpenAILLM",
|
18
|
+
"AnthropicLLM",
|
19
|
+
]
|
droidrun/__main__.py
ADDED
droidrun/adb/__init__.py
ADDED
droidrun/adb/device.py
ADDED
@@ -0,0 +1,315 @@
|
|
1
|
+
"""
|
2
|
+
Device - High-level representation of an Android device.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import os
|
6
|
+
import tempfile
|
7
|
+
import time
|
8
|
+
import random
|
9
|
+
import string
|
10
|
+
from typing import Dict, Optional, Tuple, List
|
11
|
+
from .wrapper import ADBWrapper
|
12
|
+
|
13
|
+
class Device:
|
14
|
+
"""High-level representation of an Android device."""
|
15
|
+
|
16
|
+
def __init__(self, serial: str, adb: ADBWrapper):
|
17
|
+
"""Initialize device.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
serial: Device serial number
|
21
|
+
adb: ADB wrapper instance
|
22
|
+
"""
|
23
|
+
self._serial = serial
|
24
|
+
self._adb = adb
|
25
|
+
self._properties_cache: Dict[str, str] = {}
|
26
|
+
|
27
|
+
@property
|
28
|
+
def serial(self) -> str:
|
29
|
+
"""Get device serial number."""
|
30
|
+
return self._serial
|
31
|
+
|
32
|
+
async def get_properties(self) -> Dict[str, str]:
|
33
|
+
"""Get all device properties."""
|
34
|
+
if not self._properties_cache:
|
35
|
+
self._properties_cache = await self._adb.get_properties(self._serial)
|
36
|
+
return self._properties_cache
|
37
|
+
|
38
|
+
async def get_property(self, name: str) -> str:
|
39
|
+
"""Get a specific device property."""
|
40
|
+
props = await self.get_properties()
|
41
|
+
return props.get(name, "")
|
42
|
+
|
43
|
+
@property
|
44
|
+
async def model(self) -> str:
|
45
|
+
"""Get device model."""
|
46
|
+
return await self.get_property("ro.product.model")
|
47
|
+
|
48
|
+
@property
|
49
|
+
async def brand(self) -> str:
|
50
|
+
"""Get device brand."""
|
51
|
+
return await self.get_property("ro.product.brand")
|
52
|
+
|
53
|
+
@property
|
54
|
+
async def android_version(self) -> str:
|
55
|
+
"""Get Android version."""
|
56
|
+
return await self.get_property("ro.build.version.release")
|
57
|
+
|
58
|
+
@property
|
59
|
+
async def sdk_level(self) -> str:
|
60
|
+
"""Get SDK level."""
|
61
|
+
return await self.get_property("ro.build.version.sdk")
|
62
|
+
|
63
|
+
async def tap(self, x: int, y: int) -> None:
|
64
|
+
"""Tap at coordinates.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
x: X coordinate
|
68
|
+
y: Y coordinate
|
69
|
+
"""
|
70
|
+
await self._adb.shell(self._serial, f"input tap {x} {y}")
|
71
|
+
|
72
|
+
async def swipe(
|
73
|
+
self,
|
74
|
+
start_x: int,
|
75
|
+
start_y: int,
|
76
|
+
end_x: int,
|
77
|
+
end_y: int,
|
78
|
+
duration_ms: int = 300
|
79
|
+
) -> None:
|
80
|
+
"""Perform swipe gesture.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
start_x: Starting X coordinate
|
84
|
+
start_y: Starting Y coordinate
|
85
|
+
end_x: Ending X coordinate
|
86
|
+
end_y: Ending Y coordinate
|
87
|
+
duration_ms: Swipe duration in milliseconds
|
88
|
+
"""
|
89
|
+
await self._adb.shell(
|
90
|
+
self._serial,
|
91
|
+
f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
|
92
|
+
)
|
93
|
+
|
94
|
+
async def input_text(self, text: str) -> None:
|
95
|
+
"""Input text.
|
96
|
+
|
97
|
+
Args:
|
98
|
+
text: Text to input
|
99
|
+
"""
|
100
|
+
await self._adb.shell(self._serial, f"input text {text}")
|
101
|
+
|
102
|
+
async def press_key(self, keycode: int) -> None:
|
103
|
+
"""Press a key.
|
104
|
+
|
105
|
+
Args:
|
106
|
+
keycode: Android keycode to press
|
107
|
+
"""
|
108
|
+
await self._adb.shell(self._serial, f"input keyevent {keycode}")
|
109
|
+
|
110
|
+
async def start_activity(
|
111
|
+
self,
|
112
|
+
package: str,
|
113
|
+
activity: str = ".MainActivity",
|
114
|
+
extras: Optional[Dict[str, str]] = None
|
115
|
+
) -> None:
|
116
|
+
"""Start an app activity.
|
117
|
+
|
118
|
+
Args:
|
119
|
+
package: Package name
|
120
|
+
activity: Activity name
|
121
|
+
extras: Intent extras
|
122
|
+
"""
|
123
|
+
cmd = f"am start -n {package}/{activity}"
|
124
|
+
if extras:
|
125
|
+
for key, value in extras.items():
|
126
|
+
cmd += f" -e {key} {value}"
|
127
|
+
await self._adb.shell(self._serial, cmd)
|
128
|
+
|
129
|
+
async def start_app(self, package: str, activity: str = "") -> str:
|
130
|
+
"""Start an app on the device.
|
131
|
+
|
132
|
+
Args:
|
133
|
+
package: Package name
|
134
|
+
activity: Optional activity name (if empty, launches default activity)
|
135
|
+
|
136
|
+
Returns:
|
137
|
+
Result message
|
138
|
+
"""
|
139
|
+
if activity:
|
140
|
+
if not activity.startswith(".") and "." not in activity:
|
141
|
+
activity = f".{activity}"
|
142
|
+
|
143
|
+
if not activity.startswith(".") and "." in activity and not activity.startswith(package):
|
144
|
+
# Fully qualified activity name
|
145
|
+
component = activity.split("/", 1)
|
146
|
+
return await self.start_activity(component[0], component[1] if len(component) > 1 else activity)
|
147
|
+
|
148
|
+
# Relative activity name
|
149
|
+
return await self.start_activity(package, activity)
|
150
|
+
|
151
|
+
# Start main activity using monkey
|
152
|
+
cmd = f"monkey -p {package} -c android.intent.category.LAUNCHER 1"
|
153
|
+
result = await self._adb.shell(self._serial, cmd)
|
154
|
+
return f"Started {package}"
|
155
|
+
|
156
|
+
async def install_app(self, apk_path: str, reinstall: bool = False, grant_permissions: bool = True) -> str:
|
157
|
+
"""Install an APK on the device.
|
158
|
+
|
159
|
+
Args:
|
160
|
+
apk_path: Path to the APK file
|
161
|
+
reinstall: Whether to reinstall if app exists
|
162
|
+
grant_permissions: Whether to grant all requested permissions
|
163
|
+
|
164
|
+
Returns:
|
165
|
+
Installation result
|
166
|
+
"""
|
167
|
+
if not os.path.exists(apk_path):
|
168
|
+
return f"Error: APK file not found: {apk_path}"
|
169
|
+
|
170
|
+
# Build install command args
|
171
|
+
install_args = ["install"]
|
172
|
+
if reinstall:
|
173
|
+
install_args.append("-r")
|
174
|
+
if grant_permissions:
|
175
|
+
install_args.append("-g")
|
176
|
+
install_args.append(apk_path)
|
177
|
+
|
178
|
+
try:
|
179
|
+
stdout, stderr = await self._adb._run_device_command(
|
180
|
+
self._serial,
|
181
|
+
install_args,
|
182
|
+
timeout=120 # Longer timeout for installation
|
183
|
+
)
|
184
|
+
|
185
|
+
if "success" in stdout.lower():
|
186
|
+
return f"Successfully installed {os.path.basename(apk_path)}"
|
187
|
+
return f"Installation failed: {stdout or stderr}"
|
188
|
+
|
189
|
+
except Exception as e:
|
190
|
+
return f"Installation failed: {str(e)}"
|
191
|
+
|
192
|
+
async def uninstall_app(self, package: str, keep_data: bool = False) -> str:
|
193
|
+
"""Uninstall an app from the device.
|
194
|
+
|
195
|
+
Args:
|
196
|
+
package: Package name to uninstall
|
197
|
+
keep_data: Whether to keep app data and cache directories
|
198
|
+
|
199
|
+
Returns:
|
200
|
+
Uninstallation result
|
201
|
+
"""
|
202
|
+
cmd = ["pm", "uninstall"]
|
203
|
+
if keep_data:
|
204
|
+
cmd.append("-k")
|
205
|
+
cmd.append(package)
|
206
|
+
|
207
|
+
result = await self._adb.shell(self._serial, " ".join(cmd))
|
208
|
+
return result.strip()
|
209
|
+
|
210
|
+
async def take_screenshot(self, quality: int = 75) -> Tuple[str, bytes]:
|
211
|
+
"""Take a screenshot of the device and compress it.
|
212
|
+
|
213
|
+
Args:
|
214
|
+
quality: JPEG quality (1-100, lower means smaller file size)
|
215
|
+
|
216
|
+
Returns:
|
217
|
+
Tuple of (local file path, screenshot data as bytes)
|
218
|
+
"""
|
219
|
+
# Create a temporary file for the screenshot
|
220
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp:
|
221
|
+
screenshot_path = temp.name
|
222
|
+
|
223
|
+
try:
|
224
|
+
# Generate a random filename for the device
|
225
|
+
timestamp = int(time.time())
|
226
|
+
random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
227
|
+
device_path = f"/sdcard/screenshot_{timestamp}_{random_suffix}.png"
|
228
|
+
|
229
|
+
# Take screenshot using screencap command
|
230
|
+
await self._adb.shell(self._serial, f"screencap -p {device_path}")
|
231
|
+
|
232
|
+
# Pull screenshot to local machine
|
233
|
+
await self._adb._run_device_command(
|
234
|
+
self._serial,
|
235
|
+
["pull", device_path, screenshot_path]
|
236
|
+
)
|
237
|
+
|
238
|
+
# Clean up on device
|
239
|
+
await self._adb.shell(self._serial, f"rm {device_path}")
|
240
|
+
|
241
|
+
# Read the screenshot file
|
242
|
+
with open(screenshot_path, "rb") as f:
|
243
|
+
screenshot_data = f.read()
|
244
|
+
|
245
|
+
# Convert and compress the image
|
246
|
+
try:
|
247
|
+
from PIL import Image
|
248
|
+
import io
|
249
|
+
|
250
|
+
# Create buffer for the compressed image
|
251
|
+
buffer = io.BytesIO()
|
252
|
+
|
253
|
+
# Load the PNG data into a PIL Image
|
254
|
+
with Image.open(io.BytesIO(screenshot_data)) as img:
|
255
|
+
# Convert to RGB (removing alpha channel if present) and save as JPEG
|
256
|
+
converted_img = img.convert("RGB") if img.mode == "RGBA" else img
|
257
|
+
converted_img.save(buffer, format="JPEG", quality=quality, optimize=True)
|
258
|
+
compressed_data = buffer.getvalue()
|
259
|
+
|
260
|
+
# Get size reduction info for logging
|
261
|
+
png_size = len(screenshot_data) / 1024
|
262
|
+
jpg_size = len(compressed_data) / 1024
|
263
|
+
reduction = 100 - (jpg_size / png_size * 100) if png_size > 0 else 0
|
264
|
+
|
265
|
+
import logging
|
266
|
+
logger = logging.getLogger("droidrun")
|
267
|
+
logger.info(
|
268
|
+
f"Screenshot compressed successfully: {png_size:.1f}KB → {jpg_size:.1f}KB ({reduction:.1f}% reduction)"
|
269
|
+
)
|
270
|
+
|
271
|
+
return screenshot_path, compressed_data
|
272
|
+
except ImportError:
|
273
|
+
# If PIL is not available, return the original PNG data
|
274
|
+
logger.warning("PIL not available, returning uncompressed screenshot")
|
275
|
+
return screenshot_path, screenshot_data
|
276
|
+
except Exception as e:
|
277
|
+
# If compression fails, return the original PNG data
|
278
|
+
logger.warning(f"Screenshot compression failed: {e}, returning uncompressed")
|
279
|
+
return screenshot_path, screenshot_data
|
280
|
+
|
281
|
+
except Exception as e:
|
282
|
+
# Clean up in case of error
|
283
|
+
try:
|
284
|
+
os.unlink(screenshot_path)
|
285
|
+
except OSError:
|
286
|
+
pass
|
287
|
+
raise RuntimeError(f"Screenshot capture failed: {str(e)}")
|
288
|
+
|
289
|
+
async def list_packages(self, include_system_apps: bool = False) -> List[Dict[str, str]]:
|
290
|
+
"""List installed packages on the device.
|
291
|
+
|
292
|
+
Args:
|
293
|
+
include_system_apps: Whether to include system apps
|
294
|
+
|
295
|
+
Returns:
|
296
|
+
List of package dictionaries with 'package' and 'path' keys
|
297
|
+
"""
|
298
|
+
cmd = ["pm", "list", "packages", "-f"]
|
299
|
+
if not include_system_apps:
|
300
|
+
cmd.append("-3")
|
301
|
+
|
302
|
+
output = await self._adb.shell(self._serial, " ".join(cmd))
|
303
|
+
|
304
|
+
packages = []
|
305
|
+
for line in output.splitlines():
|
306
|
+
if line.startswith("package:"):
|
307
|
+
parts = line[8:].split("=")
|
308
|
+
if len(parts) == 2:
|
309
|
+
path, package = parts
|
310
|
+
packages.append({
|
311
|
+
"package": package,
|
312
|
+
"path": path
|
313
|
+
})
|
314
|
+
|
315
|
+
return packages
|
droidrun/adb/manager.py
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
"""
|
2
|
+
Device Manager - Manages Android device connections.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from typing import Dict, List, Optional
|
6
|
+
from .wrapper import ADBWrapper
|
7
|
+
from .device import Device
|
8
|
+
|
9
|
+
class DeviceManager:
|
10
|
+
"""Manages Android device connections."""
|
11
|
+
|
12
|
+
def __init__(self, adb_path: Optional[str] = None):
|
13
|
+
"""Initialize device manager.
|
14
|
+
|
15
|
+
Args:
|
16
|
+
adb_path: Path to ADB binary
|
17
|
+
"""
|
18
|
+
self._adb = ADBWrapper(adb_path)
|
19
|
+
self._devices: Dict[str, Device] = {}
|
20
|
+
|
21
|
+
async def list_devices(self) -> List[Device]:
|
22
|
+
"""List connected devices.
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
List of connected devices
|
26
|
+
"""
|
27
|
+
devices_info = await self._adb.get_devices()
|
28
|
+
|
29
|
+
# Update device cache
|
30
|
+
current_serials = set()
|
31
|
+
for device_info in devices_info:
|
32
|
+
serial = device_info["serial"]
|
33
|
+
current_serials.add(serial)
|
34
|
+
|
35
|
+
if serial not in self._devices:
|
36
|
+
self._devices[serial] = Device(serial, self._adb)
|
37
|
+
|
38
|
+
# Remove disconnected devices
|
39
|
+
for serial in list(self._devices.keys()):
|
40
|
+
if serial not in current_serials:
|
41
|
+
del self._devices[serial]
|
42
|
+
|
43
|
+
return list(self._devices.values())
|
44
|
+
|
45
|
+
async def get_device(self, serial: str) -> Optional[Device]:
|
46
|
+
"""Get a specific device.
|
47
|
+
|
48
|
+
Args:
|
49
|
+
serial: Device serial number
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
Device instance if found, None otherwise
|
53
|
+
"""
|
54
|
+
if serial in self._devices:
|
55
|
+
return self._devices[serial]
|
56
|
+
|
57
|
+
# Try to find the device
|
58
|
+
devices = await self.list_devices()
|
59
|
+
for device in devices:
|
60
|
+
if device.serial == serial:
|
61
|
+
return device
|
62
|
+
|
63
|
+
return None
|
64
|
+
|
65
|
+
async def connect(self, host: str, port: int = 5555) -> Optional[Device]:
|
66
|
+
"""Connect to a device over TCP/IP.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
host: Device IP address
|
70
|
+
port: Device port
|
71
|
+
|
72
|
+
Returns:
|
73
|
+
Connected device instance
|
74
|
+
"""
|
75
|
+
try:
|
76
|
+
serial = await self._adb.connect(host, port)
|
77
|
+
return await self.get_device(serial)
|
78
|
+
except Exception:
|
79
|
+
return None
|
80
|
+
|
81
|
+
async def disconnect(self, serial: str) -> bool:
|
82
|
+
"""Disconnect from a device.
|
83
|
+
|
84
|
+
Args:
|
85
|
+
serial: Device serial number
|
86
|
+
|
87
|
+
Returns:
|
88
|
+
True if disconnected successfully
|
89
|
+
"""
|
90
|
+
success = await self._adb.disconnect(serial)
|
91
|
+
if success and serial in self._devices:
|
92
|
+
del self._devices[serial]
|
93
|
+
return success
|
droidrun/adb/wrapper.py
ADDED
@@ -0,0 +1,226 @@
|
|
1
|
+
"""
|
2
|
+
ADB Wrapper - Lightweight wrapper around ADB for Android device control.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
import os
|
7
|
+
import shlex
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
9
|
+
|
10
|
+
class ADBWrapper:
|
11
|
+
"""Lightweight wrapper around ADB for Android device control."""
|
12
|
+
|
13
|
+
def __init__(self, adb_path: Optional[str] = None):
|
14
|
+
"""Initialize ADB wrapper.
|
15
|
+
|
16
|
+
Args:
|
17
|
+
adb_path: Path to ADB binary (defaults to 'adb' in PATH)
|
18
|
+
"""
|
19
|
+
self.adb_path = adb_path or "adb"
|
20
|
+
self._devices_cache: List[Dict[str, str]] = []
|
21
|
+
|
22
|
+
async def _run_command(
|
23
|
+
self,
|
24
|
+
args: List[str],
|
25
|
+
timeout: Optional[float] = None,
|
26
|
+
check: bool = True
|
27
|
+
) -> Tuple[str, str]:
|
28
|
+
"""Run an ADB command.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
args: Command arguments
|
32
|
+
timeout: Command timeout in seconds
|
33
|
+
check: Whether to check return code
|
34
|
+
|
35
|
+
Returns:
|
36
|
+
Tuple of (stdout, stderr)
|
37
|
+
"""
|
38
|
+
cmd = [self.adb_path, *args]
|
39
|
+
|
40
|
+
try:
|
41
|
+
process = await asyncio.create_subprocess_exec(
|
42
|
+
*cmd,
|
43
|
+
stdout=asyncio.subprocess.PIPE,
|
44
|
+
stderr=asyncio.subprocess.PIPE,
|
45
|
+
)
|
46
|
+
|
47
|
+
if timeout is not None:
|
48
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
49
|
+
process.communicate(), timeout
|
50
|
+
)
|
51
|
+
else:
|
52
|
+
stdout_bytes, stderr_bytes = await process.communicate()
|
53
|
+
|
54
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
55
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
56
|
+
|
57
|
+
if check and process.returncode != 0:
|
58
|
+
raise RuntimeError(f"ADB command failed: {stderr or stdout}")
|
59
|
+
|
60
|
+
return stdout, stderr
|
61
|
+
|
62
|
+
except asyncio.TimeoutError:
|
63
|
+
raise TimeoutError(f"ADB command timed out: {' '.join(cmd)}")
|
64
|
+
except FileNotFoundError:
|
65
|
+
raise FileNotFoundError(f"ADB not found at {self.adb_path}")
|
66
|
+
|
67
|
+
async def _run_device_command(
|
68
|
+
self,
|
69
|
+
serial: str,
|
70
|
+
args: List[str],
|
71
|
+
timeout: Optional[float] = None,
|
72
|
+
check: bool = True
|
73
|
+
) -> Tuple[str, str]:
|
74
|
+
"""Run an ADB command for a specific device."""
|
75
|
+
return await self._run_command(["-s", serial, *args], timeout, check)
|
76
|
+
|
77
|
+
async def get_devices(self) -> List[Dict[str, str]]:
|
78
|
+
"""Get list of connected devices.
|
79
|
+
|
80
|
+
Returns:
|
81
|
+
List of device info dictionaries with 'serial' and 'status' keys
|
82
|
+
"""
|
83
|
+
stdout, _ = await self._run_command(["devices", "-l"])
|
84
|
+
|
85
|
+
devices = []
|
86
|
+
for line in stdout.splitlines()[1:]: # Skip first line (header)
|
87
|
+
if not line.strip():
|
88
|
+
continue
|
89
|
+
|
90
|
+
parts = line.split()
|
91
|
+
if not parts:
|
92
|
+
continue
|
93
|
+
|
94
|
+
serial = parts[0]
|
95
|
+
status = parts[1] if len(parts) > 1 else "unknown"
|
96
|
+
|
97
|
+
devices.append({
|
98
|
+
"serial": serial,
|
99
|
+
"status": status
|
100
|
+
})
|
101
|
+
|
102
|
+
self._devices_cache = devices
|
103
|
+
return devices
|
104
|
+
|
105
|
+
async def connect(self, host: str, port: int = 5555) -> str:
|
106
|
+
"""Connect to a device over TCP/IP.
|
107
|
+
|
108
|
+
Args:
|
109
|
+
host: Device IP address
|
110
|
+
port: Device port
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
Device serial number (host:port)
|
114
|
+
"""
|
115
|
+
serial = f"{host}:{port}"
|
116
|
+
|
117
|
+
# Check if already connected
|
118
|
+
devices = await self.get_devices()
|
119
|
+
if any(d["serial"] == serial for d in devices):
|
120
|
+
return serial
|
121
|
+
|
122
|
+
stdout, _ = await self._run_command(["connect", serial], timeout=10.0)
|
123
|
+
|
124
|
+
if "connected" not in stdout.lower():
|
125
|
+
raise RuntimeError(f"Failed to connect: {stdout}")
|
126
|
+
|
127
|
+
return serial
|
128
|
+
|
129
|
+
async def disconnect(self, serial: str) -> bool:
|
130
|
+
"""Disconnect from a device.
|
131
|
+
|
132
|
+
Args:
|
133
|
+
serial: Device serial number
|
134
|
+
|
135
|
+
Returns:
|
136
|
+
True if disconnected successfully
|
137
|
+
"""
|
138
|
+
try:
|
139
|
+
stdout, _ = await self._run_command(["disconnect", serial])
|
140
|
+
return "disconnected" in stdout.lower()
|
141
|
+
except Exception:
|
142
|
+
return False
|
143
|
+
|
144
|
+
async def shell(self, serial: str, command: str, timeout: Optional[float] = None) -> str:
|
145
|
+
"""Run a shell command on the device.
|
146
|
+
|
147
|
+
Args:
|
148
|
+
serial: Device serial number
|
149
|
+
command: Shell command to run
|
150
|
+
timeout: Command timeout in seconds
|
151
|
+
|
152
|
+
Returns:
|
153
|
+
Command output
|
154
|
+
"""
|
155
|
+
stdout, _ = await self._run_device_command(serial, ["shell", command], timeout=timeout)
|
156
|
+
return stdout
|
157
|
+
|
158
|
+
async def get_properties(self, serial: str) -> Dict[str, str]:
|
159
|
+
"""Get device properties.
|
160
|
+
|
161
|
+
Args:
|
162
|
+
serial: Device serial number
|
163
|
+
|
164
|
+
Returns:
|
165
|
+
Dictionary of device properties
|
166
|
+
"""
|
167
|
+
output = await self.shell(serial, "getprop")
|
168
|
+
|
169
|
+
properties = {}
|
170
|
+
for line in output.splitlines():
|
171
|
+
if not line or "[" not in line or "]" not in line:
|
172
|
+
continue
|
173
|
+
|
174
|
+
try:
|
175
|
+
key = line.split("[")[1].split("]")[0]
|
176
|
+
value = line.split("[")[2].split("]")[0]
|
177
|
+
properties[key] = value
|
178
|
+
except IndexError:
|
179
|
+
continue
|
180
|
+
|
181
|
+
return properties
|
182
|
+
|
183
|
+
async def install_app(
|
184
|
+
self,
|
185
|
+
serial: str,
|
186
|
+
apk_path: str,
|
187
|
+
reinstall: bool = False,
|
188
|
+
grant_permissions: bool = True
|
189
|
+
) -> Tuple[str, str]:
|
190
|
+
"""Install an APK on the device.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
serial: Device serial number
|
194
|
+
apk_path: Path to the APK file
|
195
|
+
reinstall: Whether to reinstall if app exists
|
196
|
+
grant_permissions: Whether to grant all permissions
|
197
|
+
|
198
|
+
Returns:
|
199
|
+
Tuple of (stdout, stderr)
|
200
|
+
"""
|
201
|
+
args = ["install"]
|
202
|
+
if reinstall:
|
203
|
+
args.append("-r")
|
204
|
+
if grant_permissions:
|
205
|
+
args.append("-g")
|
206
|
+
args.append(apk_path)
|
207
|
+
|
208
|
+
return await self._run_device_command(serial, args, timeout=120.0)
|
209
|
+
|
210
|
+
async def pull_file(self, serial: str, device_path: str, local_path: str) -> Tuple[str, str]:
|
211
|
+
"""Pull a file from the device.
|
212
|
+
|
213
|
+
Args:
|
214
|
+
serial: Device serial number
|
215
|
+
device_path: Path on the device
|
216
|
+
local_path: Path on the local machine
|
217
|
+
|
218
|
+
Returns:
|
219
|
+
Tuple of (stdout, stderr)
|
220
|
+
"""
|
221
|
+
# Create directory if it doesn't exist
|
222
|
+
local_dir = os.path.dirname(local_path)
|
223
|
+
if local_dir and not os.path.exists(local_dir):
|
224
|
+
os.makedirs(local_dir)
|
225
|
+
|
226
|
+
return await self._run_device_command(serial, ["pull", device_path, local_path], timeout=60.0)
|
@@ -0,0 +1,16 @@
|
|
1
|
+
"""
|
2
|
+
Droidrun Agent Module.
|
3
|
+
|
4
|
+
This module provides a ReAct agent for automating Android devices using reasoning and acting.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from .react_agent import ReActAgent, ReActStep, ReActStepType, run_agent
|
8
|
+
from .llm_reasoning import LLMReasoner
|
9
|
+
|
10
|
+
__all__ = [
|
11
|
+
"ReActAgent",
|
12
|
+
"ReActStep",
|
13
|
+
"ReActStepType",
|
14
|
+
"run_agent",
|
15
|
+
"LLMReasoner",
|
16
|
+
]
|