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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mobilerun-core-cli
3
- Version: 0.1.0
4
- Summary: Slim async Portal client + Android device driver, extracted from mobilerun.
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 Portal + Android device driver core of <a href="https://github.com/droidrun/mobilerun">mobilerun</a>.</strong><br>
37
- No CLI, no agent, no LLM providers — just enough to drive a real Android device through the Mobilerun Portal app over ADB. Intended as an embedding library for higher-level tools.
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 a real Android device `tap`, `swipe`, `type`, `screenshot`, `get_ui_tree`, `start_app`, `install_app`, …
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 — four files, four runtime deps (`async_adbutils`, `httpx`, `requests`, `rich`).
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`. Requires [ADB](https://developer.android.com/studio/releases/platform-tools) on `PATH` and a device with USB debugging enabled.
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
- └── android.py AndroidDriver — ADB-backed concrete driver
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 is a verbatim slice of the upstream framework's Android driver + Portal modules, with imports rewritten under `mobilerun_core_cli` and loggers renamed. It tracks the upstream API for those four files; new features (iOS driver, recording driver, cloud driver, agent/CLI) stay upstream.
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 only need to drive a real Android device and want to keep the dependency footprint small.
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 Portal + Android device driver core of <a href="https://github.com/droidrun/mobilerun">mobilerun</a>.</strong><br>
9
- No CLI, no agent, no LLM providers — just enough to drive a real Android device through the Mobilerun Portal app over ADB. Intended as an embedding library for higher-level tools.
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 a real Android device `tap`, `swipe`, `type`, `screenshot`, `get_ui_tree`, `start_app`, `install_app`, …
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 — four files, four runtime deps (`async_adbutils`, `httpx`, `requests`, `rich`).
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`. Requires [ADB](https://developer.android.com/studio/releases/platform-tools) on `PATH` and a device with USB debugging enabled.
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
- └── android.py AndroidDriver — ADB-backed concrete driver
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 is a verbatim slice of the upstream framework's Android driver + Portal modules, with imports rewritten under `mobilerun_core_cli` and loggers renamed. It tracks the upstream API for those four files; new features (iOS driver, recording driver, cloud driver, agent/CLI) stay upstream.
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 only need to drive a real Android device and want to keep the dependency footprint small.
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.1.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
- "drag",
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.device.keyevent(self._BUTTON_KEYCODES[button_lower])
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
- try:
136
- logger.debug(f"Starting app {package} with activity {activity}")
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
- dumpsys_output = await self.device.shell(
139
- f"cmd package resolve-activity --brief {package}"
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
- 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}"
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