droidrun 0.3.2__py3-none-any.whl → 0.3.4__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.
@@ -0,0 +1,14 @@
1
+ """
2
+ DroidRun Macro Module - Record and replay UI automation sequences.
3
+
4
+ This module provides functionality to replay macro sequences that were
5
+ recorded during DroidAgent execution.
6
+ """
7
+
8
+ from .replay import MacroPlayer, replay_macro_file, replay_macro_folder
9
+
10
+ __all__ = [
11
+ "MacroPlayer",
12
+ "replay_macro_file",
13
+ "replay_macro_folder"
14
+ ]
@@ -0,0 +1,10 @@
1
+ """
2
+ Entry point for running DroidRun macro CLI as a module.
3
+
4
+ Usage: python -m droidrun.macro <command>
5
+ """
6
+
7
+ from droidrun.macro.cli import macro_cli
8
+
9
+ if __name__ == "__main__":
10
+ macro_cli()
droidrun/macro/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """
2
+ Command-line interface for DroidRun macro replay.
3
+ """
4
+
5
+ import asyncio
6
+ import click
7
+ import logging
8
+ import os
9
+ from typing import Optional
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from droidrun.macro.replay import MacroPlayer, replay_macro_file, replay_macro_folder
13
+ from droidrun.agent.utils.trajectory import Trajectory
14
+ from adbutils import adb
15
+
16
+ console = Console()
17
+
18
+
19
+ def configure_logging(debug: bool = False):
20
+ """Configure logging for the macro CLI."""
21
+ logger = logging.getLogger("droidrun-macro")
22
+ logger.handlers = []
23
+
24
+ handler = logging.StreamHandler()
25
+
26
+ if debug:
27
+ level = logging.DEBUG
28
+ formatter = logging.Formatter("%(levelname)s %(name)s %(message)s", "%H:%M:%S")
29
+ else:
30
+ level = logging.INFO
31
+ formatter = logging.Formatter("%(message)s", "%H:%M:%S")
32
+
33
+ handler.setFormatter(formatter)
34
+ logger.addHandler(handler)
35
+ logger.setLevel(level)
36
+ logger.propagate = False
37
+
38
+ return logger
39
+
40
+
41
+ @click.group()
42
+ def macro_cli():
43
+ """Replay recorded automation sequences."""
44
+ pass
45
+
46
+
47
+ @macro_cli.command()
48
+ @click.argument("path", type=click.Path(exists=True))
49
+ @click.option("--device", "-d", help="Device serial number", default=None)
50
+ @click.option("--delay", "-t", help="Delay between actions (seconds)", default=1.0, type=float)
51
+ @click.option("--start-from", "-s", help="Start from step number (1-based)", default=1, type=int)
52
+ @click.option("--max-steps", "-m", help="Maximum steps to execute", default=None, type=int)
53
+ @click.option("--debug", is_flag=True, help="Enable debug logging", default=False)
54
+ @click.option("--dry-run", is_flag=True, help="Show actions without executing", default=False)
55
+ def replay(path: str, device: Optional[str], delay: float, start_from: int, max_steps: Optional[int], debug: bool, dry_run: bool):
56
+ """Replay a macro from a file or trajectory folder."""
57
+ logger = configure_logging(debug)
58
+
59
+ logger.info("🎬 DroidRun Macro Replay")
60
+
61
+ # Convert start_from from 1-based to 0-based
62
+ start_from_zero = max(0, start_from - 1)
63
+
64
+ if device is None:
65
+ logger.info("🔍 Finding connected device...")
66
+ devices = adb.list()
67
+ if not devices:
68
+ raise ValueError("No connected devices found.")
69
+ device = devices[0].serial
70
+ logger.info(f"📱 Using device: {device}")
71
+ else:
72
+ logger.info(f"📱 Using device: {device}")
73
+
74
+ asyncio.run(_replay_async(path, device, delay, start_from_zero, max_steps, dry_run, logger))
75
+
76
+
77
+ async def _replay_async(path: str, device: str, delay: float, start_from: int, max_steps: Optional[int], dry_run: bool, logger: logging.Logger):
78
+ """Async function to handle macro replay."""
79
+ try:
80
+ if os.path.isfile(path):
81
+ logger.info(f"📄 Loading macro from file: {path}")
82
+ player = MacroPlayer(device_serial=device, delay_between_actions=delay)
83
+ macro_data = player.load_macro_from_file(path)
84
+ elif os.path.isdir(path):
85
+ logger.info(f"📁 Loading macro from folder: {path}")
86
+ player = MacroPlayer(device_serial=device, delay_between_actions=delay)
87
+ macro_data = player.load_macro_from_folder(path)
88
+ else:
89
+ logger.error(f"❌ Invalid path: {path}")
90
+ return
91
+
92
+ if not macro_data:
93
+ logger.error("❌ Failed to load macro data")
94
+ return
95
+
96
+ # Show macro information
97
+ description = macro_data.get("description", "No description")
98
+ total_actions = macro_data.get("total_actions", 0)
99
+ version = macro_data.get("version", "unknown")
100
+
101
+ logger.info("📋 Macro Information:")
102
+ logger.info(f" Description: {description}")
103
+ logger.info(f" Version: {version}")
104
+ logger.info(f" Total actions: {total_actions}")
105
+ logger.info(f" Device: {device}")
106
+ logger.info(f" Delay between actions: {delay}s")
107
+
108
+ if start_from > 0:
109
+ logger.info(f" Starting from step: {start_from + 1}")
110
+ if max_steps:
111
+ logger.info(f" Maximum steps: {max_steps}")
112
+
113
+ if dry_run:
114
+ logger.info("🔍 DRY RUN MODE - Actions will be shown but not executed")
115
+ await _show_dry_run(macro_data, start_from, max_steps, logger)
116
+ else:
117
+ logger.info("▶️ Starting macro replay...")
118
+ success = await player.replay_macro(macro_data, start_from_step=start_from, max_steps=max_steps)
119
+
120
+ if success:
121
+ logger.info("🎉 Macro replay completed successfully!")
122
+ else:
123
+ logger.error("💥 Macro replay completed with errors")
124
+
125
+ except Exception as e:
126
+ logger.error(f"💥 Error: {e}")
127
+ if logger.isEnabledFor(logging.DEBUG):
128
+ import traceback
129
+ logger.debug(traceback.format_exc())
130
+
131
+
132
+ async def _show_dry_run(macro_data: dict, start_from: int, max_steps: Optional[int], logger: logging.Logger):
133
+ """Show what actions would be executed in dry run mode."""
134
+ actions = macro_data.get("actions", [])
135
+
136
+ # Apply filters
137
+ if start_from > 0:
138
+ actions = actions[start_from:]
139
+ if max_steps:
140
+ actions = actions[:max_steps]
141
+
142
+ logger.info(f"📋 Found {len(actions)} actions to execute:")
143
+
144
+ table = Table(title="Actions to Execute")
145
+ table.add_column("Step", style="cyan")
146
+ table.add_column("Type", style="green")
147
+ table.add_column("Details", style="white")
148
+ table.add_column("Description", style="yellow")
149
+
150
+ for i, action in enumerate(actions, start=start_from + 1):
151
+ action_type = action.get("action_type", action.get("type", "unknown"))
152
+ details = ""
153
+
154
+ if action_type == "tap":
155
+ x, y = action.get("x", 0), action.get("y", 0)
156
+ element_text = action.get("element_text", "")
157
+ details = f"({x}, {y}) - '{element_text}'"
158
+ elif action_type == "swipe":
159
+ start_x, start_y = action.get("start_x", 0), action.get("start_y", 0)
160
+ end_x, end_y = action.get("end_x", 0), action.get("end_y", 0)
161
+ details = f"({start_x}, {start_y}) → ({end_x}, {end_y})"
162
+ elif action_type == "input_text":
163
+ text = action.get("text", "")
164
+ details = f"'{text}'"
165
+ elif action_type == "key_press":
166
+ key_name = action.get("key_name", "UNKNOWN")
167
+ details = f"{key_name}"
168
+
169
+ description = action.get("description", "")
170
+ table.add_row(str(i), action_type, details, description[:50] + "..." if len(description) > 50 else description)
171
+
172
+ # Still use console for table display as it's structured data
173
+ console.print(table)
174
+
175
+
176
+ @macro_cli.command()
177
+ @click.argument("directory", type=click.Path(exists=True), default="trajectories")
178
+ @click.option("--debug", is_flag=True, help="Enable debug logging", default=False)
179
+ def list(directory: str, debug: bool):
180
+ """List available trajectory folders in a directory."""
181
+ logger = configure_logging(debug)
182
+
183
+ logger.info(f"📁 Scanning directory: {directory}")
184
+
185
+ try:
186
+ folders = []
187
+ for item in os.listdir(directory):
188
+ item_path = os.path.join(directory, item)
189
+ if os.path.isdir(item_path):
190
+ macro_file = os.path.join(item_path, "macro.json")
191
+ if os.path.exists(macro_file):
192
+ # Load macro info
193
+ try:
194
+ macro_data = Trajectory.load_macro_sequence(item_path)
195
+ description = macro_data.get("description", "No description")
196
+ total_actions = macro_data.get("total_actions", 0)
197
+ folders.append((item, description, total_actions))
198
+ except Exception as e:
199
+ logger.debug(f"Error loading macro from {item}: {e}")
200
+ folders.append((item, "Error loading", 0))
201
+
202
+ if not folders:
203
+ logger.info("📭 No trajectory folders found")
204
+ return
205
+
206
+ logger.info(f"🎯 Found {len(folders)} trajectory(s):")
207
+
208
+ table = Table(title=f"Available Trajectories in {directory}")
209
+ table.add_column("Folder", style="cyan")
210
+ table.add_column("Description", style="white")
211
+ table.add_column("Actions", style="green")
212
+
213
+ for folder, description, actions in sorted(folders):
214
+ table.add_row(folder, description[:80] + "..." if len(description) > 80 else description, str(actions))
215
+
216
+ # Still use console for table display as it's structured data
217
+ console.print(table)
218
+ logger.info(f"💡 Use 'droidrun macro replay {directory}/<folder>' to replay a trajectory")
219
+
220
+ except Exception as e:
221
+ logger.error(f"💥 Error: {e}")
222
+ if logger.isEnabledFor(logging.DEBUG):
223
+ import traceback
224
+ logger.debug(traceback.format_exc())
225
+
226
+
227
+ if __name__ == "__main__":
228
+ macro_cli()
@@ -0,0 +1,309 @@
1
+ """
2
+ Macro Replay Module - Replay recorded UI automation sequences.
3
+
4
+ This module provides functionality to load and replay macro JSON files
5
+ that were generated during DroidAgent trajectory recording.
6
+ """
7
+
8
+ import json
9
+ import asyncio
10
+ import logging
11
+ import time
12
+ import os
13
+ from typing import Dict, List, Any, Optional
14
+ from droidrun.tools.adb import AdbTools
15
+ from droidrun.agent.utils.trajectory import Trajectory
16
+
17
+ logger = logging.getLogger("droidrun-macro")
18
+
19
+
20
+ class MacroPlayer:
21
+ """
22
+ A class for loading and replaying DroidRun macro sequences.
23
+
24
+ This player can execute recorded UI actions (taps, swipes, text input, key presses)
25
+ on Android devices using AdbTools.
26
+ """
27
+
28
+ def __init__(self, device_serial: str = None, delay_between_actions: float = 1.0):
29
+ """
30
+ Initialize the MacroPlayer.
31
+
32
+ Args:
33
+ device_serial: Serial number of the target device. If None, will use first available device.
34
+ delay_between_actions: Delay in seconds between each action (default: 1.0s)
35
+ """
36
+ self.device_serial = device_serial
37
+ self.delay_between_actions = delay_between_actions
38
+ self.adb_tools = None
39
+
40
+ def _initialize_tools(self) -> AdbTools:
41
+ """Initialize ADB tools for the target device."""
42
+ if self.adb_tools is None:
43
+ self.adb_tools = AdbTools(serial=self.device_serial)
44
+ logger.info(f"🤖 Initialized ADB tools for device: {self.device_serial}")
45
+ return self.adb_tools
46
+
47
+ def load_macro_from_file(self, macro_file_path: str) -> Dict[str, Any]:
48
+ """
49
+ Load macro data from a JSON file.
50
+
51
+ Args:
52
+ macro_file_path: Path to the macro JSON file
53
+
54
+ Returns:
55
+ Dictionary containing the macro data
56
+ """
57
+ return Trajectory.load_macro_sequence(macro_file_path)
58
+
59
+ def load_macro_from_folder(self, trajectory_folder: str) -> Dict[str, Any]:
60
+ """
61
+ Load macro data from a trajectory folder.
62
+
63
+ Args:
64
+ trajectory_folder: Path to the trajectory folder containing macro.json
65
+
66
+ Returns:
67
+ Dictionary containing the macro data
68
+ """
69
+ return Trajectory.load_macro_sequence(trajectory_folder)
70
+
71
+ def replay_action(self, action: Dict[str, Any]) -> bool:
72
+ """
73
+ Replay a single action.
74
+
75
+ Args:
76
+ action: Action dictionary containing type and parameters
77
+
78
+ Returns:
79
+ True if action was executed successfully, False otherwise
80
+ """
81
+ tools = self._initialize_tools()
82
+ action_type = action.get("action_type", action.get("type", "unknown"))
83
+
84
+ try:
85
+
86
+ if action_type == "start_app":
87
+ package = action.get("package")
88
+ activity = action.get("activity", None)
89
+ tools.start_app(package, activity)
90
+ return True
91
+
92
+ elif action_type == "tap":
93
+ x = action.get("x", 0)
94
+ y = action.get("y", 0)
95
+ element_text = action.get("element_text", "")
96
+
97
+ logger.info(f"🫰 Tapping at ({x}, {y}) - Element: '{element_text}'")
98
+ result = tools.tap_by_coordinates(x, y)
99
+ logger.debug(f" Result: {result}")
100
+ return True
101
+
102
+ elif action_type == "swipe":
103
+ start_x = action.get("start_x", 0)
104
+ start_y = action.get("start_y", 0)
105
+ end_x = action.get("end_x", 0)
106
+ end_y = action.get("end_y", 0)
107
+ duration_ms = action.get("duration_ms", 300)
108
+
109
+ logger.info(
110
+ f"👆 Swiping from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms} milliseconds"
111
+ )
112
+ result = tools.swipe(start_x, start_y, end_x, end_y, duration_ms)
113
+ logger.debug(f" Result: {result}")
114
+ return True
115
+
116
+ elif action_type == "drag":
117
+ start_x = action.get("start_x", 0)
118
+ start_y = action.get("start_y", 0)
119
+ end_x = action.get("end_x", 0)
120
+ end_y = action.get("end_y", 0)
121
+ duration_ms = action.get("duration_ms", 300)
122
+
123
+ logger.info(
124
+ f"👆 Dragging from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms} milliseconds"
125
+ )
126
+ result = tools.drag(start_x, start_y, end_x, end_y, duration_ms)
127
+ logger.debug(f" Result: {result}")
128
+ return True
129
+
130
+ elif action_type == "input_text":
131
+ text = action.get("text", "")
132
+
133
+ logger.info(f"⌨️ Inputting text: '{text}'")
134
+ result = tools.input_text(text)
135
+ logger.debug(f" Result: {result}")
136
+ return True
137
+
138
+ elif action_type == "key_press":
139
+ keycode = action.get("keycode", 0)
140
+ key_name = action.get("key_name", "UNKNOWN")
141
+
142
+ logger.info(f"🔘 Pressing key: {key_name} (keycode: {keycode})")
143
+ result = tools.press_key(keycode)
144
+ logger.debug(f" Result: {result}")
145
+ return True
146
+
147
+ elif action_type == "back":
148
+ logger.info(f"⬅️ Pressing back button")
149
+ result = tools.back()
150
+ logger.debug(f" Result: {result}")
151
+ return True
152
+
153
+ else:
154
+ logger.warning(f"⚠️ Unknown action type: {action_type}")
155
+ return False
156
+
157
+ except Exception as e:
158
+ logger.error(f"❌ Error executing action {action_type}: {e}")
159
+ return False
160
+
161
+ async def replay_macro(
162
+ self,
163
+ macro_data: Dict[str, Any],
164
+ start_from_step: int = 0,
165
+ max_steps: Optional[int] = None,
166
+ ) -> bool:
167
+ """
168
+ Replay a complete macro sequence.
169
+
170
+ Args:
171
+ macro_data: Macro data dictionary loaded from JSON
172
+ start_from_step: Step number to start from (0-based, default: 0)
173
+ max_steps: Maximum number of steps to execute (default: all)
174
+
175
+ Returns:
176
+ True if all actions were executed successfully, False otherwise
177
+ """
178
+ if not macro_data or "actions" not in macro_data:
179
+ logger.error("❌ Invalid macro data - no actions found")
180
+ return False
181
+
182
+ actions = macro_data["actions"]
183
+ description = macro_data.get("description", "Unknown macro")
184
+ total_actions = len(actions)
185
+
186
+ # Apply start_from_step and max_steps filters
187
+ if start_from_step > 0:
188
+ actions = actions[start_from_step:]
189
+ logger.info(f"📍 Starting from step {start_from_step + 1}")
190
+
191
+ if max_steps is not None:
192
+ actions = actions[:max_steps]
193
+ logger.info(f"🎯 Limiting to {max_steps} steps")
194
+
195
+ logger.info(f"🎬 Starting macro replay: '{description}'")
196
+ logger.info(f"📊 Total actions to execute: {len(actions)} / {total_actions}")
197
+
198
+ success_count = 0
199
+ failed_count = 0
200
+
201
+ for i, action in enumerate(actions, start=start_from_step + 1):
202
+ action_type = action.get("action_type", action.get("type", "unknown"))
203
+ description_text = action.get("description", "")
204
+
205
+ logger.info(f"\n📍 Step {i}/{total_actions}: {action_type}")
206
+ if description_text:
207
+ logger.info(f" Description: {description_text}")
208
+
209
+ # Execute the action
210
+ success = self.replay_action(action)
211
+
212
+ if success:
213
+ success_count += 1
214
+ logger.info(f" ✅ Action completed successfully")
215
+ else:
216
+ failed_count += 1
217
+ logger.error(f" ❌ Action failed")
218
+
219
+ # Wait between actions (except for the last one)
220
+ if i < len(actions):
221
+ logger.debug(f" ⏳ Waiting {self.delay_between_actions}s...")
222
+ await asyncio.sleep(self.delay_between_actions)
223
+
224
+ # Summary
225
+ total_executed = success_count + failed_count
226
+ success_rate = (
227
+ (success_count / total_executed * 100) if total_executed > 0 else 0
228
+ )
229
+
230
+ logger.info(f"\n🎉 Macro replay completed!")
231
+ logger.info(
232
+ f"📊 Success: {success_count}/{total_executed} ({success_rate:.1f}%)"
233
+ )
234
+
235
+ if failed_count > 0:
236
+ logger.warning(f"⚠️ Failed actions: {failed_count}")
237
+
238
+ return failed_count == 0
239
+
240
+
241
+ # Utility functions for convenience
242
+
243
+
244
+ async def replay_macro_file(
245
+ macro_file_path: str,
246
+ device_serial: str = None,
247
+ delay_between_actions: float = 1.0,
248
+ start_from_step: int = 0,
249
+ max_steps: Optional[int] = None,
250
+ ) -> bool:
251
+ """
252
+ Convenience function to replay a macro from a file.
253
+
254
+ Args:
255
+ macro_file_path: Path to the macro JSON file
256
+ device_serial: Target device serial (optional)
257
+ delay_between_actions: Delay between actions in seconds
258
+ start_from_step: Step to start from (0-based)
259
+ max_steps: Maximum steps to execute
260
+
261
+ Returns:
262
+ True if replay was successful, False otherwise
263
+ """
264
+ player = MacroPlayer(
265
+ device_serial=device_serial, delay_between_actions=delay_between_actions
266
+ )
267
+
268
+ try:
269
+ macro_data = player.load_macro_from_file(macro_file_path)
270
+ return await player.replay_macro(
271
+ macro_data, start_from_step=start_from_step, max_steps=max_steps
272
+ )
273
+ except Exception as e:
274
+ logger.error(f"❌ Error replaying macro file {macro_file_path}: {e}")
275
+ return False
276
+
277
+
278
+ async def replay_macro_folder(
279
+ trajectory_folder: str,
280
+ device_serial: str = None,
281
+ delay_between_actions: float = 1.0,
282
+ start_from_step: int = 0,
283
+ max_steps: Optional[int] = None,
284
+ ) -> bool:
285
+ """
286
+ Convenience function to replay a macro from a trajectory folder.
287
+
288
+ Args:
289
+ trajectory_folder: Path to the trajectory folder containing macro.json
290
+ device_serial: Target device serial (optional)
291
+ delay_between_actions: Delay between actions in seconds
292
+ start_from_step: Step to start from (0-based)
293
+ max_steps: Maximum steps to execute
294
+
295
+ Returns:
296
+ True if replay was successful, False otherwise
297
+ """
298
+ player = MacroPlayer(
299
+ device_serial=device_serial, delay_between_actions=delay_between_actions
300
+ )
301
+
302
+ try:
303
+ macro_data = player.load_macro_from_folder(trajectory_folder)
304
+ return await player.replay_macro(
305
+ macro_data, start_from_step=start_from_step, max_steps=max_steps
306
+ )
307
+ except Exception as e:
308
+ logger.error(f"❌ Error replaying macro folder {trajectory_folder}: {e}")
309
+ return False