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 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
@@ -0,0 +1,8 @@
1
+ """
2
+ DroidRun main entry point
3
+ """
4
+
5
+ from droidrun.cli.main import cli
6
+
7
+ if __name__ == '__main__':
8
+ cli()
@@ -0,0 +1,13 @@
1
+ """
2
+ ADB Package - Android Debug Bridge functionality.
3
+ """
4
+
5
+ from .device import Device
6
+ from .manager import DeviceManager
7
+ from .wrapper import ADBWrapper
8
+
9
+ __all__ = [
10
+ 'Device',
11
+ 'DeviceManager',
12
+ 'ADBWrapper',
13
+ ]
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
@@ -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
@@ -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
+ ]