minitap-mobile-use 3.3.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.
- minitap/mobile_use/__init__.py +0 -0
- minitap/mobile_use/agents/contextor/contextor.md +55 -0
- minitap/mobile_use/agents/contextor/contextor.py +175 -0
- minitap/mobile_use/agents/contextor/types.py +36 -0
- minitap/mobile_use/agents/cortex/cortex.md +135 -0
- minitap/mobile_use/agents/cortex/cortex.py +152 -0
- minitap/mobile_use/agents/cortex/types.py +15 -0
- minitap/mobile_use/agents/executor/executor.md +42 -0
- minitap/mobile_use/agents/executor/executor.py +87 -0
- minitap/mobile_use/agents/executor/tool_node.py +152 -0
- minitap/mobile_use/agents/hopper/hopper.md +15 -0
- minitap/mobile_use/agents/hopper/hopper.py +44 -0
- minitap/mobile_use/agents/orchestrator/human.md +12 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
- minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
- minitap/mobile_use/agents/orchestrator/types.py +11 -0
- minitap/mobile_use/agents/outputter/human.md +25 -0
- minitap/mobile_use/agents/outputter/outputter.py +85 -0
- minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
- minitap/mobile_use/agents/planner/human.md +14 -0
- minitap/mobile_use/agents/planner/planner.md +126 -0
- minitap/mobile_use/agents/planner/planner.py +101 -0
- minitap/mobile_use/agents/planner/types.py +51 -0
- minitap/mobile_use/agents/planner/utils.py +70 -0
- minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
- minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
- minitap/mobile_use/agents/video_analyzer/human.md +5 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
- minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
- minitap/mobile_use/clients/browserstack_client.py +477 -0
- minitap/mobile_use/clients/idb_client.py +429 -0
- minitap/mobile_use/clients/ios_client.py +332 -0
- minitap/mobile_use/clients/ios_client_config.py +141 -0
- minitap/mobile_use/clients/ui_automator_client.py +330 -0
- minitap/mobile_use/clients/wda_client.py +526 -0
- minitap/mobile_use/clients/wda_lifecycle.py +367 -0
- minitap/mobile_use/config.py +413 -0
- minitap/mobile_use/constants.py +3 -0
- minitap/mobile_use/context.py +106 -0
- minitap/mobile_use/controllers/__init__.py +0 -0
- minitap/mobile_use/controllers/android_controller.py +524 -0
- minitap/mobile_use/controllers/controller_factory.py +46 -0
- minitap/mobile_use/controllers/device_controller.py +182 -0
- minitap/mobile_use/controllers/ios_controller.py +436 -0
- minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
- minitap/mobile_use/controllers/types.py +106 -0
- minitap/mobile_use/controllers/unified_controller.py +193 -0
- minitap/mobile_use/graph/graph.py +160 -0
- minitap/mobile_use/graph/state.py +115 -0
- minitap/mobile_use/main.py +309 -0
- minitap/mobile_use/sdk/__init__.py +12 -0
- minitap/mobile_use/sdk/agent.py +1294 -0
- minitap/mobile_use/sdk/builders/__init__.py +10 -0
- minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
- minitap/mobile_use/sdk/builders/index.py +15 -0
- minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
- minitap/mobile_use/sdk/constants.py +1 -0
- minitap/mobile_use/sdk/examples/README.md +83 -0
- minitap/mobile_use/sdk/examples/__init__.py +1 -0
- minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
- minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
- minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
- minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
- minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
- minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
- minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
- minitap/mobile_use/sdk/services/platform.py +434 -0
- minitap/mobile_use/sdk/types/__init__.py +51 -0
- minitap/mobile_use/sdk/types/agent.py +84 -0
- minitap/mobile_use/sdk/types/exceptions.py +138 -0
- minitap/mobile_use/sdk/types/platform.py +183 -0
- minitap/mobile_use/sdk/types/task.py +269 -0
- minitap/mobile_use/sdk/utils.py +29 -0
- minitap/mobile_use/services/accessibility.py +100 -0
- minitap/mobile_use/services/llm.py +247 -0
- minitap/mobile_use/services/telemetry.py +421 -0
- minitap/mobile_use/tools/index.py +67 -0
- minitap/mobile_use/tools/mobile/back.py +52 -0
- minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
- minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
- minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
- minitap/mobile_use/tools/mobile/launch_app.py +86 -0
- minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
- minitap/mobile_use/tools/mobile/open_link.py +62 -0
- minitap/mobile_use/tools/mobile/press_key.py +83 -0
- minitap/mobile_use/tools/mobile/stop_app.py +62 -0
- minitap/mobile_use/tools/mobile/swipe.py +156 -0
- minitap/mobile_use/tools/mobile/tap.py +154 -0
- minitap/mobile_use/tools/mobile/video_recording.py +177 -0
- minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
- minitap/mobile_use/tools/scratchpad.py +147 -0
- minitap/mobile_use/tools/test_utils.py +413 -0
- minitap/mobile_use/tools/tool_wrapper.py +16 -0
- minitap/mobile_use/tools/types.py +35 -0
- minitap/mobile_use/tools/utils.py +336 -0
- minitap/mobile_use/utils/app_launch_utils.py +173 -0
- minitap/mobile_use/utils/cli_helpers.py +37 -0
- minitap/mobile_use/utils/cli_selection.py +143 -0
- minitap/mobile_use/utils/conversations.py +31 -0
- minitap/mobile_use/utils/decorators.py +124 -0
- minitap/mobile_use/utils/errors.py +6 -0
- minitap/mobile_use/utils/file.py +13 -0
- minitap/mobile_use/utils/logger.py +183 -0
- minitap/mobile_use/utils/media.py +186 -0
- minitap/mobile_use/utils/recorder.py +52 -0
- minitap/mobile_use/utils/requests_utils.py +37 -0
- minitap/mobile_use/utils/shell_utils.py +20 -0
- minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
- minitap/mobile_use/utils/time.py +6 -0
- minitap/mobile_use/utils/ui_hierarchy.py +132 -0
- minitap/mobile_use/utils/video.py +281 -0
- minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
- minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
- minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
- minitap_mobile_use-3.3.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from adbutils import AdbClient, AdbDevice
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from minitap.mobile_use.clients.ui_automator_client import UIAutomatorClient
|
|
13
|
+
from minitap.mobile_use.controllers.device_controller import (
|
|
14
|
+
MobileDeviceController,
|
|
15
|
+
ScreenDataResponse,
|
|
16
|
+
)
|
|
17
|
+
from minitap.mobile_use.controllers.types import Bounds, CoordinatesSelectorRequest, TapOutput
|
|
18
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
19
|
+
from minitap.mobile_use.utils.video import (
|
|
20
|
+
ANDROID_MAX_RECORDING_DURATION_SECONDS,
|
|
21
|
+
DEFAULT_MAX_DURATION_SECONDS,
|
|
22
|
+
VIDEO_READY_DELAY_SECONDS,
|
|
23
|
+
RecordingSession,
|
|
24
|
+
VideoRecordingResult,
|
|
25
|
+
cleanup_video_segments,
|
|
26
|
+
concatenate_videos,
|
|
27
|
+
get_active_session,
|
|
28
|
+
has_active_session,
|
|
29
|
+
remove_active_session,
|
|
30
|
+
set_active_session,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AndroidDeviceController(MobileDeviceController):
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
device_id: str,
|
|
40
|
+
adb_client: AdbClient,
|
|
41
|
+
ui_adb_client: UIAutomatorClient,
|
|
42
|
+
device_width: int,
|
|
43
|
+
device_height: int,
|
|
44
|
+
):
|
|
45
|
+
self.device_id = device_id
|
|
46
|
+
self.adb_client = adb_client
|
|
47
|
+
self.ui_adb_client = ui_adb_client
|
|
48
|
+
self.device_width = device_width
|
|
49
|
+
self.device_height = device_height
|
|
50
|
+
self._device: AdbDevice | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def device(self) -> AdbDevice:
|
|
54
|
+
if self._device is None:
|
|
55
|
+
self._device = self.adb_client.device(serial=self.device_id)
|
|
56
|
+
return self._device
|
|
57
|
+
|
|
58
|
+
async def tap(
|
|
59
|
+
self,
|
|
60
|
+
coords: CoordinatesSelectorRequest,
|
|
61
|
+
long_press: bool = False,
|
|
62
|
+
long_press_duration: int = 1000,
|
|
63
|
+
) -> TapOutput:
|
|
64
|
+
try:
|
|
65
|
+
if long_press:
|
|
66
|
+
cmd = (
|
|
67
|
+
f"input swipe {coords.x} {coords.y} {coords.x} {coords.y} {long_press_duration}"
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
cmd = f"input tap {coords.x} {coords.y}"
|
|
71
|
+
|
|
72
|
+
self.device.shell(cmd)
|
|
73
|
+
return TapOutput(error=None)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return TapOutput(error=f"ADB tap failed: {str(e)}")
|
|
76
|
+
|
|
77
|
+
async def swipe(
|
|
78
|
+
self,
|
|
79
|
+
start: CoordinatesSelectorRequest,
|
|
80
|
+
end: CoordinatesSelectorRequest,
|
|
81
|
+
duration: int = 400,
|
|
82
|
+
) -> str | None:
|
|
83
|
+
try:
|
|
84
|
+
cmd = f"input touchscreen swipe {start.x} {start.y} {end.x} {end.y} {duration}"
|
|
85
|
+
self.device.shell(cmd)
|
|
86
|
+
return None
|
|
87
|
+
except Exception as e:
|
|
88
|
+
return f"ADB swipe failed: {str(e)}"
|
|
89
|
+
|
|
90
|
+
async def get_screen_data(self) -> ScreenDataResponse:
|
|
91
|
+
"""Get screen data using the UIAutomator2 client"""
|
|
92
|
+
try:
|
|
93
|
+
logger.info("Using UIAutomator2 for screen data retrieval")
|
|
94
|
+
ui_data = self.ui_adb_client.get_screen_data()
|
|
95
|
+
return ScreenDataResponse(
|
|
96
|
+
base64=ui_data.base64,
|
|
97
|
+
elements=ui_data.elements,
|
|
98
|
+
width=ui_data.width,
|
|
99
|
+
height=ui_data.height,
|
|
100
|
+
platform="android",
|
|
101
|
+
)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error(f"Failed to get screen data: {e}")
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
async def screenshot(self) -> str:
|
|
107
|
+
try:
|
|
108
|
+
return (await self.get_screen_data()).base64
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error(f"Failed to take screenshot: {e}")
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
async def input_text(self, text: str) -> bool:
|
|
114
|
+
try:
|
|
115
|
+
self.ui_adb_client.send_text(text)
|
|
116
|
+
return True
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning(f"UIAutomator2 send_text failed: {e}, falling back to ADB shell")
|
|
119
|
+
return self._input_text_adb_fallback(text)
|
|
120
|
+
|
|
121
|
+
def _input_text_adb_fallback(self, text: str) -> bool:
|
|
122
|
+
"""Fallback method using ADB shell input text command."""
|
|
123
|
+
try:
|
|
124
|
+
parts = text.split("%s")
|
|
125
|
+
for i, part in enumerate(parts):
|
|
126
|
+
to_write = ""
|
|
127
|
+
for char in part:
|
|
128
|
+
if char == " ":
|
|
129
|
+
to_write += "%s"
|
|
130
|
+
elif char in ["&", "<", ">", "|", ";", "(", ")", "$", "`", "\\", '"', "'"]:
|
|
131
|
+
to_write += f"\\{char}"
|
|
132
|
+
else:
|
|
133
|
+
to_write += char
|
|
134
|
+
|
|
135
|
+
if to_write:
|
|
136
|
+
self.device.shell(f"input text '{to_write}'")
|
|
137
|
+
|
|
138
|
+
if i < len(parts) - 1:
|
|
139
|
+
self.device.shell("input keyevent 62")
|
|
140
|
+
|
|
141
|
+
return True
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.error(f"Failed to input text via ADB fallback: {e}")
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
async def launch_app(self, package_or_bundle_id: str) -> bool:
|
|
147
|
+
try:
|
|
148
|
+
self.device.app_start(package_or_bundle_id)
|
|
149
|
+
return True
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.error(f"Failed to launch app {package_or_bundle_id}: {e}")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
async def terminate_app(self, package_or_bundle_id: str | None) -> bool:
|
|
155
|
+
try:
|
|
156
|
+
if package_or_bundle_id is None:
|
|
157
|
+
current_app = self._get_current_foreground_package()
|
|
158
|
+
if current_app:
|
|
159
|
+
logger.info(f"Stopping currently running app: {current_app}")
|
|
160
|
+
self.device.app_stop(current_app)
|
|
161
|
+
else:
|
|
162
|
+
logger.warning("No foreground app detected")
|
|
163
|
+
return False
|
|
164
|
+
else:
|
|
165
|
+
self.device.app_stop(package_or_bundle_id)
|
|
166
|
+
return True
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Failed to terminate app {package_or_bundle_id}: {e}")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
async def open_url(self, url: str) -> bool:
|
|
172
|
+
try:
|
|
173
|
+
self.device.shell(f"am start -a android.intent.action.VIEW -d {url}")
|
|
174
|
+
return True
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(f"Failed to open URL {url}: {e}")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
async def press_back(self) -> bool:
|
|
180
|
+
try:
|
|
181
|
+
self.device.shell("input keyevent 4")
|
|
182
|
+
return True
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Failed to press back: {e}")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
async def press_home(self) -> bool:
|
|
188
|
+
try:
|
|
189
|
+
self.device.shell("input keyevent 3")
|
|
190
|
+
return True
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"Failed to press home: {e}")
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
async def press_enter(self) -> bool:
|
|
196
|
+
try:
|
|
197
|
+
self.device.shell("input keyevent 66")
|
|
198
|
+
return True
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.error(f"Failed to press enter: {e}")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
async def get_ui_hierarchy(self) -> list[dict]:
|
|
204
|
+
try:
|
|
205
|
+
device_data = await self.get_screen_data()
|
|
206
|
+
return device_data.elements
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.error(f"Failed to get UI hierarchy: {e}")
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
def find_element(
|
|
212
|
+
self,
|
|
213
|
+
ui_hierarchy: list[dict],
|
|
214
|
+
resource_id: str | None = None,
|
|
215
|
+
text: str | None = None,
|
|
216
|
+
index: int = 0,
|
|
217
|
+
) -> tuple[dict | None, Bounds | None, str | None]:
|
|
218
|
+
if not resource_id and not text:
|
|
219
|
+
return None, None, "No resource_id or text provided"
|
|
220
|
+
|
|
221
|
+
matches = []
|
|
222
|
+
for element in ui_hierarchy:
|
|
223
|
+
if resource_id and element.get("resource-id") == resource_id:
|
|
224
|
+
matches.append(element)
|
|
225
|
+
elif text and (element.get("text") == text or element.get("accessibilityText") == text):
|
|
226
|
+
matches.append(element)
|
|
227
|
+
|
|
228
|
+
if not matches:
|
|
229
|
+
criteria = f"resource_id='{resource_id}'" if resource_id else f"text='{text}'"
|
|
230
|
+
return None, None, f"No element found with {criteria}"
|
|
231
|
+
|
|
232
|
+
if index >= len(matches):
|
|
233
|
+
criteria = f"resource_id='{resource_id}'" if resource_id else f"text='{text}'"
|
|
234
|
+
return (
|
|
235
|
+
None,
|
|
236
|
+
None,
|
|
237
|
+
f"Index {index} out of range for {criteria} (found {len(matches)} matches)",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
element = matches[index]
|
|
241
|
+
bounds = self._extract_bounds(element)
|
|
242
|
+
|
|
243
|
+
return element, bounds, None
|
|
244
|
+
|
|
245
|
+
def _get_current_foreground_package(self) -> str | None:
|
|
246
|
+
try:
|
|
247
|
+
result = self.device.shell("dumpsys window | grep mCurrentFocus")
|
|
248
|
+
|
|
249
|
+
# Convert to string if bytes
|
|
250
|
+
if isinstance(result, bytes):
|
|
251
|
+
result_str = result.decode("utf-8")
|
|
252
|
+
elif isinstance(result, str):
|
|
253
|
+
result_str = result
|
|
254
|
+
else:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
if result_str and "=" in result_str:
|
|
258
|
+
parts = result_str.split("/")
|
|
259
|
+
if len(parts) > 0:
|
|
260
|
+
package = parts[0].split()[-1]
|
|
261
|
+
return package if package else None
|
|
262
|
+
return None
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.error(f"Failed to get current foreground package: {e}")
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def _extract_bounds(self, element: dict) -> Bounds | None:
|
|
268
|
+
bounds_str = element.get("bounds")
|
|
269
|
+
if not bounds_str or not isinstance(bounds_str, str):
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
match = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str)
|
|
274
|
+
if match:
|
|
275
|
+
return Bounds(
|
|
276
|
+
x1=int(match.group(1)),
|
|
277
|
+
y1=int(match.group(2)),
|
|
278
|
+
x2=int(match.group(3)),
|
|
279
|
+
y2=int(match.group(4)),
|
|
280
|
+
)
|
|
281
|
+
except (ValueError, IndexError):
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
async def erase_text(self, nb_chars: int | None = None) -> bool:
|
|
287
|
+
try:
|
|
288
|
+
chars_to_delete = nb_chars if nb_chars is not None else 50
|
|
289
|
+
for _ in range(chars_to_delete):
|
|
290
|
+
self.device.shell("input keyevent KEYCODE_DEL")
|
|
291
|
+
return True
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.error(f"Failed to erase text: {e}")
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
async def cleanup(self) -> None:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
def get_compressed_b64_screenshot(self, image_base64: str, quality: int = 50) -> str:
|
|
300
|
+
if image_base64.startswith("data:image"):
|
|
301
|
+
image_base64 = image_base64.split(",")[1]
|
|
302
|
+
|
|
303
|
+
image_data = base64.b64decode(image_base64)
|
|
304
|
+
image = Image.open(BytesIO(image_data))
|
|
305
|
+
|
|
306
|
+
compressed_io = BytesIO()
|
|
307
|
+
image.save(compressed_io, format="JPEG", quality=quality, optimize=True)
|
|
308
|
+
|
|
309
|
+
compressed_base64 = base64.b64encode(compressed_io.getvalue()).decode("utf-8")
|
|
310
|
+
return compressed_base64
|
|
311
|
+
|
|
312
|
+
async def _start_android_segment(
|
|
313
|
+
self, session: RecordingSession
|
|
314
|
+
) -> asyncio.subprocess.Process | None:
|
|
315
|
+
"""Start a single Android recording segment."""
|
|
316
|
+
segment_path = f"/sdcard/screen_recording_{session.android_segment_index}.mp4"
|
|
317
|
+
cmd = f"screenrecord --time-limit {ANDROID_MAX_RECORDING_DURATION_SECONDS} {segment_path}"
|
|
318
|
+
|
|
319
|
+
process = await asyncio.create_subprocess_shell(
|
|
320
|
+
f'adb -s {self.device_id} shell "{cmd}"',
|
|
321
|
+
stdout=asyncio.subprocess.PIPE,
|
|
322
|
+
stderr=asyncio.subprocess.PIPE,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
session.android_device_path = segment_path
|
|
326
|
+
session.process = process
|
|
327
|
+
return process
|
|
328
|
+
|
|
329
|
+
async def _android_auto_restart_loop(
|
|
330
|
+
self,
|
|
331
|
+
session: RecordingSession,
|
|
332
|
+
max_duration_seconds: int,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Background task that auto-restarts Android recording when segment ends."""
|
|
335
|
+
total_elapsed = 0
|
|
336
|
+
|
|
337
|
+
while total_elapsed < max_duration_seconds:
|
|
338
|
+
process = session.process
|
|
339
|
+
if process is None:
|
|
340
|
+
break
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
await asyncio.wait_for(
|
|
344
|
+
process.wait(),
|
|
345
|
+
timeout=ANDROID_MAX_RECORDING_DURATION_SECONDS + 5,
|
|
346
|
+
)
|
|
347
|
+
except TimeoutError:
|
|
348
|
+
pass
|
|
349
|
+
except asyncio.CancelledError:
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
if not has_active_session(self.device_id):
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
total_elapsed = time.time() - session.start_time
|
|
356
|
+
if total_elapsed >= max_duration_seconds:
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
await asyncio.sleep(VIDEO_READY_DELAY_SECONDS)
|
|
360
|
+
|
|
361
|
+
temp_dir = tempfile.mkdtemp(prefix="mobile_use_video_segment_")
|
|
362
|
+
local_segment = Path(temp_dir) / f"segment_{session.android_segment_index}.mp4"
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
self.device.sync.pull(session.android_device_path, str(local_segment))
|
|
366
|
+
self.device.shell(f"rm -f {session.android_device_path}")
|
|
367
|
+
session.android_video_segments.append(local_segment)
|
|
368
|
+
logger.info(
|
|
369
|
+
f"Saved Android segment {session.android_segment_index} to {local_segment}"
|
|
370
|
+
)
|
|
371
|
+
except Exception as e:
|
|
372
|
+
error_msg = f"Failed to pull segment {session.android_segment_index}: {e}"
|
|
373
|
+
logger.warning(error_msg)
|
|
374
|
+
session.errors.append(error_msg)
|
|
375
|
+
|
|
376
|
+
session.android_segment_index += 1
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
await self._start_android_segment(session)
|
|
380
|
+
logger.info(
|
|
381
|
+
f"Auto-restarted Android recording (segment {session.android_segment_index})"
|
|
382
|
+
)
|
|
383
|
+
except Exception as e:
|
|
384
|
+
error_msg = f"Failed to restart Android recording: {e}"
|
|
385
|
+
logger.error(error_msg)
|
|
386
|
+
session.errors.append(error_msg)
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
async def start_video_recording(
|
|
390
|
+
self,
|
|
391
|
+
max_duration_seconds: int = DEFAULT_MAX_DURATION_SECONDS,
|
|
392
|
+
) -> VideoRecordingResult:
|
|
393
|
+
"""Start screen recording on Android device using adb shell screenrecord."""
|
|
394
|
+
if has_active_session(self.device_id):
|
|
395
|
+
return VideoRecordingResult(
|
|
396
|
+
success=False,
|
|
397
|
+
message=f"Recording already in progress for device {self.device_id}",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
session = RecordingSession(
|
|
402
|
+
device_id=self.device_id,
|
|
403
|
+
start_time=time.time(),
|
|
404
|
+
android_video_segments=[],
|
|
405
|
+
android_segment_index=0,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
set_active_session(self.device_id, session)
|
|
409
|
+
await self._start_android_segment(session)
|
|
410
|
+
|
|
411
|
+
restart_task = asyncio.create_task(
|
|
412
|
+
self._android_auto_restart_loop(session, max_duration_seconds)
|
|
413
|
+
)
|
|
414
|
+
session.android_restart_task = restart_task
|
|
415
|
+
|
|
416
|
+
logger.info(f"Started Android screen recording on {self.device_id}")
|
|
417
|
+
return VideoRecordingResult(
|
|
418
|
+
success=True,
|
|
419
|
+
message=(
|
|
420
|
+
f"Recording started (max {max_duration_seconds}s, "
|
|
421
|
+
f"auto-restarts every {ANDROID_MAX_RECORDING_DURATION_SECONDS}s)."
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
except Exception as e:
|
|
426
|
+
logger.error(f"Failed to start Android recording: {e}")
|
|
427
|
+
remove_active_session(self.device_id)
|
|
428
|
+
return VideoRecordingResult(
|
|
429
|
+
success=False,
|
|
430
|
+
message=f"Failed to start recording: {e}",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def stop_video_recording(self) -> VideoRecordingResult:
|
|
434
|
+
"""Stop Android recording, pull all segments, and concatenate them."""
|
|
435
|
+
session = get_active_session(self.device_id)
|
|
436
|
+
|
|
437
|
+
if not session:
|
|
438
|
+
return VideoRecordingResult(
|
|
439
|
+
success=False,
|
|
440
|
+
message=f"No active recording for device {self.device_id}",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
if session.android_restart_task:
|
|
445
|
+
session.android_restart_task.cancel()
|
|
446
|
+
try:
|
|
447
|
+
await session.android_restart_task
|
|
448
|
+
except asyncio.CancelledError:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
process = session.process
|
|
452
|
+
if process is not None:
|
|
453
|
+
try:
|
|
454
|
+
process.terminate()
|
|
455
|
+
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
456
|
+
except TimeoutError:
|
|
457
|
+
process.kill()
|
|
458
|
+
await process.wait()
|
|
459
|
+
|
|
460
|
+
self.device.shell("pkill -2 screenrecord || true")
|
|
461
|
+
await asyncio.sleep(VIDEO_READY_DELAY_SECONDS)
|
|
462
|
+
|
|
463
|
+
temp_dir = tempfile.mkdtemp(prefix="mobile_use_video_")
|
|
464
|
+
final_segment = Path(temp_dir) / f"segment_{session.android_segment_index}.mp4"
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
self.device.sync.pull(session.android_device_path, str(final_segment))
|
|
468
|
+
self.device.shell(f"rm -f {session.android_device_path}")
|
|
469
|
+
session.android_video_segments.append(final_segment)
|
|
470
|
+
except Exception as e:
|
|
471
|
+
logger.warning(f"Failed to pull final segment: {e}")
|
|
472
|
+
|
|
473
|
+
for i in range(session.android_segment_index + 1):
|
|
474
|
+
self.device.shell(f"rm -f /sdcard/screen_recording_{i}.mp4")
|
|
475
|
+
|
|
476
|
+
all_segments = session.android_video_segments
|
|
477
|
+
|
|
478
|
+
if not all_segments:
|
|
479
|
+
remove_active_session(self.device_id)
|
|
480
|
+
return VideoRecordingResult(
|
|
481
|
+
success=False,
|
|
482
|
+
message="No video segments were captured",
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
output_dir = tempfile.mkdtemp(prefix="mobile_use_video_final_")
|
|
486
|
+
output_path = Path(output_dir) / "recording.mp4"
|
|
487
|
+
|
|
488
|
+
if len(all_segments) == 1:
|
|
489
|
+
all_segments[0].rename(output_path)
|
|
490
|
+
else:
|
|
491
|
+
success = await concatenate_videos(all_segments, output_path)
|
|
492
|
+
if not success:
|
|
493
|
+
output_path = all_segments[-1]
|
|
494
|
+
logger.warning("Concatenation failed, using last segment only")
|
|
495
|
+
|
|
496
|
+
cleanup_video_segments(all_segments, keep_path=output_path)
|
|
497
|
+
|
|
498
|
+
errors = session.errors.copy()
|
|
499
|
+
remove_active_session(self.device_id)
|
|
500
|
+
|
|
501
|
+
duration = time.time() - session.start_time
|
|
502
|
+
segment_count = len(all_segments)
|
|
503
|
+
logger.info(
|
|
504
|
+
f"Stopped Android recording after {duration:.1f}s, "
|
|
505
|
+
f"{segment_count} segment(s), saved to {output_path}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
message = f"Recording stopped after {duration:.1f}s ({segment_count} segments)"
|
|
509
|
+
if errors:
|
|
510
|
+
message += f". Warnings during recording: {'; '.join(errors)}"
|
|
511
|
+
|
|
512
|
+
return VideoRecordingResult(
|
|
513
|
+
success=True,
|
|
514
|
+
message=message,
|
|
515
|
+
video_path=output_path,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.error(f"Failed to stop Android recording: {e}")
|
|
520
|
+
remove_active_session(self.device_id)
|
|
521
|
+
return VideoRecordingResult(
|
|
522
|
+
success=False,
|
|
523
|
+
message=f"Failed to stop recording: {e}",
|
|
524
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from minitap.mobile_use.context import DevicePlatform, MobileUseContext
|
|
2
|
+
from minitap.mobile_use.controllers.android_controller import AndroidDeviceController
|
|
3
|
+
from minitap.mobile_use.controllers.device_controller import MobileDeviceController
|
|
4
|
+
from minitap.mobile_use.controllers.ios_controller import iOSDeviceController
|
|
5
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_device_controller(ctx: MobileUseContext) -> MobileDeviceController:
|
|
11
|
+
platform = ctx.device.mobile_platform
|
|
12
|
+
|
|
13
|
+
if platform == DevicePlatform.ANDROID:
|
|
14
|
+
if ctx.adb_client is None:
|
|
15
|
+
raise ValueError("ADB client not initialized for Android device")
|
|
16
|
+
|
|
17
|
+
if ctx.ui_adb_client is None:
|
|
18
|
+
raise ValueError("UIAutomator client not initialized for Android device")
|
|
19
|
+
|
|
20
|
+
logger.info(f"Creating Android controller for device {ctx.device.device_id}")
|
|
21
|
+
return AndroidDeviceController(
|
|
22
|
+
device_id=ctx.device.device_id,
|
|
23
|
+
adb_client=ctx.adb_client,
|
|
24
|
+
ui_adb_client=ctx.ui_adb_client,
|
|
25
|
+
device_width=ctx.device.device_width,
|
|
26
|
+
device_height=ctx.device.device_height,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
elif platform == DevicePlatform.IOS:
|
|
30
|
+
if ctx.ios_client is None:
|
|
31
|
+
raise ValueError("iOS client not initialized for iOS device")
|
|
32
|
+
|
|
33
|
+
logger.info(f"Creating iOS controller for device {ctx.device.device_id}")
|
|
34
|
+
return iOSDeviceController(
|
|
35
|
+
ios_client=ctx.ios_client,
|
|
36
|
+
device_id=ctx.device.device_id,
|
|
37
|
+
device_width=ctx.device.device_width,
|
|
38
|
+
device_height=ctx.device.device_height,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"Unsupported platform: {platform}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_controller(ctx: MobileUseContext) -> MobileDeviceController:
|
|
46
|
+
return create_device_controller(ctx)
|