mobilerun-core-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → mobilerun_core_cli-0.2.0}/PKG-INFO +51 -11
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/README.md +49 -9
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/mobilerun_core_cli/__init__.py +17 -1
- mobilerun_core_cli-0.2.0/mobilerun_core_cli/driver/__init__.py +24 -0
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/mobilerun_core_cli/driver/android.py +44 -13
- mobilerun_core_cli-0.2.0/mobilerun_core_cli/driver/android_http.py +272 -0
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/mobilerun_core_cli/driver/base.py +12 -0
- mobilerun_core_cli-0.2.0/mobilerun_core_cli/driver/ios.py +431 -0
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/mobilerun_core_cli/transport/portal_client.py +15 -1
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/pyproject.toml +2 -2
- mobilerun_core_cli-0.2.0/tests/test_local_drivers.py +295 -0
- mobilerun_core_cli-0.2.0/uv.lock +368 -0
- mobilerun_core_cli-0.1.0/mobilerun_core_cli/driver/__init__.py +0 -4
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/.gitignore +0 -0
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/LICENSE +0 -0
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/mobilerun_core_cli/portal.py +0 -0
- {mobilerun_core_cli-0.1.0 → mobilerun_core_cli-0.2.0}/mobilerun_core_cli/transport/__init__.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mobilerun-core-cli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Slim async
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Slim async local device drivers for Android Portal and iOS Portal, extracted from mobilerun.
|
|
5
5
|
Project-URL: Homepage, https://github.com/droidrun/mobilerun-core-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/droidrun/mobilerun-core-cli
|
|
7
7
|
Project-URL: Bug Tracker, https://github.com/droidrun/mobilerun-core-cli/issues
|
|
@@ -33,8 +33,8 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
</picture>
|
|
34
34
|
|
|
35
35
|
<p align="center">
|
|
36
|
-
<strong>mobilerun-core-cli is the slim async
|
|
37
|
-
No CLI, no agent, no LLM providers — just
|
|
36
|
+
<strong>mobilerun-core-cli is the slim async local-driver core of <a href="https://github.com/droidrun/mobilerun">mobilerun</a>.</strong><br>
|
|
37
|
+
No CLI, no agent, no LLM providers — just local Android/iOS drivers for higher-level tools such as <code>mobilerun-core</code>.
|
|
38
38
|
</p>
|
|
39
39
|
|
|
40
40
|
<div align="center">
|
|
@@ -54,16 +54,17 @@ Description-Content-Type: text/markdown
|
|
|
54
54
|
|
|
55
55
|
---
|
|
56
56
|
|
|
57
|
-
- 📱 Drive
|
|
58
|
-
- ⚡ TCP-with-content-provider fallback — fast HTTP path over `adb forward`, transparent fallback to content-provider RPC.
|
|
57
|
+
- 📱 Drive local devices — Android over ADB+Portal, Android Portal HTTP-only, or iOS Portal HTTP.
|
|
58
|
+
- ⚡ TCP-with-content-provider fallback — fast Android HTTP path over `adb forward`, transparent fallback to content-provider RPC.
|
|
59
|
+
- 🔌 HTTP-only drivers — connect to already-running Android/iOS portals without taking over setup.
|
|
59
60
|
- 🛠 Portal lifecycle — download, install, accessibility enablement, auto-upgrade.
|
|
60
|
-
- 🪶 Slim —
|
|
61
|
+
- 🪶 Slim — a small async driver package with four runtime deps (`async_adbutils`, `httpx`, `requests`, `rich`).
|
|
61
62
|
- 🔌 Embeddable — designed to be wrapped by sync facades (e.g. `mobilerun-core`) or used directly.
|
|
62
63
|
- 🤝 In sync with upstream — verbatim slice of [`droidrun/mobilerun`](https://github.com/droidrun/mobilerun); behaviour and API track upstream.
|
|
63
64
|
|
|
64
65
|
## 📦 Installation
|
|
65
66
|
|
|
66
|
-
> **Note:** Python `>=3.11,<3.14`.
|
|
67
|
+
> **Note:** Python `>=3.11,<3.14`. The Android ADB driver requires [ADB](https://developer.android.com/studio/releases/platform-tools) on `PATH` and a device with USB debugging enabled. HTTP-only drivers require an already-running portal URL.
|
|
67
68
|
|
|
68
69
|
```bash
|
|
69
70
|
uv pip install mobilerun-core-cli
|
|
@@ -93,6 +94,41 @@ async def main():
|
|
|
93
94
|
tree = await driver.get_ui_tree()
|
|
94
95
|
|
|
95
96
|
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
HTTP-only Android:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import asyncio
|
|
104
|
+
from mobilerun_core_cli import AndroidPortalHttpDriver
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def main():
|
|
108
|
+
driver = AndroidPortalHttpDriver(
|
|
109
|
+
url="http://127.0.0.1:18080",
|
|
110
|
+
token="...",
|
|
111
|
+
)
|
|
112
|
+
await driver.connect()
|
|
113
|
+
await driver.tap(540, 1200)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
asyncio.run(main())
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
iOS Portal:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
import asyncio
|
|
123
|
+
from mobilerun_core_cli import IOSPortalDriver
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def main():
|
|
127
|
+
driver = IOSPortalDriver("http://127.0.0.1:6643")
|
|
128
|
+
await driver.connect()
|
|
129
|
+
await driver.start_app("com.apple.Preferences")
|
|
130
|
+
|
|
131
|
+
|
|
96
132
|
asyncio.run(main())
|
|
97
133
|
```
|
|
98
134
|
|
|
@@ -104,7 +140,9 @@ mobilerun_core_cli/
|
|
|
104
140
|
├── portal.py Portal APK lifecycle + content-provider helpers
|
|
105
141
|
├── driver/
|
|
106
142
|
│ ├── base.py DeviceDriver ABC, DeviceDisconnectedError
|
|
107
|
-
│
|
|
143
|
+
│ ├── android.py AndroidDriver — ADB-backed concrete driver
|
|
144
|
+
│ ├── android_http.py AndroidPortalHttpDriver — HTTP-only Android driver
|
|
145
|
+
│ └── ios.py IOSPortalDriver — ios-portal HTTP driver
|
|
108
146
|
└── transport/
|
|
109
147
|
└── portal_client.py PortalClient — TCP-with-content-provider fallback
|
|
110
148
|
```
|
|
@@ -116,6 +154,8 @@ Re-exported from `mobilerun_core_cli`:
|
|
|
116
154
|
| Symbol | What it is |
|
|
117
155
|
|---|---|
|
|
118
156
|
| `AndroidDriver` | ADB+Portal device driver. Async methods: `tap`, `swipe`, `input_text`, `press_button`, `start_app`, `install_app`, `screenshot`, `get_ui_tree`, `get_apps`, `list_packages`, `get_date`. |
|
|
157
|
+
| `AndroidPortalHttpDriver` | HTTP-only Android Portal driver. Requires `url` and bearer `token`; does not use ADB at runtime. |
|
|
158
|
+
| `IOSPortalDriver` / `IOSDriver` | iOS Portal HTTP driver. Requires an already-running `ios-portal` URL. |
|
|
119
159
|
| `DeviceDriver` | Abstract base for drivers. `supported: set[str]` declares which verbs a subclass implements. |
|
|
120
160
|
| `DeviceDisconnectedError` | Raised when the device drops mid-call. |
|
|
121
161
|
| `PortalClient` | HTTP-or-content-provider client for the on-device Portal. Used internally by `AndroidDriver`; can be constructed directly for low-level access. |
|
|
@@ -144,9 +184,9 @@ logging.getLogger("mobilerun_core_cli").setLevel(logging.DEBUG)
|
|
|
144
184
|
|
|
145
185
|
## 🔗 Relationship to upstream `mobilerun`
|
|
146
186
|
|
|
147
|
-
This package
|
|
187
|
+
This package owns local execution drivers. The full framework imports these drivers for CLI/agent flows; `mobilerun-core` wraps them behind a sync, backend-neutral API.
|
|
148
188
|
|
|
149
|
-
Use `mobilerun-core-cli` when you
|
|
189
|
+
Use `mobilerun-core-cli` when you need async local drivers directly.
|
|
150
190
|
Use [`mobilerun`](https://github.com/droidrun/mobilerun) when you want the full LLM-agent experience, CLI/TUI, and multi-platform support out of the box.
|
|
151
191
|
|
|
152
192
|
## 📄 License
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>mobilerun-core-cli is the slim async
|
|
9
|
-
No CLI, no agent, no LLM providers — just
|
|
8
|
+
<strong>mobilerun-core-cli is the slim async local-driver core of <a href="https://github.com/droidrun/mobilerun">mobilerun</a>.</strong><br>
|
|
9
|
+
No CLI, no agent, no LLM providers — just local Android/iOS drivers for higher-level tools such as <code>mobilerun-core</code>.
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<div align="center">
|
|
@@ -26,16 +26,17 @@
|
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
29
|
-
- 📱 Drive
|
|
30
|
-
- ⚡ TCP-with-content-provider fallback — fast HTTP path over `adb forward`, transparent fallback to content-provider RPC.
|
|
29
|
+
- 📱 Drive local devices — Android over ADB+Portal, Android Portal HTTP-only, or iOS Portal HTTP.
|
|
30
|
+
- ⚡ TCP-with-content-provider fallback — fast Android HTTP path over `adb forward`, transparent fallback to content-provider RPC.
|
|
31
|
+
- 🔌 HTTP-only drivers — connect to already-running Android/iOS portals without taking over setup.
|
|
31
32
|
- 🛠 Portal lifecycle — download, install, accessibility enablement, auto-upgrade.
|
|
32
|
-
- 🪶 Slim —
|
|
33
|
+
- 🪶 Slim — a small async driver package with four runtime deps (`async_adbutils`, `httpx`, `requests`, `rich`).
|
|
33
34
|
- 🔌 Embeddable — designed to be wrapped by sync facades (e.g. `mobilerun-core`) or used directly.
|
|
34
35
|
- 🤝 In sync with upstream — verbatim slice of [`droidrun/mobilerun`](https://github.com/droidrun/mobilerun); behaviour and API track upstream.
|
|
35
36
|
|
|
36
37
|
## 📦 Installation
|
|
37
38
|
|
|
38
|
-
> **Note:** Python `>=3.11,<3.14`.
|
|
39
|
+
> **Note:** Python `>=3.11,<3.14`. The Android ADB driver requires [ADB](https://developer.android.com/studio/releases/platform-tools) on `PATH` and a device with USB debugging enabled. HTTP-only drivers require an already-running portal URL.
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
42
|
uv pip install mobilerun-core-cli
|
|
@@ -65,6 +66,41 @@ async def main():
|
|
|
65
66
|
tree = await driver.get_ui_tree()
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
HTTP-only Android:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import asyncio
|
|
76
|
+
from mobilerun_core_cli import AndroidPortalHttpDriver
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def main():
|
|
80
|
+
driver = AndroidPortalHttpDriver(
|
|
81
|
+
url="http://127.0.0.1:18080",
|
|
82
|
+
token="...",
|
|
83
|
+
)
|
|
84
|
+
await driver.connect()
|
|
85
|
+
await driver.tap(540, 1200)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
iOS Portal:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import asyncio
|
|
95
|
+
from mobilerun_core_cli import IOSPortalDriver
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def main():
|
|
99
|
+
driver = IOSPortalDriver("http://127.0.0.1:6643")
|
|
100
|
+
await driver.connect()
|
|
101
|
+
await driver.start_app("com.apple.Preferences")
|
|
102
|
+
|
|
103
|
+
|
|
68
104
|
asyncio.run(main())
|
|
69
105
|
```
|
|
70
106
|
|
|
@@ -76,7 +112,9 @@ mobilerun_core_cli/
|
|
|
76
112
|
├── portal.py Portal APK lifecycle + content-provider helpers
|
|
77
113
|
├── driver/
|
|
78
114
|
│ ├── base.py DeviceDriver ABC, DeviceDisconnectedError
|
|
79
|
-
│
|
|
115
|
+
│ ├── android.py AndroidDriver — ADB-backed concrete driver
|
|
116
|
+
│ ├── android_http.py AndroidPortalHttpDriver — HTTP-only Android driver
|
|
117
|
+
│ └── ios.py IOSPortalDriver — ios-portal HTTP driver
|
|
80
118
|
└── transport/
|
|
81
119
|
└── portal_client.py PortalClient — TCP-with-content-provider fallback
|
|
82
120
|
```
|
|
@@ -88,6 +126,8 @@ Re-exported from `mobilerun_core_cli`:
|
|
|
88
126
|
| Symbol | What it is |
|
|
89
127
|
|---|---|
|
|
90
128
|
| `AndroidDriver` | ADB+Portal device driver. Async methods: `tap`, `swipe`, `input_text`, `press_button`, `start_app`, `install_app`, `screenshot`, `get_ui_tree`, `get_apps`, `list_packages`, `get_date`. |
|
|
129
|
+
| `AndroidPortalHttpDriver` | HTTP-only Android Portal driver. Requires `url` and bearer `token`; does not use ADB at runtime. |
|
|
130
|
+
| `IOSPortalDriver` / `IOSDriver` | iOS Portal HTTP driver. Requires an already-running `ios-portal` URL. |
|
|
91
131
|
| `DeviceDriver` | Abstract base for drivers. `supported: set[str]` declares which verbs a subclass implements. |
|
|
92
132
|
| `DeviceDisconnectedError` | Raised when the device drops mid-call. |
|
|
93
133
|
| `PortalClient` | HTTP-or-content-provider client for the on-device Portal. Used internally by `AndroidDriver`; can be constructed directly for low-level access. |
|
|
@@ -116,9 +156,9 @@ logging.getLogger("mobilerun_core_cli").setLevel(logging.DEBUG)
|
|
|
116
156
|
|
|
117
157
|
## 🔗 Relationship to upstream `mobilerun`
|
|
118
158
|
|
|
119
|
-
This package
|
|
159
|
+
This package owns local execution drivers. The full framework imports these drivers for CLI/agent flows; `mobilerun-core` wraps them behind a sync, backend-neutral API.
|
|
120
160
|
|
|
121
|
-
Use `mobilerun-core-cli` when you
|
|
161
|
+
Use `mobilerun-core-cli` when you need async local drivers directly.
|
|
122
162
|
Use [`mobilerun`](https://github.com/droidrun/mobilerun) when you want the full LLM-agent experience, CLI/TUI, and multi-platform support out of the box.
|
|
123
163
|
|
|
124
164
|
## 📄 License
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
"""Slim async Portal+driver core, extracted from droidrun/mobilerun."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
4
|
|
|
5
5
|
from mobilerun_core_cli.driver.android import AndroidDriver
|
|
6
|
+
from mobilerun_core_cli.driver.android_http import (
|
|
7
|
+
AndroidPortalHttpDriver,
|
|
8
|
+
validate_android_portal_url,
|
|
9
|
+
)
|
|
6
10
|
from mobilerun_core_cli.driver.base import DeviceDisconnectedError, DeviceDriver
|
|
11
|
+
from mobilerun_core_cli.driver.ios import (
|
|
12
|
+
IOSDriver,
|
|
13
|
+
IOSPortalDriver,
|
|
14
|
+
discover_ios_portal,
|
|
15
|
+
validate_ios_portal_url,
|
|
16
|
+
)
|
|
7
17
|
from mobilerun_core_cli.portal import (
|
|
8
18
|
A11Y_SERVICE_NAME,
|
|
9
19
|
PORTAL_PACKAGE_NAME,
|
|
@@ -19,8 +29,14 @@ from mobilerun_core_cli.transport.portal_client import PortalClient
|
|
|
19
29
|
|
|
20
30
|
__all__ = [
|
|
21
31
|
"AndroidDriver",
|
|
32
|
+
"AndroidPortalHttpDriver",
|
|
22
33
|
"DeviceDriver",
|
|
23
34
|
"DeviceDisconnectedError",
|
|
35
|
+
"IOSDriver",
|
|
36
|
+
"IOSPortalDriver",
|
|
37
|
+
"discover_ios_portal",
|
|
38
|
+
"validate_android_portal_url",
|
|
39
|
+
"validate_ios_portal_url",
|
|
24
40
|
"PortalClient",
|
|
25
41
|
"PORTAL_PACKAGE_NAME",
|
|
26
42
|
"A11Y_SERVICE_NAME",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from mobilerun_core_cli.driver.android import AndroidDriver
|
|
2
|
+
from mobilerun_core_cli.driver.android_http import (
|
|
3
|
+
AndroidPortalHttpDriver,
|
|
4
|
+
validate_android_portal_url,
|
|
5
|
+
)
|
|
6
|
+
from mobilerun_core_cli.driver.base import DeviceDisconnectedError, DeviceDriver
|
|
7
|
+
from mobilerun_core_cli.driver.ios import (
|
|
8
|
+
IOSDriver,
|
|
9
|
+
IOSPortalDriver,
|
|
10
|
+
discover_ios_portal,
|
|
11
|
+
validate_ios_portal_url,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AndroidDriver",
|
|
16
|
+
"AndroidPortalHttpDriver",
|
|
17
|
+
"DeviceDriver",
|
|
18
|
+
"DeviceDisconnectedError",
|
|
19
|
+
"IOSDriver",
|
|
20
|
+
"IOSPortalDriver",
|
|
21
|
+
"discover_ios_portal",
|
|
22
|
+
"validate_android_portal_url",
|
|
23
|
+
"validate_ios_portal_url",
|
|
24
|
+
]
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import logging
|
|
11
11
|
import os
|
|
12
|
+
import shlex
|
|
12
13
|
from typing import Any, Dict, List, Optional
|
|
13
14
|
|
|
14
15
|
from async_adbutils import adb
|
|
@@ -38,7 +39,9 @@ class AndroidDriver(DeviceDriver):
|
|
|
38
39
|
"get_apps",
|
|
39
40
|
"list_packages",
|
|
40
41
|
"install_app",
|
|
41
|
-
"
|
|
42
|
+
"stop_app",
|
|
43
|
+
"uninstall_app",
|
|
44
|
+
"press_key_code",
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
supported_buttons = {"back", "home", "enter"}
|
|
@@ -115,7 +118,11 @@ class AndroidDriver(DeviceDriver):
|
|
|
115
118
|
f"Button '{button}' not supported. "
|
|
116
119
|
f"Supported: {', '.join(sorted(self.supported_buttons))}"
|
|
117
120
|
)
|
|
118
|
-
await self.
|
|
121
|
+
await self.press_key_code(self._BUTTON_KEYCODES[button_lower])
|
|
122
|
+
|
|
123
|
+
async def press_key_code(self, key_code: int) -> None:
|
|
124
|
+
await self.ensure_connected()
|
|
125
|
+
await self.device.keyevent(int(key_code))
|
|
119
126
|
|
|
120
127
|
async def drag(
|
|
121
128
|
self,
|
|
@@ -132,20 +139,34 @@ class AndroidDriver(DeviceDriver):
|
|
|
132
139
|
|
|
133
140
|
async def start_app(self, package: str, activity: Optional[str] = None) -> str:
|
|
134
141
|
await self.ensure_connected()
|
|
135
|
-
|
|
136
|
-
|
|
142
|
+
logger.debug(f"Starting app {package} with activity {activity}")
|
|
143
|
+
if not activity:
|
|
144
|
+
dumpsys_output = await self.device.shell(
|
|
145
|
+
f"cmd package resolve-activity --brief {shlex.quote(package)}"
|
|
146
|
+
)
|
|
147
|
+
component = next(
|
|
148
|
+
(
|
|
149
|
+
line.strip()
|
|
150
|
+
for line in reversed(dumpsys_output.splitlines())
|
|
151
|
+
if "/" in line
|
|
152
|
+
),
|
|
153
|
+
None,
|
|
154
|
+
)
|
|
155
|
+
if not component:
|
|
156
|
+
detail = dumpsys_output.strip() or "<empty>"
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
f"Could not resolve launch activity for {package}: {detail}"
|
|
159
|
+
)
|
|
160
|
+
activity = component.split("/", 1)[1].strip()
|
|
137
161
|
if not activity:
|
|
138
|
-
|
|
139
|
-
f"
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
f"Could not resolve launch activity for {package}: {component}"
|
|
140
164
|
)
|
|
141
|
-
activity = dumpsys_output.splitlines()[1].split("/")[1]
|
|
142
165
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
except Exception as e:
|
|
148
|
-
return f"Failed to start app {package}: {e}"
|
|
166
|
+
logger.debug(f"Activity: {activity}")
|
|
167
|
+
await self.device.app_start(package, activity)
|
|
168
|
+
logger.debug(f"App started: {package} with activity {activity}")
|
|
169
|
+
return f"App started: {package} with activity {activity}"
|
|
149
170
|
|
|
150
171
|
async def install_app(self, path: str, **kwargs) -> str:
|
|
151
172
|
await self.ensure_connected()
|
|
@@ -169,6 +190,16 @@ class AndroidDriver(DeviceDriver):
|
|
|
169
190
|
logger.debug(f"Installed app: {path} with result: {result}")
|
|
170
191
|
return result
|
|
171
192
|
|
|
193
|
+
async def stop_app(self, package: str) -> str:
|
|
194
|
+
await self.ensure_connected()
|
|
195
|
+
await self.device.shell(f"am force-stop {shlex.quote(package)}")
|
|
196
|
+
return f"Stopped {package}"
|
|
197
|
+
|
|
198
|
+
async def uninstall_app(self, package: str) -> str:
|
|
199
|
+
await self.ensure_connected()
|
|
200
|
+
result = await self.device.shell(f"pm uninstall {shlex.quote(package)}")
|
|
201
|
+
return result.strip() or f"Uninstalled {package}"
|
|
202
|
+
|
|
172
203
|
async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
|
|
173
204
|
await self.ensure_connected()
|
|
174
205
|
return await self.portal.get_apps(include_system)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Android Portal HTTP-only driver.
|
|
2
|
+
|
|
3
|
+
This backend talks to a running Mobilerun Portal HTTP server with an
|
|
4
|
+
explicit bearer token. It deliberately does not use ADB: setup, token
|
|
5
|
+
retrieval, port forwarding, and APK installation belong to the ADB-backed
|
|
6
|
+
driver or external provisioning.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from mobilerun_core_cli.driver.base import DeviceDriver
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("mobilerun_core_cli")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_ANDROID_KEY_CODES = {
|
|
25
|
+
"back": 4,
|
|
26
|
+
"home": 3,
|
|
27
|
+
"menu": 82,
|
|
28
|
+
"enter": 66,
|
|
29
|
+
"delete": 67,
|
|
30
|
+
"escape": 111,
|
|
31
|
+
"tab": 61,
|
|
32
|
+
"space": 62,
|
|
33
|
+
"search": 84,
|
|
34
|
+
"page_up": 92,
|
|
35
|
+
"page_down": 93,
|
|
36
|
+
"volume_up": 24,
|
|
37
|
+
"volume_down": 25,
|
|
38
|
+
"power": 26,
|
|
39
|
+
"media_play_pause": 85,
|
|
40
|
+
"media_next": 87,
|
|
41
|
+
"media_prev": 88,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_android_portal_url(url: str) -> str:
|
|
46
|
+
"""Validate and normalize an Android Portal HTTP base URL."""
|
|
47
|
+
normalized = url.rstrip("/")
|
|
48
|
+
parsed = urlparse(normalized)
|
|
49
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Android HTTP device must be the portal base URL, "
|
|
52
|
+
"e.g. http://127.0.0.1:8080"
|
|
53
|
+
)
|
|
54
|
+
return normalized
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AndroidPortalHttpDriver(DeviceDriver):
|
|
58
|
+
"""Android device driver for Portal HTTP without ADB."""
|
|
59
|
+
|
|
60
|
+
platform = "Android"
|
|
61
|
+
|
|
62
|
+
supported = {
|
|
63
|
+
"tap",
|
|
64
|
+
"swipe",
|
|
65
|
+
"input_text",
|
|
66
|
+
"press_button",
|
|
67
|
+
"press_key_code",
|
|
68
|
+
"start_app",
|
|
69
|
+
"stop_app",
|
|
70
|
+
"screenshot",
|
|
71
|
+
"get_ui_tree",
|
|
72
|
+
"get_apps",
|
|
73
|
+
"list_packages",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
supported_buttons = set(_ANDROID_KEY_CODES)
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
url: str,
|
|
81
|
+
token: str,
|
|
82
|
+
*,
|
|
83
|
+
timeout: float = 30.0,
|
|
84
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
if not token:
|
|
87
|
+
raise ValueError("Android Portal HTTP requires a non-empty auth token")
|
|
88
|
+
self.url = validate_android_portal_url(url)
|
|
89
|
+
self.token = token
|
|
90
|
+
self.timeout = timeout
|
|
91
|
+
self.transport = transport
|
|
92
|
+
self._client: httpx.AsyncClient | None = None
|
|
93
|
+
self._connected = False
|
|
94
|
+
|
|
95
|
+
# -- lifecycle ---------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async def connect(self) -> None:
|
|
98
|
+
if self._connected:
|
|
99
|
+
return
|
|
100
|
+
self._client = httpx.AsyncClient(
|
|
101
|
+
base_url=self.url,
|
|
102
|
+
timeout=self.timeout,
|
|
103
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
104
|
+
transport=self.transport,
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
response = await self._client.get("/version")
|
|
108
|
+
if response.status_code in {401, 403}:
|
|
109
|
+
raise PermissionError(
|
|
110
|
+
"Android Portal HTTP authentication failed; check the bearer token"
|
|
111
|
+
)
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
except PermissionError:
|
|
114
|
+
await self._client.aclose()
|
|
115
|
+
self._client = None
|
|
116
|
+
raise
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
await self._client.aclose()
|
|
119
|
+
self._client = None
|
|
120
|
+
raise ConnectionError(
|
|
121
|
+
f"Could not connect to Android Portal HTTP at {self.url}"
|
|
122
|
+
) from exc
|
|
123
|
+
self._connected = True
|
|
124
|
+
logger.info("Connected to Android Portal HTTP at %s", self.url)
|
|
125
|
+
|
|
126
|
+
async def ensure_connected(self) -> None:
|
|
127
|
+
if not self._connected:
|
|
128
|
+
await self.connect()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def _http(self) -> httpx.AsyncClient:
|
|
132
|
+
if self._client is None:
|
|
133
|
+
raise ConnectionError("AndroidPortalHttpDriver is not connected")
|
|
134
|
+
return self._client
|
|
135
|
+
|
|
136
|
+
# -- helpers -----------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _unwrap_json(data: Any) -> Any:
|
|
140
|
+
if isinstance(data, dict):
|
|
141
|
+
status = data.get("status")
|
|
142
|
+
if status == "error":
|
|
143
|
+
message = data.get("message") or data.get("error") or data
|
|
144
|
+
raise RuntimeError(f"Portal returned error: {message}")
|
|
145
|
+
inner_key = "result" if "result" in data else "data" if "data" in data else None
|
|
146
|
+
if inner_key is not None:
|
|
147
|
+
inner = data[inner_key]
|
|
148
|
+
if isinstance(inner, str):
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(inner)
|
|
151
|
+
except json.JSONDecodeError:
|
|
152
|
+
return inner
|
|
153
|
+
return inner
|
|
154
|
+
return data
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def _unwrap_response(cls, response: httpx.Response) -> Any:
|
|
158
|
+
response.raise_for_status()
|
|
159
|
+
if not response.content:
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
return cls._unwrap_json(response.json())
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
return response.content
|
|
165
|
+
|
|
166
|
+
async def _post(self, path: str, payload: dict[str, Any]) -> Any:
|
|
167
|
+
await self.ensure_connected()
|
|
168
|
+
return self._unwrap_response(await self._http.post(path, json=payload))
|
|
169
|
+
|
|
170
|
+
# -- input actions -----------------------------------------------------
|
|
171
|
+
|
|
172
|
+
async def tap(self, x: int, y: int) -> None:
|
|
173
|
+
await self._post("/tap", {"x": int(x), "y": int(y)})
|
|
174
|
+
|
|
175
|
+
async def swipe(
|
|
176
|
+
self,
|
|
177
|
+
x1: int,
|
|
178
|
+
y1: int,
|
|
179
|
+
x2: int,
|
|
180
|
+
y2: int,
|
|
181
|
+
duration_ms: float = 1000,
|
|
182
|
+
) -> None:
|
|
183
|
+
await self._post(
|
|
184
|
+
"/swipe",
|
|
185
|
+
{
|
|
186
|
+
"startX": int(x1),
|
|
187
|
+
"startY": int(y1),
|
|
188
|
+
"endX": int(x2),
|
|
189
|
+
"endY": int(y2),
|
|
190
|
+
"duration": float(duration_ms),
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
async def input_text(
|
|
195
|
+
self,
|
|
196
|
+
text: str,
|
|
197
|
+
clear: bool = False,
|
|
198
|
+
stealth: bool = False,
|
|
199
|
+
wpm: int = 0,
|
|
200
|
+
) -> bool:
|
|
201
|
+
encoded = base64.b64encode(text.encode()).decode("ascii")
|
|
202
|
+
await self._post("/keyboard/input", {"base64_text": encoded, "clear": clear})
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
async def press_button(self, button: str) -> None:
|
|
206
|
+
button_lower = button.lower()
|
|
207
|
+
if button_lower not in self.supported_buttons:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"Button '{button}' not supported. "
|
|
210
|
+
f"Supported: {', '.join(sorted(self.supported_buttons))}"
|
|
211
|
+
)
|
|
212
|
+
await self.press_key_code(_ANDROID_KEY_CODES[button_lower])
|
|
213
|
+
|
|
214
|
+
async def press_key_code(self, key_code: int) -> None:
|
|
215
|
+
await self._post("/keyboard/key", {"key_code": int(key_code)})
|
|
216
|
+
|
|
217
|
+
# -- app management ----------------------------------------------------
|
|
218
|
+
|
|
219
|
+
async def start_app(self, package: str, activity: Optional[str] = None) -> str:
|
|
220
|
+
payload = {"package": package}
|
|
221
|
+
if activity:
|
|
222
|
+
payload["activity"] = activity
|
|
223
|
+
await self._post("/app", payload)
|
|
224
|
+
return f"Started {package}"
|
|
225
|
+
|
|
226
|
+
async def stop_app(self, package: str) -> str:
|
|
227
|
+
await self._post("/app/stop", {"package": package})
|
|
228
|
+
return f"Stopped {package}"
|
|
229
|
+
|
|
230
|
+
async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
|
|
231
|
+
await self.ensure_connected()
|
|
232
|
+
data = self._unwrap_response(await self._http.get("/packages"))
|
|
233
|
+
if isinstance(data, dict) and "packages" in data:
|
|
234
|
+
data = data["packages"]
|
|
235
|
+
if not isinstance(data, list):
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
apps: list[dict[str, str]] = []
|
|
239
|
+
for app in data:
|
|
240
|
+
if not isinstance(app, dict):
|
|
241
|
+
continue
|
|
242
|
+
if not include_system and app.get("isSystemApp", False):
|
|
243
|
+
continue
|
|
244
|
+
package = app.get("packageName") or app.get("package") or ""
|
|
245
|
+
label = app.get("label") or package
|
|
246
|
+
apps.append({"package": package, "label": label})
|
|
247
|
+
return apps
|
|
248
|
+
|
|
249
|
+
async def list_packages(self, include_system: bool = False) -> List[str]:
|
|
250
|
+
apps = await self.get_apps(include_system=include_system)
|
|
251
|
+
return [app["package"] for app in apps if app.get("package")]
|
|
252
|
+
|
|
253
|
+
# -- state / observation ----------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def screenshot(self, hide_overlay: bool = True) -> bytes:
|
|
256
|
+
await self.ensure_connected()
|
|
257
|
+
params = {"hideOverlay": "true" if hide_overlay else "false"}
|
|
258
|
+
response = await self._http.get("/screenshot", params=params)
|
|
259
|
+
response.raise_for_status()
|
|
260
|
+
content_type = response.headers.get("content-type", "")
|
|
261
|
+
if "image/" in content_type or response.content.startswith(b"\x89PNG"):
|
|
262
|
+
return response.content
|
|
263
|
+
|
|
264
|
+
data = self._unwrap_response(response)
|
|
265
|
+
if isinstance(data, str):
|
|
266
|
+
return base64.b64decode(data)
|
|
267
|
+
raise ValueError("Invalid screenshot response from Android Portal HTTP")
|
|
268
|
+
|
|
269
|
+
async def get_ui_tree(self) -> Dict[str, Any]:
|
|
270
|
+
await self.ensure_connected()
|
|
271
|
+
data = self._unwrap_response(await self._http.get("/state_full"))
|
|
272
|
+
return data if isinstance(data, dict) else {}
|
|
@@ -76,6 +76,10 @@ class DeviceDriver:
|
|
|
76
76
|
"""
|
|
77
77
|
raise NotImplementedError
|
|
78
78
|
|
|
79
|
+
async def press_key_code(self, key_code: int) -> None:
|
|
80
|
+
"""Press a platform-native integer key code."""
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
79
83
|
async def drag(
|
|
80
84
|
self,
|
|
81
85
|
x1: int,
|
|
@@ -100,6 +104,14 @@ class DeviceDriver:
|
|
|
100
104
|
"""Install an APK/IPA at *path*."""
|
|
101
105
|
raise NotImplementedError
|
|
102
106
|
|
|
107
|
+
async def stop_app(self, package: str) -> str:
|
|
108
|
+
"""Stop a running application."""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
async def uninstall_app(self, package: str) -> str:
|
|
112
|
+
"""Uninstall an application."""
|
|
113
|
+
raise NotImplementedError
|
|
114
|
+
|
|
103
115
|
async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
|
|
104
116
|
"""Return installed apps as ``[{"package": …, "label": …}, …]``."""
|
|
105
117
|
raise NotImplementedError
|