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.
Files changed (115) hide show
  1. minitap/mobile_use/__init__.py +0 -0
  2. minitap/mobile_use/agents/contextor/contextor.md +55 -0
  3. minitap/mobile_use/agents/contextor/contextor.py +175 -0
  4. minitap/mobile_use/agents/contextor/types.py +36 -0
  5. minitap/mobile_use/agents/cortex/cortex.md +135 -0
  6. minitap/mobile_use/agents/cortex/cortex.py +152 -0
  7. minitap/mobile_use/agents/cortex/types.py +15 -0
  8. minitap/mobile_use/agents/executor/executor.md +42 -0
  9. minitap/mobile_use/agents/executor/executor.py +87 -0
  10. minitap/mobile_use/agents/executor/tool_node.py +152 -0
  11. minitap/mobile_use/agents/hopper/hopper.md +15 -0
  12. minitap/mobile_use/agents/hopper/hopper.py +44 -0
  13. minitap/mobile_use/agents/orchestrator/human.md +12 -0
  14. minitap/mobile_use/agents/orchestrator/orchestrator.md +21 -0
  15. minitap/mobile_use/agents/orchestrator/orchestrator.py +134 -0
  16. minitap/mobile_use/agents/orchestrator/types.py +11 -0
  17. minitap/mobile_use/agents/outputter/human.md +25 -0
  18. minitap/mobile_use/agents/outputter/outputter.py +85 -0
  19. minitap/mobile_use/agents/outputter/test_outputter.py +167 -0
  20. minitap/mobile_use/agents/planner/human.md +14 -0
  21. minitap/mobile_use/agents/planner/planner.md +126 -0
  22. minitap/mobile_use/agents/planner/planner.py +101 -0
  23. minitap/mobile_use/agents/planner/types.py +51 -0
  24. minitap/mobile_use/agents/planner/utils.py +70 -0
  25. minitap/mobile_use/agents/summarizer/summarizer.py +35 -0
  26. minitap/mobile_use/agents/video_analyzer/__init__.py +5 -0
  27. minitap/mobile_use/agents/video_analyzer/human.md +5 -0
  28. minitap/mobile_use/agents/video_analyzer/video_analyzer.md +37 -0
  29. minitap/mobile_use/agents/video_analyzer/video_analyzer.py +111 -0
  30. minitap/mobile_use/clients/browserstack_client.py +477 -0
  31. minitap/mobile_use/clients/idb_client.py +429 -0
  32. minitap/mobile_use/clients/ios_client.py +332 -0
  33. minitap/mobile_use/clients/ios_client_config.py +141 -0
  34. minitap/mobile_use/clients/ui_automator_client.py +330 -0
  35. minitap/mobile_use/clients/wda_client.py +526 -0
  36. minitap/mobile_use/clients/wda_lifecycle.py +367 -0
  37. minitap/mobile_use/config.py +413 -0
  38. minitap/mobile_use/constants.py +3 -0
  39. minitap/mobile_use/context.py +106 -0
  40. minitap/mobile_use/controllers/__init__.py +0 -0
  41. minitap/mobile_use/controllers/android_controller.py +524 -0
  42. minitap/mobile_use/controllers/controller_factory.py +46 -0
  43. minitap/mobile_use/controllers/device_controller.py +182 -0
  44. minitap/mobile_use/controllers/ios_controller.py +436 -0
  45. minitap/mobile_use/controllers/platform_specific_commands_controller.py +199 -0
  46. minitap/mobile_use/controllers/types.py +106 -0
  47. minitap/mobile_use/controllers/unified_controller.py +193 -0
  48. minitap/mobile_use/graph/graph.py +160 -0
  49. minitap/mobile_use/graph/state.py +115 -0
  50. minitap/mobile_use/main.py +309 -0
  51. minitap/mobile_use/sdk/__init__.py +12 -0
  52. minitap/mobile_use/sdk/agent.py +1294 -0
  53. minitap/mobile_use/sdk/builders/__init__.py +10 -0
  54. minitap/mobile_use/sdk/builders/agent_config_builder.py +307 -0
  55. minitap/mobile_use/sdk/builders/index.py +15 -0
  56. minitap/mobile_use/sdk/builders/task_request_builder.py +236 -0
  57. minitap/mobile_use/sdk/constants.py +1 -0
  58. minitap/mobile_use/sdk/examples/README.md +83 -0
  59. minitap/mobile_use/sdk/examples/__init__.py +1 -0
  60. minitap/mobile_use/sdk/examples/app_lock_messaging.py +54 -0
  61. minitap/mobile_use/sdk/examples/platform_manual_task_example.py +67 -0
  62. minitap/mobile_use/sdk/examples/platform_minimal_example.py +48 -0
  63. minitap/mobile_use/sdk/examples/simple_photo_organizer.py +76 -0
  64. minitap/mobile_use/sdk/examples/smart_notification_assistant.py +225 -0
  65. minitap/mobile_use/sdk/examples/video_transcription_example.py +117 -0
  66. minitap/mobile_use/sdk/services/cloud_mobile.py +656 -0
  67. minitap/mobile_use/sdk/services/platform.py +434 -0
  68. minitap/mobile_use/sdk/types/__init__.py +51 -0
  69. minitap/mobile_use/sdk/types/agent.py +84 -0
  70. minitap/mobile_use/sdk/types/exceptions.py +138 -0
  71. minitap/mobile_use/sdk/types/platform.py +183 -0
  72. minitap/mobile_use/sdk/types/task.py +269 -0
  73. minitap/mobile_use/sdk/utils.py +29 -0
  74. minitap/mobile_use/services/accessibility.py +100 -0
  75. minitap/mobile_use/services/llm.py +247 -0
  76. minitap/mobile_use/services/telemetry.py +421 -0
  77. minitap/mobile_use/tools/index.py +67 -0
  78. minitap/mobile_use/tools/mobile/back.py +52 -0
  79. minitap/mobile_use/tools/mobile/erase_one_char.py +56 -0
  80. minitap/mobile_use/tools/mobile/focus_and_clear_text.py +317 -0
  81. minitap/mobile_use/tools/mobile/focus_and_input_text.py +153 -0
  82. minitap/mobile_use/tools/mobile/launch_app.py +86 -0
  83. minitap/mobile_use/tools/mobile/long_press_on.py +169 -0
  84. minitap/mobile_use/tools/mobile/open_link.py +62 -0
  85. minitap/mobile_use/tools/mobile/press_key.py +83 -0
  86. minitap/mobile_use/tools/mobile/stop_app.py +62 -0
  87. minitap/mobile_use/tools/mobile/swipe.py +156 -0
  88. minitap/mobile_use/tools/mobile/tap.py +154 -0
  89. minitap/mobile_use/tools/mobile/video_recording.py +177 -0
  90. minitap/mobile_use/tools/mobile/wait_for_delay.py +81 -0
  91. minitap/mobile_use/tools/scratchpad.py +147 -0
  92. minitap/mobile_use/tools/test_utils.py +413 -0
  93. minitap/mobile_use/tools/tool_wrapper.py +16 -0
  94. minitap/mobile_use/tools/types.py +35 -0
  95. minitap/mobile_use/tools/utils.py +336 -0
  96. minitap/mobile_use/utils/app_launch_utils.py +173 -0
  97. minitap/mobile_use/utils/cli_helpers.py +37 -0
  98. minitap/mobile_use/utils/cli_selection.py +143 -0
  99. minitap/mobile_use/utils/conversations.py +31 -0
  100. minitap/mobile_use/utils/decorators.py +124 -0
  101. minitap/mobile_use/utils/errors.py +6 -0
  102. minitap/mobile_use/utils/file.py +13 -0
  103. minitap/mobile_use/utils/logger.py +183 -0
  104. minitap/mobile_use/utils/media.py +186 -0
  105. minitap/mobile_use/utils/recorder.py +52 -0
  106. minitap/mobile_use/utils/requests_utils.py +37 -0
  107. minitap/mobile_use/utils/shell_utils.py +20 -0
  108. minitap/mobile_use/utils/test_ui_hierarchy.py +178 -0
  109. minitap/mobile_use/utils/time.py +6 -0
  110. minitap/mobile_use/utils/ui_hierarchy.py +132 -0
  111. minitap/mobile_use/utils/video.py +281 -0
  112. minitap_mobile_use-3.3.0.dist-info/METADATA +329 -0
  113. minitap_mobile_use-3.3.0.dist-info/RECORD +115 -0
  114. minitap_mobile_use-3.3.0.dist-info/WHEEL +4 -0
  115. 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)