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,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from minitap.mobile_use.context import MobileUseContext
|
|
8
|
+
from minitap.mobile_use.controllers.types import (
|
|
9
|
+
CoordinatesSelectorRequest,
|
|
10
|
+
PercentagesSelectorRequest,
|
|
11
|
+
)
|
|
12
|
+
from minitap.mobile_use.controllers.unified_controller import UnifiedMobileController
|
|
13
|
+
from minitap.mobile_use.graph.state import State
|
|
14
|
+
from minitap.mobile_use.tools.types import Target
|
|
15
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
16
|
+
from minitap.mobile_use.utils.ui_hierarchy import (
|
|
17
|
+
ElementBounds,
|
|
18
|
+
Point,
|
|
19
|
+
find_element_by_resource_id,
|
|
20
|
+
get_bounds_for_element,
|
|
21
|
+
get_element_text,
|
|
22
|
+
is_element_focused,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_element_by_text(
|
|
29
|
+
ui_hierarchy: list[dict], text: str, index: int | None = None
|
|
30
|
+
) -> dict | None:
|
|
31
|
+
"""
|
|
32
|
+
Find a UI element by its text content (adapted to both flat and rich hierarchy)
|
|
33
|
+
|
|
34
|
+
This function performs a recursive, case-insensitive partial search.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
ui_hierarchy: List of UI element dictionaries.
|
|
38
|
+
text: The text content to search for.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The complete UI element dictionary if found, None otherwise.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def search_recursive(elements: list[dict]) -> dict | None:
|
|
45
|
+
for element in elements:
|
|
46
|
+
if isinstance(element, dict):
|
|
47
|
+
src = element.get("attributes", element)
|
|
48
|
+
element_text = src.get("text", "")
|
|
49
|
+
# Guard against non-string text values (e.g., dict)
|
|
50
|
+
if not isinstance(element_text, str):
|
|
51
|
+
element_text = ""
|
|
52
|
+
if text and text.lower() == element_text.lower():
|
|
53
|
+
idx = index or 0
|
|
54
|
+
if idx == 0:
|
|
55
|
+
return element
|
|
56
|
+
idx -= 1
|
|
57
|
+
continue
|
|
58
|
+
if (children := element.get("children", [])) and (
|
|
59
|
+
found := search_recursive(children)
|
|
60
|
+
):
|
|
61
|
+
return found
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
return search_recursive(ui_hierarchy)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def tap_bottom_right_of_element(bounds: ElementBounds, ctx: MobileUseContext):
|
|
68
|
+
bottom_right: Point = bounds.get_relative_point(x_percent=0.99, y_percent=0.99)
|
|
69
|
+
await tap(
|
|
70
|
+
ctx=ctx,
|
|
71
|
+
selector_request=SelectorRequestWithCoordinates(
|
|
72
|
+
coordinates=CoordinatesSelectorRequest(
|
|
73
|
+
x=bottom_right.x,
|
|
74
|
+
y=bottom_right.y,
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def move_cursor_to_end_if_bounds(
|
|
81
|
+
ctx: MobileUseContext,
|
|
82
|
+
state: State,
|
|
83
|
+
target: Target,
|
|
84
|
+
elt: dict | None = None,
|
|
85
|
+
) -> dict | None:
|
|
86
|
+
"""
|
|
87
|
+
Best-effort move of the text cursor near the end of the input by tapping the
|
|
88
|
+
bottom-right area of the focused element (if bounds are available).
|
|
89
|
+
"""
|
|
90
|
+
if target.resource_id:
|
|
91
|
+
if not elt:
|
|
92
|
+
elt = find_element_by_resource_id(
|
|
93
|
+
ui_hierarchy=state.latest_ui_hierarchy or [],
|
|
94
|
+
resource_id=target.resource_id,
|
|
95
|
+
index=target.resource_id_index,
|
|
96
|
+
)
|
|
97
|
+
if not elt:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
bounds = get_bounds_for_element(elt)
|
|
101
|
+
if not bounds:
|
|
102
|
+
return elt
|
|
103
|
+
|
|
104
|
+
logger.debug("Tapping near the end of the input to move the cursor")
|
|
105
|
+
await tap_bottom_right_of_element(bounds=bounds, ctx=ctx)
|
|
106
|
+
logger.debug(f"Tapped end of input {target.resource_id}")
|
|
107
|
+
return elt
|
|
108
|
+
|
|
109
|
+
if target.bounds:
|
|
110
|
+
await tap_bottom_right_of_element(target.bounds, ctx=ctx)
|
|
111
|
+
logger.debug("Tapped end of input by coordinates")
|
|
112
|
+
return elt
|
|
113
|
+
|
|
114
|
+
if target.text:
|
|
115
|
+
text_elt = find_element_by_text(
|
|
116
|
+
state.latest_ui_hierarchy or [], target.text, index=target.text_index
|
|
117
|
+
)
|
|
118
|
+
if text_elt:
|
|
119
|
+
bounds = get_bounds_for_element(text_elt)
|
|
120
|
+
if bounds:
|
|
121
|
+
await tap_bottom_right_of_element(bounds=bounds, ctx=ctx)
|
|
122
|
+
logger.debug(f"Tapped end of input that had text'{target.text}'")
|
|
123
|
+
return text_elt
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def focus_element_if_needed(
|
|
130
|
+
ctx: MobileUseContext, target: Target
|
|
131
|
+
) -> Literal["resource_id", "coordinates", "text"] | None:
|
|
132
|
+
"""
|
|
133
|
+
Ensures the element is focused, with a sanity check to prevent trusting misleading IDs.
|
|
134
|
+
"""
|
|
135
|
+
controller = UnifiedMobileController(ctx)
|
|
136
|
+
rich_hierarchy = await controller.get_ui_elements()
|
|
137
|
+
elt_from_id = None
|
|
138
|
+
if target.resource_id:
|
|
139
|
+
elt_from_id = find_element_by_resource_id(
|
|
140
|
+
ui_hierarchy=rich_hierarchy,
|
|
141
|
+
resource_id=target.resource_id,
|
|
142
|
+
index=target.resource_id_index,
|
|
143
|
+
is_rich_hierarchy=False,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if elt_from_id and target.text:
|
|
147
|
+
text_from_id_elt = get_element_text(elt_from_id)
|
|
148
|
+
if not text_from_id_elt or target.text.lower() != text_from_id_elt.lower():
|
|
149
|
+
logger.warning(
|
|
150
|
+
f"ID '{target.resource_id}' and text '{target.text}' seem to be on different "
|
|
151
|
+
"elements. Ignoring the resource_id and falling back to other locators."
|
|
152
|
+
)
|
|
153
|
+
elt_from_id = None
|
|
154
|
+
|
|
155
|
+
if elt_from_id:
|
|
156
|
+
if not is_element_focused(elt_from_id):
|
|
157
|
+
tap(
|
|
158
|
+
ctx=ctx,
|
|
159
|
+
selector_request=IdSelectorRequest(id=target.resource_id), # type: ignore
|
|
160
|
+
index=target.resource_id_index,
|
|
161
|
+
)
|
|
162
|
+
logger.debug(f"Focused (tap) on resource_id={target.resource_id}")
|
|
163
|
+
rich_hierarchy = await controller.get_ui_elements()
|
|
164
|
+
elt_from_id = find_element_by_resource_id(
|
|
165
|
+
ui_hierarchy=rich_hierarchy,
|
|
166
|
+
resource_id=target.resource_id, # type: ignore
|
|
167
|
+
index=target.resource_id_index,
|
|
168
|
+
is_rich_hierarchy=False,
|
|
169
|
+
)
|
|
170
|
+
if elt_from_id and is_element_focused(elt_from_id):
|
|
171
|
+
logger.debug(f"Text input is focused: {target.resource_id}")
|
|
172
|
+
return "resource_id"
|
|
173
|
+
logger.warning(f"Failed to focus using resource_id='{target.resource_id}'. Fallback...")
|
|
174
|
+
|
|
175
|
+
if target.bounds:
|
|
176
|
+
relative_point = target.bounds.get_center()
|
|
177
|
+
await tap(
|
|
178
|
+
ctx=ctx,
|
|
179
|
+
selector_request=SelectorRequestWithCoordinates(
|
|
180
|
+
coordinates=CoordinatesSelectorRequest(x=relative_point.x, y=relative_point.y)
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
logger.debug(f"Tapped on coordinates ({relative_point.x}, {relative_point.y}) to focus.")
|
|
184
|
+
return "coordinates"
|
|
185
|
+
|
|
186
|
+
if target.text:
|
|
187
|
+
text_elt = find_element_by_text(rich_hierarchy, target.text, index=target.text_index)
|
|
188
|
+
if text_elt:
|
|
189
|
+
bounds = get_bounds_for_element(text_elt)
|
|
190
|
+
if bounds:
|
|
191
|
+
relative_point = bounds.get_center()
|
|
192
|
+
await tap(
|
|
193
|
+
ctx=ctx,
|
|
194
|
+
selector_request=SelectorRequestWithCoordinates(
|
|
195
|
+
coordinates=CoordinatesSelectorRequest(
|
|
196
|
+
x=relative_point.x, y=relative_point.y
|
|
197
|
+
)
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
logger.debug(f"Tapped on text element '{target.text}' to focus.")
|
|
201
|
+
return "text"
|
|
202
|
+
|
|
203
|
+
logger.error(
|
|
204
|
+
"Failed to focus element. No valid locator (resource_id, coordinates, or text) succeeded."
|
|
205
|
+
)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def validate_coordinates_bounds(
|
|
210
|
+
target: Target, screen_width: int, screen_height: int
|
|
211
|
+
) -> str | None:
|
|
212
|
+
"""
|
|
213
|
+
Validate that coordinates are within screen bounds.
|
|
214
|
+
Returns error message if invalid, None if valid.
|
|
215
|
+
"""
|
|
216
|
+
if not target.bounds:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
center = target.bounds.get_center()
|
|
220
|
+
errors = []
|
|
221
|
+
|
|
222
|
+
if center.x < 0 or center.x >= screen_width:
|
|
223
|
+
errors.append(f"x={center.x} is outside screen width (0-{screen_width})")
|
|
224
|
+
if center.y < 0 or center.y >= screen_height:
|
|
225
|
+
errors.append(f"y={center.y} is outside screen height (0-{screen_height})")
|
|
226
|
+
|
|
227
|
+
return "; ".join(errors) if errors else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def has_valid_selectors(target: Target) -> bool:
|
|
231
|
+
"""Check if target has at least one valid selector."""
|
|
232
|
+
has_coordinates = target.bounds is not None
|
|
233
|
+
has_resource_id = target.resource_id is not None and target.resource_id != ""
|
|
234
|
+
has_text = target.text is not None and target.text != ""
|
|
235
|
+
return has_coordinates or has_resource_id or has_text
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class IdSelectorRequest(BaseModel):
|
|
239
|
+
model_config = ConfigDict(extra="forbid")
|
|
240
|
+
id: str
|
|
241
|
+
|
|
242
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
243
|
+
return {"id": self.id}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class TextSelectorRequest(BaseModel):
|
|
247
|
+
model_config = ConfigDict(extra="forbid")
|
|
248
|
+
text: str
|
|
249
|
+
|
|
250
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
251
|
+
return {"text": self.text}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class SelectorRequestWithCoordinates(BaseModel):
|
|
255
|
+
model_config = ConfigDict(extra="forbid")
|
|
256
|
+
coordinates: CoordinatesSelectorRequest
|
|
257
|
+
|
|
258
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
259
|
+
return {"point": self.coordinates.to_str()}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class SelectorRequestWithPercentages(BaseModel):
|
|
263
|
+
model_config = ConfigDict(extra="forbid")
|
|
264
|
+
percentages: PercentagesSelectorRequest
|
|
265
|
+
|
|
266
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
267
|
+
return {"point": self.percentages.to_str()}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class IdWithTextSelectorRequest(BaseModel):
|
|
271
|
+
model_config = ConfigDict(extra="forbid")
|
|
272
|
+
id: str
|
|
273
|
+
text: str
|
|
274
|
+
|
|
275
|
+
def to_dict(self) -> dict[str, str | int]:
|
|
276
|
+
return {"id": self.id, "text": self.text}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
SelectorRequest = (
|
|
280
|
+
IdSelectorRequest
|
|
281
|
+
| SelectorRequestWithCoordinates
|
|
282
|
+
| SelectorRequestWithPercentages
|
|
283
|
+
| TextSelectorRequest
|
|
284
|
+
| IdWithTextSelectorRequest
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_resource_id_and_text_from_selector(
|
|
289
|
+
selector: SelectorRequest,
|
|
290
|
+
) -> tuple[str | None, str | None]:
|
|
291
|
+
"""Extract resource_id and text from a selector."""
|
|
292
|
+
resource_id = None
|
|
293
|
+
text = None
|
|
294
|
+
|
|
295
|
+
if isinstance(selector, IdSelectorRequest):
|
|
296
|
+
resource_id = selector.id
|
|
297
|
+
elif isinstance(selector, TextSelectorRequest):
|
|
298
|
+
text = selector.text
|
|
299
|
+
elif isinstance(selector, IdWithTextSelectorRequest):
|
|
300
|
+
resource_id = selector.id
|
|
301
|
+
text = selector.text
|
|
302
|
+
|
|
303
|
+
return resource_id, text
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def tap(
|
|
307
|
+
ctx: MobileUseContext,
|
|
308
|
+
selector_request: SelectorRequest,
|
|
309
|
+
index: int | None = None,
|
|
310
|
+
):
|
|
311
|
+
"""
|
|
312
|
+
Tap on a selector.
|
|
313
|
+
Index is optional and is used when you have multiple views matching the same selector.
|
|
314
|
+
ui_hierarchy is optional and used for ADB taps to find elements.
|
|
315
|
+
"""
|
|
316
|
+
controller = UnifiedMobileController(ctx)
|
|
317
|
+
if isinstance(selector_request, SelectorRequestWithCoordinates):
|
|
318
|
+
result = await controller.tap_at(
|
|
319
|
+
x=selector_request.coordinates.x, y=selector_request.coordinates.y
|
|
320
|
+
)
|
|
321
|
+
return result.error if result.error else None
|
|
322
|
+
if isinstance(selector_request, SelectorRequestWithPercentages):
|
|
323
|
+
coords = selector_request.percentages.to_coords(
|
|
324
|
+
width=ctx.device.device_width,
|
|
325
|
+
height=ctx.device.device_height,
|
|
326
|
+
)
|
|
327
|
+
return await controller.tap_at(coords.x, coords.y)
|
|
328
|
+
|
|
329
|
+
# For other selectors, we need the UI hierarchy
|
|
330
|
+
resource_id, text = _extract_resource_id_and_text_from_selector(selector_request)
|
|
331
|
+
|
|
332
|
+
return await controller.tap_element(
|
|
333
|
+
resource_id=resource_id,
|
|
334
|
+
text=text,
|
|
335
|
+
index=index if index is not None else 0,
|
|
336
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for handling app locking and initial app launch logic.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from minitap.mobile_use.context import AppLaunchResult, MobileUseContext
|
|
8
|
+
from minitap.mobile_use.controllers.platform_specific_commands_controller import (
|
|
9
|
+
get_current_foreground_package,
|
|
10
|
+
)
|
|
11
|
+
from minitap.mobile_use.controllers.unified_controller import UnifiedMobileController
|
|
12
|
+
from minitap.mobile_use.utils.logger import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _poll_for_app_ready(
|
|
18
|
+
ctx: MobileUseContext,
|
|
19
|
+
app_package: str,
|
|
20
|
+
max_poll_seconds: int = 15,
|
|
21
|
+
poll_interval: float = 1.0,
|
|
22
|
+
) -> tuple[bool, str | None]:
|
|
23
|
+
"""
|
|
24
|
+
Poll for app to be ready after launch.
|
|
25
|
+
|
|
26
|
+
Treats mCurrentFocus=null as a loading state and keeps polling.
|
|
27
|
+
Only fails if we get a different (non-null) package or timeout.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
ctx: Mobile use context
|
|
31
|
+
app_package: Expected package name
|
|
32
|
+
max_poll_seconds: Maximum time to poll (default: 15s)
|
|
33
|
+
poll_interval: Time between polls (default: 1s)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (success: bool, error_message: str | None)
|
|
37
|
+
"""
|
|
38
|
+
polls = int(max_poll_seconds / poll_interval)
|
|
39
|
+
|
|
40
|
+
for i in range(polls):
|
|
41
|
+
current_package = get_current_foreground_package(ctx)
|
|
42
|
+
|
|
43
|
+
if current_package == app_package:
|
|
44
|
+
logger.success(f"App {app_package} is ready (took ~{i * poll_interval:.1f}s)")
|
|
45
|
+
return True, None
|
|
46
|
+
|
|
47
|
+
if current_package is None:
|
|
48
|
+
logger.debug(f"Poll {i + 1}/{polls}: App loading (mCurrentFocus=null)...")
|
|
49
|
+
else:
|
|
50
|
+
error_msg = (
|
|
51
|
+
f"Wrong app in foreground: expected '{app_package}', got '{current_package}'"
|
|
52
|
+
)
|
|
53
|
+
logger.warning(error_msg)
|
|
54
|
+
return False, error_msg
|
|
55
|
+
|
|
56
|
+
if i < polls - 1:
|
|
57
|
+
await asyncio.sleep(poll_interval)
|
|
58
|
+
|
|
59
|
+
current_package = get_current_foreground_package(ctx)
|
|
60
|
+
error_msg = (
|
|
61
|
+
f"Timeout waiting for {app_package} to load after {max_poll_seconds}s. "
|
|
62
|
+
f"Current foreground: {current_package}"
|
|
63
|
+
)
|
|
64
|
+
logger.error(error_msg)
|
|
65
|
+
return False, error_msg
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def launch_app_with_retries(
|
|
69
|
+
ctx: MobileUseContext,
|
|
70
|
+
app_package: str,
|
|
71
|
+
max_retries: int = 3,
|
|
72
|
+
max_poll_seconds: int = 15,
|
|
73
|
+
) -> tuple[bool, str | None]:
|
|
74
|
+
"""
|
|
75
|
+
Launch an app with retry logic and smart polling.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
ctx: Mobile use context
|
|
79
|
+
app_package: Package name (Android) or bundle ID (iOS) to launch
|
|
80
|
+
max_retries: Maximum number of launch attempts (default: 3)
|
|
81
|
+
max_poll_seconds: Maximum time to wait for app to load per attempt (default: 15s)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Tuple of (success: bool, error_message: str | None)
|
|
85
|
+
"""
|
|
86
|
+
for attempt in range(1, max_retries + 1):
|
|
87
|
+
logger.info(f"Launch attempt {attempt}/{max_retries} for app {app_package}")
|
|
88
|
+
|
|
89
|
+
controller = UnifiedMobileController(ctx)
|
|
90
|
+
launch_success = await controller.launch_app(app_package)
|
|
91
|
+
if not launch_success:
|
|
92
|
+
error_msg = f"Failed to execute launch command for {app_package}"
|
|
93
|
+
logger.error(error_msg)
|
|
94
|
+
if attempt == max_retries:
|
|
95
|
+
return False, error_msg
|
|
96
|
+
await asyncio.sleep(2)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
await asyncio.sleep(1)
|
|
100
|
+
|
|
101
|
+
success, error_msg = await _poll_for_app_ready(ctx, app_package, max_poll_seconds)
|
|
102
|
+
|
|
103
|
+
if success:
|
|
104
|
+
return True, None
|
|
105
|
+
|
|
106
|
+
if attempt < max_retries:
|
|
107
|
+
logger.warning(f"Attempt {attempt} failed: {error_msg}. Retrying...")
|
|
108
|
+
await asyncio.sleep(1)
|
|
109
|
+
|
|
110
|
+
error_msg = f"Failed to launch {app_package} after {max_retries} attempts"
|
|
111
|
+
logger.error(error_msg)
|
|
112
|
+
return False, error_msg
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _handle_initial_app_launch(
|
|
116
|
+
ctx: MobileUseContext,
|
|
117
|
+
locked_app_package: str,
|
|
118
|
+
) -> AppLaunchResult:
|
|
119
|
+
"""
|
|
120
|
+
Handle initial app launch verification and launching if needed.
|
|
121
|
+
|
|
122
|
+
If locked_app_package is set:
|
|
123
|
+
1. Check if the app is already in the foreground
|
|
124
|
+
2. If not, attempt to launch it (with retries)
|
|
125
|
+
3. Return status with success/error information
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
ctx: Mobile use context
|
|
129
|
+
locked_app_package: Package name (Android) or bundle ID (iOS) to lock to, or None
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
AppLaunchResult with launch status and error information
|
|
133
|
+
"""
|
|
134
|
+
if not locked_app_package:
|
|
135
|
+
error_msg = f"Invalid locked_app_package: '{locked_app_package}'"
|
|
136
|
+
logger.error(error_msg)
|
|
137
|
+
return AppLaunchResult(
|
|
138
|
+
locked_app_package=locked_app_package,
|
|
139
|
+
locked_app_initial_launch_success=False,
|
|
140
|
+
locked_app_initial_launch_error=error_msg,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
logger.info(f"Starting initial app launch for package: {locked_app_package}")
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
current_package = get_current_foreground_package(ctx)
|
|
147
|
+
logger.info(f"Current foreground app: {current_package}")
|
|
148
|
+
|
|
149
|
+
if current_package == locked_app_package:
|
|
150
|
+
logger.info(f"App {locked_app_package} is already in foreground")
|
|
151
|
+
return AppLaunchResult(
|
|
152
|
+
locked_app_package=locked_app_package,
|
|
153
|
+
locked_app_initial_launch_success=True,
|
|
154
|
+
locked_app_initial_launch_error=None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
logger.info(f"App {locked_app_package} not in foreground, attempting to launch")
|
|
158
|
+
success, error_msg = await launch_app_with_retries(ctx, locked_app_package)
|
|
159
|
+
|
|
160
|
+
return AppLaunchResult(
|
|
161
|
+
locked_app_package=locked_app_package,
|
|
162
|
+
locked_app_initial_launch_success=success,
|
|
163
|
+
locked_app_initial_launch_error=error_msg,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
error_msg = f"Exception during initial app launch: {str(e)}"
|
|
168
|
+
logger.error(error_msg)
|
|
169
|
+
return AppLaunchResult(
|
|
170
|
+
locked_app_package=locked_app_package,
|
|
171
|
+
locked_app_initial_launch_success=False,
|
|
172
|
+
locked_app_initial_launch_error=error_msg,
|
|
173
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from adbutils import AdbClient
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from minitap.mobile_use.clients.ios_client import format_device_info, get_all_ios_devices_detailed
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def display_device_status(console: Console, adb_client: AdbClient | None = None):
|
|
10
|
+
"""Checks for connected devices and displays the status."""
|
|
11
|
+
console.print("\n[bold]📱 Device Status[/bold]")
|
|
12
|
+
devices = None
|
|
13
|
+
if adb_client is not None:
|
|
14
|
+
devices = adb_client.device_list()
|
|
15
|
+
if devices:
|
|
16
|
+
console.print("✅ [bold green]Android device(s) connected:[/bold green]")
|
|
17
|
+
for device in devices:
|
|
18
|
+
console.print(f" - {device.serial}")
|
|
19
|
+
else:
|
|
20
|
+
console.print("❌ [bold red]No Android device found.[/bold red]")
|
|
21
|
+
command = "emulator -avd <avd_name>"
|
|
22
|
+
if sys.platform not in ["win32", "darwin"]:
|
|
23
|
+
command = f"./{command}"
|
|
24
|
+
console.print(
|
|
25
|
+
f"You can start an emulator using a command like: [bold]'{command}'[/bold]"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ios_devices = get_all_ios_devices_detailed()
|
|
29
|
+
if ios_devices:
|
|
30
|
+
console.print("✅ [bold green]iOS device(s) connected:[/bold green]")
|
|
31
|
+
for device in ios_devices:
|
|
32
|
+
console.print(f" - [green]{format_device_info(device)}[/green]")
|
|
33
|
+
else:
|
|
34
|
+
console.print("❌ [bold red]No iOS device found.[/bold red]")
|
|
35
|
+
console.print(
|
|
36
|
+
"[iOS] Please make sure your emulator is running or a device is connected via USB."
|
|
37
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import inquirer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.prompt import Prompt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def select_provider_and_model(
|
|
9
|
+
console: Console,
|
|
10
|
+
available_providers: list[str],
|
|
11
|
+
available_models: dict,
|
|
12
|
+
default_provider: str,
|
|
13
|
+
default_model: str,
|
|
14
|
+
provider: str | None = None,
|
|
15
|
+
model: str | None = None,
|
|
16
|
+
) -> tuple[str, str]:
|
|
17
|
+
"""
|
|
18
|
+
Interactive selection of LLM provider and model with arrow-key dropdowns when available.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
console: Rich console for output
|
|
22
|
+
available_providers: List of available provider names
|
|
23
|
+
available_models: Dict mapping providers to their available models
|
|
24
|
+
default_provider: Default provider to use
|
|
25
|
+
default_model: Default model to use
|
|
26
|
+
provider: Pre-selected provider (optional)
|
|
27
|
+
model: Pre-selected model (optional)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of (selected_provider, selected_model)
|
|
31
|
+
"""
|
|
32
|
+
final_provider = provider
|
|
33
|
+
final_model = model
|
|
34
|
+
|
|
35
|
+
# Interactive provider selection
|
|
36
|
+
if not final_provider:
|
|
37
|
+
console.print("\n🤖 [bold cyan]LLM Provider Selection[/bold cyan]")
|
|
38
|
+
final_provider = _select_from_list(
|
|
39
|
+
console=console,
|
|
40
|
+
item_type="provider",
|
|
41
|
+
choices=available_providers,
|
|
42
|
+
default=default_provider,
|
|
43
|
+
message="Select LLM provider",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Interactive model selection
|
|
47
|
+
if not final_model:
|
|
48
|
+
console.print(f"\n🎯 [bold green]Model Selection for {final_provider}[/bold green]")
|
|
49
|
+
available_model_list = (
|
|
50
|
+
available_models[final_provider]
|
|
51
|
+
if final_provider
|
|
52
|
+
else available_models[default_provider]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
default_model_for_provider = (
|
|
56
|
+
default_model if default_model in available_model_list else available_model_list[0]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
final_model = _select_from_list(
|
|
60
|
+
console=console,
|
|
61
|
+
item_type="model",
|
|
62
|
+
choices=available_model_list,
|
|
63
|
+
default=default_model_for_provider,
|
|
64
|
+
message=f"Select model for {final_provider}",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return final_provider, final_model
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _select_from_list(
|
|
71
|
+
console: Console,
|
|
72
|
+
item_type: str,
|
|
73
|
+
choices: list[str],
|
|
74
|
+
default: str,
|
|
75
|
+
message: str,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""
|
|
78
|
+
Select an item from a list using arrow keys when available, fallback to numbered selection.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
console: Rich console for output
|
|
82
|
+
item_type: Type of item being selected (for error messages)
|
|
83
|
+
choices: List of choices to select from
|
|
84
|
+
default: Default choice
|
|
85
|
+
message: Message to display in dropdown
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Selected choice
|
|
89
|
+
"""
|
|
90
|
+
# Try arrow-key dropdown if TTY is available, fallback to numbered selection
|
|
91
|
+
if sys.stdin.isatty():
|
|
92
|
+
try:
|
|
93
|
+
questions = [
|
|
94
|
+
inquirer.List(
|
|
95
|
+
"selection",
|
|
96
|
+
message=f"{message} (use arrow keys)",
|
|
97
|
+
choices=choices,
|
|
98
|
+
default=default,
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
answers = inquirer.prompt(questions)
|
|
102
|
+
return answers["selection"] if answers else default
|
|
103
|
+
except Exception:
|
|
104
|
+
# Fallback to numbered selection
|
|
105
|
+
return _numbered_selection(console, item_type, choices, default)
|
|
106
|
+
else:
|
|
107
|
+
return _numbered_selection(console, item_type, choices, default)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _numbered_selection(console: Console, item_type: str, choices: list[str], default: str) -> str:
|
|
111
|
+
"""Fallback numbered selection when arrow keys aren't available."""
|
|
112
|
+
choices_text = "\n".join([f" {i + 1}. {choice}" for i, choice in enumerate(choices)])
|
|
113
|
+
console.print(f"Available {item_type}s:\n{choices_text}")
|
|
114
|
+
|
|
115
|
+
default_idx = choices.index(default) + 1 if default in choices else 1
|
|
116
|
+
|
|
117
|
+
while True:
|
|
118
|
+
choice = Prompt.ask(
|
|
119
|
+
f"Select {item_type} (1-{len(choices)}) or press Enter for default",
|
|
120
|
+
default=str(default_idx),
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
choice_idx = int(choice) - 1
|
|
124
|
+
if 0 <= choice_idx < len(choices):
|
|
125
|
+
return choices[choice_idx]
|
|
126
|
+
else:
|
|
127
|
+
console.print("[red]Invalid choice. Please try again.[/red]")
|
|
128
|
+
except ValueError:
|
|
129
|
+
console.print("[red]Please enter a number.[/red]")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def display_llm_config(console: Console, provider: str, model: str) -> None:
|
|
133
|
+
"""Display the selected LLM configuration with colors."""
|
|
134
|
+
from rich.text import Text
|
|
135
|
+
|
|
136
|
+
config_text = Text()
|
|
137
|
+
config_text.append("🤖 LLM Configuration: ", style="bold white")
|
|
138
|
+
config_text.append("Provider: ", style="white")
|
|
139
|
+
config_text.append(f"{provider}", style="bold cyan")
|
|
140
|
+
config_text.append(" | Model: ", style="white")
|
|
141
|
+
config_text.append(f"{model}", style="bold green")
|
|
142
|
+
|
|
143
|
+
console.print(config_text)
|