mobilerun-core-cli 0.1.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.
- mobilerun_core_cli/__init__.py +34 -0
- mobilerun_core_cli/driver/__init__.py +4 -0
- mobilerun_core_cli/driver/android.py +194 -0
- mobilerun_core_cli/driver/base.py +140 -0
- mobilerun_core_cli/portal.py +798 -0
- mobilerun_core_cli/transport/__init__.py +3 -0
- mobilerun_core_cli/transport/portal_client.py +744 -0
- mobilerun_core_cli-0.1.0.dist-info/METADATA +154 -0
- mobilerun_core_cli-0.1.0.dist-info/RECORD +11 -0
- mobilerun_core_cli-0.1.0.dist-info/WHEEL +4 -0
- mobilerun_core_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Slim async Portal+driver core, extracted from droidrun/mobilerun."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from mobilerun_core_cli.driver.android import AndroidDriver
|
|
6
|
+
from mobilerun_core_cli.driver.base import DeviceDisconnectedError, DeviceDriver
|
|
7
|
+
from mobilerun_core_cli.portal import (
|
|
8
|
+
A11Y_SERVICE_NAME,
|
|
9
|
+
PORTAL_PACKAGE_NAME,
|
|
10
|
+
ensure_portal_ready,
|
|
11
|
+
ping_portal,
|
|
12
|
+
portal_a11y_service,
|
|
13
|
+
portal_content_uri,
|
|
14
|
+
portal_ime_id,
|
|
15
|
+
setup_keyboard,
|
|
16
|
+
setup_portal,
|
|
17
|
+
)
|
|
18
|
+
from mobilerun_core_cli.transport.portal_client import PortalClient
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AndroidDriver",
|
|
22
|
+
"DeviceDriver",
|
|
23
|
+
"DeviceDisconnectedError",
|
|
24
|
+
"PortalClient",
|
|
25
|
+
"PORTAL_PACKAGE_NAME",
|
|
26
|
+
"A11Y_SERVICE_NAME",
|
|
27
|
+
"ensure_portal_ready",
|
|
28
|
+
"setup_portal",
|
|
29
|
+
"setup_keyboard",
|
|
30
|
+
"ping_portal",
|
|
31
|
+
"portal_content_uri",
|
|
32
|
+
"portal_a11y_service",
|
|
33
|
+
"portal_ime_id",
|
|
34
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""AndroidDriver — ADB-based device driver.
|
|
2
|
+
|
|
3
|
+
Wraps ``async_adbutils.AdbDevice`` + ``PortalClient`` to provide clean device I/O
|
|
4
|
+
without event emission, formatting, or element lookup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from async_adbutils import adb
|
|
15
|
+
|
|
16
|
+
from mobilerun_core_cli.transport.portal_client import PortalClient
|
|
17
|
+
from mobilerun_core_cli.driver.base import DeviceDriver
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("mobilerun_core_cli")
|
|
20
|
+
|
|
21
|
+
PORTAL_DEFAULT_TCP_PORT = 8080
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AndroidDriver(DeviceDriver):
|
|
25
|
+
"""Raw Android device I/O via ADB + Portal."""
|
|
26
|
+
|
|
27
|
+
platform = "Android"
|
|
28
|
+
|
|
29
|
+
supported = {
|
|
30
|
+
"tap",
|
|
31
|
+
"swipe",
|
|
32
|
+
"input_text",
|
|
33
|
+
"press_button",
|
|
34
|
+
"start_app",
|
|
35
|
+
"screenshot",
|
|
36
|
+
"get_ui_tree",
|
|
37
|
+
"get_date",
|
|
38
|
+
"get_apps",
|
|
39
|
+
"list_packages",
|
|
40
|
+
"install_app",
|
|
41
|
+
"drag",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
supported_buttons = {"back", "home", "enter"}
|
|
45
|
+
|
|
46
|
+
_BUTTON_KEYCODES = {
|
|
47
|
+
"back": 4,
|
|
48
|
+
"home": 3,
|
|
49
|
+
"enter": 66,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
serial: str | None = None,
|
|
55
|
+
use_tcp: bool = False,
|
|
56
|
+
remote_tcp_port: int = PORTAL_DEFAULT_TCP_PORT,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._serial = serial
|
|
59
|
+
self._use_tcp = use_tcp
|
|
60
|
+
self._remote_tcp_port = remote_tcp_port
|
|
61
|
+
self.device = None
|
|
62
|
+
self.portal: PortalClient | None = None
|
|
63
|
+
self._connected = False
|
|
64
|
+
|
|
65
|
+
# -- lifecycle -----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
async def connect(self) -> None:
|
|
68
|
+
if self._connected:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
self.device = await adb.device(serial=self._serial)
|
|
72
|
+
state = await self.device.get_state()
|
|
73
|
+
if state != "device":
|
|
74
|
+
raise ConnectionError(f"Device is not online. State: {state}")
|
|
75
|
+
|
|
76
|
+
self.portal = PortalClient(self.device, prefer_tcp=self._use_tcp)
|
|
77
|
+
await self.portal.connect()
|
|
78
|
+
|
|
79
|
+
from mobilerun_core_cli.portal import setup_keyboard # circular import guard
|
|
80
|
+
|
|
81
|
+
await setup_keyboard(self.device)
|
|
82
|
+
self._connected = True
|
|
83
|
+
|
|
84
|
+
async def ensure_connected(self) -> None:
|
|
85
|
+
if not self._connected:
|
|
86
|
+
await self.connect()
|
|
87
|
+
|
|
88
|
+
# -- input actions -------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def tap(self, x: int, y: int) -> None:
|
|
91
|
+
await self.ensure_connected()
|
|
92
|
+
await self.device.click(x, y)
|
|
93
|
+
|
|
94
|
+
async def swipe(
|
|
95
|
+
self,
|
|
96
|
+
x1: int,
|
|
97
|
+
y1: int,
|
|
98
|
+
x2: int,
|
|
99
|
+
y2: int,
|
|
100
|
+
duration_ms: float = 1000,
|
|
101
|
+
) -> None:
|
|
102
|
+
await self.ensure_connected()
|
|
103
|
+
await self.device.swipe(x1, y1, x2, y2, float(duration_ms / 1000))
|
|
104
|
+
await asyncio.sleep(duration_ms / 1000)
|
|
105
|
+
|
|
106
|
+
async def input_text(self, text: str, clear: bool = False) -> bool:
|
|
107
|
+
await self.ensure_connected()
|
|
108
|
+
return await self.portal.input_text(text, clear)
|
|
109
|
+
|
|
110
|
+
async def press_button(self, button: str) -> None:
|
|
111
|
+
await self.ensure_connected()
|
|
112
|
+
button_lower = button.lower()
|
|
113
|
+
if button_lower not in self.supported_buttons:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Button '{button}' not supported. "
|
|
116
|
+
f"Supported: {', '.join(sorted(self.supported_buttons))}"
|
|
117
|
+
)
|
|
118
|
+
await self.device.keyevent(self._BUTTON_KEYCODES[button_lower])
|
|
119
|
+
|
|
120
|
+
async def drag(
|
|
121
|
+
self,
|
|
122
|
+
x1: int,
|
|
123
|
+
y1: int,
|
|
124
|
+
x2: int,
|
|
125
|
+
y2: int,
|
|
126
|
+
duration: float = 3.0,
|
|
127
|
+
) -> None:
|
|
128
|
+
await self.ensure_connected()
|
|
129
|
+
raise NotImplementedError("Drag is not implemented yet")
|
|
130
|
+
|
|
131
|
+
# -- app management ------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async def start_app(self, package: str, activity: Optional[str] = None) -> str:
|
|
134
|
+
await self.ensure_connected()
|
|
135
|
+
try:
|
|
136
|
+
logger.debug(f"Starting app {package} with activity {activity}")
|
|
137
|
+
if not activity:
|
|
138
|
+
dumpsys_output = await self.device.shell(
|
|
139
|
+
f"cmd package resolve-activity --brief {package}"
|
|
140
|
+
)
|
|
141
|
+
activity = dumpsys_output.splitlines()[1].split("/")[1]
|
|
142
|
+
|
|
143
|
+
logger.debug(f"Activity: {activity}")
|
|
144
|
+
await self.device.app_start(package, activity)
|
|
145
|
+
logger.debug(f"App started: {package} with activity {activity}")
|
|
146
|
+
return f"App started: {package} with activity {activity}"
|
|
147
|
+
except Exception as e:
|
|
148
|
+
return f"Failed to start app {package}: {e}"
|
|
149
|
+
|
|
150
|
+
async def install_app(self, path: str, **kwargs) -> str:
|
|
151
|
+
await self.ensure_connected()
|
|
152
|
+
if not os.path.exists(path):
|
|
153
|
+
return f"Failed to install app: APK file not found at {path}"
|
|
154
|
+
|
|
155
|
+
reinstall = kwargs.get("reinstall", False)
|
|
156
|
+
grant_permissions = kwargs.get("grant_permissions", True)
|
|
157
|
+
|
|
158
|
+
logger.debug(
|
|
159
|
+
f"Installing app: {path} with reinstall: {reinstall} "
|
|
160
|
+
f"and grant_permissions: {grant_permissions}"
|
|
161
|
+
)
|
|
162
|
+
result = await self.device.install(
|
|
163
|
+
path,
|
|
164
|
+
nolaunch=True,
|
|
165
|
+
uninstall=reinstall,
|
|
166
|
+
flags=["-g"] if grant_permissions else [],
|
|
167
|
+
silent=True,
|
|
168
|
+
)
|
|
169
|
+
logger.debug(f"Installed app: {path} with result: {result}")
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
|
|
173
|
+
await self.ensure_connected()
|
|
174
|
+
return await self.portal.get_apps(include_system)
|
|
175
|
+
|
|
176
|
+
async def list_packages(self, include_system: bool = False) -> List[str]:
|
|
177
|
+
await self.ensure_connected()
|
|
178
|
+
filter_list = [] if include_system else ["-3"]
|
|
179
|
+
return await self.device.list_packages(filter_list)
|
|
180
|
+
|
|
181
|
+
# -- state / observation -------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async def screenshot(self, hide_overlay: bool = True) -> bytes:
|
|
184
|
+
await self.ensure_connected()
|
|
185
|
+
return await self.portal.take_screenshot(hide_overlay)
|
|
186
|
+
|
|
187
|
+
async def get_ui_tree(self) -> Dict[str, Any]:
|
|
188
|
+
await self.ensure_connected()
|
|
189
|
+
return await self.portal.get_state()
|
|
190
|
+
|
|
191
|
+
async def get_date(self) -> str:
|
|
192
|
+
await self.ensure_connected()
|
|
193
|
+
result = await self.device.shell("date")
|
|
194
|
+
return result.strip()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""DeviceDriver — raw device I/O interface.
|
|
2
|
+
|
|
3
|
+
Subclasses implement the actual communication (ADB, iOS HTTP, cloud SDK, etc.).
|
|
4
|
+
Unsupported methods are detected via the ``supported`` set, not introspection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeviceDisconnectedError(Exception):
|
|
13
|
+
"""Raised when the device is no longer reachable."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeviceDriver:
|
|
19
|
+
"""Base class for all device drivers.
|
|
20
|
+
|
|
21
|
+
Every method raises ``NotImplementedError`` by default.
|
|
22
|
+
Concrete drivers override the methods they support and declare them
|
|
23
|
+
in the ``supported`` class-level set.
|
|
24
|
+
|
|
25
|
+
``platform`` identifies the device type (e.g. "Android", "iOS").
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
platform: str = "Android"
|
|
29
|
+
supported: set[str] = set()
|
|
30
|
+
supported_buttons: set[str] = set()
|
|
31
|
+
|
|
32
|
+
# -- lifecycle -----------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
async def connect(self) -> None:
|
|
35
|
+
"""Establish connection to the device."""
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
async def ensure_connected(self) -> None:
|
|
39
|
+
"""Connect if not already connected."""
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
# -- input actions -------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
async def tap(self, x: int, y: int) -> None:
|
|
45
|
+
"""Tap at absolute pixel coordinates."""
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
async def swipe(
|
|
49
|
+
self,
|
|
50
|
+
x1: int,
|
|
51
|
+
y1: int,
|
|
52
|
+
x2: int,
|
|
53
|
+
y2: int,
|
|
54
|
+
duration_ms: float = 1000,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Swipe from (x1, y1) to (x2, y2)."""
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
async def input_text(
|
|
60
|
+
self,
|
|
61
|
+
text: str,
|
|
62
|
+
clear: bool = False,
|
|
63
|
+
stealth: bool = False,
|
|
64
|
+
wpm: int = 0,
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""Type *text* into the currently focused field.
|
|
67
|
+
|
|
68
|
+
Returns ``True`` on success, ``False`` on failure.
|
|
69
|
+
"""
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
|
|
72
|
+
async def press_button(self, button: str) -> None:
|
|
73
|
+
"""Press a named button (e.g. back, home, enter).
|
|
74
|
+
|
|
75
|
+
Raises ``ValueError`` if *button* is not in ``supported_buttons``.
|
|
76
|
+
"""
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
async def drag(
|
|
80
|
+
self,
|
|
81
|
+
x1: int,
|
|
82
|
+
y1: int,
|
|
83
|
+
x2: int,
|
|
84
|
+
y2: int,
|
|
85
|
+
duration: float = 3.0,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Drag from (x1, y1) to (x2, y2)."""
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
|
|
90
|
+
# -- app management ------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
async def start_app(self, package: str, activity: Optional[str] = None) -> str:
|
|
93
|
+
"""Launch an application.
|
|
94
|
+
|
|
95
|
+
Returns a human-readable result string.
|
|
96
|
+
"""
|
|
97
|
+
raise NotImplementedError
|
|
98
|
+
|
|
99
|
+
async def install_app(self, path: str, **kwargs) -> str:
|
|
100
|
+
"""Install an APK/IPA at *path*."""
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
|
|
104
|
+
"""Return installed apps as ``[{"package": …, "label": …}, …]``."""
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
async def list_packages(self, include_system: bool = False) -> List[str]:
|
|
108
|
+
"""Return installed package names."""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
# -- state / observation -------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async def screenshot(self, hide_overlay: bool = True) -> bytes:
|
|
114
|
+
"""Capture the current screen.
|
|
115
|
+
|
|
116
|
+
Returns raw PNG bytes.
|
|
117
|
+
"""
|
|
118
|
+
raise NotImplementedError
|
|
119
|
+
|
|
120
|
+
async def input_coordinate_size(
|
|
121
|
+
self,
|
|
122
|
+
screenshot_width: int,
|
|
123
|
+
screenshot_height: int,
|
|
124
|
+
) -> tuple[int, int]:
|
|
125
|
+
"""Return the coordinate size expected by input methods.
|
|
126
|
+
|
|
127
|
+
Most backends accept screenshot pixel coordinates, so the default input
|
|
128
|
+
coordinate size matches the captured screenshot. Backends whose input
|
|
129
|
+
layer uses a different coordinate system, such as XCTest points on iOS,
|
|
130
|
+
override this method.
|
|
131
|
+
"""
|
|
132
|
+
return screenshot_width, screenshot_height
|
|
133
|
+
|
|
134
|
+
async def get_ui_tree(self) -> Dict[str, Any]:
|
|
135
|
+
"""Return the raw UI / accessibility tree from the device."""
|
|
136
|
+
raise NotImplementedError
|
|
137
|
+
|
|
138
|
+
async def get_date(self) -> str:
|
|
139
|
+
"""Return the device's current date/time as a string."""
|
|
140
|
+
raise NotImplementedError
|