diff --git a/docs/sdk/adb-tools.mdx b/docs/sdk/adb-tools.mdx
index 0df29606..100d1b53 100644
--- a/docs/sdk/adb-tools.mdx
+++ b/docs/sdk/adb-tools.mdx
@@ -4,7 +4,7 @@ title: AndroidDriver
Raw Android device I/O via ADB + Portal.
-
+
## AndroidDriver
@@ -16,7 +16,7 @@ Raw Android device I/O via ADB and the Mobilerun Portal app.
AndroidDriver provides low-level device communication for Android devices through ADB (Android Debug Bridge). It supports both TCP communication and content provider modes via the Mobilerun Portal app. AndroidDriver declares its capabilities in the `supported` set, and unsupported methods raise `NotImplementedError`.
-
+
#### AndroidDriver.\_\_init\_\_
@@ -73,7 +73,7 @@ AndroidDriver.supported_buttons = {"back", "home", "enter"}
## Lifecycle Methods
-
+
#### AndroidDriver.connect
@@ -90,7 +90,7 @@ driver = AndroidDriver(serial="emulator-5554")
await driver.connect()
```
-
+
#### AndroidDriver.ensure\_connected
@@ -104,7 +104,7 @@ Connect if not already connected. Safe to call multiple times.
## Input Action Methods
-
+
#### AndroidDriver.tap
@@ -125,7 +125,7 @@ Tap at absolute pixel coordinates on the device screen.
await driver.tap(540, 960)
```
-
+
#### AndroidDriver.swipe
@@ -163,7 +163,7 @@ await driver.swipe(800, 960, 200, 960, duration_ms=250)
- Duration is converted to seconds internally (dividing by 1000)
- Includes an async sleep matching the swipe duration for UI settling
-
+
#### AndroidDriver.input\_text
@@ -196,7 +196,7 @@ success = await driver.input_text("New text", clear=True)
- Uses the Mobilerun Portal app keyboard for reliable text input via PortalClient
- Supports Unicode characters and special characters
-
+
#### AndroidDriver.press\_button
@@ -222,7 +222,7 @@ await driver.press_button("home")
await driver.press_button("back")
```
-
+
#### AndroidDriver.drag
@@ -253,7 +253,7 @@ Drag from (x1, y1) to (x2, y2).
## App Management Methods
-
+
#### AndroidDriver.start\_app
@@ -284,7 +284,7 @@ result = await driver.start_app("com.android.settings")
result = await driver.start_app("com.android.settings", ".Settings")
```
-
+
#### AndroidDriver.install\_app
@@ -311,7 +311,7 @@ result = await driver.install_app("/path/to/app.apk")
result = await driver.install_app("/path/to/app.apk", reinstall=True)
```
-
+
#### AndroidDriver.list\_packages
@@ -329,7 +329,7 @@ Return installed package names.
- `List[str]` - List of package names
-
+
#### AndroidDriver.get\_apps
@@ -351,7 +351,7 @@ Return installed apps as list of dicts with 'package' and 'label' keys.
## State and Observation Methods
-
+
#### AndroidDriver.screenshot
@@ -377,7 +377,7 @@ with open("screenshot.png", "wb") as f:
f.write(png_bytes)
```
-
+
#### AndroidDriver.get\_ui\_tree
@@ -393,7 +393,7 @@ Returns a dictionary containing both the accessibility tree and phone state data
- `Dict[str, Any]` - Raw UI tree data from the device
-
+
#### AndroidDriver.get\_date
diff --git a/docs/sdk/base-tools.mdx b/docs/sdk/base-tools.mdx
index 21cf7ab2..cdf950fb 100644
--- a/docs/sdk/base-tools.mdx
+++ b/docs/sdk/base-tools.mdx
@@ -4,7 +4,7 @@ title: DeviceDriver Base Class
Base class defining the interface for all device drivers.
-
+
## DeviceDriver
@@ -33,9 +33,9 @@ Every method raises `NotImplementedError` by default. Concrete drivers override
The tools architecture follows a multi-layer pattern:
-1. **DeviceDriver** (`tools/driver/base.py`): Base class for raw device I/O. Methods raise `NotImplementedError` by default.
+1. **DeviceDriver**: Base class for raw device I/O (provided by `mobilerun-core-cli`). Methods raise `NotImplementedError` by default.
2. **Driver Implementations**: Platform-specific drivers
- - `AndroidDriver` (`tools/driver/android.py`): Android devices via ADB + Portal app
+ - `AndroidDriver`: Android devices via ADB + Portal app (provided by `mobilerun-core-cli`)
- `IOSDriver` (`tools/driver/ios.py`): iOS devices via HTTP REST API to Portal app
- `StealthDriver` (`tools/driver/stealth.py`): Wraps another driver, adds human-like timing jitter
- `RecordingDriver` (`tools/driver/recording.py`): Wraps another driver with trajectory recording
diff --git a/mobilerun/agent/action_context.py b/mobilerun/agent/action_context.py
index ca5d4a70..6160e691 100644
--- a/mobilerun/agent/action_context.py
+++ b/mobilerun/agent/action_context.py
@@ -9,10 +9,11 @@
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
+ from mobilerun_core_cli.driver.base import DeviceDriver
+
from mobilerun.agent.droid.state import MobileAgentState
from mobilerun.credential_manager import CredentialManager
from mobilerun.macro.recorder import MacroRecorder
- from mobilerun.tools.driver.base import DeviceDriver
from mobilerun.tools.ui.provider import StateProvider
from mobilerun.tools.ui.state import UIState
diff --git a/mobilerun/agent/droid/droid_agent.py b/mobilerun/agent/droid/droid_agent.py
index eec54e60..dbedb6a0 100644
--- a/mobilerun/agent/droid/droid_agent.py
+++ b/mobilerun/agent/droid/droid_agent.py
@@ -15,6 +15,9 @@
from async_adbutils import adb
from llama_index.core.llms.llm import LLM
from llama_index.core.workflow import Context, StartEvent, StopEvent, Workflow, step
+from mobilerun_core_cli.driver.android import AndroidDriver
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError
+from mobilerun_core_cli.portal import ensure_portal_ready
from opentelemetry import trace
from pydantic import BaseModel
from workflows.events import Event
@@ -70,15 +73,12 @@
from mobilerun.mcp.adapter import mcp_to_mobilerun_tools
from mobilerun.mcp.client import MCPClientManager
from mobilerun.mcp.config import MCPConfig
-from mobilerun.portal import ensure_portal_ready
from mobilerun.telemetry import (
MobileAgentFinalizeEvent,
MobileAgentInitEvent,
capture,
flush,
)
-from mobilerun.tools.driver.android import AndroidDriver
-from mobilerun.tools.driver.base import DeviceDisconnectedError
from mobilerun.tools.driver.ios import IOSDriver, discover_ios_portal
from mobilerun.tools.driver.recording import RecordingDriver
from mobilerun.tools.driver.stealth import StealthDriver
@@ -94,7 +94,8 @@
from mobilerun.tools.ui.screenshot_provider import ScreenshotOnlyStateProvider
if TYPE_CHECKING:
- from mobilerun.tools.driver.base import DeviceDriver
+ from mobilerun_core_cli.driver.base import DeviceDriver
+
from mobilerun.tools.ui.provider import StateProvider
logger = logging.getLogger("mobilerun")
diff --git a/mobilerun/agent/fast_agent/fast_agent.py b/mobilerun/agent/fast_agent/fast_agent.py
index ed18338a..83605689 100644
--- a/mobilerun/agent/fast_agent/fast_agent.py
+++ b/mobilerun/agent/fast_agent/fast_agent.py
@@ -14,6 +14,7 @@
from llama_index.core.base.llms.types import ChatMessage, ImageBlock, TextBlock
from llama_index.core.llms.llm import LLM
from llama_index.core.workflow import Context, StartEvent, StopEvent, Workflow, step
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError
from opentelemetry import trace
from pydantic import BaseModel
@@ -45,7 +46,6 @@
from mobilerun.agent.utils.tracing_setup import record_langfuse_screenshot
from mobilerun.config_manager.config_manager import AgentConfig, TracingConfig
from mobilerun.config_manager.prompt_loader import PromptLoader
-from mobilerun.tools.driver.base import DeviceDisconnectedError
from mobilerun.tools.helpers.images import resize_image_to_max_side_with_grid
if TYPE_CHECKING:
diff --git a/mobilerun/agent/manager/manager_agent.py b/mobilerun/agent/manager/manager_agent.py
index 1220f8a7..a4c4262a 100644
--- a/mobilerun/agent/manager/manager_agent.py
+++ b/mobilerun/agent/manager/manager_agent.py
@@ -24,6 +24,7 @@
)
from llama_index.core.llms.llm import LLM
from llama_index.core.workflow import Context, StartEvent, StopEvent, Workflow, step
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError
from opentelemetry import trace
from pydantic import BaseModel
@@ -47,7 +48,6 @@
ServerAppCardProvider,
)
from mobilerun.config_manager.prompt_loader import PromptLoader
-from mobilerun.tools.driver.base import DeviceDisconnectedError
from mobilerun.tools.helpers.images import resize_image_to_max_side_with_grid
if TYPE_CHECKING:
diff --git a/mobilerun/agent/manager/stateless_manager_agent.py b/mobilerun/agent/manager/stateless_manager_agent.py
index 200ed257..8f3da5f0 100644
--- a/mobilerun/agent/manager/stateless_manager_agent.py
+++ b/mobilerun/agent/manager/stateless_manager_agent.py
@@ -9,6 +9,7 @@
from llama_index.core.llms.llm import LLM
from llama_index.core.workflow import Context, StartEvent, StopEvent, Workflow, step
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError
from opentelemetry import trace
from pydantic import BaseModel
@@ -25,7 +26,6 @@
from mobilerun.agent.utils.prompt_resolver import PromptResolver
from mobilerun.agent.utils.tracing_setup import record_langfuse_screenshot
from mobilerun.config_manager.prompt_loader import PromptLoader
-from mobilerun.tools.driver.base import DeviceDisconnectedError
from mobilerun.tools.helpers.images import resize_image_to_max_side_with_grid
if TYPE_CHECKING:
diff --git a/mobilerun/agent/oneflows/app_starter_workflow.py b/mobilerun/agent/oneflows/app_starter_workflow.py
index 3b2c162c..6c3924f2 100644
--- a/mobilerun/agent/oneflows/app_starter_workflow.py
+++ b/mobilerun/agent/oneflows/app_starter_workflow.py
@@ -116,8 +116,7 @@ async def main():
Example of how to use the OpenAppWorkflow.
"""
from llama_index.llms.openai import OpenAI
-
- from mobilerun.tools.driver.android import AndroidDriver
+ from mobilerun_core_cli.driver.android import AndroidDriver
# Initialize driver with device serial (None for default device)
driver = AndroidDriver(serial=None)
diff --git a/mobilerun/cli/device_commands.py b/mobilerun/cli/device_commands.py
index c7195a36..73fa7a1e 100644
--- a/mobilerun/cli/device_commands.py
+++ b/mobilerun/cli/device_commands.py
@@ -12,11 +12,11 @@
import click
from async_adbutils import adb
+from mobilerun_core_cli.driver.android import AndroidDriver
+from mobilerun_core_cli.portal import ensure_portal_ready
from rich.console import Console
from mobilerun.config_manager import ConfigLoader
-from mobilerun.portal import ensure_portal_ready
-from mobilerun.tools.driver.android import AndroidDriver
from mobilerun.tools.driver.ios import (
IOSDriver,
discover_ios_portal,
@@ -97,7 +97,7 @@ async def _create_driver(
async def _teardown_android(driver):
"""Disable Mobilerun keyboard after direct command execution."""
if isinstance(driver, AndroidDriver) and driver.device:
- from mobilerun.portal import PORTAL_PACKAGE_NAME, portal_ime_id
+ from mobilerun_core_cli.portal import PORTAL_PACKAGE_NAME, portal_ime_id
try:
ime = portal_ime_id(PORTAL_PACKAGE_NAME)
diff --git a/mobilerun/cli/doctor.py b/mobilerun/cli/doctor.py
index 22d29ebe..dd9b467b 100644
--- a/mobilerun/cli/doctor.py
+++ b/mobilerun/cli/doctor.py
@@ -11,10 +11,7 @@
import httpx
import requests
from async_adbutils import AdbDevice, adb
-from rich.console import Console
-
-from mobilerun import __version__
-from mobilerun.portal import (
+from mobilerun_core_cli.portal import (
GITHUB_API_HOSTS,
PORTAL_PACKAGE_NAME,
REPO,
@@ -24,7 +21,10 @@
portal_content_uri,
portal_ime_id,
)
-from mobilerun.tools.android.portal_client import PortalClient
+from mobilerun_core_cli.transport.portal_client import PortalClient
+from rich.console import Console
+
+from mobilerun import __version__
console = Console()
@@ -511,7 +511,7 @@ async def check_portal_state(
device: AdbDevice, use_tcp: bool, debug: bool
) -> tuple[CheckResult, Any]:
"""Fetch full state and verify a11y_tree, phone_state, device_context are present."""
- from mobilerun.tools.android.portal_client import PortalClient
+ from mobilerun_core_cli.transport.portal_client import PortalClient
try:
portal = PortalClient(device, prefer_tcp=use_tcp)
diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py
index d3eb2e6e..131bf3dc 100644
--- a/mobilerun/cli/main.py
+++ b/mobilerun/cli/main.py
@@ -15,6 +15,17 @@
import click
from async_adbutils import adb
+from mobilerun_core_cli.portal import (
+ DOWNLOAD_BASE,
+ PORTAL_PACKAGE_NAME,
+ download_portal_apk,
+ download_versioned_portal_apk,
+ enable_portal_accessibility,
+ ping_portal,
+ ping_portal_content,
+ ping_portal_tcp,
+ setup_portal,
+)
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
@@ -47,17 +58,6 @@
)
from mobilerun.log_handlers import CLILogHandler, configure_logging
from mobilerun.macro.cli import macro_cli
-from mobilerun.portal import (
- DOWNLOAD_BASE,
- PORTAL_PACKAGE_NAME,
- download_portal_apk,
- download_versioned_portal_apk,
- enable_portal_accessibility,
- ping_portal,
- ping_portal_content,
- ping_portal_tcp,
- setup_portal,
-)
from mobilerun.telemetry import print_telemetry_message
from mobilerun.tools.driver.ios import discover_ios_portal, validate_ios_portal_url
from mobilerun.tools.driver.visual_remote import VISUAL_REMOTE_CONNECTION
@@ -329,7 +329,7 @@ async def _cleanup_android_keyboard(config: MobileConfig) -> None:
try:
device_obj = await adb.device(config.device.serial)
if device_obj:
- from mobilerun.portal import PORTAL_PACKAGE_NAME, portal_ime_id
+ from mobilerun_core_cli.portal import PORTAL_PACKAGE_NAME, portal_ime_id
ime = portal_ime_id(PORTAL_PACKAGE_NAME)
await device_obj.shell(f"ime disable {ime}")
diff --git a/mobilerun/cli/tui/app.py b/mobilerun/cli/tui/app.py
index f6699e67..d6f442bc 100644
--- a/mobilerun/cli/tui/app.py
+++ b/mobilerun/cli/tui/app.py
@@ -566,8 +566,7 @@ def on_device_picker_cancelled(self, message: DevicePicker.Cancelled) -> None:
async def _verify_portal(self, serial: str) -> None:
"""Check portal connectivity. Raises on failure."""
from async_adbutils import adb
-
- from mobilerun.tools.android.portal_client import PortalClient
+ from mobilerun_core_cli.transport.portal_client import PortalClient
device_obj = await adb.device(serial)
portal = PortalClient(device_obj, prefer_tcp=self.settings.use_tcp)
@@ -630,8 +629,7 @@ async def _run_device_setup(self, serial: str) -> None:
import requests
from async_adbutils import adb
-
- from mobilerun.portal import (
+ from mobilerun_core_cli.portal import (
_resolve_latest_portal_apk_asset,
_resolve_versioned_portal_apk_asset,
enable_portal_accessibility,
diff --git a/mobilerun/macro/replay.py b/mobilerun/macro/replay.py
index 86985e79..6f5d0ab9 100644
--- a/mobilerun/macro/replay.py
+++ b/mobilerun/macro/replay.py
@@ -10,6 +10,8 @@
import time
from typing import Any, Dict, Optional
+from mobilerun_core_cli.driver.android import AndroidDriver
+
from mobilerun.agent.utils.trajectory import Trajectory
from mobilerun.macro.handoff import run_agent_handoff
from mobilerun.macro.matcher import StateMatchResult, compare_states
@@ -18,7 +20,6 @@
UNSUPPORTED_SCHEMA_MESSAGE,
normalize_ui_state,
)
-from mobilerun.tools.driver.android import AndroidDriver
from mobilerun.tools.filters import DetailedFilter
from mobilerun.tools.formatters import IndexedFormatter
from mobilerun.tools.ui.provider import AndroidStateProvider
diff --git a/mobilerun/portal.py b/mobilerun/portal.py
deleted file mode 100644
index 1454d30a..00000000
--- a/mobilerun/portal.py
+++ /dev/null
@@ -1,798 +0,0 @@
-"""
-Portal APK management and device communication utilities.
-
-This module handles downloading, installing, and managing the Mobilerun Portal app
-on Android devices. It also provides utilities for checking accessibility service
-status and managing device communication modes (TCP and content provider).
-"""
-
-import asyncio
-import contextlib
-import json
-import logging
-import os
-import tempfile
-from urllib.parse import urlparse
-
-import requests
-from async_adbutils import AdbDevice, adb
-from rich.console import Console
-
-from mobilerun import __version__
-
-logger = logging.getLogger("mobilerun")
-
-REPO = "droidrun/mobilerun-portal"
-ASSET_NAME = "mobilerun-portal"
-DOWNLOAD_BASE = f"https://github.com/{REPO}/releases/download"
-GITHUB_API_HOSTS = ["https://api.github.com", "https://ungh.cc"]
-
-VERSION_MAP_GIST_URL = "https://raw.githubusercontent.com/droidrun/gists/refs/heads/main/version_map_android.json"
-
-PORTAL_PACKAGE_NAME = "com.mobilerun.portal"
-PORTAL_APK_ASSET_PREFIXES = (
- PORTAL_PACKAGE_NAME,
- "mobilerun-portal-internal",
- ASSET_NAME,
-)
-
-# ── Centralized portal identity resolution ──
-# ALL portal identifiers (package, a11y service, IME, content URIs) MUST be
-# resolved through these helpers. No file should hard-code these strings.
-
-_PORTAL_META = {
- PORTAL_PACKAGE_NAME: {
- "a11y": f"{PORTAL_PACKAGE_NAME}/{PORTAL_PACKAGE_NAME}.service.MobilerunAccessibilityService",
- "ime": f"{PORTAL_PACKAGE_NAME}/.input.MobilerunKeyboardIME",
- },
-}
-
-# Artifact channels — mobilerun-portal-internal is a repo/artifact convention,
-# not an Android package name.
-_ARTIFACT_CHANNELS = {
- PORTAL_PACKAGE_NAME: {
- "repo": "droidrun/mobilerun-portal",
- "asset_name": "mobilerun-portal",
- },
-}
-
-A11Y_SERVICE_NAME = _PORTAL_META[PORTAL_PACKAGE_NAME]["a11y"]
-
-
-def portal_content_uri(pkg: str, path: str) -> str:
- """Build a content URI for the given portal package."""
- return f"content://{pkg}/{path}"
-
-
-def portal_a11y_service(pkg: str) -> str:
- """Return the accessibility service component name."""
- return _PORTAL_META[pkg]["a11y"]
-
-
-def portal_ime_id(pkg: str) -> str:
- """Return the IME component name."""
- return _PORTAL_META[pkg]["ime"]
-
-
-def get_portal_artifact_source(target_package: str) -> dict:
- """Return repo/asset_name for the given portal package."""
- return _ARTIFACT_CHANNELS[target_package]
-
-
-def get_version_mapping(debug: bool = False) -> dict | None:
- try:
- response = requests.get(VERSION_MAP_GIST_URL, timeout=10)
- response.raise_for_status()
- return response.json()
- except Exception as e:
- if debug:
- print(f"Failed to fetch version mapping: {e}")
- return None
-
-
-def _version_in_range(version: str, range_str: str) -> bool:
- if "-" not in range_str:
- return False
- try:
- start, end = range_str.split("-", 1)
- v_parts = [int(x) for x in version.split(".")]
- s_parts = [int(x) for x in start.split(".")]
- e_parts = [int(x) for x in end.split(".")]
- return s_parts <= v_parts <= e_parts
- except (ValueError, AttributeError):
- return False
-
-
-def get_compatible_portal_version(
- mobilerun_version: str, debug: bool = False
-) -> tuple[str | None, str, bool]:
- mapping = get_version_mapping(debug)
- if mapping is None:
- return (None, "", False)
-
- mappings = mapping.get("mappings", {})
- download_base = _normalize_download_base(
- mapping.get("download_base", DOWNLOAD_BASE)
- )
-
- # Try exact match first
- if mobilerun_version in mappings:
- return (mappings[mobilerun_version], download_base, True)
-
- # Try range match (e.g., "0.4.0-0.4.14": "1.0.0")
- for key, portal_version in mappings.items():
- if "-" in key and _version_in_range(mobilerun_version, key):
- return (portal_version, download_base, True)
-
- return (None, download_base, True)
-
-
-def _normalize_download_base(download_base: str | None) -> str:
- if not download_base:
- return DOWNLOAD_BASE
- return download_base.replace(
- "droidrun/droidrun-portal", "droidrun/mobilerun-portal"
- )
-
-
-def _normalize_portal_release_tag(version: str) -> str:
- version = version.strip()
- return version if version.startswith("v") else f"v{version}"
-
-
-def _extract_release_assets(release: dict) -> list[dict]:
- if "release" in release:
- return release["release"].get("assets", [])
- return release.get("assets", [])
-
-
-def _asset_download_url(asset: dict) -> str | None:
- return asset.get("browser_download_url") or asset.get("downloadUrl")
-
-
-def _asset_file_name(asset: dict) -> str:
- name = asset.get("name")
- if name:
- return name
-
- asset_url = _asset_download_url(asset)
- if not asset_url:
- return ""
-
- return os.path.basename(urlparse(asset_url).path)
-
-
-def _is_portal_apk_asset_name(asset_name: str) -> bool:
- lower_name = asset_name.lower()
- if not lower_name.endswith(".apk"):
- return False
-
- return any(
- lower_name.startswith(prefix.lower()) for prefix in PORTAL_APK_ASSET_PREFIXES
- )
-
-
-def _portal_apk_asset_priority(asset_name: str) -> tuple[int, str]:
- lower_name = asset_name.lower()
- if "unsigned" in lower_name:
- return (3, lower_name)
- if "debug" in lower_name:
- return (2, lower_name)
- if "release" in lower_name or "stable" in lower_name:
- return (1, lower_name)
- return (0, lower_name)
-
-
-def _portal_apk_fallback_name(version: str) -> str:
- return f"{PORTAL_PACKAGE_NAME}-{version}.apk"
-
-
-def _portal_apk_fallback_url(
- version: str, download_base: str, tag: str
-) -> tuple[str, str]:
- asset_name = _portal_apk_fallback_name(version)
- base = _normalize_download_base(download_base).rstrip("/")
- return f"{base}/{tag}/{asset_name}", asset_name
-
-
-def _parse_portal_asset_version(asset_name: str) -> str | None:
- stem = os.path.basename(asset_name).removesuffix(".apk")
- lower_stem = stem.lower()
-
- for suffix in (
- "-release-unsigned",
- "-release-signed",
- "-unsigned",
- "-release",
- "-debug",
- "-stable",
- ):
- if lower_stem.endswith(suffix):
- stem = stem[: -len(suffix)]
- lower_stem = lower_stem[: -len(suffix)]
- break
-
- for prefix in PORTAL_APK_ASSET_PREFIXES:
- marker = f"{prefix}-"
- if lower_stem.startswith(marker.lower()):
- version = stem[len(marker) :]
- return version.removeprefix("v") or None
-
- return None
-
-
-def _format_asset_names(assets: list[dict]) -> str:
- names = [_asset_file_name(asset) or "" for asset in assets]
- return ", ".join(names) if names else "none"
-
-
-def _select_portal_apk_asset(assets: list[dict]) -> tuple[str, str, str | None]:
- candidates: list[tuple[tuple[int, str], str, str]] = []
-
- for asset in assets:
- asset_name = _asset_file_name(asset)
- asset_url = _asset_download_url(asset)
- if not asset_name or not asset_url:
- continue
- if not _is_portal_apk_asset_name(asset_name):
- continue
- candidates.append(
- (_portal_apk_asset_priority(asset_name), asset_name, asset_url)
- )
-
- if not candidates:
- raise Exception(
- "Portal APK asset not found in release. "
- f"Saw assets: {_format_asset_names(assets)}"
- )
-
- _, asset_name, asset_url = min(candidates, key=lambda candidate: candidate[0])
- return asset_url, asset_name, _parse_portal_asset_version(asset_name)
-
-
-def _fetch_release_json(release_path: str, debug: bool = False) -> dict:
- path = release_path.lstrip("/")
- response = None
- last_request_error: requests.RequestException | None = None
-
- for host in GITHUB_API_HOSTS:
- url = f"{host}/repos/{REPO}/{path}"
- try:
- response = requests.get(url)
- except requests.RequestException as e:
- last_request_error = e
- if debug:
- print(f"Failed to fetch release from {host}: {e}")
- continue
-
- if response.status_code == 200:
- if debug:
- print(f"Using GitHub release on {host}")
- return response.json()
-
- if response is not None:
- response.raise_for_status()
-
- if last_request_error is not None:
- raise Exception(
- "Failed to fetch Portal release metadata from all configured hosts"
- ) from last_request_error
-
- raise Exception("No GitHub API hosts configured")
-
-
-def _get_release_assets_by_tag(version: str, debug: bool = False) -> list[dict]:
- tag = _normalize_portal_release_tag(version)
- release = _fetch_release_json(f"releases/tags/{tag}", debug)
- return _extract_release_assets(release)
-
-
-def _resolve_versioned_portal_apk_asset(
- version: str, download_base: str, debug: bool = False
-) -> tuple[str, str, str]:
- tag = _normalize_portal_release_tag(version)
-
- try:
- assets = _get_release_assets_by_tag(tag, debug)
- asset_url, asset_name, asset_version = _select_portal_apk_asset(assets)
- return asset_url, asset_version or tag.removeprefix("v"), asset_name
- except Exception as e:
- if debug:
- print(
- f"Failed to resolve release assets for {tag}, using fallback URL: {e}"
- )
-
- asset_version = tag.removeprefix("v")
- asset_url, asset_name = _portal_apk_fallback_url(asset_version, download_base, tag)
- return asset_url, asset_version, asset_name
-
-
-def _resolve_latest_portal_apk_asset(debug: bool = False) -> tuple[str, str, str]:
- assets = get_latest_release_assets(debug)
- asset_url, asset_name, asset_version = _select_portal_apk_asset(assets)
- return asset_url, asset_version or "unknown", asset_name
-
-
-@contextlib.contextmanager
-def download_versioned_portal_apk(
- version: str, download_base: str, debug: bool = False
-):
- """Download a specific Portal APK version."""
- console = Console()
- asset_url, asset_version, _ = _resolve_versioned_portal_apk_asset(
- version, download_base, debug
- )
-
- console.print(f"Downloading Portal APK [bold]{asset_version}[/bold]")
- if debug:
- console.print(f"Asset URL: {asset_url}")
-
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".apk")
- try:
- r = requests.get(asset_url, stream=True)
- r.raise_for_status()
- for chunk in r.iter_content(chunk_size=8192):
- if chunk:
- tmp.write(chunk)
- tmp.close()
- yield tmp.name
- finally:
- if os.path.exists(tmp.name):
- os.unlink(tmp.name)
-
-
-def get_latest_release_assets(debug: bool = False):
- """
- Fetch the latest Portal APK release assets from GitHub.
-
- Args:
- debug: Enable debug logging
-
- Returns:
- List of asset dictionaries from the latest GitHub release
-
- Raises:
- requests.HTTPError: If the GitHub API request fails
- """
- latest_release = _fetch_release_json("releases/latest", debug)
- return _extract_release_assets(latest_release)
-
-
-@contextlib.contextmanager
-def download_portal_apk(debug: bool = False):
- """
- Download the latest Portal APK from GitHub releases.
-
- This context manager downloads the APK to a temporary file and yields
- the file path. The file is automatically deleted when the context exits.
-
- Args:
- debug: Enable debug logging
-
- Yields:
- str: Path to the downloaded APK file
-
- Raises:
- Exception: If the Portal APK asset is not found in the release
- requests.HTTPError: If the download fails
- """
- console = Console()
- asset_url, asset_version, _ = _resolve_latest_portal_apk_asset(debug)
-
- console.print(f"Found Portal APK [bold]{asset_version}[/bold]")
- if debug:
- console.print(f"Asset URL: {asset_url}")
-
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".apk")
- try:
- r = requests.get(asset_url, stream=True)
- r.raise_for_status()
- for chunk in r.iter_content(chunk_size=8192):
- if chunk:
- tmp.write(chunk)
- tmp.close()
- yield tmp.name
- finally:
- if os.path.exists(tmp.name):
- os.unlink(tmp.name)
-
-
-async def enable_portal_accessibility(
- device: AdbDevice, service_name: str = A11Y_SERVICE_NAME
-):
- """
- Enable the Portal accessibility service on the device.
-
- Args:
- device: ADB device connection
- service_name: Full accessibility service name (default: Portal service)
-
- Note:
- This may fail on some devices due to security restrictions.
- Manual enablement may be required.
- """
- await device.shell(
- f"settings put secure enabled_accessibility_services {service_name}"
- )
- await device.shell("settings put secure accessibility_enabled 1")
-
-
-async def check_portal_accessibility(
- device: AdbDevice, service_name: str = A11Y_SERVICE_NAME, debug: bool = False
-) -> bool:
- """
- Check if the Portal accessibility service is enabled.
-
- Args:
- device: ADB device connection
- service_name: Full accessibility service name to check
- debug: Enable debug logging
-
- Returns:
- True if the accessibility service is enabled, False otherwise
- """
- a11y_services = await device.shell(
- "settings get secure enabled_accessibility_services"
- )
- if service_name not in a11y_services:
- if debug:
- print(a11y_services)
- return False
-
- a11y_enabled = await device.shell("settings get secure accessibility_enabled")
- if a11y_enabled != "1":
- if debug:
- print(a11y_enabled)
- return False
-
- return True
-
-
-async def ping_portal(device: AdbDevice, debug: bool = False):
- """
- Ping the Mobilerun Portal to check if it is installed and accessible.
- """
- try:
- packages = await device.list_packages()
- except Exception as e:
- raise Exception("Failed to list packages") from e
-
- if PORTAL_PACKAGE_NAME not in packages:
- if debug:
- print(packages)
- raise Exception("Portal is not installed on the device")
-
- if not await check_portal_accessibility(device, debug=debug):
- await device.shell("am start -a android.settings.ACCESSIBILITY_SETTINGS")
- raise Exception(
- "Mobilerun Portal is not enabled as an accessibility service on the device"
- )
-
-
-async def ping_portal_content(device: AdbDevice, debug: bool = False):
- """
- Test Portal accessibility via content provider.
-
- Args:
- device: ADB device connection
- debug: Enable debug logging
-
- Raises:
- Exception: If Portal is not reachable via content provider
- """
- try:
- uri = portal_content_uri(PORTAL_PACKAGE_NAME, "state")
- state = await device.shell(f"content query --uri {uri}")
- if "Row: 0 result=" not in state:
- raise Exception("Failed to get state from Mobilerun Portal")
- except Exception as e:
- raise Exception("Mobilerun Portal is not reachable") from e
-
-
-async def ping_portal_tcp(device: AdbDevice, debug: bool = False):
- """
- Test Portal accessibility via TCP mode.
-
- Args:
- device: ADB device connection
- debug: Enable debug logging
-
- Raises:
- Exception: If Portal is not reachable via TCP or port forwarding fails
- """
- from mobilerun.tools.driver.android import AndroidDriver
-
- try:
- driver = AndroidDriver(serial=device.serial, use_tcp=True)
- await driver.connect()
- except Exception as e:
- raise Exception("Failed to setup TCP forwarding") from e
-
-
-async def set_overlay_offset(device: AdbDevice, offset: int):
- """
- Set the overlay offset using the /overlay_offset portal content provider endpoint.
- """
- try:
- uri = portal_content_uri(PORTAL_PACKAGE_NAME, "overlay_offset")
- cmd = f'content insert --uri "{uri}" --bind offset:i:{offset}'
- await device.shell(cmd)
- except Exception as e:
- raise Exception("Error setting overlay offset") from e
-
-
-async def toggle_overlay(device: AdbDevice, visible: bool):
- """Toggle the overlay visibility.
-
- Args:
- device: Device to toggle the overlay on
- visible: Whether to show the overlay
-
- throws:
- Exception: If the overlay toggle fails
- """
- try:
- visible_str = "true" if visible else "false"
- uri = portal_content_uri(PORTAL_PACKAGE_NAME, "overlay_visible")
- cmd = f'content insert --uri "{uri}" --bind visible:b:{visible_str}'
- await device.shell(cmd)
- except Exception as e:
- raise Exception("Failed to toggle overlay") from e
-
-
-async def setup_keyboard(device: AdbDevice):
- """
- Set up the Mobilerun keyboard as the default input method.
- Simple setup that just switches to Mobilerun keyboard without saving/restoring.
-
- throws:
- Exception: If the keyboard setup fails
- """
- try:
- ime = portal_ime_id(PORTAL_PACKAGE_NAME)
- await device.shell(f"ime enable {ime}")
- await device.shell(f"ime set {ime}")
- except Exception as e:
- raise Exception("Error setting up keyboard") from e
-
-
-async def disable_keyboard(
- device: AdbDevice,
- target_ime: str | None = None,
-):
- """
- Disable a specific IME (keyboard) and optionally switch to another.
- By default, disables the Mobilerun keyboard.
-
- Args:
- target_ime: The IME package/activity to disable (default: Mobilerun keyboard)
-
- Returns:
- bool: True if disabled successfully, False otherwise
- """
- if target_ime is None:
- target_ime = portal_ime_id(PORTAL_PACKAGE_NAME)
- try:
- await device.shell(f"ime disable {target_ime}")
- return True
- except Exception as e:
- raise Exception("Error disabling keyboard") from e
-
-
-async def setup_portal(
- device: AdbDevice,
- debug: bool = False,
-) -> bool:
- """Download, install, and enable the Portal APK on a device.
-
- Uses version mapping to find the compatible Portal version for the
- current mobilerun SDK version. Falls back to the latest release if
- the mapping is unavailable.
-
- Args:
- device: ADB device connection.
- debug: Enable debug logging.
-
- Returns:
- True if setup completed successfully, False otherwise.
- """
- try:
- portal_version, download_base, mapping_fetched = get_compatible_portal_version(
- __version__, debug
- )
-
- if portal_version:
- apk_context = download_versioned_portal_apk(
- portal_version, download_base, debug
- )
- else:
- if not mapping_fetched:
- logger.warning(
- "Could not fetch version mapping, falling back to latest portal"
- )
- apk_context = download_portal_apk(debug)
-
- with apk_context as apk_path:
- if not os.path.exists(apk_path):
- logger.error(f"APK file not found at {apk_path}")
- return False
-
- logger.info("Installing Portal APK...")
- try:
- await device.install(
- apk_path, uninstall=True, flags=["-g"], silent=not debug
- )
- except Exception as e:
- logger.error(f"Portal installation failed: {e}")
- return False
-
- logger.info("Portal APK installed")
-
- try:
- await enable_portal_accessibility(device)
- # Wait for the service to become responsive
- await _wait_for_portal_service(device)
- logger.info("Accessibility service enabled")
- except Exception as e:
- logger.warning(f"Could not auto-enable accessibility service: {e}")
- try:
- await device.shell(
- "am start -a android.settings.ACCESSIBILITY_SETTINGS"
- )
- except Exception:
- pass
- return False
-
- return True
-
- except Exception as e:
- logger.error(f"Portal setup failed: {e}")
- if debug:
- import traceback
-
- logger.debug(traceback.format_exc())
- return False
-
-
-async def _wait_for_portal_service(
- device: AdbDevice, timeout: float = 10.0, interval: float = 1.0
-) -> None:
- """Poll the content provider until the accessibility service is responsive.
-
- Uses the simple ``/state`` endpoint which responds as soon as the
- service process is alive, without requiring an active window.
- """
- deadline = asyncio.get_event_loop().time() + timeout
- while asyncio.get_event_loop().time() < deadline:
- try:
- uri = portal_content_uri(PORTAL_PACKAGE_NAME, "state")
- state = await device.shell(f"content query --uri {uri}")
- if '"status":"success"' in state:
- return
- except Exception:
- pass
- await asyncio.sleep(interval)
- logger.warning("Portal service did not become responsive within timeout")
-
-
-def _parse_portal_version(raw_output: str) -> str | None:
- """Extract portal version string from content provider output."""
- try:
- if "result=" in raw_output:
- json_str = raw_output.split("result=", 1)[1].strip()
- data = json.loads(json_str)
- if data.get("status") == "success":
- return data.get("result") or data.get("data")
- except Exception:
- pass
- return None
-
-
-async def ensure_portal_ready(
- device: AdbDevice,
- debug: bool = False,
-) -> None:
- """Run parallel health checks and auto-fix portal issues.
-
- Performs three checks concurrently:
- 1. Is the Portal APK installed?
- 2. Is the installed version compatible?
- 3. Is the accessibility service enabled?
-
- If any check fails, attempts to fix automatically (install/upgrade
- APK, enable accessibility). Raises on unrecoverable failure.
-
- Args:
- device: ADB device connection.
- debug: Enable debug logging.
-
- Raises:
- RuntimeError: If portal cannot be made ready after auto-fix.
- """
- # ── parallel checks ──────────────────────────────────────────
- packages_task = device.list_packages()
- version_task = device.shell(
- f"content query --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'version')}"
- )
- a11y_task = device.shell("settings get secure enabled_accessibility_services")
-
- packages, version_raw, a11y_services = await asyncio.gather(
- packages_task, version_task, a11y_task, return_exceptions=True
- )
-
- # If all checks failed, the device is likely unreachable — skip
- # auto-setup and let AndroidDriver.connect() surface the real error.
- if (
- isinstance(packages, Exception)
- and isinstance(version_raw, Exception)
- and isinstance(a11y_services, Exception)
- ):
- logger.debug(f"Portal health check skipped (device unreachable): {packages}")
- return
-
- # ── evaluate results ─────────────────────────────────────────
- is_installed = isinstance(packages, list) and PORTAL_PACKAGE_NAME in packages
-
- installed_version = (
- _parse_portal_version(version_raw) if isinstance(version_raw, str) else None
- )
-
- a11y_enabled = isinstance(a11y_services, str) and A11Y_SERVICE_NAME in a11y_services
-
- # Check version compatibility
- needs_upgrade = False
- if is_installed and installed_version:
- expected_version, _, mapping_fetched = get_compatible_portal_version(
- __version__, debug
- )
- if expected_version and mapping_fetched:
- needs_upgrade = installed_version != expected_version.lstrip("v")
- if needs_upgrade:
- logger.info(
- f"Portal version mismatch: installed={installed_version}, "
- f"expected={expected_version}"
- )
-
- # ── fix if needed ────────────────────────────────────────────
- if not is_installed or needs_upgrade:
- reason = "not installed" if not is_installed else "outdated"
- logger.info(f"Portal {reason}, running auto-setup...")
- success = await setup_portal(device, debug)
- if not success:
- raise RuntimeError(
- f"Portal auto-setup failed ({reason}). "
- "Run 'mobilerun doctor' for diagnostics."
- )
- # After install, accessibility is already enabled by setup_portal
- return
-
- if not a11y_enabled:
- logger.info("Portal accessibility service not enabled, enabling...")
- try:
- await enable_portal_accessibility(device)
- # Verify settings were applied
- if not await check_portal_accessibility(device, debug=debug):
- raise RuntimeError(
- "Could not enable Portal accessibility service. "
- "Please enable it manually in device settings, "
- "or run 'mobilerun setup'."
- )
- # Wait for the service process to start and become responsive
- await _wait_for_portal_service(device)
- logger.info("Accessibility service enabled")
- except RuntimeError:
- raise
- except Exception as e:
- raise RuntimeError(
- f"Failed to enable accessibility service: {e}. "
- "Run 'mobilerun doctor' for diagnostics."
- ) from e
-
-
-async def test():
- device = await adb.device()
- await ping_portal(device, debug=False)
-
-
-if __name__ == "__main__":
- asyncio.run(test())
diff --git a/mobilerun/tools/android/__init__.py b/mobilerun/tools/android/__init__.py
deleted file mode 100644
index 22a60da5..00000000
--- a/mobilerun/tools/android/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Android tools."""
-
-from .portal_client import PortalClient
-
-__all__ = ["PortalClient"]
diff --git a/mobilerun/tools/android/portal_client.py b/mobilerun/tools/android/portal_client.py
deleted file mode 100644
index 5cfcef71..00000000
--- a/mobilerun/tools/android/portal_client.py
+++ /dev/null
@@ -1,744 +0,0 @@
-"""
-Portal Client - Unified communication layer for Mobilerun Portal app.
-
-This module provides automatic TCP/Content Provider fallback for Portal communication.
-"""
-
-import asyncio
-import base64
-import json
-import logging
-import re
-from typing import Any, Dict, List, Optional
-
-import httpx
-from async_adbutils import AdbDevice
-
-from mobilerun.portal import PORTAL_PACKAGE_NAME, portal_content_uri
-
-logger = logging.getLogger("mobilerun")
-
-PORTAL_REMOTE_PORT = 8080 # Port on device where Portal HTTP server runs
-
-
-class PortalClient:
- """
- Unified client for Mobilerun Portal communication.
-
- Automatically handles TCP vs Content Provider fallback with the following strategy:
- - On init, checks for existing port forward and reuses it
- - If no forward exists, creates new one
- - Tests connection and sets tcp_available flag
- - All methods auto-select TCP or content provider based on availability
- - Port forwards persist until device disconnect (no explicit cleanup needed)
-
- Key features:
- - Reuses existing port forwards (no cleanup needed)
- - Automatic fallback to content provider if TCP fails
- - Zero explicit resource management
- - Graceful degradation
-
- Note: TCP mode is significantly faster but requires ADB port forwarding.
- Content provider mode works without port forwarding but has higher latency.
- """
-
- def __init__(self, device: AdbDevice, prefer_tcp: bool = False):
- """
- Initialize Portal client.
-
- Args:
- device: ADB device instance
- prefer_tcp: Whether to prefer TCP communication (will fallback to content provider if unavailable)
-
- Note:
- Call `await client.connect()` after initialization to establish connection.
- """
- self.device = device
- self.prefer_tcp = prefer_tcp
- self.tcp_available = False
- self.tcp_base_url = None
- self.local_tcp_port = None
- self._auth_token: Optional[str] = None
- self._connected = False
-
- async def connect(self) -> None:
- """
- Establish connection...
- """
- if self._connected:
- return
-
- if self.prefer_tcp:
- await self._try_enable_tcp()
-
- self._connected = True
-
- async def _ensure_connected(self) -> None:
- """Check if connected, raise error if not."""
- if not self._connected:
- await self.connect()
-
- async def _fetch_auth_token(self) -> Optional[str]:
- """Fetch the auth token from the Portal via the content provider.
-
- The Portal HTTP server requires a Bearer token for all requests.
- The token is generated by the Portal app and exposed via the content
- provider, which is only accessible over ADB (secure channel).
-
- Returns:
- The auth token string, or None if unavailable.
- """
- try:
- output = await self.device.shell(
- f"content query --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'auth_token')}"
- )
- data = self._parse_content_provider_output(output)
- if data is None:
- logger.debug("Auth token: unparseable content provider output")
- return None
-
- # Extract token — handle various response shapes
- token = None
- if isinstance(data, dict):
- token = (
- data.get("token")
- or data.get("auth_token")
- or data.get("result")
- or data.get("data")
- )
- # Unwrap nested dict: {"status": "success", "result": {"token": "..."}}
- if isinstance(token, dict):
- token = token.get("token") or token.get("auth_token")
- elif isinstance(data, str):
- token = data
-
- if token and isinstance(token, str):
- logger.debug("Auth token retrieved from Portal")
- return token
-
- logger.debug(f"Auth token: unexpected response format: {data}")
- return None
- except Exception as e:
- logger.debug(f"Failed to fetch auth token: {e}")
- return None
-
- @property
- def _tcp_headers(self) -> Dict[str, str]:
- """HTTP headers for all TCP requests, including auth when available."""
- headers: Dict[str, str] = {}
- if self._auth_token:
- headers["Authorization"] = f"Bearer {self._auth_token}"
- return headers
-
- async def _try_enable_tcp(self) -> None:
- """
- Try to enable TCP communication. Fails silently and falls back to content provider.
-
- Strategy:
- 1. Fetch auth token via content provider (secure ADB channel)
- 2. Check if port forward already exists → reuse
- 3. If not, create new forward
- 4. Test connection with authenticated ping
- 5. Set tcp_available flag
- """
- try:
- # Step 1: Fetch auth token before any HTTP calls
- self._auth_token = await self._fetch_auth_token()
- if not self._auth_token:
- logger.debug(
- "No auth token available — Portal may not require auth, "
- "proceeding without it"
- )
-
- # Step 2: Check for existing forward
- local_port = await self._find_existing_forward()
-
- # Step 3: If no forward exists, create one
- if local_port is None:
- logger.debug(
- f"No existing forward found, creating new forward for port {PORTAL_REMOTE_PORT}"
- )
- local_port = await self.device.forward_port(PORTAL_REMOTE_PORT)
- logger.debug(
- f"Created forward: localhost:{local_port} -> device:{PORTAL_REMOTE_PORT}"
- )
- else:
- logger.debug(
- f"Reusing existing forward: localhost:{local_port} -> device:{PORTAL_REMOTE_PORT}"
- )
-
- # Store local port
- self.local_tcp_port = local_port
-
- # Step 4: Test connection with auth
- self.tcp_base_url = f"http://localhost:{local_port}"
- if await self._test_connection():
- self.tcp_available = True
- logger.debug(f"✓ TCP mode enabled: {self.tcp_base_url}")
- else:
- # Step 4b: Try enabling the HTTP server via content provider
- logger.debug("TCP ping failed, trying to enable Portal HTTP server...")
- await self.device.shell(
- f"content insert --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'toggle_socket_server')} --bind enabled:b:true"
- )
- await asyncio.sleep(1)
-
- # Re-fetch token — server restart may generate a new one
- new_token = await self._fetch_auth_token()
- if new_token:
- self._auth_token = new_token
-
- if await self._test_connection():
- self.tcp_available = True
- logger.debug(
- f"✓ TCP mode enabled after starting server: {self.tcp_base_url}"
- )
- else:
- logger.warning("TCP unavailable, using content provider fallback")
- self.tcp_available = False
-
- except Exception as e:
- logger.warning(f"TCP unavailable ({e}), using content provider fallback")
- self.tcp_available = False
-
- async def _find_existing_forward(self) -> Optional[int]:
- """
- Check if a forward already exists for the Portal remote port.
-
- Returns:
- Local port number if forward exists, None otherwise
- """
- try:
- forwards = []
- async for forward in self.device.forward_list():
- forwards.append(forward)
- # forwards is a list of ForwardItem objects with serial, local, remote attributes
- for forward in forwards:
- if (
- forward.serial == self.device.serial
- and forward.remote == f"tcp:{PORTAL_REMOTE_PORT}"
- ):
- # Extract local port from "tcp:12345"
- match = re.search(r"tcp:(\d+)", forward.local)
- if match:
- local_port = int(match.group(1))
- logger.debug(
- f"Found existing forward: localhost:{local_port} -> {PORTAL_REMOTE_PORT}"
- )
- return local_port
- except Exception as e:
- logger.debug(f"Failed to check existing forwards: {e}")
-
- return None
-
- async def _tcp_request(
- self,
- client: httpx.AsyncClient,
- method: str,
- url: str,
- extra_headers: Optional[Dict[str, str]] = None,
- **kwargs,
- ) -> httpx.Response:
- """Make an authenticated TCP request, re-fetching the token once on 401/403.
-
- This is the single choke-point for all TCP HTTP traffic so that token
- rotation is handled uniformly rather than duplicated per call-site.
-
- Args:
- client: Shared httpx.AsyncClient for the request.
- method: HTTP method string ("GET", "POST", …).
- url: Full URL to request.
- extra_headers: Additional headers merged on top of auth headers
- (e.g. ``{"Content-Type": "application/json"}``).
- **kwargs: Passed straight through to ``client.request()``.
-
- Returns:
- The httpx.Response (possibly from the retry attempt).
- """
- headers = {**self._tcp_headers, **(extra_headers or {})}
- response = await client.request(method, url, headers=headers, **kwargs)
-
- if response.status_code in (401, 403):
- logger.debug(
- f"TCP auth rejected ({response.status_code}), re-fetching token..."
- )
- self._auth_token = await self._fetch_auth_token()
- if self._auth_token:
- headers = {**self._tcp_headers, **(extra_headers or {})}
- response = await client.request(method, url, headers=headers, **kwargs)
-
- return response
-
- async def _test_connection(self) -> bool:
- """Test if TCP connection to Portal is working (with auth)."""
- try:
- async with httpx.AsyncClient() as client:
- response = await self._tcp_request(
- client, "GET", f"{self.tcp_base_url}/ping", timeout=5
- )
- return response.status_code == 200
- except Exception as e:
- logger.debug(f"TCP connection test failed: {e}")
- return False
-
- def _parse_content_provider_output(
- self, raw_output: str
- ) -> Optional[Dict[str, Any]]:
- """
- Parse the raw ADB content provider output and extract JSON data.
-
- Args:
- raw_output: Raw output from ADB content query command
-
- Returns:
- Parsed JSON data or None if parsing failed
- """
- lines = raw_output.strip().split("\n")
-
- # Try line-by-line parsing
- for line in lines:
- line = line.strip()
-
- # Look for "result=" pattern (common content provider format)
- if "result=" in line:
- result_start = line.find("result=") + 7
- json_str = line[result_start:]
- try:
- json_data = json.loads(json_str)
- # Handle nested "result" or "data" field with JSON string (backward compatible)
- if isinstance(json_data, dict):
- # Check for 'result' first (new portal format), then 'data' (legacy)
- inner_key = (
- "result"
- if "result" in json_data
- else "data" if "data" in json_data else None
- )
- if inner_key:
- inner_value = json_data[inner_key]
- if isinstance(inner_value, str):
- try:
- return json.loads(inner_value)
- except json.JSONDecodeError:
- return inner_value
- return inner_value
- return json_data
- except json.JSONDecodeError:
- continue
-
- # Fallback: try lines starting with JSON
- elif line.startswith("{") or line.startswith("["):
- try:
- return json.loads(line)
- except json.JSONDecodeError:
- continue
-
- # Last resort: try parsing entire output
- try:
- return json.loads(raw_output.strip())
- except json.JSONDecodeError:
- return None
-
- async def get_state(self) -> Dict[str, Any]:
- """
- Get device state (accessibility tree + phone state).
- Auto-selects TCP or content provider.
-
- Returns:
- Dictionary containing 'a11y_tree' and 'phone_state' keys
- """
- await self._ensure_connected()
- if self.tcp_available:
- return await self._get_state_tcp()
- return await self._get_state_content_provider()
-
- async def _get_state_tcp(self) -> Dict[str, Any]:
- """Get state via TCP."""
- try:
- async with httpx.AsyncClient() as client:
- response = await self._tcp_request(
- client, "GET", f"{self.tcp_base_url}/state_full", timeout=10
- )
- if response.status_code == 200:
- data = response.json()
-
- # Handle nested "result" or "data" field (backward compatible)
- if isinstance(data, dict):
- # Check for 'result' first (new portal format), then 'data' (legacy)
- inner_key = (
- "result"
- if "result" in data
- else "data" if "data" in data else None
- )
- if inner_key:
- inner_value = data[inner_key]
- if isinstance(inner_value, str):
- try:
- return json.loads(inner_value)
- except json.JSONDecodeError:
- pass
- elif isinstance(inner_value, dict):
- return inner_value
- return data
- else:
- logger.debug(
- f"TCP get_state failed ({response.status_code}), using fallback"
- )
- return await self._get_state_content_provider()
- except Exception as e:
- logger.debug(f"TCP get_state error: {e}, using fallback")
- return await self._get_state_content_provider()
-
- async def _get_state_content_provider(self) -> Dict[str, Any]:
- """Get state via content provider (fallback)."""
- try:
- output = await self.device.shell(
- f"content query --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'state_full')}"
- )
- state_data = self._parse_content_provider_output(output)
-
- if state_data is None:
- return {
- "error": "Parse Error",
- "message": "Failed to parse state data from ContentProvider",
- }
-
- # Handle nested "result" or "data" field if present (backward compatible)
- if isinstance(state_data, dict):
- # Check for 'result' first (new portal format), then 'data' (legacy)
- inner_key = (
- "result"
- if "result" in state_data
- else "data" if "data" in state_data else None
- )
- if inner_key:
- inner_value = state_data[inner_key]
- if isinstance(inner_value, str):
- try:
- return json.loads(inner_value)
- except json.JSONDecodeError:
- return {
- "error": "Parse Error",
- "message": "Failed to parse nested JSON data",
- }
- elif isinstance(inner_value, dict):
- return inner_value
-
- return state_data
-
- except Exception as e:
- return {"error": "ContentProvider Error", "message": str(e)}
-
- async def input_text(self, text: str, clear: bool = False) -> bool:
- """
- Input text via keyboard.
- Auto-selects TCP or content provider.
-
- Args:
- text: Text to input
- clear: Whether to clear existing text first
-
- Returns:
- True if successful, False otherwise
- """
- await self._ensure_connected()
- if self.tcp_available:
- return await self._input_text_tcp(text, clear)
- return await self._input_text_content_provider(text, clear)
-
- async def _input_text_tcp(self, text: str, clear: bool) -> bool:
- """Input text via TCP."""
- try:
- encoded = base64.b64encode(text.encode()).decode()
- payload = {"base64_text": encoded, "clear": clear}
- async with httpx.AsyncClient() as client:
- response = await self._tcp_request(
- client,
- "POST",
- f"{self.tcp_base_url}/keyboard/input",
- extra_headers={"Content-Type": "application/json"},
- json=payload,
- timeout=10,
- )
- if response.status_code == 200:
- logger.debug("TCP input_text successful")
- return True
- else:
- logger.debug(
- f"TCP input_text failed ({response.status_code}), using fallback"
- )
- return await self._input_text_content_provider(text, clear)
- except Exception as e:
- logger.debug(f"TCP input_text error: {e}, using fallback")
- return await self._input_text_content_provider(text, clear)
-
- async def _input_text_content_provider(self, text: str, clear: bool) -> bool:
- """Input text via content provider (fallback)."""
- try:
- encoded = base64.b64encode(text.encode()).decode()
- clear_str = "true" if clear else "false"
- cmd = (
- f'content insert --uri "{portal_content_uri(PORTAL_PACKAGE_NAME, "keyboard/input")}" '
- f'--bind base64_text:s:"{encoded}" '
- f"--bind clear:b:{clear_str}"
- )
- await self.device.shell(cmd)
- logger.debug("Content provider input_text successful")
- return True
- except Exception as e:
- logger.error(f"Content provider input_text error: {e}")
- return False
-
- async def take_screenshot(self, hide_overlay: bool = True) -> bytes:
- """
- Take screenshot of device.
- Auto-selects TCP or ADB screencap.
-
- Args:
- hide_overlay: Whether to hide Portal overlay during screenshot
-
- Returns:
- Screenshot image bytes (PNG format)
- """
- await self._ensure_connected()
- if self.tcp_available:
- return await self._take_screenshot_tcp(hide_overlay)
- return await self._take_screenshot_adb()
-
- async def _take_screenshot_tcp(self, hide_overlay: bool) -> bytes:
- """Take screenshot via TCP."""
- try:
- url = f"{self.tcp_base_url}/screenshot"
- if not hide_overlay:
- url += "?hideOverlay=false"
-
- async with httpx.AsyncClient() as client:
- response = await self._tcp_request(client, "GET", url, timeout=10.0)
- if response.status_code == 200:
- data = response.json()
- # Check for 'result' first (new portal format), then 'data' (legacy)
- if data.get("status") == "success":
- inner_key = (
- "result"
- if "result" in data
- else "data" if "data" in data else None
- )
- if inner_key:
- logger.debug("Screenshot taken via TCP")
- return base64.b64decode(data[inner_key])
- logger.debug(
- "TCP screenshot failed (invalid response), using fallback"
- )
- return await self._take_screenshot_adb()
- else:
- logger.debug(
- f"TCP screenshot failed ({response.status_code}), using fallback"
- )
- return await self._take_screenshot_adb()
- except Exception as e:
- logger.debug(f"TCP screenshot error: {e}, using fallback")
- return await self._take_screenshot_adb()
-
- async def _take_screenshot_adb(self) -> bytes:
- """Take screenshot via ADB screencap (fallback)."""
- data = await self.device.screenshot_bytes()
- logger.debug("Screenshot taken via ADB")
- return data
-
- async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
- """
- Get installed apps with package name and label.
-
- Note: Currently only supports content provider (no TCP endpoint exists yet)
-
- Args:
- include_system: Whether to include system apps
-
- Returns:
- List of dicts with 'package' and 'label' keys
- """
- await self._ensure_connected()
- try:
- logger.debug("Getting apps via content provider")
-
- # Query content provider
- output = await self.device.shell(
- f"content query --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'packages')}"
- )
- packages_data = self._parse_content_provider_output(output)
-
- if not packages_data:
- logger.warning("No packages data found in content provider response")
- return []
-
- # Handle both formats:
- # - New format: array directly (via RawArray -> result: [...])
- # - Legacy format: wrapped in {"packages": [...]}
- packages_list = None
- if isinstance(packages_data, list):
- # New format: packages_data is already the list
- packages_list = packages_data
- elif isinstance(packages_data, dict):
- if "packages" in packages_data:
- # Legacy format: wrapped in {"packages": [...]}
- packages_list = packages_data["packages"]
- else:
- # May be wrapped in result/data
- inner_key = (
- "result"
- if "result" in packages_data
- else "data" if "data" in packages_data else None
- )
- if inner_key:
- inner_value = packages_data[inner_key]
- if isinstance(inner_value, list):
- packages_list = inner_value
- elif (
- isinstance(inner_value, dict) and "packages" in inner_value
- ):
- packages_list = inner_value["packages"]
-
- if not packages_list:
- logger.warning("Could not extract packages list from response")
- return []
-
- # Filter and format apps
- apps = []
- for package_info in packages_list:
- if not include_system and package_info.get("isSystemApp", False):
- continue
-
- apps.append(
- {
- "package": package_info.get("packageName", ""),
- "label": package_info.get("label", ""),
- }
- )
-
- logger.debug(f"Found {len(apps)} apps")
- return apps
-
- except Exception as e:
- logger.error(f"Error getting apps: {e}")
- raise ValueError(f"Error getting apps: {e}") from e
-
- async def get_version(self) -> str:
- """Get Portal app version."""
- await self._ensure_connected()
- if self.tcp_available:
- try:
- async with httpx.AsyncClient() as client:
- response = await self._tcp_request(
- client, "GET", f"{self.tcp_base_url}/version", timeout=5.0
- )
- if response.status_code == 200:
- data = response.json()
- # Check for 'result' first (new portal format), then 'data' (legacy)
- inner_key = (
- "result"
- if "result" in data
- else "data" if "data" in data else None
- )
- if inner_key:
- return data[inner_key]
- return data.get("status", "unknown")
- except Exception:
- pass
-
- # Fallback to content provider
- try:
- output = await self.device.shell(
- f"content query --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'version')}"
- )
- result = self._parse_content_provider_output(output)
- if result:
- # Check for 'result' first (new portal format), then 'data' (legacy)
- inner_key = (
- "result"
- if "result" in result
- else "data" if "data" in result else None
- )
- if inner_key:
- return result[inner_key]
- except Exception:
- pass
-
- return "unknown"
-
- async def ping(self) -> Dict[str, Any]:
- """
- Test Portal connection and verify state availability.
-
- Returns:
- Dictionary with status and connection details
- """
- await self._ensure_connected()
- if self.tcp_available:
- try:
- async with httpx.AsyncClient() as client:
- response = await self._tcp_request(
- client, "GET", f"{self.tcp_base_url}/ping", timeout=5.0
- )
- if response.status_code == 200:
- try:
- tcp_response = response.json() if response.content else {}
- result = {
- "status": "success",
- "method": "tcp",
- "url": self.tcp_base_url,
- "response": tcp_response,
- }
- except json.JSONDecodeError:
- result = {
- "status": "success",
- "method": "tcp",
- "url": self.tcp_base_url,
- "response": response.text,
- }
- else:
- return {
- "status": "error",
- "method": "tcp",
- "message": f"HTTP {response.status_code}: {response.text}",
- }
- except Exception as e:
- return {"status": "error", "method": "tcp", "message": str(e)}
- else:
- # Test content provider
- try:
- output = await self.device.shell(
- f"content query --uri {portal_content_uri(PORTAL_PACKAGE_NAME, 'state_full')}"
- )
- if "Row: 0 result=" in output:
- result = {"status": "success", "method": "content_provider"}
- else:
- return {
- "status": "error",
- "method": "content_provider",
- "message": "Invalid response",
- }
- except Exception as e:
- return {
- "status": "error",
- "method": "content_provider",
- "message": str(e),
- }
-
- # Verify state has the required keys
- try:
- state = await self.get_state()
- required = ("a11y_tree", "phone_state", "device_context")
- missing = [k for k in required if k not in state]
- if missing:
- return {
- "status": "error",
- "method": result.get("method", "unknown"),
- "message": f"incompatible portal — missing {', '.join(missing)}",
- }
- except Exception as e:
- return {
- "status": "error",
- "method": result.get("method", "unknown"),
- "message": f"state check failed: {e}",
- }
-
- return result
diff --git a/mobilerun/tools/driver/__init__.py b/mobilerun/tools/driver/__init__.py
index 917e6c97..341d60b5 100644
--- a/mobilerun/tools/driver/__init__.py
+++ b/mobilerun/tools/driver/__init__.py
@@ -1,7 +1,8 @@
"""Device driver abstractions for Mobilerun."""
-from mobilerun.tools.driver.android import AndroidDriver
-from mobilerun.tools.driver.base import DeviceDisconnectedError, DeviceDriver
+from mobilerun_core_cli.driver.android import AndroidDriver
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError, DeviceDriver
+
from mobilerun.tools.driver.cloud import CloudDriver
from mobilerun.tools.driver.ios import IOSDriver
from mobilerun.tools.driver.recording import RecordingDriver
diff --git a/mobilerun/tools/driver/android.py b/mobilerun/tools/driver/android.py
deleted file mode 100644
index 474accb6..00000000
--- a/mobilerun/tools/driver/android.py
+++ /dev/null
@@ -1,194 +0,0 @@
-"""AndroidDriver — ADB-based device driver.
-
-Wraps ``async_adbutils.AdbDevice`` + ``PortalClient`` to provide clean device I/O
-without event emission, formatting, or element lookup.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import os
-from typing import Any, Dict, List, Optional
-
-from async_adbutils import adb
-
-from mobilerun.tools.android.portal_client import PortalClient
-from mobilerun.tools.driver.base import DeviceDriver
-
-logger = logging.getLogger("mobilerun")
-
-PORTAL_DEFAULT_TCP_PORT = 8080
-
-
-class AndroidDriver(DeviceDriver):
- """Raw Android device I/O via ADB + Portal."""
-
- platform = "Android"
-
- supported = {
- "tap",
- "swipe",
- "input_text",
- "press_button",
- "start_app",
- "screenshot",
- "get_ui_tree",
- "get_date",
- "get_apps",
- "list_packages",
- "install_app",
- "drag",
- }
-
- supported_buttons = {"back", "home", "enter"}
-
- _BUTTON_KEYCODES = {
- "back": 4,
- "home": 3,
- "enter": 66,
- }
-
- def __init__(
- self,
- serial: str | None = None,
- use_tcp: bool = False,
- remote_tcp_port: int = PORTAL_DEFAULT_TCP_PORT,
- ) -> None:
- self._serial = serial
- self._use_tcp = use_tcp
- self._remote_tcp_port = remote_tcp_port
- self.device = None
- self.portal: PortalClient | None = None
- self._connected = False
-
- # -- lifecycle -----------------------------------------------------------
-
- async def connect(self) -> None:
- if self._connected:
- return
-
- self.device = await adb.device(serial=self._serial)
- state = await self.device.get_state()
- if state != "device":
- raise ConnectionError(f"Device is not online. State: {state}")
-
- self.portal = PortalClient(self.device, prefer_tcp=self._use_tcp)
- await self.portal.connect()
-
- from mobilerun.portal import setup_keyboard # circular import guard
-
- await setup_keyboard(self.device)
- self._connected = True
-
- async def ensure_connected(self) -> None:
- if not self._connected:
- await self.connect()
-
- # -- input actions -------------------------------------------------------
-
- async def tap(self, x: int, y: int) -> None:
- await self.ensure_connected()
- await self.device.click(x, y)
-
- async def swipe(
- self,
- x1: int,
- y1: int,
- x2: int,
- y2: int,
- duration_ms: float = 1000,
- ) -> None:
- await self.ensure_connected()
- await self.device.swipe(x1, y1, x2, y2, float(duration_ms / 1000))
- await asyncio.sleep(duration_ms / 1000)
-
- async def input_text(self, text: str, clear: bool = False) -> bool:
- await self.ensure_connected()
- return await self.portal.input_text(text, clear)
-
- async def press_button(self, button: str) -> None:
- await self.ensure_connected()
- button_lower = button.lower()
- if button_lower not in self.supported_buttons:
- raise ValueError(
- f"Button '{button}' not supported. "
- f"Supported: {', '.join(sorted(self.supported_buttons))}"
- )
- await self.device.keyevent(self._BUTTON_KEYCODES[button_lower])
-
- async def drag(
- self,
- x1: int,
- y1: int,
- x2: int,
- y2: int,
- duration: float = 3.0,
- ) -> None:
- await self.ensure_connected()
- raise NotImplementedError("Drag is not implemented yet")
-
- # -- app management ------------------------------------------------------
-
- async def start_app(self, package: str, activity: Optional[str] = None) -> str:
- await self.ensure_connected()
- try:
- logger.debug(f"Starting app {package} with activity {activity}")
- if not activity:
- dumpsys_output = await self.device.shell(
- f"cmd package resolve-activity --brief {package}"
- )
- activity = dumpsys_output.splitlines()[1].split("/")[1]
-
- logger.debug(f"Activity: {activity}")
- await self.device.app_start(package, activity)
- logger.debug(f"App started: {package} with activity {activity}")
- return f"App started: {package} with activity {activity}"
- except Exception as e:
- return f"Failed to start app {package}: {e}"
-
- async def install_app(self, path: str, **kwargs) -> str:
- await self.ensure_connected()
- if not os.path.exists(path):
- return f"Failed to install app: APK file not found at {path}"
-
- reinstall = kwargs.get("reinstall", False)
- grant_permissions = kwargs.get("grant_permissions", True)
-
- logger.debug(
- f"Installing app: {path} with reinstall: {reinstall} "
- f"and grant_permissions: {grant_permissions}"
- )
- result = await self.device.install(
- path,
- nolaunch=True,
- uninstall=reinstall,
- flags=["-g"] if grant_permissions else [],
- silent=True,
- )
- logger.debug(f"Installed app: {path} with result: {result}")
- return result
-
- async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
- await self.ensure_connected()
- return await self.portal.get_apps(include_system)
-
- async def list_packages(self, include_system: bool = False) -> List[str]:
- await self.ensure_connected()
- filter_list = [] if include_system else ["-3"]
- return await self.device.list_packages(filter_list)
-
- # -- state / observation -------------------------------------------------
-
- async def screenshot(self, hide_overlay: bool = True) -> bytes:
- await self.ensure_connected()
- return await self.portal.take_screenshot(hide_overlay)
-
- async def get_ui_tree(self) -> Dict[str, Any]:
- await self.ensure_connected()
- return await self.portal.get_state()
-
- async def get_date(self) -> str:
- await self.ensure_connected()
- result = await self.device.shell("date")
- return result.strip()
diff --git a/mobilerun/tools/driver/base.py b/mobilerun/tools/driver/base.py
deleted file mode 100644
index b84d154a..00000000
--- a/mobilerun/tools/driver/base.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""DeviceDriver — raw device I/O interface.
-
-Subclasses implement the actual communication (ADB, iOS HTTP, cloud SDK, etc.).
-Unsupported methods are detected via the ``supported`` set, not introspection.
-"""
-
-from __future__ import annotations
-
-from typing import Any, Dict, List, Optional
-
-
-class DeviceDisconnectedError(Exception):
- """Raised when the device is no longer reachable."""
-
- pass
-
-
-class DeviceDriver:
- """Base class for all device drivers.
-
- Every method raises ``NotImplementedError`` by default.
- Concrete drivers override the methods they support and declare them
- in the ``supported`` class-level set.
-
- ``platform`` identifies the device type (e.g. "Android", "iOS").
- """
-
- platform: str = "Android"
- supported: set[str] = set()
- supported_buttons: set[str] = set()
-
- # -- lifecycle -----------------------------------------------------------
-
- async def connect(self) -> None:
- """Establish connection to the device."""
- raise NotImplementedError
-
- async def ensure_connected(self) -> None:
- """Connect if not already connected."""
- raise NotImplementedError
-
- # -- input actions -------------------------------------------------------
-
- async def tap(self, x: int, y: int) -> None:
- """Tap at absolute pixel coordinates."""
- raise NotImplementedError
-
- async def swipe(
- self,
- x1: int,
- y1: int,
- x2: int,
- y2: int,
- duration_ms: float = 1000,
- ) -> None:
- """Swipe from (x1, y1) to (x2, y2)."""
- raise NotImplementedError
-
- async def input_text(
- self,
- text: str,
- clear: bool = False,
- stealth: bool = False,
- wpm: int = 0,
- ) -> bool:
- """Type *text* into the currently focused field.
-
- Returns ``True`` on success, ``False`` on failure.
- """
- raise NotImplementedError
-
- async def press_button(self, button: str) -> None:
- """Press a named button (e.g. back, home, enter).
-
- Raises ``ValueError`` if *button* is not in ``supported_buttons``.
- """
- raise NotImplementedError
-
- async def drag(
- self,
- x1: int,
- y1: int,
- x2: int,
- y2: int,
- duration: float = 3.0,
- ) -> None:
- """Drag from (x1, y1) to (x2, y2)."""
- raise NotImplementedError
-
- # -- app management ------------------------------------------------------
-
- async def start_app(self, package: str, activity: Optional[str] = None) -> str:
- """Launch an application.
-
- Returns a human-readable result string.
- """
- raise NotImplementedError
-
- async def install_app(self, path: str, **kwargs) -> str:
- """Install an APK/IPA at *path*."""
- raise NotImplementedError
-
- async def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
- """Return installed apps as ``[{"package": …, "label": …}, …]``."""
- raise NotImplementedError
-
- async def list_packages(self, include_system: bool = False) -> List[str]:
- """Return installed package names."""
- raise NotImplementedError
-
- # -- state / observation -------------------------------------------------
-
- async def screenshot(self, hide_overlay: bool = True) -> bytes:
- """Capture the current screen.
-
- Returns raw PNG bytes.
- """
- raise NotImplementedError
-
- async def input_coordinate_size(
- self,
- screenshot_width: int,
- screenshot_height: int,
- ) -> tuple[int, int]:
- """Return the coordinate size expected by input methods.
-
- Most backends accept screenshot pixel coordinates, so the default input
- coordinate size matches the captured screenshot. Backends whose input
- layer uses a different coordinate system, such as XCTest points on iOS,
- override this method.
- """
- return screenshot_width, screenshot_height
-
- async def get_ui_tree(self) -> Dict[str, Any]:
- """Return the raw UI / accessibility tree from the device."""
- raise NotImplementedError
-
- async def get_date(self) -> str:
- """Return the device's current date/time as a string."""
- raise NotImplementedError
diff --git a/mobilerun/tools/driver/cloud.py b/mobilerun/tools/driver/cloud.py
index 43d82f3f..bfbc017c 100644
--- a/mobilerun/tools/driver/cloud.py
+++ b/mobilerun/tools/driver/cloud.py
@@ -9,11 +9,10 @@
import logging
from typing import Any, Awaitable, Dict, List, Optional, TypeVar
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError, DeviceDriver
from mobilerun_sdk import AsyncMobilerun
from mobilerun_sdk._exceptions import APIConnectionError, APITimeoutError, ConflictError
-from mobilerun.tools.driver.base import DeviceDisconnectedError, DeviceDriver
-
logger = logging.getLogger("mobilerun")
T = TypeVar("T")
diff --git a/mobilerun/tools/driver/ios.py b/mobilerun/tools/driver/ios.py
index 25d49186..82800277 100644
--- a/mobilerun/tools/driver/ios.py
+++ b/mobilerun/tools/driver/ios.py
@@ -17,8 +17,7 @@
from urllib.parse import urlparse
import httpx
-
-from mobilerun.tools.driver.base import DeviceDriver
+from mobilerun_core_cli.driver.base import DeviceDriver
logger = logging.getLogger("mobilerun")
diff --git a/mobilerun/tools/driver/recording.py b/mobilerun/tools/driver/recording.py
index 8c2d6c6b..e9c47b7f 100644
--- a/mobilerun/tools/driver/recording.py
+++ b/mobilerun/tools/driver/recording.py
@@ -9,7 +9,7 @@
from typing import Any, Dict, List, Optional
-from mobilerun.tools.driver.base import DeviceDriver
+from mobilerun_core_cli.driver.base import DeviceDriver
class RecordingDriver:
diff --git a/mobilerun/tools/driver/stealth.py b/mobilerun/tools/driver/stealth.py
index 93c7afe9..06a812cc 100644
--- a/mobilerun/tools/driver/stealth.py
+++ b/mobilerun/tools/driver/stealth.py
@@ -14,7 +14,7 @@
import random
from typing import Any, List, Tuple
-from mobilerun.tools.driver.base import DeviceDriver
+from mobilerun_core_cli.driver.base import DeviceDriver
# ---------------------------------------------------------------------------
# Path generation helpers
diff --git a/mobilerun/tools/driver/visual_remote.py b/mobilerun/tools/driver/visual_remote.py
index 7a387618..e6ff05df 100644
--- a/mobilerun/tools/driver/visual_remote.py
+++ b/mobilerun/tools/driver/visual_remote.py
@@ -12,8 +12,8 @@
from urllib.parse import quote, urlparse
import httpx
+from mobilerun_core_cli.driver.base import DeviceDriver
-from mobilerun.tools.driver.base import DeviceDriver
from mobilerun.tools.helpers.images import image_dimensions
logger = logging.getLogger("mobilerun")
diff --git a/mobilerun/tools/ui/ios_provider.py b/mobilerun/tools/ui/ios_provider.py
index dbc5236f..cc794603 100644
--- a/mobilerun/tools/ui/ios_provider.py
+++ b/mobilerun/tools/ui/ios_provider.py
@@ -14,7 +14,8 @@
import re
from typing import Any, Dict, List
-from mobilerun.tools.driver.base import DeviceDisconnectedError, DeviceDriver
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError, DeviceDriver
+
from mobilerun.tools.ui.provider import StateProvider
from mobilerun.tools.ui.state import UIState
diff --git a/mobilerun/tools/ui/provider.py b/mobilerun/tools/ui/provider.py
index 1d4956ee..05e27492 100644
--- a/mobilerun/tools/ui/provider.py
+++ b/mobilerun/tools/ui/provider.py
@@ -11,12 +11,14 @@
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional
-from mobilerun.tools.driver.base import DeviceDisconnectedError
+from mobilerun_core_cli.driver.base import DeviceDisconnectedError
+
from mobilerun.tools.ui.state import UIState
from mobilerun.tools.ui.stealth_state import StealthUIState
if TYPE_CHECKING:
- from mobilerun.tools.driver.base import DeviceDriver
+ from mobilerun_core_cli.driver.base import DeviceDriver
+
from mobilerun.tools.filters import TreeFilter
from mobilerun.tools.formatters import TreeFormatter
@@ -174,7 +176,7 @@ def __init__(
async def _recover_portal(self) -> None:
"""Restart Portal's accessibility service and TCP socket server."""
- from mobilerun.tools.driver.android import AndroidDriver
+ from mobilerun_core_cli.driver.android import AndroidDriver
if not isinstance(self.driver, AndroidDriver):
return
@@ -182,7 +184,7 @@ async def _recover_portal(self) -> None:
if device is None:
return
- from mobilerun.portal import (
+ from mobilerun_core_cli.portal import (
PORTAL_PACKAGE_NAME,
portal_a11y_service,
portal_content_uri,
diff --git a/mobilerun/tools/ui/screenshot_provider.py b/mobilerun/tools/ui/screenshot_provider.py
index 858c3391..ec5a0857 100644
--- a/mobilerun/tools/ui/screenshot_provider.py
+++ b/mobilerun/tools/ui/screenshot_provider.py
@@ -9,7 +9,7 @@
from mobilerun.tools.ui.state import UIState
if TYPE_CHECKING:
- from mobilerun.tools.driver.base import DeviceDriver
+ from mobilerun_core_cli.driver.base import DeviceDriver
class ScreenshotOnlyStateProvider(StateProvider):
diff --git a/pyproject.toml b/pyproject.toml
index 188d90f9..ea8db63a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,7 @@ dependencies = [
"llama-index-llms-ollama>=0.7.2",
"llama-index-llms-openrouter>=0.4.2",
"mobilerun-sdk>=3.2.0",
+ "mobilerun-core-cli>=0.1.0",
]
requires-python = ">=3.11,<3.14"
readme = "README.md"
diff --git a/tests/test_portal_asset_selection.py b/tests/test_portal_asset_selection.py
index f4b62344..0b7dbdba 100644
--- a/tests/test_portal_asset_selection.py
+++ b/tests/test_portal_asset_selection.py
@@ -1,64 +1,7 @@
-import importlib.util
-import sys
-import types
import unittest
-from pathlib import Path
+from mobilerun_core_cli import portal
-def _load_portal_module():
- console_module = types.ModuleType("rich.console")
-
- class Console:
- def print(self, *args, **kwargs):
- pass
-
- console_module.Console = Console
-
- mobilerun_module = types.ModuleType("mobilerun")
- mobilerun_module.__version__ = "0.6.0"
-
- async_adbutils_module = types.ModuleType("async_adbutils")
- async_adbutils_module.AdbDevice = object
- async_adbutils_module.adb = object()
-
- requests_module = types.ModuleType("requests")
- requests_module.RequestException = type("RequestException", (Exception,), {})
- requests_module.ConnectionError = type(
- "ConnectionError", (requests_module.RequestException,), {}
- )
-
- def get(*args, **kwargs):
- return None
-
- requests_module.get = get
-
- stubs = {
- "async_adbutils": async_adbutils_module,
- "mobilerun": mobilerun_module,
- "requests": requests_module,
- "rich": types.ModuleType("rich"),
- "rich.console": console_module,
- }
- missing = object()
- previous = {name: sys.modules.get(name, missing) for name in stubs}
-
- try:
- sys.modules.update(stubs)
- path = Path(__file__).resolve().parents[1] / "mobilerun" / "portal.py"
- spec = importlib.util.spec_from_file_location("portal_asset_module", path)
- module = importlib.util.module_from_spec(spec)
- assert spec.loader is not None
- spec.loader.exec_module(module)
- return module
- finally:
- for name, old_module in previous.items():
- if old_module is missing:
- sys.modules.pop(name, None)
- else:
- sys.modules[name] = old_module
-
-
-portal = _load_portal_module()
_get_release_assets_by_tag = portal._get_release_assets_by_tag
_normalize_download_base = portal._normalize_download_base
_parse_portal_asset_version = portal._parse_portal_asset_version
diff --git a/uv.lock b/uv.lock
index b848e73d..35c011a7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1839,6 +1839,7 @@ dependencies = [
{ name = "llama-index-llms-openrouter" },
{ name = "llama-index-workflows" },
{ name = "mcp" },
+ { name = "mobilerun-core-cli" },
{ name = "mobilerun-sdk" },
{ name = "posthog" },
{ name = "pydantic" },
@@ -1901,6 +1902,7 @@ requires-dist = [
{ name = "llama-index-workflows", specifier = ">=2.16.0,<3.0.0" },
{ name = "mcp", specifier = ">=1.26.0" },
{ name = "mobilerun", extras = ["langfuse"], marker = "extra == 'dev'" },
+ { name = "mobilerun-core-cli", specifier = ">=0.1.0" },
{ name = "mobilerun-sdk", specifier = ">=3.2.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" },
{ name = "openinference-instrumentation-llama-index", marker = "extra == 'langfuse'", specifier = ">=3.0.0" },
@@ -1924,6 +1926,21 @@ dev = [
{ name = "safety", specifier = ">=3.2.11" },
]
+[[package]]
+name = "mobilerun-core-cli"
+version = "0.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "async-adbutils" },
+ { name = "httpx" },
+ { name = "requests" },
+ { name = "rich" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/c1/fcd8dae643a3e3aebe97b47a5e3f90615d84ec5a7ba349c89dcd3cdc8e53/mobilerun_core_cli-0.1.0.tar.gz", hash = "sha256:fb137e2b084d51c8d19ae8d64d6f4ed7efe41072f10cc3b724d1025fc0b337a2", size = 19707, upload-time = "2026-06-02T13:34:07.477Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/b0/81683eec1a9d27a06cb3d0674844f7b898ee2220444305b80b741f4a7ec8/mobilerun_core_cli-0.1.0-py3-none-any.whl", hash = "sha256:44b9735e486976db48bad9143875cbdcc2744cdba758d7539224cf15f295eaed", size = 22391, upload-time = "2026-06-02T13:34:06.195Z" },
+]
+
[[package]]
name = "mobilerun-sdk"
version = "3.2.0"