Spaces:
Sleeping
Sleeping
Upload 3 files
Browse files
examples/tools/android/action/actions.py
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# coding: utf-8
|
2 |
+
# Copyright (c) 2025 inclusionAI.
|
3 |
+
|
4 |
+
import json
|
5 |
+
|
6 |
+
from examples.tools.tool_action import AndroidAction
|
7 |
+
from aworld.core.tool.action_factory import ActionFactory
|
8 |
+
from aworld.core.common import ActionModel, ActionResult
|
9 |
+
from examples.tools.android.action.adb_controller import ADBController
|
10 |
+
from examples.tools.android.config.android_action_space import AndroidActionParamEnum
|
11 |
+
from aworld.core.tool.action import ExecutableAction
|
12 |
+
|
13 |
+
|
14 |
+
@ActionFactory.register(name=AndroidAction.TAP.value.name,
|
15 |
+
desc=AndroidAction.TAP.value.desc,
|
16 |
+
tool_name="android")
|
17 |
+
class Tap(ExecutableAction):
|
18 |
+
def act(self, action: ActionModel, **kwargs) -> ActionResult:
|
19 |
+
controller: ADBController = kwargs.get('controller')
|
20 |
+
tap_index = action.params[AndroidActionParamEnum.TAP_INDEX.value]
|
21 |
+
if tap_index is None:
|
22 |
+
raise Exception(f'Invalid action: {action}')
|
23 |
+
controller.tap(tap_index)
|
24 |
+
return ActionResult(content="", keep=True)
|
25 |
+
|
26 |
+
|
27 |
+
@ActionFactory.register(name=AndroidAction.INPUT_TEXT.value.name,
|
28 |
+
desc=AndroidAction.INPUT_TEXT.value.desc,
|
29 |
+
tool_name="android")
|
30 |
+
class InputText(ExecutableAction):
|
31 |
+
def act(self, action: ActionModel, **kwargs) -> ActionResult:
|
32 |
+
controller: ADBController = kwargs.get('controller')
|
33 |
+
input_text = action.params[AndroidActionParamEnum.INPUT_TEXT.value]
|
34 |
+
if input_text is None:
|
35 |
+
raise Exception(f'Invalid action: {action}')
|
36 |
+
controller.text(input_text)
|
37 |
+
return ActionResult(content="", keep=True)
|
38 |
+
|
39 |
+
|
40 |
+
@ActionFactory.register(name=AndroidAction.LONG_PRESS.value.name,
|
41 |
+
desc=AndroidAction.LONG_PRESS.value.desc,
|
42 |
+
tool_name="android")
|
43 |
+
class LongPress(ExecutableAction):
|
44 |
+
def act(self, action: ActionModel, **kwargs) -> ActionResult:
|
45 |
+
controller: ADBController = kwargs.get('controller')
|
46 |
+
long_press_index = action.params[AndroidActionParamEnum.LONG_PRESS_INDEX.value]
|
47 |
+
if long_press_index is None:
|
48 |
+
raise Exception(f'Invalid action: {action}')
|
49 |
+
controller.long_press(long_press_index)
|
50 |
+
return ActionResult(content="", keep=True)
|
51 |
+
|
52 |
+
|
53 |
+
@ActionFactory.register(name=AndroidAction.SWIPE.value.name,
|
54 |
+
desc=AndroidAction.SWIPE.value.desc,
|
55 |
+
tool_name="android")
|
56 |
+
class Swipe(ExecutableAction):
|
57 |
+
def act(self, action: ActionModel, **kwargs) -> ActionResult:
|
58 |
+
controller: ADBController = kwargs.get('controller')
|
59 |
+
swipe_start_index = action.params[AndroidActionParamEnum.SWIPE_START_INDEX.value]
|
60 |
+
direction = action.params[AndroidActionParamEnum.DIRECTION.value]
|
61 |
+
dist = action.params.get(AndroidActionParamEnum.DIST.value, None)
|
62 |
+
if swipe_start_index is None or direction is None:
|
63 |
+
raise Exception(f'Invalid action: {action}')
|
64 |
+
if dist:
|
65 |
+
controller.swipe(swipe_start_index, direction, dist)
|
66 |
+
else:
|
67 |
+
controller.swipe(swipe_start_index, direction)
|
68 |
+
return ActionResult(content="", keep=True)
|
69 |
+
|
70 |
+
|
71 |
+
@ActionFactory.register(name=AndroidAction.DONE.value.name,
|
72 |
+
desc=AndroidAction.DONE.value.desc,
|
73 |
+
tool_name="android")
|
74 |
+
class Done(ExecutableAction):
|
75 |
+
def act(self, action: ActionModel, **kwargs) -> ActionResult:
|
76 |
+
output_dict = action.model_dump(exclude={'success'})
|
77 |
+
return ActionResult(is_done=True, success=True, content=json.dumps(output_dict))
|
examples/tools/android/action/adb_controller.py
ADDED
@@ -0,0 +1,541 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# coding: utf-8
|
2 |
+
# Copyright (c) 2025 inclusionAI.
|
3 |
+
|
4 |
+
import subprocess
|
5 |
+
import time
|
6 |
+
import re
|
7 |
+
import traceback
|
8 |
+
from time import sleep
|
9 |
+
from typing import Optional, Tuple, List
|
10 |
+
import base64
|
11 |
+
|
12 |
+
import xml.etree.ElementTree as ET
|
13 |
+
import os
|
14 |
+
|
15 |
+
from aworld.logs.util import logger, color_log, Color
|
16 |
+
from aworld.utils import import_package
|
17 |
+
|
18 |
+
configs = {"MIN_DIST": 30}
|
19 |
+
|
20 |
+
|
21 |
+
class AndroidElement:
|
22 |
+
def __init__(self, uid, bbox, attrib):
|
23 |
+
self.uid = uid
|
24 |
+
self.bbox = bbox
|
25 |
+
self.attrib = attrib
|
26 |
+
import_package('cv2', install_name='opencv-python')
|
27 |
+
import_package('pyshine')
|
28 |
+
|
29 |
+
def get_id_from_element(elem):
|
30 |
+
bounds = elem.attrib["bounds"][1:-1].split("][")
|
31 |
+
x1, y1 = map(int, bounds[0].split(","))
|
32 |
+
x2, y2 = map(int, bounds[1].split(","))
|
33 |
+
elem_w, elem_h = x2 - x1, y2 - y1
|
34 |
+
if "resource-id" in elem.attrib and elem.attrib["resource-id"]:
|
35 |
+
elem_id = elem.attrib["resource-id"].replace(":", ".").replace("/", "_")
|
36 |
+
else:
|
37 |
+
elem_id = f"{elem.attrib['class']}_{elem_w}_{elem_h}"
|
38 |
+
if "content-desc" in elem.attrib and elem.attrib["content-desc"] and len(elem.attrib["content-desc"]) < 20:
|
39 |
+
content_desc = elem.attrib['content-desc'].replace("/", "_").replace(" ", "").replace(":", "_")
|
40 |
+
elem_id += f"_{content_desc}"
|
41 |
+
return elem_id
|
42 |
+
|
43 |
+
|
44 |
+
def traverse_tree(xml_path, elem_list, attrib, add_index=False):
|
45 |
+
path = []
|
46 |
+
for event, elem in ET.iterparse(xml_path, ['start', 'end']):
|
47 |
+
if event == 'start':
|
48 |
+
path.append(elem)
|
49 |
+
if attrib in elem.attrib and elem.attrib[attrib] == "true":
|
50 |
+
parent_prefix = ""
|
51 |
+
if len(path) > 1:
|
52 |
+
parent_elem = path[-2]
|
53 |
+
# Checks if the parent element has the required attributes
|
54 |
+
has_bounds = "bounds" in parent_elem.attrib
|
55 |
+
has_rid_or_class = "resource-id" in parent_elem.attrib or "class" in parent_elem.attrib
|
56 |
+
if has_bounds and has_rid_or_class:
|
57 |
+
parent_prefix = get_id_from_element(parent_elem)
|
58 |
+
bounds = elem.attrib["bounds"][1:-1].split("][")
|
59 |
+
x1, y1 = map(int, bounds[0].split(","))
|
60 |
+
x2, y2 = map(int, bounds[1].split(","))
|
61 |
+
center = (x1 + x2) // 2, (y1 + y2) // 2
|
62 |
+
elem_id = get_id_from_element(elem)
|
63 |
+
if parent_prefix:
|
64 |
+
elem_id = parent_prefix + "_" + elem_id
|
65 |
+
if add_index:
|
66 |
+
elem_id += f"_{elem.attrib['index']}"
|
67 |
+
close = False
|
68 |
+
for e in elem_list:
|
69 |
+
bbox = e.bbox
|
70 |
+
center_ = (bbox[0][0] + bbox[1][0]) // 2, (bbox[0][1] + bbox[1][1]) // 2
|
71 |
+
dist = (abs(center[0] - center_[0]) ** 2 + abs(center[1] - center_[1]) ** 2) ** 0.5
|
72 |
+
if dist <= configs["MIN_DIST"]:
|
73 |
+
close = True
|
74 |
+
break
|
75 |
+
if not close:
|
76 |
+
elem_list.append(AndroidElement(elem_id, ((x1, y1), (x2, y2)), attrib))
|
77 |
+
|
78 |
+
if event == 'end':
|
79 |
+
path.pop()
|
80 |
+
|
81 |
+
|
82 |
+
def create_directory_for_file(file_path):
|
83 |
+
# Extract the directory from the file path
|
84 |
+
directory = os.path.dirname(file_path)
|
85 |
+
|
86 |
+
# Check if the directory exists
|
87 |
+
if not os.path.exists(directory):
|
88 |
+
# Create the directory
|
89 |
+
os.makedirs(directory)
|
90 |
+
# Print the absolute path of the directory
|
91 |
+
absolute_directory_path = os.path.abspath(directory)
|
92 |
+
logger.info(f"Directory absolute path: {absolute_directory_path}")
|
93 |
+
|
94 |
+
|
95 |
+
def draw_bbox_multi(img_path, output_path, elem_list):
|
96 |
+
import cv2
|
97 |
+
import pyshine as ps
|
98 |
+
|
99 |
+
imgcv = cv2.imread(img_path)
|
100 |
+
count = 1
|
101 |
+
for elem in elem_list:
|
102 |
+
try:
|
103 |
+
top_left = elem.bbox[0]
|
104 |
+
bottom_right = elem.bbox[1]
|
105 |
+
left, top = top_left[0], top_left[1]
|
106 |
+
right, bottom = bottom_right[0], bottom_right[1]
|
107 |
+
|
108 |
+
# draw rectangle
|
109 |
+
cv2.rectangle(imgcv,
|
110 |
+
(left, top),
|
111 |
+
(right, bottom),
|
112 |
+
(0, 0, 221),
|
113 |
+
3)
|
114 |
+
|
115 |
+
label = str(count)
|
116 |
+
imgcv = ps.putBText(imgcv, label, text_offset_x=(left + right) // 2 + 10,
|
117 |
+
text_offset_y=(top + bottom) // 2 + 10,
|
118 |
+
vspace=10, hspace=10, font_scale=1, thickness=2, background_RGB=(221, 0, 0),
|
119 |
+
text_RGB=(255, 255, 255), alpha=0.0)
|
120 |
+
|
121 |
+
except Exception as e:
|
122 |
+
color_log(f"ERROR: An exception occurs while labeling the image\n{e}", Color.red)
|
123 |
+
logger.info(traceback.print_exc())
|
124 |
+
count += 1
|
125 |
+
cv2.imwrite(output_path, imgcv)
|
126 |
+
return imgcv
|
127 |
+
|
128 |
+
|
129 |
+
def draw_grid(img_path, output_path):
|
130 |
+
import cv2
|
131 |
+
|
132 |
+
def get_unit_len(n):
|
133 |
+
for i in range(1, n + 1):
|
134 |
+
if n % i == 0 and 120 <= i <= 180:
|
135 |
+
return i
|
136 |
+
return -1
|
137 |
+
|
138 |
+
image = cv2.imread(img_path)
|
139 |
+
height, width, _ = image.shape
|
140 |
+
color = (255, 116, 113)
|
141 |
+
unit_height = get_unit_len(height)
|
142 |
+
if unit_height < 0:
|
143 |
+
unit_height = 120
|
144 |
+
unit_width = get_unit_len(width)
|
145 |
+
if unit_width < 0:
|
146 |
+
unit_width = 120
|
147 |
+
thick = int(unit_width // 50)
|
148 |
+
rows = height // unit_height
|
149 |
+
cols = width // unit_width
|
150 |
+
for i in range(rows):
|
151 |
+
for j in range(cols):
|
152 |
+
label = i * cols + j + 1
|
153 |
+
left = int(j * unit_width)
|
154 |
+
top = int(i * unit_height)
|
155 |
+
right = int((j + 1) * unit_width)
|
156 |
+
bottom = int((i + 1) * unit_height)
|
157 |
+
cv2.rectangle(image, (left, top), (right, bottom), color, thick // 2)
|
158 |
+
cv2.putText(image, str(label), (left + int(unit_width * 0.05) + 3, top + int(unit_height * 0.3) + 3), 0,
|
159 |
+
int(0.01 * unit_width), (0, 0, 0), thick)
|
160 |
+
cv2.putText(image, str(label), (left + int(unit_width * 0.05), top + int(unit_height * 0.3)), 0,
|
161 |
+
int(0.01 * unit_width), color, thick)
|
162 |
+
cv2.imwrite(output_path, image)
|
163 |
+
return rows, cols
|
164 |
+
|
165 |
+
|
166 |
+
def encode_image(image_path):
|
167 |
+
with open(image_path, "rb") as image_file:
|
168 |
+
return base64.b64encode(image_file.read()).decode('utf-8')
|
169 |
+
|
170 |
+
|
171 |
+
class ADBController:
|
172 |
+
def __init__(self, avd_name: str = None,
|
173 |
+
adb_path: str = os.path.expanduser('~') + "/Library/Android/sdk/platform-tools/adb",
|
174 |
+
emulator_path: str = os.path.expanduser('~') + "/Library/Android/sdk/emulator/emulator",
|
175 |
+
timeout: int = 30):
|
176 |
+
self.avd_name = avd_name
|
177 |
+
self.adb_path = adb_path
|
178 |
+
self.emulator_path = emulator_path
|
179 |
+
self.timeout = timeout
|
180 |
+
self.emulator_process = None
|
181 |
+
self.device_serial = "emulator-5554" # default
|
182 |
+
self.current_elem_list = []
|
183 |
+
self.width, self.height = 0, 0
|
184 |
+
|
185 |
+
def start_emulator(self, avd_name: str = None, headless: bool = False,
|
186 |
+
max_retry: int = 2) -> bool:
|
187 |
+
avd = avd_name or self.avd_name
|
188 |
+
if not avd:
|
189 |
+
raise ValueError("AVD name must be specified")
|
190 |
+
|
191 |
+
for attempt in range(max_retry + 1):
|
192 |
+
if self._start_emulator_process(avd, headless):
|
193 |
+
if self._wait_for_device():
|
194 |
+
logger.info(f"start success,attempt count:{attempt + 1}")
|
195 |
+
self.width, self.height = self.get_screen_size()
|
196 |
+
return True
|
197 |
+
self.stop_emulator()
|
198 |
+
return False
|
199 |
+
|
200 |
+
def _start_emulator_process(self, avd: str, headless: bool) -> bool:
|
201 |
+
try:
|
202 |
+
cmd = [
|
203 |
+
self.emulator_path,
|
204 |
+
f"@{avd}",
|
205 |
+
"-no-snapshot",
|
206 |
+
"-no-audio",
|
207 |
+
"-gpu", "swiftshader",
|
208 |
+
"-wipe-data"
|
209 |
+
]
|
210 |
+
if headless:
|
211 |
+
cmd.append("-no-window")
|
212 |
+
|
213 |
+
self.emulator_process = subprocess.Popen(
|
214 |
+
cmd,
|
215 |
+
stdout=subprocess.DEVNULL,
|
216 |
+
stderr=subprocess.STDOUT
|
217 |
+
)
|
218 |
+
return True
|
219 |
+
except Exception as e:
|
220 |
+
logger.warning(f"adb start fail: {str(e)}")
|
221 |
+
return False
|
222 |
+
|
223 |
+
def stop_emulator(self) -> bool:
|
224 |
+
try:
|
225 |
+
result = subprocess.run(
|
226 |
+
[self.adb_path, "-s", self.device_serial, "emu", "kill"],
|
227 |
+
timeout=self.timeout,
|
228 |
+
capture_output=True,
|
229 |
+
text=True
|
230 |
+
)
|
231 |
+
return "OK" in result.stdout
|
232 |
+
except subprocess.TimeoutExpired:
|
233 |
+
return False
|
234 |
+
finally:
|
235 |
+
if self.emulator_process:
|
236 |
+
self.emulator_process.terminate()
|
237 |
+
|
238 |
+
def execute_adb(self, command: list, device_serial: str = None) -> Tuple[bool, str]:
|
239 |
+
"""execute adb command"""
|
240 |
+
device = device_serial or self.device_serial
|
241 |
+
full_cmd = [self.adb_path, "-s", device] + command
|
242 |
+
|
243 |
+
try:
|
244 |
+
result = subprocess.run(
|
245 |
+
full_cmd,
|
246 |
+
timeout=self.timeout,
|
247 |
+
check=True,
|
248 |
+
capture_output=True,
|
249 |
+
text=True
|
250 |
+
)
|
251 |
+
return True, result.stdout.strip()
|
252 |
+
except subprocess.CalledProcessError as e:
|
253 |
+
return False, f"Command failed: {e.stderr}"
|
254 |
+
except Exception as e:
|
255 |
+
return False, str(e)
|
256 |
+
|
257 |
+
def execute_adb_with_stdout(self, command: List[str]) -> Tuple[bool, Optional[str]]:
|
258 |
+
try:
|
259 |
+
result = subprocess.run(
|
260 |
+
["adb", "-s", self.device_serial] + command,
|
261 |
+
stdout=subprocess.PIPE,
|
262 |
+
stderr=subprocess.PIPE,
|
263 |
+
text=True,
|
264 |
+
timeout=10
|
265 |
+
)
|
266 |
+
if result.returncode == 0:
|
267 |
+
return True, result.stdout.strip()
|
268 |
+
else:
|
269 |
+
return False, None
|
270 |
+
except subprocess.TimeoutExpired:
|
271 |
+
return False, None
|
272 |
+
except Exception as e:
|
273 |
+
return False, None
|
274 |
+
|
275 |
+
# ---------- device operate ----------
|
276 |
+
|
277 |
+
def screenshot(self, save_path: str) -> bool:
|
278 |
+
timestamp = int(time.time())
|
279 |
+
remote_path = f"/sdcard/screenshot_{timestamp}.png"
|
280 |
+
|
281 |
+
success, _ = self.execute_adb(["shell", "screencap", "-p", remote_path])
|
282 |
+
if not success:
|
283 |
+
return False
|
284 |
+
|
285 |
+
return self._pull_file(remote_path, save_path)
|
286 |
+
|
287 |
+
def dump_ui_xml(self, save_path: str) -> Optional[str]:
|
288 |
+
remote_path = "/sdcard/ui_dump.xml"
|
289 |
+
success, _ = self.execute_adb(["shell", "uiautomator", "dump", remote_path])
|
290 |
+
if not success:
|
291 |
+
logger.info("dump ui xml fail")
|
292 |
+
return None
|
293 |
+
success = self._pull_file(remote_path, save_path)
|
294 |
+
if not success:
|
295 |
+
logger.info("pull ui xml fail")
|
296 |
+
return None
|
297 |
+
|
298 |
+
with open(save_path, 'r', encoding='utf-8') as f:
|
299 |
+
xml_content = f.read()
|
300 |
+
return xml_content
|
301 |
+
|
302 |
+
def tap(self, element: int):
|
303 |
+
x, y = self.__get_element_center(element)
|
304 |
+
self.__tap_coordinate(x, y)
|
305 |
+
|
306 |
+
def text(self, text: str):
|
307 |
+
"""
|
308 |
+
Input text, automatically replacing spaces with %s for proper ADB text input.
|
309 |
+
|
310 |
+
Parameters:
|
311 |
+
text: The text to input
|
312 |
+
"""
|
313 |
+
# Replace spaces with %s for proper handling in ADB
|
314 |
+
formatted_text = text.replace(" ", "%s")
|
315 |
+
success, _ = self.execute_adb(["shell", "input", "text", formatted_text])
|
316 |
+
return success
|
317 |
+
|
318 |
+
def long_press(self, element: int):
|
319 |
+
x, y = self.__get_element_center(element)
|
320 |
+
self.__swipe_coordinate(x, y, x, y, 2000)
|
321 |
+
|
322 |
+
def swipe(self, element: int, direction: str, dist: str = "medium"):
|
323 |
+
"""
|
324 |
+
Perform swipe operations based on screen element labels
|
325 |
+
|
326 |
+
Parameters:
|
327 |
+
element_tag: digital label displayed on the interface (1-based)
|
328 |
+
direction: swipe direction ["up", "down", "left", "right"]
|
329 |
+
dist: swipe distance ["short", "medium", "long"]
|
330 |
+
"""
|
331 |
+
|
332 |
+
# 获取元素坐标
|
333 |
+
x, y = self.__get_element_center(element)
|
334 |
+
|
335 |
+
unit_dist = int(self.width / 10)
|
336 |
+
if dist == "long":
|
337 |
+
unit_dist *= 3
|
338 |
+
elif dist == "medium":
|
339 |
+
unit_dist *= 2
|
340 |
+
if direction == "up":
|
341 |
+
offset = 0, -2 * unit_dist
|
342 |
+
elif direction == "down":
|
343 |
+
offset = 0, 2 * unit_dist
|
344 |
+
elif direction == "left":
|
345 |
+
offset = -1 * unit_dist, 0
|
346 |
+
elif direction == "right":
|
347 |
+
offset = unit_dist, 0
|
348 |
+
else:
|
349 |
+
return False
|
350 |
+
|
351 |
+
self.__swipe_coordinate(x, y, x + offset[0], y + offset[1])
|
352 |
+
|
353 |
+
def screenshot_and_annotate(self, name_prefix=None, return_base64=True):
|
354 |
+
import cv2
|
355 |
+
|
356 |
+
"""Collect screen information and mark interactive elements, and return data containing Base64 images"""
|
357 |
+
sleep(3)
|
358 |
+
if name_prefix is None:
|
359 |
+
name_prefix = str(time.time())
|
360 |
+
tmp_files_dir = os.path.join(os.path.dirname(__file__), "tmp_files")
|
361 |
+
os.makedirs(tmp_files_dir, exist_ok=True)
|
362 |
+
screenshot_path = os.path.join(tmp_files_dir, f"{name_prefix}_origin.png")
|
363 |
+
screenshot_res = self.screenshot(screenshot_path)
|
364 |
+
xml_path = os.path.join(tmp_files_dir, f"{name_prefix}.xml")
|
365 |
+
xml_res = self.dump_ui_xml(xml_path)
|
366 |
+
if screenshot_res == "ERROR" or xml_res is None:
|
367 |
+
logger.warning(f"Failed to take screenshot or read XML")
|
368 |
+
return None, None
|
369 |
+
|
370 |
+
# Parsing interactive elements
|
371 |
+
clickable_list = []
|
372 |
+
focusable_list = []
|
373 |
+
traverse_tree(xml_path, clickable_list, "clickable", True)
|
374 |
+
traverse_tree(xml_path, focusable_list, "focusable", True)
|
375 |
+
|
376 |
+
# Merge a list of duplicate elements
|
377 |
+
elem_list = clickable_list.copy()
|
378 |
+
for elem in focusable_list:
|
379 |
+
bbox = elem.bbox
|
380 |
+
center = (bbox[0][0] + bbox[1][0]) // 2, (bbox[0][1] + bbox[1][1]) // 2
|
381 |
+
if not any(
|
382 |
+
((center[0] - ((e.bbox[0][0] + e.bbox[1][0]) // 2)) ** 2 +
|
383 |
+
(center[1] - ((e.bbox[0][1] + e.bbox[1][1]) // 2)) ** 2) ** 0.5 <= configs["MIN_DIST"]
|
384 |
+
for e in clickable_list
|
385 |
+
):
|
386 |
+
elem_list.append(elem)
|
387 |
+
|
388 |
+
# Generate annotated images
|
389 |
+
labeled_path = os.path.join(tmp_files_dir, f"{name_prefix}_labeled.png")
|
390 |
+
labeled_img = draw_bbox_multi(screenshot_path, labeled_path, elem_list)
|
391 |
+
|
392 |
+
# Show Image Window
|
393 |
+
# cv2.imshow("image", labeled_img)
|
394 |
+
# cv2.waitKey(0)
|
395 |
+
# cv2.destroyAllWindows()
|
396 |
+
|
397 |
+
# Base64 encoding
|
398 |
+
base64_str = None
|
399 |
+
if return_base64:
|
400 |
+
# Convert color space BGR->RGB
|
401 |
+
rgb_image = cv2.cvtColor(labeled_img, cv2.COLOR_BGR2RGB)
|
402 |
+
# Compress to JPEG format (with adjustable quality parameters)
|
403 |
+
success, buffer = cv2.imencode(".jpg", rgb_image, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
|
404 |
+
if success:
|
405 |
+
base64_str = base64.b64encode(buffer).decode("utf-8")
|
406 |
+
|
407 |
+
self.current_elem_list = elem_list.copy()
|
408 |
+
logger.info(f"Current elem size{len(self.current_elem_list)}")
|
409 |
+
return xml_res, base64_str
|
410 |
+
|
411 |
+
def setup_connection(self) -> bool:
|
412 |
+
"""Intelligent initialization device connection"""
|
413 |
+
# Prioritize physical equipment testing
|
414 |
+
if self.__connect_physical_device():
|
415 |
+
return True
|
416 |
+
|
417 |
+
# Try connecting to the simulator
|
418 |
+
if self.avd_name and self.start_emulator():
|
419 |
+
return True
|
420 |
+
|
421 |
+
raise ConnectionError("No available device found, please connect your phone or configure the simulator")
|
422 |
+
|
423 |
+
# ---------- Helper Methods ----------
|
424 |
+
def __connect_physical_device(self) -> bool:
|
425 |
+
"""Connect an authorized USB device"""
|
426 |
+
devices = self.__get_authorized_devices()
|
427 |
+
if not devices:
|
428 |
+
return False
|
429 |
+
|
430 |
+
self.device = devices[0]
|
431 |
+
logger.info(f"Connected physical device: {self.device}")
|
432 |
+
self.device_serial = self.device
|
433 |
+
self.width, self.height = self.get_screen_size()
|
434 |
+
return True
|
435 |
+
|
436 |
+
def __get_authorized_devices(self) -> list:
|
437 |
+
"""Get a list of authorized devices"""
|
438 |
+
success, output = self.execute_adb(["devices"])
|
439 |
+
if not success:
|
440 |
+
return []
|
441 |
+
|
442 |
+
return [
|
443 |
+
line.split("\t")[0]
|
444 |
+
for line in output.splitlines()
|
445 |
+
if "\tdevice" in line and "emulator" not in line
|
446 |
+
]
|
447 |
+
|
448 |
+
def __tap_coordinate(self, x: int, y: int) -> bool:
|
449 |
+
"""Click screen coordinates"""
|
450 |
+
success, _ = self.execute_adb(["shell", "input", "tap", str(x), str(y)])
|
451 |
+
return success
|
452 |
+
|
453 |
+
def __get_element_center(self, elem_idx: int) -> tuple:
|
454 |
+
"""Calculate the coordinates of the center of the element"""
|
455 |
+
tl, br = self.current_elem_list[int(elem_idx) - 1].bbox
|
456 |
+
return (tl[0] + br[0]) // 2, (tl[1] + br[1]) // 2
|
457 |
+
|
458 |
+
def __swipe_coordinate(self, x1: int, y1: int, x2: int, y2: int, duration: int = 300) -> bool:
|
459 |
+
"""Slide Operation"""
|
460 |
+
success, _ = self.execute_adb([
|
461 |
+
"shell", "input", "swipe",
|
462 |
+
str(x1), str(y1), str(x2), str(y2),
|
463 |
+
str(duration)
|
464 |
+
])
|
465 |
+
return success
|
466 |
+
|
467 |
+
def _wait_for_device(self, timeout: int = 300) -> bool:
|
468 |
+
"""Three-level waiting detection strategy"""
|
469 |
+
start_time = time.time()
|
470 |
+
stages = {
|
471 |
+
"adb_connected": False,
|
472 |
+
"boot_completed": False,
|
473 |
+
"services_ready": False
|
474 |
+
}
|
475 |
+
|
476 |
+
while time.time() - start_time < timeout:
|
477 |
+
# Step 1: Detect adb connection
|
478 |
+
if not stages["adb_connected"]:
|
479 |
+
_, devices = self.execute_adb(["devices"])
|
480 |
+
if self.device_serial in devices:
|
481 |
+
stages["adb_connected"] = True
|
482 |
+
|
483 |
+
# Step 2: Detection system boot completed
|
484 |
+
if stages["adb_connected"] and not stages["boot_completed"]:
|
485 |
+
_, output = self.execute_adb([
|
486 |
+
"shell", "getprop", "sys.boot_completed"
|
487 |
+
])
|
488 |
+
if output.strip() == "1":
|
489 |
+
stages["boot_completed"] = True
|
490 |
+
|
491 |
+
# Step 3: Detecting Graphics Service Readiness
|
492 |
+
if stages["boot_completed"] and not stages["services_ready"]:
|
493 |
+
_, output = self.execute_adb([
|
494 |
+
"shell", "service check SurfaceFlinger"
|
495 |
+
])
|
496 |
+
if "found" in output.lower():
|
497 |
+
return True
|
498 |
+
|
499 |
+
return False
|
500 |
+
|
501 |
+
def _pull_file(self, remote: str, local: str) -> bool:
|
502 |
+
"""Pull device files to local"""
|
503 |
+
create_directory_for_file(local)
|
504 |
+
success, _ = self.execute_adb(["pull", remote, local])
|
505 |
+
if success:
|
506 |
+
self.execute_adb(["shell", "rm", remote]) # 清理临时文件
|
507 |
+
return success
|
508 |
+
|
509 |
+
def get_screen_size(self) -> Optional[Tuple[int, int]]:
|
510 |
+
"""Get screen resolution"""
|
511 |
+
success, output = self.execute_adb(["shell", "wm", "size"])
|
512 |
+
if not success:
|
513 |
+
return None
|
514 |
+
|
515 |
+
match = re.search(r"(\d+)x(\d+)", output)
|
516 |
+
if match:
|
517 |
+
return int(match.group(1)), int(match.group(2))
|
518 |
+
return None
|
519 |
+
|
520 |
+
|
521 |
+
if __name__ == "__main__":
|
522 |
+
# Examples
|
523 |
+
controller = ADBController(avd_name="Medium_Phone_API_35")
|
524 |
+
|
525 |
+
# controller.stop_emulator()
|
526 |
+
if controller.setup_connection():
|
527 |
+
logger.info("Simulator started successfully")
|
528 |
+
width, height = controller.get_screen_size()
|
529 |
+
logger.info(f"Get the screen size{width},{height}")
|
530 |
+
|
531 |
+
# Take screenshots and annotate them
|
532 |
+
controller.screenshot_and_annotate()
|
533 |
+
controller.swipe(6, "up")
|
534 |
+
|
535 |
+
# controller.screenshot_and_annotate()
|
536 |
+
# controller.tap(6)
|
537 |
+
xml_txt, base64_txt = controller.screenshot_and_annotate()
|
538 |
+
logger.info(xml_txt)
|
539 |
+
|
540 |
+
# controller.stop_emulator()
|
541 |
+
logger.info("Close the simulator")
|
examples/tools/android/action/executor.py
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# coding: utf-8
|
2 |
+
# Copyright (c) 2025 inclusionAI.
|
3 |
+
|
4 |
+
from typing import List
|
5 |
+
from aworld.core.tool.action_factory import ActionFactory
|
6 |
+
from aworld.core.common import ActionModel, ActionResult
|
7 |
+
from aworld.logs.util import logger
|
8 |
+
from examples.tools.android.action.adb_controller import ADBController
|
9 |
+
from aworld.core.tool.base import ToolActionExecutor
|
10 |
+
|
11 |
+
|
12 |
+
class AndroidToolActionExecutor(ToolActionExecutor):
|
13 |
+
|
14 |
+
def __init__(self, controller: ADBController):
|
15 |
+
self.controller = controller
|
16 |
+
|
17 |
+
def execute_action(self, actions: List[ActionModel], **kwargs) -> list[ActionResult]:
|
18 |
+
"""Execute the specified android action sequence by agent policy.
|
19 |
+
|
20 |
+
Args:
|
21 |
+
actions: Tool action sequence.
|
22 |
+
|
23 |
+
Returns:
|
24 |
+
Browser action result list.
|
25 |
+
"""
|
26 |
+
action_results = []
|
27 |
+
for action in actions:
|
28 |
+
action_result = self._exec(action, **kwargs)
|
29 |
+
action_results.append(action_result)
|
30 |
+
return action_results
|
31 |
+
|
32 |
+
def _exec(self, action_model: ActionModel, **kwargs):
|
33 |
+
action_name = action_model.action_name
|
34 |
+
if action_name not in ActionFactory:
|
35 |
+
action_name = action_model.tool_name + action_model.action_name
|
36 |
+
if action_name not in ActionFactory:
|
37 |
+
raise ValueError(f'Action {action_name} not found')
|
38 |
+
|
39 |
+
action = ActionFactory(action_name)
|
40 |
+
action_result = action.act(action_model, controller=self.controller, **kwargs)
|
41 |
+
logger.info(f"{action_name} execute finished")
|
42 |
+
return action_result
|