droidrun 0.3.1__py3-none-any.whl → 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- droidrun/__init__.py +7 -12
- droidrun/agent/codeact/codeact_agent.py +9 -7
- droidrun/agent/common/events.py +44 -1
- droidrun/agent/context/personas/__init__.py +2 -2
- droidrun/agent/context/personas/big_agent.py +96 -0
- droidrun/agent/context/personas/ui_expert.py +1 -0
- droidrun/agent/droid/droid_agent.py +63 -11
- droidrun/agent/droid/events.py +4 -0
- droidrun/agent/planner/planner_agent.py +2 -2
- droidrun/agent/utils/executer.py +10 -2
- droidrun/agent/utils/llm_picker.py +1 -0
- droidrun/agent/utils/trajectory.py +258 -11
- droidrun/cli/main.py +179 -86
- droidrun/macro/__init__.py +14 -0
- droidrun/macro/__main__.py +10 -0
- droidrun/macro/cli.py +228 -0
- droidrun/macro/replay.py +309 -0
- droidrun/portal.py +138 -0
- droidrun/telemetry/__init__.py +4 -0
- droidrun/telemetry/events.py +27 -0
- droidrun/telemetry/tracker.py +84 -0
- droidrun/tools/adb.py +704 -372
- droidrun/tools/ios.py +169 -166
- droidrun/tools/tools.py +70 -17
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/METADATA +31 -29
- droidrun-0.3.3.dist-info/RECORD +54 -0
- droidrun/adb/__init__.py +0 -13
- droidrun/adb/device.py +0 -315
- droidrun/adb/manager.py +0 -93
- droidrun/adb/wrapper.py +0 -226
- droidrun/agent/context/personas/extractor.py +0 -52
- droidrun-0.3.1.dist-info/RECORD +0 -50
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/WHEEL +0 -0
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/entry_points.txt +0 -0
- {droidrun-0.3.1.dist-info → droidrun-0.3.3.dist-info}/licenses/LICENSE +0 -0
droidrun/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()
|
droidrun/macro/replay.py
ADDED
@@ -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
|
droidrun/portal.py
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
import requests
|
2
|
+
import tempfile
|
3
|
+
import os
|
4
|
+
import contextlib
|
5
|
+
from adbutils import adb, AdbDevice
|
6
|
+
|
7
|
+
REPO = "droidrun/droidrun-portal"
|
8
|
+
ASSET_NAME = "droidrun-portal"
|
9
|
+
GITHUB_API_HOSTS = ["https://api.github.com", "https://ungh.cc"]
|
10
|
+
|
11
|
+
PORTAL_PACKAGE_NAME = "com.droidrun.portal"
|
12
|
+
A11Y_SERVICE_NAME = (
|
13
|
+
f"{PORTAL_PACKAGE_NAME}/com.droidrun.portal.DroidrunAccessibilityService"
|
14
|
+
)
|
15
|
+
|
16
|
+
|
17
|
+
def get_latest_release_assets(debug: bool = False):
|
18
|
+
for host in GITHUB_API_HOSTS:
|
19
|
+
url = f"{host}/repos/{REPO}/releases/latest"
|
20
|
+
response = requests.get(url)
|
21
|
+
if response.status_code == 200:
|
22
|
+
if debug:
|
23
|
+
print(f"Using GitHub release on {host}")
|
24
|
+
break
|
25
|
+
|
26
|
+
response.raise_for_status()
|
27
|
+
latest_release = response.json()
|
28
|
+
|
29
|
+
if "release" in latest_release:
|
30
|
+
assets = latest_release["release"]["assets"]
|
31
|
+
else:
|
32
|
+
assets = latest_release.get("assets", [])
|
33
|
+
|
34
|
+
return assets
|
35
|
+
|
36
|
+
|
37
|
+
@contextlib.contextmanager
|
38
|
+
def download_portal_apk(debug: bool = False):
|
39
|
+
assets = get_latest_release_assets(debug)
|
40
|
+
|
41
|
+
asset_url = None
|
42
|
+
for asset in assets:
|
43
|
+
if (
|
44
|
+
"browser_download_url" in asset
|
45
|
+
and "name" in asset
|
46
|
+
and asset["name"].startswith(ASSET_NAME)
|
47
|
+
):
|
48
|
+
asset_url = asset["browser_download_url"]
|
49
|
+
break
|
50
|
+
elif "downloadUrl" in asset and os.path.basename(
|
51
|
+
asset["downloadUrl"]
|
52
|
+
).startswith(ASSET_NAME):
|
53
|
+
asset_url = asset["downloadUrl"]
|
54
|
+
break
|
55
|
+
else:
|
56
|
+
if debug:
|
57
|
+
print(asset)
|
58
|
+
|
59
|
+
if not asset_url:
|
60
|
+
raise Exception(f"Asset named '{ASSET_NAME}' not found in the latest release.")
|
61
|
+
|
62
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".apk")
|
63
|
+
try:
|
64
|
+
r = requests.get(asset_url, stream=True)
|
65
|
+
r.raise_for_status()
|
66
|
+
for chunk in r.iter_content(chunk_size=8192):
|
67
|
+
if chunk:
|
68
|
+
tmp.write(chunk)
|
69
|
+
tmp.close()
|
70
|
+
yield tmp.name
|
71
|
+
finally:
|
72
|
+
if os.path.exists(tmp.name):
|
73
|
+
os.unlink(tmp.name)
|
74
|
+
|
75
|
+
|
76
|
+
def enable_portal_accessibility(device: AdbDevice):
|
77
|
+
device.shell(
|
78
|
+
f"settings put secure enabled_accessibility_services {A11Y_SERVICE_NAME}"
|
79
|
+
)
|
80
|
+
device.shell("settings put secure accessibility_enabled 1")
|
81
|
+
|
82
|
+
|
83
|
+
def check_portal_accessibility(device: AdbDevice, debug: bool = False) -> bool:
|
84
|
+
a11y_services = device.shell(
|
85
|
+
"settings get secure enabled_accessibility_services"
|
86
|
+
)
|
87
|
+
if not A11Y_SERVICE_NAME in a11y_services:
|
88
|
+
if debug:
|
89
|
+
print(a11y_services)
|
90
|
+
return False
|
91
|
+
|
92
|
+
a11y_enabled = device.shell("settings get secure accessibility_enabled")
|
93
|
+
if a11y_enabled != "1":
|
94
|
+
if debug:
|
95
|
+
print(a11y_enabled)
|
96
|
+
return False
|
97
|
+
|
98
|
+
return True
|
99
|
+
|
100
|
+
|
101
|
+
def ping_portal(device: AdbDevice, debug: bool = False):
|
102
|
+
"""
|
103
|
+
Ping the Droidrun Portal to check if it is installed and accessible.
|
104
|
+
"""
|
105
|
+
try:
|
106
|
+
packages = device.list_packages()
|
107
|
+
except Exception as e:
|
108
|
+
raise Exception(f"Failed to list packages: {e}")
|
109
|
+
|
110
|
+
if not PORTAL_PACKAGE_NAME in packages:
|
111
|
+
if debug:
|
112
|
+
print(packages)
|
113
|
+
raise Exception("Portal is not installed on the device")
|
114
|
+
|
115
|
+
if not check_portal_accessibility(device, debug):
|
116
|
+
device.shell("am start -a android.settings.ACCESSIBILITY_SETTINGS")
|
117
|
+
raise Exception(
|
118
|
+
"Droidrun Portal is not enabled as an accessibility service on the device"
|
119
|
+
)
|
120
|
+
|
121
|
+
try:
|
122
|
+
state = device.shell(
|
123
|
+
"content query --uri content://com.droidrun.portal/state"
|
124
|
+
)
|
125
|
+
if not "Row: 0 result=" in state:
|
126
|
+
raise Exception("Failed to get state from Droidrun Portal")
|
127
|
+
|
128
|
+
except Exception as e:
|
129
|
+
raise Exception(f"Droidrun Portal is not reachable: {e}")
|
130
|
+
|
131
|
+
|
132
|
+
def test():
|
133
|
+
device = adb.device()
|
134
|
+
ping_portal(device, debug=False)
|
135
|
+
|
136
|
+
|
137
|
+
if __name__ == "__main__":
|
138
|
+
test()
|