Replace dict returns with typed Pydantic response models across all 65 tools

Every tool now returns a structured BaseModel instead of dict[str, Any],
giving callers attribute access, IDE autocomplete, and schema validation.
Adds ~30 model classes to models.py and updates all test assertions.
This commit is contained in:
Ryan Malloy 2026-02-11 03:57:25 -07:00
parent fb297f7937
commit 3614ba8f8f
19 changed files with 1558 additions and 1054 deletions

View File

@ -4,12 +4,17 @@ Provides tools for app management and launching.
""" """
import re import re
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool
from ..config import is_developer_mode from ..config import is_developer_mode
from ..models import (
AppActionResult,
AppCurrentResult,
IntentResult,
PackageListResult,
)
from .base import ADBBaseMixin from .base import ADBBaseMixin
# Common Android intent flags (hex values for am start -f) # Common Android intent flags (hex values for am start -f)
@ -40,7 +45,7 @@ class AppsMixin(ADBBaseMixin):
self, self,
package_name: str, package_name: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> AppActionResult:
"""Launch an app by package name. """Launch an app by package name.
Starts the main activity of the specified application. Starts the main activity of the specified application.
@ -70,19 +75,19 @@ class AppsMixin(ADBBaseMixin):
], ],
device_id, device_id,
) )
return { return AppActionResult(
"success": result.success, success=result.success,
"action": "launch", action="launch",
"package": package_name, package=package_name,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def app_open_url( async def app_open_url(
self, self,
url: str, url: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> AppActionResult:
"""Open a URL in the default browser. """Open a URL in the default browser.
Launches the default browser and navigates to the URL. Launches the default browser and navigates to the URL.
@ -106,19 +111,19 @@ class AppsMixin(ADBBaseMixin):
], ],
device_id, device_id,
) )
return { return AppActionResult(
"success": result.success, success=result.success,
"action": "open_url", action="open_url",
"url": url, url=url,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def app_close( async def app_close(
self, self,
package_name: str, package_name: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> AppActionResult:
"""Force stop an app. """Force stop an app.
Stops the application and all its background services. Stops the application and all its background services.
@ -133,18 +138,18 @@ class AppsMixin(ADBBaseMixin):
result = await self.run_shell_args( result = await self.run_shell_args(
["am", "force-stop", package_name], device_id ["am", "force-stop", package_name], device_id
) )
return { return AppActionResult(
"success": result.success, success=result.success,
"action": "close", action="close",
"package": package_name, package=package_name,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def app_current( async def app_current(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> AppCurrentResult:
"""Get the currently focused app. """Get the currently focused app.
Returns the package name of the app currently in foreground. Returns the package name of the app currently in foreground.
@ -172,17 +177,17 @@ class AppsMixin(ADBBaseMixin):
package = match.group(1) package = match.group(1)
activity = match.group(2) activity = match.group(2)
break break
return { return AppCurrentResult(
"success": True, success=True,
"package": package, package=package,
"activity": activity, activity=activity,
"raw": result.stdout[:500] if not package else None, raw=result.stdout[:500] if not package else None,
} )
return { return AppCurrentResult(
"success": False, success=False,
"error": result.stderr, error=result.stderr,
} )
# === Developer Mode Tools === # === Developer Mode Tools ===
@ -196,7 +201,7 @@ class AppsMixin(ADBBaseMixin):
system_only: bool = False, system_only: bool = False,
third_party_only: bool = False, third_party_only: bool = False,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> PackageListResult:
"""List installed packages. """List installed packages.
[DEVELOPER MODE] Retrieves all installed application packages. [DEVELOPER MODE] Retrieves all installed application packages.
@ -211,10 +216,10 @@ class AppsMixin(ADBBaseMixin):
List of package names List of package names
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return PackageListResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
cmd = ["pm", "list", "packages"] cmd = ["pm", "list", "packages"]
if system_only: if system_only:
@ -232,16 +237,16 @@ class AppsMixin(ADBBaseMixin):
if filter_text is None or filter_text.lower() in pkg.lower(): if filter_text is None or filter_text.lower() in pkg.lower():
packages.append(pkg) packages.append(pkg)
return { return PackageListResult(
"success": True, success=True,
"packages": sorted(packages), packages=sorted(packages),
"count": len(packages), count=len(packages),
} )
return { return PackageListResult(
"success": False, success=False,
"error": result.stderr, error=result.stderr,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -251,7 +256,7 @@ class AppsMixin(ADBBaseMixin):
self, self,
apk_path: str, apk_path: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> AppActionResult:
"""Install an APK file. """Install an APK file.
[DEVELOPER MODE] Installs an APK from the host machine to the device. [DEVELOPER MODE] Installs an APK from the host machine to the device.
@ -264,19 +269,20 @@ class AppsMixin(ADBBaseMixin):
Installation result Installation result
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return AppActionResult(
"success": False, success=False,
"error": "Developer mode required", action="install",
} error="Developer mode required",
)
result = await self.run_adb(["install", "-r", apk_path], device_id) result = await self.run_adb(["install", "-r", apk_path], device_id)
return { return AppActionResult(
"success": result.success, success=result.success,
"action": "install", action="install",
"apk": apk_path, apk=apk_path,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -288,7 +294,7 @@ class AppsMixin(ADBBaseMixin):
package_name: str, package_name: str,
keep_data: bool = False, keep_data: bool = False,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> AppActionResult:
"""Uninstall an app. """Uninstall an app.
[DEVELOPER MODE] Removes an application from the device. [DEVELOPER MODE] Removes an application from the device.
@ -304,10 +310,11 @@ class AppsMixin(ADBBaseMixin):
Uninstall result Uninstall result
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return AppActionResult(
"success": False, success=False,
"error": "Developer mode required", action="uninstall",
} error="Developer mode required",
)
# Elicit confirmation # Elicit confirmation
await ctx.warning(f"Uninstall requested: {package_name}") await ctx.warning(f"Uninstall requested: {package_name}")
@ -320,11 +327,12 @@ class AppsMixin(ADBBaseMixin):
if confirmation.action != "accept" or confirmation.content == "Cancel": if confirmation.action != "accept" or confirmation.content == "Cancel":
await ctx.info("Uninstall cancelled by user") await ctx.info("Uninstall cancelled by user")
return { return AppActionResult(
"success": False, success=False,
"cancelled": True, action="uninstall",
"message": "Uninstall cancelled by user", cancelled=True,
} message="Uninstall cancelled by user",
)
await ctx.info(f"Uninstalling {package_name}...") await ctx.info(f"Uninstalling {package_name}...")
@ -340,13 +348,13 @@ class AppsMixin(ADBBaseMixin):
else: else:
await ctx.error(f"Uninstall failed: {result.stderr}") await ctx.error(f"Uninstall failed: {result.stderr}")
return { return AppActionResult(
"success": result.success, success=result.success,
"action": "uninstall", action="uninstall",
"package": package_name, package=package_name,
"kept_data": keep_data, kept_data=keep_data,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -357,7 +365,7 @@ class AppsMixin(ADBBaseMixin):
ctx: Context, ctx: Context,
package_name: str, package_name: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> AppActionResult:
"""Clear app data and cache. """Clear app data and cache.
[DEVELOPER MODE] Clears all data for an application (like a fresh [DEVELOPER MODE] Clears all data for an application (like a fresh
@ -372,10 +380,11 @@ class AppsMixin(ADBBaseMixin):
Clear result Clear result
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return AppActionResult(
"success": False, success=False,
"error": "Developer mode required", action="clear_data",
} error="Developer mode required",
)
# Elicit confirmation # Elicit confirmation
await ctx.warning(f"Clear data requested: {package_name}") await ctx.warning(f"Clear data requested: {package_name}")
@ -390,11 +399,12 @@ class AppsMixin(ADBBaseMixin):
if confirmation.action != "accept" or confirmation.content == "Cancel": if confirmation.action != "accept" or confirmation.content == "Cancel":
await ctx.info("Clear data cancelled by user") await ctx.info("Clear data cancelled by user")
return { return AppActionResult(
"success": False, success=False,
"cancelled": True, action="clear_data",
"message": "Clear data cancelled by user", cancelled=True,
} message="Clear data cancelled by user",
)
await ctx.info(f"Clearing data for {package_name}...") await ctx.info(f"Clearing data for {package_name}...")
@ -405,12 +415,12 @@ class AppsMixin(ADBBaseMixin):
else: else:
await ctx.error(f"Clear data failed: {result.stderr}") await ctx.error(f"Clear data failed: {result.stderr}")
return { return AppActionResult(
"success": result.success, success=result.success,
"action": "clear_data", action="clear_data",
"package": package_name, package=package_name,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -424,7 +434,7 @@ class AppsMixin(ADBBaseMixin):
extras: dict[str, str] | None = None, extras: dict[str, str] | None = None,
flags: list[str] | None = None, flags: list[str] | None = None,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> IntentResult:
"""Start a specific activity with intent. """Start a specific activity with intent.
[DEVELOPER MODE] Launch an activity with full intent control. [DEVELOPER MODE] Launch an activity with full intent control.
@ -450,10 +460,11 @@ class AppsMixin(ADBBaseMixin):
data_uri="myapp://product/123" data_uri="myapp://product/123"
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return IntentResult(
"success": False, success=False,
"error": "Developer mode required", action="activity_start",
} error="Developer mode required",
)
cmd_args = ["am", "start"] cmd_args = ["am", "start"]
@ -490,15 +501,15 @@ class AppsMixin(ADBBaseMixin):
result = await self.run_shell_args(cmd_args, device_id) result = await self.run_shell_args(cmd_args, device_id)
return { return IntentResult(
"success": result.success, success=result.success,
"action": "activity_start", action="activity_start",
"component": component, component=component,
"intent_action": action, intent_action=action,
"data_uri": data_uri, data_uri=data_uri,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -510,7 +521,7 @@ class AppsMixin(ADBBaseMixin):
extras: dict[str, str] | None = None, extras: dict[str, str] | None = None,
package: str | None = None, package: str | None = None,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> IntentResult:
"""Send a broadcast intent. """Send a broadcast intent.
[DEVELOPER MODE] Sends a broadcast that can be received by [DEVELOPER MODE] Sends a broadcast that can be received by
@ -531,10 +542,11 @@ class AppsMixin(ADBBaseMixin):
- android.net.conn.CONNECTIVITY_CHANGE - android.net.conn.CONNECTIVITY_CHANGE
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return IntentResult(
"success": False, success=False,
"error": "Developer mode required", action="broadcast_send",
} error="Developer mode required",
)
cmd_args = ["am", "broadcast", "-a", action] cmd_args = ["am", "broadcast", "-a", action]
@ -552,18 +564,18 @@ class AppsMixin(ADBBaseMixin):
result = await self.run_shell_args(cmd_args, device_id) result = await self.run_shell_args(cmd_args, device_id)
return { return IntentResult(
"success": result.success, success=result.success,
"action": "broadcast_send", action="broadcast_send",
"broadcast_action": action, broadcast_action=action,
"package": package, package=package,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
# === Resources === # === Resources ===
@mcp_resource(uri="adb://apps/current") @mcp_resource(uri="adb://apps/current")
async def resource_current_app(self) -> dict[str, Any]: async def resource_current_app(self) -> AppCurrentResult:
"""Resource: Get currently focused app.""" """Resource: Get currently focused app."""
return await self.app_current() return await self.app_current()

View File

@ -4,12 +4,12 @@ Provides tools for managing ADB network connections and device properties.
""" """
import re import re
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool from fastmcp.contrib.mcp_mixin import mcp_tool
from ..config import is_developer_mode from ..config import is_developer_mode
from ..models import ConnectResult, DevicePropertiesResult, TcpipResult
from .base import ADBBaseMixin from .base import ADBBaseMixin
@ -28,7 +28,7 @@ class ConnectivityMixin(ADBBaseMixin):
self, self,
host: str, host: str,
port: int = 5555, port: int = 5555,
) -> dict[str, Any]: ) -> ConnectResult:
"""Connect to a device over TCP/IP. """Connect to a device over TCP/IP.
Establishes an ADB connection to a device on the network. Establishes an ADB connection to a device on the network.
@ -49,20 +49,20 @@ class ConnectivityMixin(ADBBaseMixin):
connected = result.success and "connected" in result.stdout.lower() connected = result.success and "connected" in result.stdout.lower()
already = "already connected" in result.stdout.lower() already = "already connected" in result.stdout.lower()
return { return ConnectResult(
"success": connected, success=connected,
"already_connected": already, already_connected=already,
"address": target, address=target,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not connected else None, error=result.stderr if not connected else None,
} )
@mcp_tool() @mcp_tool()
async def adb_disconnect( async def adb_disconnect(
self, self,
host: str, host: str,
port: int = 5555, port: int = 5555,
) -> dict[str, Any]: ) -> ConnectResult:
"""Disconnect a network-connected device. """Disconnect a network-connected device.
Drops the ADB TCP/IP connection to the specified device. Drops the ADB TCP/IP connection to the specified device.
@ -79,12 +79,12 @@ class ConnectivityMixin(ADBBaseMixin):
disconnected = result.success and "disconnected" in result.stdout.lower() disconnected = result.success and "disconnected" in result.stdout.lower()
return { return ConnectResult(
"success": disconnected, success=disconnected,
"address": target, address=target,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not disconnected else None, error=result.stderr if not disconnected else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -95,7 +95,7 @@ class ConnectivityMixin(ADBBaseMixin):
ctx: Context, ctx: Context,
port: int = 5555, port: int = 5555,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> TcpipResult:
"""Switch a USB-connected device to TCP/IP mode. """Switch a USB-connected device to TCP/IP mode.
[DEVELOPER MODE] Restarts ADB on the device in TCP/IP mode, [DEVELOPER MODE] Restarts ADB on the device in TCP/IP mode,
@ -115,21 +115,21 @@ class ConnectivityMixin(ADBBaseMixin):
Result with device IP address for subsequent adb_connect Result with device IP address for subsequent adb_connect
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return TcpipResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
# Reject if device_id looks like a network device (IP:port format) # Reject if device_id looks like a network device (IP:port format)
target = device_id or self.get_current_device() target = device_id or self.get_current_device()
if target and re.match(r"\d+\.\d+\.\d+\.\d+:\d+", target): if target and re.match(r"\d+\.\d+\.\d+\.\d+:\d+", target):
return { return TcpipResult(
"success": False, success=False,
"error": ( error=(
f"Device '{target}' is already a network device. " f"Device '{target}' is already a network device. "
"adb_tcpip only works on USB-connected devices." "adb_tcpip only works on USB-connected devices."
), ),
} )
# Get device IP before switching (wlan0) # Get device IP before switching (wlan0)
ip_result = await self.run_shell_args( ip_result = await self.run_shell_args(
@ -142,13 +142,13 @@ class ConnectivityMixin(ADBBaseMixin):
device_ip = match.group(1) device_ip = match.group(1)
if not device_ip: if not device_ip:
return { return TcpipResult(
"success": False, success=False,
"error": ( error=(
"Could not determine device IP address. " "Could not determine device IP address. "
"Ensure the device is connected to WiFi." "Ensure the device is connected to WiFi."
), ),
} )
await ctx.info(f"Switching device to TCP/IP mode on port {port}...") await ctx.info(f"Switching device to TCP/IP mode on port {port}...")
@ -156,26 +156,26 @@ class ConnectivityMixin(ADBBaseMixin):
result = await self.run_adb(["tcpip", str(port)], device_id) result = await self.run_adb(["tcpip", str(port)], device_id)
if not result.success: if not result.success:
return { return TcpipResult(
"success": False, success=False,
"error": result.stderr or result.stdout, error=result.stderr or result.stdout,
} )
await ctx.info( await ctx.info(
f"Device switched to TCP/IP on port {port}. " f"Device switched to TCP/IP on port {port}. "
f"Connect with: adb_connect('{device_ip}', {port})" f"Connect with: adb_connect('{device_ip}', {port})"
) )
return { return TcpipResult(
"success": True, success=True,
"port": port, port=port,
"device_ip": device_ip, device_ip=device_ip,
"connect_address": f"{device_ip}:{port}", connect_address=f"{device_ip}:{port}",
"message": ( message=(
f"Device now listening on {device_ip}:{port}. " f"Device now listening on {device_ip}:{port}. "
"USB connection will drop. Use adb_connect() to reconnect." "USB connection will drop. Use adb_connect() to reconnect."
), ),
} )
@mcp_tool() @mcp_tool()
async def adb_pair( async def adb_pair(
@ -183,7 +183,7 @@ class ConnectivityMixin(ADBBaseMixin):
host: str, host: str,
port: int, port: int,
pairing_code: str, pairing_code: str,
) -> dict[str, Any]: ) -> ConnectResult:
"""Pair with a device for wireless debugging (Android 11+). """Pair with a device for wireless debugging (Android 11+).
Pairs with a device using the wireless debugging pairing code Pairs with a device using the wireless debugging pairing code
@ -205,18 +205,18 @@ class ConnectivityMixin(ADBBaseMixin):
paired = result.success and "successfully paired" in result.stdout.lower() paired = result.success and "successfully paired" in result.stdout.lower()
return { return ConnectResult(
"success": paired, success=paired,
"address": target, address=target,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not paired else None, error=result.stderr if not paired else None,
} )
@mcp_tool() @mcp_tool()
async def device_properties( async def device_properties(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> DevicePropertiesResult:
"""Get detailed device properties via getprop. """Get detailed device properties via getprop.
Fetches a comprehensive batch of system properties including Fetches a comprehensive batch of system properties including
@ -226,7 +226,7 @@ class ConnectivityMixin(ADBBaseMixin):
device_id: Target device device_id: Target device
Returns: Returns:
Dictionary of device properties grouped by category Device properties grouped by category
""" """
props_to_fetch = { props_to_fetch = {
"identity": [ "identity": [
@ -257,7 +257,7 @@ class ConnectivityMixin(ADBBaseMixin):
], ],
} }
result: dict[str, Any] = {"success": True} categories: dict[str, dict[str, str]] = {}
for category, prop_list in props_to_fetch.items(): for category, prop_list in props_to_fetch.items():
category_data: dict[str, str] = {} category_data: dict[str, str] = {}
@ -266,11 +266,19 @@ class ConnectivityMixin(ADBBaseMixin):
if value: if value:
category_data[friendly_name] = value category_data[friendly_name] = value
if category_data: if category_data:
result[category] = category_data categories[category] = category_data
# Check if we got anything at all # Check if we got anything at all
if len(result) == 1: # only "success" key if not categories:
result["success"] = False return DevicePropertiesResult(
result["error"] = "No properties returned. Is the device connected?" success=False,
error="No properties returned. Is the device connected?",
)
return result return DevicePropertiesResult(
success=True,
identity=categories.get("identity"),
software=categories.get("software"),
hardware=categories.get("hardware"),
system=categories.get("system"),
)

View File

@ -11,7 +11,13 @@ from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool
from ..config import is_developer_mode from ..config import is_developer_mode
from ..models import DeviceInfo from ..models import (
DeviceInfo,
DeviceInfoResult,
DeviceSelectResult,
LogcatResult,
RebootResult,
)
from .base import ADBBaseMixin from .base import ADBBaseMixin
@ -84,7 +90,7 @@ class DevicesMixin(ADBBaseMixin):
return await self._refresh_devices() return await self._refresh_devices()
@mcp_tool() @mcp_tool()
async def devices_use(self, device_id: str) -> dict[str, Any]: async def devices_use(self, device_id: str) -> DeviceSelectResult:
"""Set the current working device. """Set the current working device.
All subsequent commands will target this device by default. All subsequent commands will target this device by default.
@ -101,28 +107,28 @@ class DevicesMixin(ADBBaseMixin):
device = next((d for d in devices if d.device_id == device_id), None) device = next((d for d in devices if d.device_id == device_id), None)
if not device: if not device:
return { return DeviceSelectResult(
"success": False, success=False,
"error": f"Device {device_id} not found", error=f"Device {device_id} not found",
"available": [d.device_id for d in devices], available=[d.device_id for d in devices],
} )
if device.status != "device": if device.status != "device":
return { return DeviceSelectResult(
"success": False, success=False,
"error": f"Device {device_id} is {device.status}, not ready", error=f"Device {device_id} is {device.status}, not ready",
} )
self.set_current_device(device_id) self.set_current_device(device_id)
return { return DeviceSelectResult(
"success": True, success=True,
"message": f"Now using device {device_id}", message=f"Now using device {device_id}",
"device": device.model_dump(), device=device.model_dump(),
} )
@mcp_tool() @mcp_tool()
async def devices_current(self) -> dict[str, Any]: async def devices_current(self) -> DeviceSelectResult:
"""Get information about the current working device. """Get information about the current working device.
Returns: Returns:
@ -134,22 +140,31 @@ class DevicesMixin(ADBBaseMixin):
devices = await self._refresh_devices() devices = await self._refresh_devices()
if len(devices) == 1: if len(devices) == 1:
# Auto-select if only one device # Auto-select if only one device
return { return DeviceSelectResult(
"device": None, success=True,
"message": "No device set, but only one available", device=None,
"available": devices[0].model_dump(), message="No device set, but only one available",
} available=devices[0].model_dump(),
return { )
"device": None, return DeviceSelectResult(
"error": "No current device set. Use devices_use() first.", success=False,
"available": [d.device_id for d in devices], device=None,
} error="No current device set. Use devices_use() first.",
available=[d.device_id for d in devices],
)
device = self._devices_cache.get(current) device = self._devices_cache.get(current)
if device: if device:
return {"device": device.model_dump()} return DeviceSelectResult(
success=True,
device=device.model_dump(),
)
return {"device": current, "cached_info": None} return DeviceSelectResult(
success=True,
device=current,
cached_info=None,
)
@mcp_resource(uri="adb://devices") @mcp_resource(uri="adb://devices")
async def resource_devices_list(self) -> dict[str, Any]: async def resource_devices_list(self) -> dict[str, Any]:
@ -203,7 +218,7 @@ class DevicesMixin(ADBBaseMixin):
async def device_info( async def device_info(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> DeviceInfoResult:
"""Get comprehensive device information. """Get comprehensive device information.
Returns device state including battery, wifi, storage, and system info. Returns device state including battery, wifi, storage, and system info.
@ -215,17 +230,13 @@ class DevicesMixin(ADBBaseMixin):
Returns: Returns:
Device information including battery, wifi, storage, etc. Device information including battery, wifi, storage, etc.
""" """
info: dict[str, Any] = {}
# Battery info — also serves as connectivity check # Battery info — also serves as connectivity check
battery = await self.run_shell_args(["dumpsys", "battery"], device_id) battery = await self.run_shell_args(["dumpsys", "battery"], device_id)
if not battery.success: if not battery.success:
return { return DeviceInfoResult(
"success": False, success=False,
"error": battery.stderr or "No device connected", error=battery.stderr or "No device connected",
} )
info["success"] = True
battery_info: dict[str, Any] = {} battery_info: dict[str, Any] = {}
for line in battery.stdout.split("\n"): for line in battery.stdout.split("\n"):
@ -251,7 +262,16 @@ class DevicesMixin(ADBBaseMixin):
} }
plugged = line.split(":")[1].strip() plugged = line.split(":")[1].strip()
battery_info["plugged"] = plugged_map.get(plugged, plugged) battery_info["plugged"] = plugged_map.get(plugged, plugged)
info["battery"] = battery_info
# Collect fields progressively
ip_address: str | None = None
wifi_ssid: str | None = None
model: str | None = None
manufacturer: str | None = None
device_name: str | None = None
android_version: str | None = None
sdk_version: str | None = None
storage: dict[str, int] | None = None
# Get IP address — parse ip addr output in Python (no pipes) # Get IP address — parse ip addr output in Python (no pipes)
ip_result = await self.run_shell_args( ip_result = await self.run_shell_args(
@ -260,7 +280,7 @@ class DevicesMixin(ADBBaseMixin):
if ip_result.success: if ip_result.success:
inet_match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", ip_result.stdout) inet_match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", ip_result.stdout)
if inet_match: if inet_match:
info["ip_address"] = inet_match.group(1) ip_address = inet_match.group(1)
# WiFi connection info — parse dumpsys in Python (no pipes) # WiFi connection info — parse dumpsys in Python (no pipes)
wifi = await self.run_shell_args(["dumpsys", "wifi"], device_id) wifi = await self.run_shell_args(["dumpsys", "wifi"], device_id)
@ -269,39 +289,57 @@ class DevicesMixin(ADBBaseMixin):
if "mWifiInfo" in wifi_line and "SSID:" in wifi_line: if "mWifiInfo" in wifi_line and "SSID:" in wifi_line:
try: try:
ssid_part = wifi_line.split("SSID:")[1].split(",")[0].strip() ssid_part = wifi_line.split("SSID:")[1].split(",")[0].strip()
info["wifi_ssid"] = ssid_part.strip('"') wifi_ssid = ssid_part.strip('"')
except IndexError: except IndexError:
pass pass
break break
# System properties # System properties
props_to_fetch = [ props_to_fetch = [
("android_version", "ro.build.version.release"),
("sdk_version", "ro.build.version.sdk"),
("model", "ro.product.model"), ("model", "ro.product.model"),
("manufacturer", "ro.product.manufacturer"), ("manufacturer", "ro.product.manufacturer"),
("device_name", "ro.product.device"), ("device_name", "ro.product.device"),
("android_version", "ro.build.version.release"),
("sdk_version", "ro.build.version.sdk"),
] ]
prop_values: dict[str, str] = {}
for key, prop in props_to_fetch: for key, prop in props_to_fetch:
value = await self.get_device_property(prop, device_id) value = await self.get_device_property(prop, device_id)
if value: if value:
info[key] = value prop_values[key] = value
model = prop_values.get("model")
manufacturer = prop_values.get("manufacturer")
device_name = prop_values.get("device_name")
android_version = prop_values.get("android_version")
sdk_version = prop_values.get("sdk_version")
# Storage info — parse df output in Python (no pipes) # Storage info — parse df output in Python (no pipes)
storage = await self.run_shell_args(["df", "/data"], device_id) storage_result = await self.run_shell_args(["df", "/data"], device_id)
if storage.success: if storage_result.success:
lines = storage.stdout.strip().split("\n") lines = storage_result.stdout.strip().split("\n")
if len(lines) >= 2: if len(lines) >= 2:
parts = lines[-1].split() parts = lines[-1].split()
if len(parts) >= 4: if len(parts) >= 4:
with contextlib.suppress(ValueError): with contextlib.suppress(ValueError):
info["storage"] = { storage = {
"total_kb": int(parts[1]), "total_kb": int(parts[1]),
"used_kb": int(parts[2]), "used_kb": int(parts[2]),
"available_kb": int(parts[3]), "available_kb": int(parts[3]),
} }
return info return DeviceInfoResult(
success=True,
battery=battery_info or None,
ip_address=ip_address,
wifi_ssid=wifi_ssid,
model=model,
manufacturer=manufacturer,
device_name=device_name,
android_version=android_version,
sdk_version=sdk_version,
storage=storage,
)
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -312,7 +350,7 @@ class DevicesMixin(ADBBaseMixin):
ctx: Context, ctx: Context,
mode: str | None = None, mode: str | None = None,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> RebootResult:
"""Reboot the device. """Reboot the device.
[DEVELOPER MODE] Reboots the Android device. [DEVELOPER MODE] Reboots the Android device.
@ -330,10 +368,12 @@ class DevicesMixin(ADBBaseMixin):
Reboot command result Reboot command result
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return RebootResult(
"success": False, success=False,
"error": "Developer mode required", action="reboot",
} mode=mode or "normal",
error="Developer mode required",
)
# Elicit confirmation for this dangerous action # Elicit confirmation for this dangerous action
mode_desc = mode or "normal" mode_desc = mode or "normal"
@ -348,11 +388,13 @@ class DevicesMixin(ADBBaseMixin):
if confirmation.action != "accept" or confirmation.content == "Cancel": if confirmation.action != "accept" or confirmation.content == "Cancel":
await ctx.info("Reboot cancelled by user") await ctx.info("Reboot cancelled by user")
return { return RebootResult(
"success": False, success=False,
"cancelled": True, action="reboot",
"message": "Reboot cancelled by user", mode=mode_desc,
} cancelled=True,
message="Reboot cancelled by user",
)
await ctx.info(f"Initiating {mode_desc} reboot...") await ctx.info(f"Initiating {mode_desc} reboot...")
@ -367,12 +409,12 @@ class DevicesMixin(ADBBaseMixin):
else: else:
await ctx.error(f"Reboot failed: {result.stderr}") await ctx.error(f"Reboot failed: {result.stderr}")
return { return RebootResult(
"success": result.success, success=result.success,
"action": "reboot", action="reboot",
"mode": mode_desc, mode=mode_desc,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -384,7 +426,7 @@ class DevicesMixin(ADBBaseMixin):
filter_spec: str | None = None, filter_spec: str | None = None,
clear_first: bool = False, clear_first: bool = False,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> LogcatResult:
"""Capture logcat output. """Capture logcat output.
[DEVELOPER MODE] Retrieves Android system logs. [DEVELOPER MODE] Retrieves Android system logs.
@ -400,10 +442,10 @@ class DevicesMixin(ADBBaseMixin):
Logcat output Logcat output
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return LogcatResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
# Clear first if requested # Clear first if requested
if clear_first: if clear_first:
@ -418,13 +460,13 @@ class DevicesMixin(ADBBaseMixin):
result = await self.run_shell_args(cmd, device_id) result = await self.run_shell_args(cmd, device_id)
return { return LogcatResult(
"success": result.success, success=result.success,
"lines_requested": lines, lines_requested=lines,
"filter": filter_spec, filter=filter_spec,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -433,7 +475,7 @@ class DevicesMixin(ADBBaseMixin):
async def logcat_clear( async def logcat_clear(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> LogcatResult:
"""Clear the logcat buffer. """Clear the logcat buffer.
[DEVELOPER MODE] Clears all logs from the device log buffer. [DEVELOPER MODE] Clears all logs from the device log buffer.
@ -446,15 +488,15 @@ class DevicesMixin(ADBBaseMixin):
Success status Success status
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return LogcatResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
result = await self.run_shell_args(["logcat", "-c"], device_id) result = await self.run_shell_args(["logcat", "-c"], device_id)
return { return LogcatResult(
"success": result.success, success=result.success,
"action": "logcat_clear", action="logcat_clear",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )

View File

@ -4,12 +4,17 @@ Provides tools for file transfer between host and device.
""" """
from pathlib import Path from pathlib import Path
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool from fastmcp.contrib.mcp_mixin import mcp_tool
from ..config import is_developer_mode from ..config import is_developer_mode
from ..models import (
FileDeleteResult,
FileExistsResult,
FileListResult,
FileTransferResult,
)
from .base import ADBBaseMixin from .base import ADBBaseMixin
@ -32,7 +37,7 @@ class FilesMixin(ADBBaseMixin):
local_path: str, local_path: str,
device_path: str, device_path: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> FileTransferResult:
"""Push a file from host to device. """Push a file from host to device.
[DEVELOPER MODE] Transfers a file from the local machine to the [DEVELOPER MODE] Transfers a file from the local machine to the
@ -53,18 +58,20 @@ class FilesMixin(ADBBaseMixin):
Transfer result with bytes transferred Transfer result with bytes transferred
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return FileTransferResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} action="push",
)
# Verify local file exists # Verify local file exists
local = Path(local_path) local = Path(local_path)
if not local.exists(): if not local.exists():
return { return FileTransferResult(
"success": False, success=False,
"error": f"Local file not found: {local_path}", error=f"Local file not found: {local_path}",
} action="push",
)
file_size = local.stat().st_size file_size = local.stat().st_size
await ctx.info(f"Pushing {local.name} ({file_size:,} bytes) to {device_path}") await ctx.info(f"Pushing {local.name} ({file_size:,} bytes) to {device_path}")
@ -78,14 +85,14 @@ class FilesMixin(ADBBaseMixin):
else: else:
await ctx.error(f"Push failed: {result.stderr}") await ctx.error(f"Push failed: {result.stderr}")
return { return FileTransferResult(
"success": result.success, success=result.success,
"action": "push", action="push",
"local_path": str(local.absolute()), local_path=str(local.absolute()),
"device_path": device_path, device_path=device_path,
"output": result.stdout, output=result.stdout,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -97,7 +104,7 @@ class FilesMixin(ADBBaseMixin):
device_path: str, device_path: str,
local_path: str | None = None, local_path: str | None = None,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> FileTransferResult:
"""Pull a file from device to host. """Pull a file from device to host.
[DEVELOPER MODE] Transfers a file from the Android device to the [DEVELOPER MODE] Transfers a file from the Android device to the
@ -118,10 +125,11 @@ class FilesMixin(ADBBaseMixin):
Transfer result with local file path Transfer result with local file path
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return FileTransferResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} action="pull",
)
# Default local path to current directory with same filename # Default local path to current directory with same filename
if not local_path: if not local_path:
@ -139,14 +147,14 @@ class FilesMixin(ADBBaseMixin):
else: else:
await ctx.error(f"Pull failed: {result.stderr}") await ctx.error(f"Pull failed: {result.stderr}")
return { return FileTransferResult(
"success": result.success, success=result.success,
"action": "pull", action="pull",
"device_path": device_path, device_path=device_path,
"local_path": str(local), local_path=str(local),
"output": result.stdout, output=result.stdout,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -156,7 +164,7 @@ class FilesMixin(ADBBaseMixin):
self, self,
device_path: str = "/sdcard/", device_path: str = "/sdcard/",
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> FileListResult:
"""List files in a directory on the device. """List files in a directory on the device.
[DEVELOPER MODE] Lists files and directories at the specified path. [DEVELOPER MODE] Lists files and directories at the specified path.
@ -169,18 +177,18 @@ class FilesMixin(ADBBaseMixin):
List of files and directories List of files and directories
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return FileListResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
result = await self.run_shell_args(["ls", "-la", device_path], device_id) result = await self.run_shell_args(["ls", "-la", device_path], device_id)
if not result.success: if not result.success:
return { return FileListResult(
"success": False, success=False,
"error": result.stderr or "Failed to list directory", error=result.stderr or "Failed to list directory",
} )
# Parse ls output — Android uses ISO dates (YYYY-MM-DD HH:MM) # Parse ls output — Android uses ISO dates (YYYY-MM-DD HH:MM)
# while traditional ls uses (Mon DD HH:MM), so date takes 2 or 3 fields # while traditional ls uses (Mon DD HH:MM), so date takes 2 or 3 fields
@ -217,12 +225,12 @@ class FilesMixin(ADBBaseMixin):
} }
) )
return { return FileListResult(
"success": True, success=True,
"path": device_path, path=device_path,
"files": files, files=files,
"count": len(files), count=len(files),
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -233,7 +241,7 @@ class FilesMixin(ADBBaseMixin):
ctx: Context, ctx: Context,
device_path: str, device_path: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> FileDeleteResult:
"""Delete a file on the device. """Delete a file on the device.
[DEVELOPER MODE] Removes a file from the device storage. [DEVELOPER MODE] Removes a file from the device storage.
@ -248,10 +256,11 @@ class FilesMixin(ADBBaseMixin):
Deletion result Deletion result
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return FileDeleteResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} action="delete",
)
# Elicit confirmation # Elicit confirmation
await ctx.warning(f"Delete requested: {device_path}") await ctx.warning(f"Delete requested: {device_path}")
@ -263,11 +272,12 @@ class FilesMixin(ADBBaseMixin):
if confirmation.action != "accept" or confirmation.content == "Cancel": if confirmation.action != "accept" or confirmation.content == "Cancel":
await ctx.info("Delete cancelled by user") await ctx.info("Delete cancelled by user")
return { return FileDeleteResult(
"success": False, success=False,
"cancelled": True, action="delete",
"message": "Delete cancelled by user", cancelled=True,
} message="Delete cancelled by user",
)
await ctx.info(f"Deleting {device_path}...") await ctx.info(f"Deleting {device_path}...")
@ -278,12 +288,12 @@ class FilesMixin(ADBBaseMixin):
else: else:
await ctx.error(f"Delete failed: {result.stderr}") await ctx.error(f"Delete failed: {result.stderr}")
return { return FileDeleteResult(
"success": result.success, success=result.success,
"action": "delete", action="delete",
"path": device_path, path=device_path,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -293,7 +303,7 @@ class FilesMixin(ADBBaseMixin):
self, self,
device_path: str, device_path: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> FileExistsResult:
"""Check if a file exists on the device. """Check if a file exists on the device.
[DEVELOPER MODE] Tests for file existence. [DEVELOPER MODE] Tests for file existence.
@ -306,16 +316,18 @@ class FilesMixin(ADBBaseMixin):
Existence check result Existence check result
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return FileExistsResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} path=device_path,
exists=False,
)
# Use test -e and check returncode (injection-safe via run_shell_args) # Use test -e and check returncode (injection-safe via run_shell_args)
result = await self.run_shell_args(["test", "-e", device_path], device_id) result = await self.run_shell_args(["test", "-e", device_path], device_id)
return { return FileExistsResult(
"success": True, success=True,
"path": device_path, path=device_path,
"exists": result.success, exists=result.success,
} )

View File

@ -4,11 +4,16 @@ Provides tools for simulating user input on Android devices.
""" """
import re import re
from typing import Any
from fastmcp.contrib.mcp_mixin import mcp_tool from fastmcp.contrib.mcp_mixin import mcp_tool
from ..config import is_developer_mode from ..config import is_developer_mode
from ..models import (
ClipboardSetResult,
InputResult,
ShellResult,
SwipeResult,
)
from .base import ADBBaseMixin from .base import ADBBaseMixin
# Characters that ADB's input text command cannot handle — suggest clipboard # Characters that ADB's input text command cannot handle — suggest clipboard
@ -45,7 +50,7 @@ class InputMixin(ADBBaseMixin):
x: int, x: int,
y: int, y: int,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Tap at screen coordinates. """Tap at screen coordinates.
Simulates a finger tap at the specified position. Simulates a finger tap at the specified position.
@ -59,12 +64,12 @@ class InputMixin(ADBBaseMixin):
Success status Success status
""" """
result = await self.run_shell_args(["input", "tap", str(x), str(y)], device_id) result = await self.run_shell_args(["input", "tap", str(x), str(y)], device_id)
return { return InputResult(
"success": result.success, success=result.success,
"action": "tap", action="tap",
"coordinates": {"x": x, "y": y}, coordinates={"x": x, "y": y},
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_swipe( async def input_swipe(
@ -75,7 +80,7 @@ class InputMixin(ADBBaseMixin):
y2: int, y2: int,
duration_ms: int = 300, duration_ms: int = 300,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> SwipeResult:
"""Swipe between two points. """Swipe between two points.
Simulates a finger swipe gesture. Use for scrolling, dragging, etc. Simulates a finger swipe gesture. Use for scrolling, dragging, etc.
@ -109,20 +114,20 @@ class InputMixin(ADBBaseMixin):
], ],
device_id, device_id,
) )
return { return SwipeResult(
"success": result.success, success=result.success,
"action": "swipe", action="swipe",
"from": {"x": x1, "y": y1}, start={"x": x1, "y": y1},
"to": {"x": x2, "y": y2}, end={"x": x2, "y": y2},
"duration_ms": duration_ms, duration_ms=duration_ms,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_scroll_down( async def input_scroll_down(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Scroll down one page. """Scroll down one page.
Convenience method for common scroll-down gesture. Convenience method for common scroll-down gesture.
@ -151,17 +156,17 @@ class InputMixin(ADBBaseMixin):
], ],
device_id, device_id,
) )
return { return InputResult(
"success": result.success, success=result.success,
"action": "scroll_down", action="scroll_down",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_scroll_up( async def input_scroll_up(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Scroll up one page. """Scroll up one page.
Convenience method for common scroll-up gesture. Convenience method for common scroll-up gesture.
@ -190,17 +195,17 @@ class InputMixin(ADBBaseMixin):
], ],
device_id, device_id,
) )
return { return InputResult(
"success": result.success, success=result.success,
"action": "scroll_up", action="scroll_up",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_back( async def input_back(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Press the Back button. """Press the Back button.
Simulates pressing the Android back button. Simulates pressing the Android back button.
@ -214,17 +219,17 @@ class InputMixin(ADBBaseMixin):
result = await self.run_shell_args( result = await self.run_shell_args(
["input", "keyevent", "KEYCODE_BACK"], device_id ["input", "keyevent", "KEYCODE_BACK"], device_id
) )
return { return InputResult(
"success": result.success, success=result.success,
"action": "back", action="back",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_home( async def input_home(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Press the Home button. """Press the Home button.
Returns to the home screen. Returns to the home screen.
@ -238,17 +243,17 @@ class InputMixin(ADBBaseMixin):
result = await self.run_shell_args( result = await self.run_shell_args(
["input", "keyevent", "KEYCODE_HOME"], device_id ["input", "keyevent", "KEYCODE_HOME"], device_id
) )
return { return InputResult(
"success": result.success, success=result.success,
"action": "home", action="home",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_recent_apps( async def input_recent_apps(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Open recent apps / app switcher. """Open recent apps / app switcher.
Shows the recent applications overview. Shows the recent applications overview.
@ -262,18 +267,18 @@ class InputMixin(ADBBaseMixin):
result = await self.run_shell_args( result = await self.run_shell_args(
["input", "keyevent", "KEYCODE_APP_SWITCH"], device_id ["input", "keyevent", "KEYCODE_APP_SWITCH"], device_id
) )
return { return InputResult(
"success": result.success, success=result.success,
"action": "recent_apps", action="recent_apps",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_key( async def input_key(
self, self,
key_code: str, key_code: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Send a key event. """Send a key event.
Send any Android key event by code name. Send any Android key event by code name.
@ -298,19 +303,19 @@ class InputMixin(ADBBaseMixin):
clean = f"KEYCODE_{clean.upper()}" clean = f"KEYCODE_{clean.upper()}"
result = await self.run_shell_args(["input", "keyevent", clean], device_id) result = await self.run_shell_args(["input", "keyevent", clean], device_id)
return { return InputResult(
"success": result.success, success=result.success,
"action": "key", action="key",
"key_code": clean, key_code=clean,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def input_text( async def input_text(
self, self,
text: str, text: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Type text into the focused input field. """Type text into the focused input field.
Types the specified text as if entered via keyboard. Types the specified text as if entered via keyboard.
@ -330,25 +335,26 @@ class InputMixin(ADBBaseMixin):
# Check for characters that ADB input text can't handle # Check for characters that ADB input text can't handle
has_unsafe = any(c in _INPUT_TEXT_UNSAFE for c in text) has_unsafe = any(c in _INPUT_TEXT_UNSAFE for c in text)
if has_unsafe: if has_unsafe:
return { return InputResult(
"success": False, success=False,
"error": ( action="text",
error=(
"Text contains special characters that ADB input " "Text contains special characters that ADB input "
"text cannot handle reliably. Use " "text cannot handle reliably. Use "
"clipboard_set(text, paste=True) instead." "clipboard_set(text, paste=True) instead."
), ),
"text": text, text=text,
} )
# ADB input text: spaces must be %s, no shell metacharacters # ADB input text: spaces must be %s, no shell metacharacters
escaped = text.replace(" ", "%s") escaped = text.replace(" ", "%s")
result = await self.run_shell_args(["input", "text", escaped], device_id) result = await self.run_shell_args(["input", "text", escaped], device_id)
return { return InputResult(
"success": result.success, success=result.success,
"action": "text", action="text",
"text": text, text=text,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
# === Developer Mode Tools === # === Developer Mode Tools ===
@ -360,7 +366,7 @@ class InputMixin(ADBBaseMixin):
self, self,
command: str, command: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ShellResult:
"""Execute arbitrary shell command on device. """Execute arbitrary shell command on device.
[DEVELOPER MODE] Run any shell command on the Android device. [DEVELOPER MODE] Run any shell command on the Android device.
@ -381,24 +387,25 @@ class InputMixin(ADBBaseMixin):
Command output with stdout, stderr, and return code Command output with stdout, stderr, and return code
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return ShellResult(
"success": False, success=False,
"error": ( command=command,
error=(
"Developer mode required. " "Developer mode required. "
"Enable with config_set_developer_mode(True)" "Enable with config_set_developer_mode(True)"
), ),
} )
# Developer shell_command intentionally uses run_shell (string form) # Developer shell_command intentionally uses run_shell (string form)
# since the user explicitly provides the command string # since the user explicitly provides the command string
result = await self.run_shell(command, device_id) result = await self.run_shell(command, device_id)
return { return ShellResult(
"success": result.success, success=result.success,
"command": command, command=command,
"stdout": result.stdout, stdout=result.stdout,
"stderr": result.stderr, stderr=result.stderr,
"returncode": result.returncode, returncode=result.returncode,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -410,7 +417,7 @@ class InputMixin(ADBBaseMixin):
y: int, y: int,
duration_ms: int = 1000, duration_ms: int = 1000,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> InputResult:
"""Long press at screen coordinates. """Long press at screen coordinates.
[DEVELOPER MODE] Simulates a long press / press-and-hold gesture. [DEVELOPER MODE] Simulates a long press / press-and-hold gesture.
@ -425,10 +432,11 @@ class InputMixin(ADBBaseMixin):
Success status Success status
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return InputResult(
"success": False, success=False,
"error": "Developer mode required", action="long_press",
} error="Developer mode required",
)
# Long press is a swipe with no movement # Long press is a swipe with no movement
result = await self.run_shell_args( result = await self.run_shell_args(
@ -443,13 +451,13 @@ class InputMixin(ADBBaseMixin):
], ],
device_id, device_id,
) )
return { return InputResult(
"success": result.success, success=result.success,
"action": "long_press", action="long_press",
"coordinates": {"x": x, "y": y}, coordinates={"x": x, "y": y},
"duration_ms": duration_ms, duration_ms=duration_ms,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def clipboard_set( async def clipboard_set(
@ -457,7 +465,7 @@ class InputMixin(ADBBaseMixin):
text: str, text: str,
paste: bool = False, paste: bool = False,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ClipboardSetResult:
"""Set clipboard text and optionally paste. """Set clipboard text and optionally paste.
Sets the device clipboard to the specified text. Unlike input_text, Sets the device clipboard to the specified text. Unlike input_text,
@ -510,20 +518,20 @@ class InputMixin(ADBBaseMixin):
) )
preview = text[:100] + "..." if len(text) > 100 else text preview = text[:100] + "..." if len(text) > 100 else text
response: dict[str, Any] = { response = ClipboardSetResult(
"success": result.success, success=result.success,
"action": "clipboard_set", action="clipboard_set",
"text": preview, text=preview,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
# Paste if requested # Paste if requested
if paste and result.success: if paste and result.success:
paste_result = await self.run_shell_args( paste_result = await self.run_shell_args(
["input", "keyevent", "KEYCODE_PASTE"], device_id ["input", "keyevent", "KEYCODE_PASTE"], device_id
) )
response["pasted"] = paste_result.success response.pasted = paste_result.success
if not paste_result.success: if not paste_result.success:
response["paste_error"] = paste_result.stderr response.paste_error = paste_result.stderr
return response return response

View File

@ -11,7 +11,14 @@ from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool
from ..config import get_config, is_developer_mode from ..config import get_config, is_developer_mode
from ..models import ScreenshotResult from ..models import (
ActionResult,
RecordingResult,
ScreenDensityResult,
ScreenSetResult,
ScreenshotResult,
ScreenSizeResult,
)
from .base import ADBBaseMixin from .base import ADBBaseMixin
@ -99,7 +106,7 @@ class ScreenshotMixin(ADBBaseMixin):
async def screen_size( async def screen_size(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ScreenSizeResult:
"""Get the screen dimensions. """Get the screen dimensions.
Returns the physical screen resolution in pixels. Returns the physical screen resolution in pixels.
@ -121,24 +128,24 @@ class ScreenshotMixin(ADBBaseMixin):
size = parts[1].strip() size = parts[1].strip()
if "x" in size: if "x" in size:
w, h = size.split("x") w, h = size.split("x")
return { return ScreenSizeResult(
"success": True, success=True,
"width": int(w), width=int(w),
"height": int(h), height=int(h),
"raw": result.stdout, raw=result.stdout,
} )
return { return ScreenSizeResult(
"success": False, success=False,
"error": result.stderr or "Could not parse screen size", error=result.stderr or "Could not parse screen size",
"raw": result.stdout, raw=result.stdout,
} )
@mcp_tool() @mcp_tool()
async def screen_density( async def screen_density(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ScreenDensityResult:
"""Get the screen density (DPI). """Get the screen density (DPI).
Args: Args:
@ -156,24 +163,24 @@ class ScreenshotMixin(ADBBaseMixin):
if len(parts) == 2: if len(parts) == 2:
try: try:
dpi = int(parts[1].strip()) dpi = int(parts[1].strip())
return { return ScreenDensityResult(
"success": True, success=True,
"dpi": dpi, dpi=dpi,
} )
except ValueError: except ValueError:
pass pass
return { return ScreenDensityResult(
"success": False, success=False,
"error": result.stderr or "Could not parse density", error=result.stderr or "Could not parse density",
"raw": result.stdout, raw=result.stdout,
} )
@mcp_tool() @mcp_tool()
async def screen_on( async def screen_on(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ActionResult:
"""Turn the screen on. """Turn the screen on.
Wakes up the device display. Does not unlock. Wakes up the device display. Does not unlock.
@ -187,17 +194,17 @@ class ScreenshotMixin(ADBBaseMixin):
result = await self.run_shell_args( result = await self.run_shell_args(
["input", "keyevent", "KEYCODE_WAKEUP"], device_id ["input", "keyevent", "KEYCODE_WAKEUP"], device_id
) )
return { return ActionResult(
"success": result.success, success=result.success,
"action": "screen_on", action="screen_on",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def screen_off( async def screen_off(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ActionResult:
"""Turn the screen off. """Turn the screen off.
Puts the device display to sleep. Puts the device display to sleep.
@ -211,11 +218,11 @@ class ScreenshotMixin(ADBBaseMixin):
result = await self.run_shell_args( result = await self.run_shell_args(
["input", "keyevent", "KEYCODE_SLEEP"], device_id ["input", "keyevent", "KEYCODE_SLEEP"], device_id
) )
return { return ActionResult(
"success": result.success, success=result.success,
"action": "screen_off", action="screen_off",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
# === Developer Mode Tools === # === Developer Mode Tools ===
@ -229,7 +236,7 @@ class ScreenshotMixin(ADBBaseMixin):
filename: str | None = None, filename: str | None = None,
duration_seconds: int = 10, duration_seconds: int = 10,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> RecordingResult:
"""Record the screen. """Record the screen.
[DEVELOPER MODE] Records the device screen to a video file. [DEVELOPER MODE] Records the device screen to a video file.
@ -245,10 +252,10 @@ class ScreenshotMixin(ADBBaseMixin):
Recording result with file path Recording result with file path
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return RecordingResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
# Generate default filename # Generate default filename
if not filename: if not filename:
@ -283,10 +290,10 @@ class ScreenshotMixin(ADBBaseMixin):
) )
if not result.success: if not result.success:
return { return RecordingResult(
"success": False, success=False,
"error": f"Failed to record: {result.stderr}", error=f"Failed to record: {result.stderr}",
} )
await ctx.info("Transferring recording to host...") await ctx.info("Transferring recording to host...")
@ -299,18 +306,18 @@ class ScreenshotMixin(ADBBaseMixin):
await self.run_shell_args(["rm", device_temp], device_id) await self.run_shell_args(["rm", device_temp], device_id)
if not pull_result.success: if not pull_result.success:
return { return RecordingResult(
"success": False, success=False,
"error": (f"Failed to pull recording: {pull_result.stderr}"), error=f"Failed to pull recording: {pull_result.stderr}",
} )
await ctx.info(f"Recording saved: {output_path}") await ctx.info(f"Recording saved: {output_path}")
return { return RecordingResult(
"success": True, success=True,
"local_path": str(output_path), local_path=str(output_path),
"duration_seconds": duration, duration_seconds=duration,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -321,7 +328,7 @@ class ScreenshotMixin(ADBBaseMixin):
width: int, width: int,
height: int, height: int,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ScreenSetResult:
"""Override screen resolution. """Override screen resolution.
[DEVELOPER MODE] Changes the display resolution. [DEVELOPER MODE] Changes the display resolution.
@ -336,21 +343,22 @@ class ScreenshotMixin(ADBBaseMixin):
Success status Success status
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return ScreenSetResult(
"success": False, success=False,
"error": "Developer mode required", action="set_size",
} error="Developer mode required",
)
result = await self.run_shell_args( result = await self.run_shell_args(
["wm", "size", f"{width}x{height}"], device_id ["wm", "size", f"{width}x{height}"], device_id
) )
return { return ScreenSetResult(
"success": result.success, success=result.success,
"action": "set_size", action="set_size",
"width": width, width=width,
"height": height, height=height,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -359,7 +367,7 @@ class ScreenshotMixin(ADBBaseMixin):
async def screen_reset_size( async def screen_reset_size(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ActionResult:
"""Reset screen to physical resolution. """Reset screen to physical resolution.
[DEVELOPER MODE] Restores the original display resolution. [DEVELOPER MODE] Restores the original display resolution.
@ -371,17 +379,18 @@ class ScreenshotMixin(ADBBaseMixin):
Success status Success status
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return ActionResult(
"success": False, success=False,
"error": "Developer mode required", action="reset_size",
} error="Developer mode required",
)
result = await self.run_shell_args(["wm", "size", "reset"], device_id) result = await self.run_shell_args(["wm", "size", "reset"], device_id)
return { return ActionResult(
"success": result.success, success=result.success,
"action": "reset_size", action="reset_size",
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
# === Resources === # === Resources ===
@ -392,7 +401,7 @@ class ScreenshotMixin(ADBBaseMixin):
density = await self.screen_density() density = await self.screen_density()
return { return {
"width": size.get("width"), "width": size.width,
"height": size.get("height"), "height": size.height,
"dpi": density.get("dpi"), "dpi": density.dpi,
} }

View File

@ -5,12 +5,21 @@ display configuration, notification access, clipboard, and media control.
""" """
import re import re
from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool from fastmcp.contrib.mcp_mixin import mcp_tool
from ..config import is_developer_mode from ..config import is_developer_mode
from ..models import (
BrightnessResult,
ClipboardGetResult,
MediaControlResult,
NotificationListResult,
SettingGetResult,
SettingPutResult,
TimeoutResult,
ToggleResult,
)
from .base import ADBBaseMixin from .base import ADBBaseMixin
_VALID_NAMESPACES = {"system", "global", "secure"} _VALID_NAMESPACES = {"system", "global", "secure"}
@ -47,7 +56,7 @@ class SettingsMixin(ADBBaseMixin):
namespace: str, namespace: str,
key: str, key: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> SettingGetResult:
"""Read an Android system setting. """Read an Android system setting.
Reads a value from the device's settings database. Reads a value from the device's settings database.
@ -65,43 +74,43 @@ class SettingsMixin(ADBBaseMixin):
The setting value The setting value
""" """
if namespace not in _VALID_NAMESPACES: if namespace not in _VALID_NAMESPACES:
return { return SettingGetResult(
"success": False, success=False,
"error": ( error=(
f"Invalid namespace '{namespace}'. " f"Invalid namespace '{namespace}'. "
f"Must be one of: {', '.join(sorted(_VALID_NAMESPACES))}" f"Must be one of: {', '.join(sorted(_VALID_NAMESPACES))}"
), ),
} )
if not _SETTING_KEY_PATTERN.match(key): if not _SETTING_KEY_PATTERN.match(key):
return { return SettingGetResult(
"success": False, success=False,
"error": ( error=(
f"Invalid key '{key}'. Keys must contain " f"Invalid key '{key}'. Keys must contain "
"only letters, digits, underscores, and dots." "only letters, digits, underscores, and dots."
), ),
} )
result = await self.run_shell_args( result = await self.run_shell_args(
["settings", "get", namespace, key], device_id ["settings", "get", namespace, key], device_id
) )
if not result.success: if not result.success:
return { return SettingGetResult(
"success": False, success=False,
"error": result.stderr, error=result.stderr,
} )
value = result.stdout.strip() value = result.stdout.strip()
is_null = value == "null" is_null = value == "null"
return { return SettingGetResult(
"success": True, success=True,
"namespace": namespace, namespace=namespace,
"key": key, key=key,
"value": None if is_null else value, value=None if is_null else value,
"exists": not is_null, exists=not is_null,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -114,7 +123,7 @@ class SettingsMixin(ADBBaseMixin):
key: str, key: str,
value: str, value: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> SettingPutResult:
"""Write an Android system setting. """Write an Android system setting.
[DEVELOPER MODE] Writes a value to the device's settings database. [DEVELOPER MODE] Writes a value to the device's settings database.
@ -132,28 +141,28 @@ class SettingsMixin(ADBBaseMixin):
Result with read-back verification Result with read-back verification
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return SettingPutResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
if namespace not in _VALID_NAMESPACES: if namespace not in _VALID_NAMESPACES:
return { return SettingPutResult(
"success": False, success=False,
"error": ( error=(
f"Invalid namespace '{namespace}'. " f"Invalid namespace '{namespace}'. "
f"Must be one of: {', '.join(sorted(_VALID_NAMESPACES))}" f"Must be one of: {', '.join(sorted(_VALID_NAMESPACES))}"
), ),
} )
if not _SETTING_KEY_PATTERN.match(key): if not _SETTING_KEY_PATTERN.match(key):
return { return SettingPutResult(
"success": False, success=False,
"error": ( error=(
f"Invalid key '{key}'. Keys must contain " f"Invalid key '{key}'. Keys must contain "
"only letters, digits, underscores, and dots." "only letters, digits, underscores, and dots."
), ),
} )
# Extra confirmation for secure namespace # Extra confirmation for secure namespace
if namespace == "secure": if namespace == "secure":
@ -165,21 +174,21 @@ class SettingsMixin(ADBBaseMixin):
["Yes, write setting", "Cancel"], ["Yes, write setting", "Cancel"],
) )
if confirmation.action != "accept" or confirmation.content == "Cancel": if confirmation.action != "accept" or confirmation.content == "Cancel":
return { return SettingPutResult(
"success": False, success=False,
"cancelled": True, cancelled=True,
"message": "Settings write cancelled by user", message="Settings write cancelled by user",
} )
result = await self.run_shell_args( result = await self.run_shell_args(
["settings", "put", namespace, key, value], device_id ["settings", "put", namespace, key, value], device_id
) )
if not result.success: if not result.success:
return { return SettingPutResult(
"success": False, success=False,
"error": result.stderr, error=result.stderr,
} )
# Read back to verify # Read back to verify
verify = await self.run_shell_args( verify = await self.run_shell_args(
@ -189,14 +198,14 @@ class SettingsMixin(ADBBaseMixin):
await ctx.info(f"Set {namespace}/{key} = {value}") await ctx.info(f"Set {namespace}/{key} = {value}")
return { return SettingPutResult(
"success": True, success=True,
"namespace": namespace, namespace=namespace,
"key": key, key=key,
"value": value, value=value,
"readback": readback, readback=readback,
"verified": readback == value, verified=readback == value,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -206,7 +215,7 @@ class SettingsMixin(ADBBaseMixin):
self, self,
enabled: bool, enabled: bool,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ToggleResult:
"""Toggle WiFi on or off. """Toggle WiFi on or off.
[DEVELOPER MODE] Enables or disables WiFi using the svc command. [DEVELOPER MODE] Enables or disables WiFi using the svc command.
@ -220,19 +229,21 @@ class SettingsMixin(ADBBaseMixin):
Result with verified WiFi state Result with verified WiFi state
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return ToggleResult(
"success": False, success=False,
"error": "Developer mode required", action="enable" if enabled else "disable",
} error="Developer mode required",
)
action = "enable" if enabled else "disable" action = "enable" if enabled else "disable"
result = await self.run_shell_args(["svc", "wifi", action], device_id) result = await self.run_shell_args(["svc", "wifi", action], device_id)
if not result.success: if not result.success:
return { return ToggleResult(
"success": False, success=False,
"error": result.stderr, action=action,
} error=result.stderr,
)
# Verify state change # Verify state change
verify = await self.run_shell_args( verify = await self.run_shell_args(
@ -240,12 +251,12 @@ class SettingsMixin(ADBBaseMixin):
) )
current = verify.stdout.strip() if verify.success else "unknown" current = verify.stdout.strip() if verify.success else "unknown"
return { return ToggleResult(
"success": True, success=True,
"action": action, action=action,
"wifi_on": current, wifi_on=current,
"verified": current == ("1" if enabled else "0"), verified=current == ("1" if enabled else "0"),
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -255,7 +266,7 @@ class SettingsMixin(ADBBaseMixin):
self, self,
enabled: bool, enabled: bool,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ToggleResult:
"""Toggle Bluetooth on or off. """Toggle Bluetooth on or off.
[DEVELOPER MODE] Enables or disables Bluetooth using the svc command. [DEVELOPER MODE] Enables or disables Bluetooth using the svc command.
@ -268,19 +279,20 @@ class SettingsMixin(ADBBaseMixin):
Result with action taken Result with action taken
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return ToggleResult(
"success": False, success=False,
"error": "Developer mode required", action="enable" if enabled else "disable",
} error="Developer mode required",
)
action = "enable" if enabled else "disable" action = "enable" if enabled else "disable"
result = await self.run_shell_args(["svc", "bluetooth", action], device_id) result = await self.run_shell_args(["svc", "bluetooth", action], device_id)
return { return ToggleResult(
"success": result.success, success=result.success,
"action": action, action=action,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -291,7 +303,7 @@ class SettingsMixin(ADBBaseMixin):
ctx: Context, ctx: Context,
enabled: bool, enabled: bool,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ToggleResult:
"""Toggle airplane mode on or off. """Toggle airplane mode on or off.
[DEVELOPER MODE] Enables or disables airplane mode. [DEVELOPER MODE] Enables or disables airplane mode.
@ -307,10 +319,11 @@ class SettingsMixin(ADBBaseMixin):
Result with airplane mode state Result with airplane mode state
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return ToggleResult(
"success": False, success=False,
"error": "Developer mode required", action="enabled" if enabled else "disabled",
} error="Developer mode required",
)
# Warn about network disconnection risk # Warn about network disconnection risk
if enabled: if enabled:
@ -331,11 +344,12 @@ class SettingsMixin(ADBBaseMixin):
["Yes, enable airplane mode", "Cancel"], ["Yes, enable airplane mode", "Cancel"],
) )
if confirmation.action != "accept" or confirmation.content == "Cancel": if confirmation.action != "accept" or confirmation.content == "Cancel":
return { return ToggleResult(
"success": False, success=False,
"cancelled": True, action="enabled" if enabled else "disabled",
"message": "Airplane mode toggle cancelled by user", cancelled=True,
} message="Airplane mode toggle cancelled by user",
)
# Set the setting # Set the setting
value = "1" if enabled else "0" value = "1" if enabled else "0"
@ -344,10 +358,11 @@ class SettingsMixin(ADBBaseMixin):
) )
if not put_result.success: if not put_result.success:
return { return ToggleResult(
"success": False, success=False,
"error": put_result.stderr, action="enabled" if enabled else "disabled",
} error=put_result.stderr,
)
# Broadcast the change so the system acts on it # Broadcast the change so the system acts on it
await self.run_shell_args( await self.run_shell_args(
@ -366,11 +381,11 @@ class SettingsMixin(ADBBaseMixin):
action = "enabled" if enabled else "disabled" action = "enabled" if enabled else "disabled"
await ctx.info(f"Airplane mode {action}") await ctx.info(f"Airplane mode {action}")
return { return ToggleResult(
"success": True, success=True,
"airplane_mode": enabled, action=action,
"action": action, airplane_mode=enabled,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -380,7 +395,7 @@ class SettingsMixin(ADBBaseMixin):
self, self,
level: int, level: int,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> BrightnessResult:
"""Set screen brightness level. """Set screen brightness level.
[DEVELOPER MODE] Sets the screen brightness to a specific level. [DEVELOPER MODE] Sets the screen brightness to a specific level.
@ -394,16 +409,16 @@ class SettingsMixin(ADBBaseMixin):
Result with brightness level set Result with brightness level set
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return BrightnessResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
if not 0 <= level <= 255: if not 0 <= level <= 255:
return { return BrightnessResult(
"success": False, success=False,
"error": f"Brightness level must be 0-255, got {level}", error=f"Brightness level must be 0-255, got {level}",
} )
# Disable auto-brightness first # Disable auto-brightness first
await self.run_shell_args( await self.run_shell_args(
@ -417,12 +432,12 @@ class SettingsMixin(ADBBaseMixin):
device_id, device_id,
) )
return { return BrightnessResult(
"success": result.success, success=result.success,
"brightness": level, brightness=level,
"auto_brightness": False, auto_brightness=False,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool( @mcp_tool(
tags={"developer"}, tags={"developer"},
@ -432,7 +447,7 @@ class SettingsMixin(ADBBaseMixin):
self, self,
seconds: int, seconds: int,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> TimeoutResult:
"""Set screen timeout duration. """Set screen timeout duration.
[DEVELOPER MODE] Sets how long the screen stays on before [DEVELOPER MODE] Sets how long the screen stays on before
@ -446,16 +461,16 @@ class SettingsMixin(ADBBaseMixin):
Result with timeout value set Result with timeout value set
""" """
if not is_developer_mode(): if not is_developer_mode():
return { return TimeoutResult(
"success": False, success=False,
"error": "Developer mode required", error="Developer mode required",
} )
if seconds < 1 or seconds > 1800: if seconds < 1 or seconds > 1800:
return { return TimeoutResult(
"success": False, success=False,
"error": f"Timeout must be 1-1800 seconds, got {seconds}", error=f"Timeout must be 1-1800 seconds, got {seconds}",
} )
# Android stores timeout in milliseconds # Android stores timeout in milliseconds
ms = seconds * 1000 ms = seconds * 1000
@ -464,19 +479,19 @@ class SettingsMixin(ADBBaseMixin):
device_id, device_id,
) )
return { return TimeoutResult(
"success": result.success, success=result.success,
"timeout_seconds": seconds, timeout_seconds=seconds,
"timeout_ms": ms, timeout_ms=ms,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )
@mcp_tool() @mcp_tool()
async def notification_list( async def notification_list(
self, self,
limit: int = 50, limit: int = 50,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> NotificationListResult:
"""List recent notifications. """List recent notifications.
Retrieves notifications from the notification shade. Retrieves notifications from the notification shade.
@ -494,10 +509,10 @@ class SettingsMixin(ADBBaseMixin):
) )
if not result.success: if not result.success:
return { return NotificationListResult(
"success": False, success=False,
"error": result.stderr, error=result.stderr,
} )
notifications: list[dict[str, str | None]] = [] notifications: list[dict[str, str | None]] = []
current: dict[str, str | None] = {} current: dict[str, str | None] = {}
@ -537,17 +552,17 @@ class SettingsMixin(ADBBaseMixin):
if current and len(notifications) < limit: if current and len(notifications) < limit:
notifications.append(current) notifications.append(current)
return { return NotificationListResult(
"success": True, success=True,
"notifications": notifications, notifications=notifications,
"count": len(notifications), count=len(notifications),
} )
@mcp_tool() @mcp_tool()
async def clipboard_get( async def clipboard_get(
self, self,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> ClipboardGetResult:
"""Read the device clipboard contents. """Read the device clipboard contents.
Retrieves the current text from the device clipboard. Retrieves the current text from the device clipboard.
@ -577,19 +592,19 @@ class SettingsMixin(ADBBaseMixin):
if result.success and "Parcel(" in result.stdout: if result.success and "Parcel(" in result.stdout:
text = self._parse_clipboard_parcel(result.stdout) text = self._parse_clipboard_parcel(result.stdout)
if text is not None: if text is not None:
return { return ClipboardGetResult(
"success": True, success=True,
"text": text, text=text,
"method": "service_call", method="service_call",
} )
return { return ClipboardGetResult(
"success": False, success=False,
"error": ( error=(
"Could not read clipboard. The device may have " "Could not read clipboard. The device may have "
"an empty clipboard or use an unsupported format." "an empty clipboard or use an unsupported format."
), ),
} )
@staticmethod @staticmethod
def _parse_clipboard_parcel(raw: str) -> str | None: def _parse_clipboard_parcel(raw: str) -> str | None:
@ -656,7 +671,7 @@ class SettingsMixin(ADBBaseMixin):
self, self,
action: str, action: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> MediaControlResult:
"""Control media playback. """Control media playback.
Sends media key events to control the active media player. Sends media key events to control the active media player.
@ -683,19 +698,20 @@ class SettingsMixin(ADBBaseMixin):
keycode = _MEDIA_KEYCODES.get(action_lower) keycode = _MEDIA_KEYCODES.get(action_lower)
if not keycode: if not keycode:
return { return MediaControlResult(
"success": False, success=False,
"error": ( action=action_lower,
error=(
f"Unknown action '{action}'. " f"Unknown action '{action}'. "
f"Available: {', '.join(sorted(_MEDIA_KEYCODES))}" f"Available: {', '.join(sorted(_MEDIA_KEYCODES))}"
), ),
} )
result = await self.run_shell_args(["input", "keyevent", keycode], device_id) result = await self.run_shell_args(["input", "keyevent", keycode], device_id)
return { return MediaControlResult(
"success": result.success, success=result.success,
"action": action_lower, action=action_lower,
"keycode": keycode, keycode=keycode,
"error": result.stderr if not result.success else None, error=result.stderr if not result.success else None,
} )

View File

@ -11,6 +11,7 @@ from typing import Any
from fastmcp import Context from fastmcp import Context
from fastmcp.contrib.mcp_mixin import mcp_tool from fastmcp.contrib.mcp_mixin import mcp_tool
from ..models import TapTextResult, UIDumpResult, UIFindResult, WaitResult
from .base import ADBBaseMixin from .base import ADBBaseMixin
@ -28,7 +29,7 @@ class UIMixin(ADBBaseMixin):
self, self,
ctx: Context | None = None, ctx: Context | None = None,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> UIDumpResult:
"""Dump the current UI hierarchy. """Dump the current UI hierarchy.
Returns the accessibility tree as XML, showing all visible elements Returns the accessibility tree as XML, showing all visible elements
@ -62,10 +63,10 @@ class UIMixin(ADBBaseMixin):
if not result.success: if not result.success:
if ctx: if ctx:
await ctx.error(f"UI dump failed: {result.stderr}") await ctx.error(f"UI dump failed: {result.stderr}")
return { return UIDumpResult(
"success": False, success=False,
"error": f"Failed to dump UI: {result.stderr}", error=f"Failed to dump UI: {result.stderr}",
} )
# Read the dump # Read the dump
cat_result = await self.run_shell_args(["cat", device_path], device_id) cat_result = await self.run_shell_args(["cat", device_path], device_id)
@ -73,10 +74,10 @@ class UIMixin(ADBBaseMixin):
if not cat_result.success: if not cat_result.success:
if ctx: if ctx:
await ctx.error(f"Failed to read dump: {cat_result.stderr}") await ctx.error(f"Failed to read dump: {cat_result.stderr}")
return { return UIDumpResult(
"success": False, success=False,
"error": f"Failed to read UI dump: {cat_result.stderr}", error=f"Failed to read UI dump: {cat_result.stderr}",
} )
# Clean up # Clean up
await self.run_shell_args(["rm", device_path], device_id) await self.run_shell_args(["rm", device_path], device_id)
@ -89,12 +90,12 @@ class UIMixin(ADBBaseMixin):
if ctx: if ctx:
await ctx.info(f"Found {len(clickable_elements)} interactive elements") await ctx.info(f"Found {len(clickable_elements)} interactive elements")
return { return UIDumpResult(
"success": True, success=True,
"xml": xml_content, xml=xml_content,
"clickable_elements": clickable_elements, clickable_elements=clickable_elements,
"element_count": len(clickable_elements), element_count=len(clickable_elements),
} )
def _parse_ui_elements(self, xml_content: str) -> list[dict[str, Any]]: def _parse_ui_elements(self, xml_content: str) -> list[dict[str, Any]]:
"""Parse UI XML to extract clickable/important elements.""" """Parse UI XML to extract clickable/important elements."""
@ -147,7 +148,7 @@ class UIMixin(ADBBaseMixin):
resource_id: str | None = None, resource_id: str | None = None,
class_name: str | None = None, class_name: str | None = None,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> UIFindResult:
"""Find UI elements matching criteria. """Find UI elements matching criteria.
Searches the current UI for elements matching the specified Searches the current UI for elements matching the specified
@ -167,10 +168,10 @@ class UIMixin(ADBBaseMixin):
# Get UI dump (internal call, no ctx) # Get UI dump (internal call, no ctx)
dump = await self.ui_dump(device_id=device_id) dump = await self.ui_dump(device_id=device_id)
if not dump.get("success"): if not dump.success:
return dump return UIFindResult(success=False, error=dump.error)
elements = dump["clickable_elements"] elements = dump.clickable_elements
matches = [] matches = []
for elem in elements: for elem in elements:
@ -190,11 +191,11 @@ class UIMixin(ADBBaseMixin):
if match: if match:
matches.append(elem) matches.append(elem)
return { return UIFindResult(
"success": True, success=True,
"matches": matches, matches=matches,
"count": len(matches), count=len(matches),
} )
@mcp_tool() @mcp_tool()
async def wait_for_text( async def wait_for_text(
@ -203,7 +204,7 @@ class UIMixin(ADBBaseMixin):
timeout_seconds: float = 10.0, timeout_seconds: float = 10.0,
poll_interval: float = 0.5, poll_interval: float = 0.5,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> WaitResult:
"""Wait for text to appear on screen. """Wait for text to appear on screen.
Polls the UI hierarchy until the specified text is found Polls the UI hierarchy until the specified text is found
@ -227,27 +228,27 @@ class UIMixin(ADBBaseMixin):
# Internal call, no ctx # Internal call, no ctx
dump = await self.ui_dump(device_id=device_id) dump = await self.ui_dump(device_id=device_id)
if dump.get("success"): if dump.success:
for elem in dump.get("clickable_elements", []): for elem in dump.clickable_elements:
if text in elem.get("text", "") or text in elem.get( if text in elem.get("text", "") or text in elem.get(
"content_desc", "" "content_desc", ""
): ):
return { return WaitResult(
"success": True, success=True,
"found": True, found=True,
"element": elem, element=elem,
"wait_time": round(time.time() - start_time, 2), wait_time=round(time.time() - start_time, 2),
"attempts": attempts, attempts=attempts,
} )
await asyncio.sleep(poll_interval) await asyncio.sleep(poll_interval)
return { return WaitResult(
"success": False, success=False,
"found": False, found=False,
"error": (f"Text '{text}' not found after {timeout_seconds}s"), error=f"Text '{text}' not found after {timeout_seconds}s",
"attempts": attempts, attempts=attempts,
} )
@mcp_tool() @mcp_tool()
async def wait_for_text_gone( async def wait_for_text_gone(
@ -256,7 +257,7 @@ class UIMixin(ADBBaseMixin):
timeout_seconds: float = 10.0, timeout_seconds: float = 10.0,
poll_interval: float = 0.5, poll_interval: float = 0.5,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> WaitResult:
"""Wait for text to disappear from screen. """Wait for text to disappear from screen.
Useful for waiting for loading indicators to finish, Useful for waiting for loading indicators to finish,
@ -279,9 +280,9 @@ class UIMixin(ADBBaseMixin):
dump = await self.ui_dump(device_id=device_id) dump = await self.ui_dump(device_id=device_id)
if dump.get("success"): if dump.success:
found = False found = False
for elem in dump.get("clickable_elements", []): for elem in dump.clickable_elements:
if text in elem.get("text", "") or text in elem.get( if text in elem.get("text", "") or text in elem.get(
"content_desc", "" "content_desc", ""
): ):
@ -289,28 +290,28 @@ class UIMixin(ADBBaseMixin):
break break
if not found: if not found:
return { return WaitResult(
"success": True, success=True,
"gone": True, gone=True,
"wait_time": round(time.time() - start_time, 2), wait_time=round(time.time() - start_time, 2),
"attempts": attempts, attempts=attempts,
} )
await asyncio.sleep(poll_interval) await asyncio.sleep(poll_interval)
return { return WaitResult(
"success": False, success=False,
"gone": False, gone=False,
"error": (f"Text '{text}' still present after {timeout_seconds}s"), error=f"Text '{text}' still present after {timeout_seconds}s",
"attempts": attempts, attempts=attempts,
} )
@mcp_tool() @mcp_tool()
async def tap_text( async def tap_text(
self, self,
text: str, text: str,
device_id: str | None = None, device_id: str | None = None,
) -> dict[str, Any]: ) -> TapTextResult:
"""Find element by text and tap it. """Find element by text and tap it.
Convenience method that combines ui_find_element + input_tap. Convenience method that combines ui_find_element + input_tap.
@ -326,30 +327,32 @@ class UIMixin(ADBBaseMixin):
# Find element # Find element
result = await self.ui_find_element(text=text, device_id=device_id) result = await self.ui_find_element(text=text, device_id=device_id)
if not result.get("success"): if not result.success:
return result return TapTextResult(success=False, error=result.error, action="tap_text")
matches = result.get("matches", []) matches = result.matches
if not matches: if not matches:
# Try content-desc as fallback # Try content-desc as fallback
result = await self.ui_find_element(content_desc=text, device_id=device_id) result = await self.ui_find_element(content_desc=text, device_id=device_id)
matches = result.get("matches", []) matches = result.matches
if not matches: if not matches:
return { return TapTextResult(
"success": False, success=False,
"error": f"No element found with text '{text}'", error=f"No element found with text '{text}'",
} action="tap_text",
)
element = matches[0] element = matches[0]
center = element.get("center") center = element.get("center")
if not center: if not center:
return { return TapTextResult(
"success": False, success=False,
"error": "Element found but could not determine coordinates", error="Element found but could not determine coordinates",
"element": element, action="tap_text",
} element=element,
)
# Tap the center # Tap the center
tap_result = await self.run_shell_args( tap_result = await self.run_shell_args(
@ -357,11 +360,11 @@ class UIMixin(ADBBaseMixin):
device_id, device_id,
) )
return { return TapTextResult(
"success": tap_result.success, success=tap_result.success,
"action": "tap_text", action="tap_text",
"text": text, text=text,
"coordinates": center, coordinates=center,
"element": element, element=element,
"error": tap_result.stderr if not tap_result.success else None, error=tap_result.stderr if not tap_result.success else None,
} )

View File

@ -1,7 +1,11 @@
"""Pydantic models for Android ADB MCP Server.""" """Pydantic models for Android ADB MCP Server."""
from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ── Data Models (not tool results) ──────────────────────────────────
class DeviceInfo(BaseModel): class DeviceInfo(BaseModel):
"""Android device information returned by ADB.""" """Android device information returned by ADB."""
@ -18,7 +22,7 @@ class DeviceInfo(BaseModel):
class CommandResult(BaseModel): class CommandResult(BaseModel):
"""Result of an ADB command execution.""" """Result of an ADB command execution (internal)."""
success: bool = Field(description="Whether the command succeeded") success: bool = Field(description="Whether the command succeeded")
stdout: str = Field(default="", description="Standard output from command") stdout: str = Field(default="", description="Standard output from command")
@ -26,11 +30,399 @@ class CommandResult(BaseModel):
returncode: int = Field(description="Command exit code") returncode: int = Field(description="Command exit code")
class ScreenshotResult(BaseModel): # ── Base Result ─────────────────────────────────────────────────────
class ADBResult(BaseModel):
"""Base result for all ADB tool operations."""
success: bool = Field(description="Whether the operation succeeded")
error: str | None = Field(None, description="Error message if operation failed")
class ActionResult(ADBResult):
"""Result of a simple action (tap, press, toggle, etc.)."""
action: str = Field(description="Action that was performed")
# ── Screenshot / Screen ─────────────────────────────────────────────
class ScreenshotResult(ADBResult):
"""Screenshot capture operation result.""" """Screenshot capture operation result."""
success: bool = Field(description="Whether screenshot was captured successfully")
local_path: str | None = Field( local_path: str | None = Field(
None, description="Absolute path to the saved screenshot file" None, description="Absolute path to the saved screenshot file"
) )
error: str | None = Field(None, description="Error message if operation failed")
class ScreenSizeResult(ADBResult):
"""Screen resolution information."""
width: int | None = Field(None, description="Screen width in pixels")
height: int | None = Field(None, description="Screen height in pixels")
raw: str | None = Field(None, description="Raw wm size output")
class ScreenDensityResult(ADBResult):
"""Screen density information."""
dpi: int | None = Field(None, description="Screen density in DPI")
raw: str | None = Field(None, description="Raw wm density output")
class RecordingResult(ADBResult):
"""Screen recording result."""
local_path: str | None = Field(None, description="Path to saved recording file")
duration_seconds: int | None = Field(None, description="Actual recording duration")
class ScreenSetResult(ActionResult):
"""Screen size override result."""
width: int | None = Field(None, description="New width in pixels")
height: int | None = Field(None, description="New height in pixels")
# ── Input ────────────────────────────────────────────────────────────
class InputResult(ActionResult):
"""Result of an input simulation action."""
coordinates: dict[str, int] | None = Field(
None, description="Tap/press coordinates {x, y}"
)
key_code: str | None = Field(None, description="Key code sent")
text: str | None = Field(None, description="Text typed or on clipboard")
duration_ms: int | None = Field(None, description="Duration in ms")
class SwipeResult(ActionResult):
"""Result of a swipe gesture."""
start: dict[str, int] = Field(description="Start coordinates {x, y}")
end: dict[str, int] = Field(description="End coordinates {x, y}")
duration_ms: int = Field(description="Swipe duration in ms")
class ClipboardSetResult(ActionResult):
"""Result of a clipboard set operation."""
text: str = Field(description="Text placed on clipboard (preview)")
pasted: bool | None = Field(None, description="Whether paste was performed")
paste_error: str | None = Field(None, description="Paste error if any")
class ShellResult(ADBResult):
"""Result of a shell command execution."""
command: str = Field(description="Command that was executed")
stdout: str = Field(default="", description="Standard output")
stderr: str = Field(default="", description="Standard error")
returncode: int = Field(default=0, description="Exit code")
# ── Apps ─────────────────────────────────────────────────────────────
class AppActionResult(ActionResult):
"""Result of an app management action."""
package: str | None = Field(None, description="Target package name")
url: str | None = Field(None, description="URL opened")
output: str | None = Field(None, description="Command output")
apk: str | None = Field(None, description="APK path")
kept_data: bool | None = Field(None, description="Whether data was preserved")
cancelled: bool | None = Field(None, description="Whether operation was cancelled")
message: str | None = Field(None, description="Status message")
class AppCurrentResult(ADBResult):
"""Currently focused app information."""
package: str | None = Field(None, description="Foreground package name")
activity: str | None = Field(None, description="Current activity class")
raw: str | None = Field(
None, description="Raw dumpsys output (if no package found)"
)
class PackageListResult(ADBResult):
"""List of installed packages."""
packages: list[str] = Field(default_factory=list, description="Package names")
count: int = Field(default=0, description="Number of packages")
class IntentResult(ActionResult):
"""Result of an intent-based operation."""
component: str | None = Field(None, description="Activity component")
intent_action: str | None = Field(None, description="Intent action")
data_uri: str | None = Field(None, description="Data URI")
broadcast_action: str | None = Field(None, description="Broadcast action sent")
package: str | None = Field(None, description="Target package")
output: str | None = Field(None, description="Command output")
# ── Devices ──────────────────────────────────────────────────────────
class DeviceSelectResult(ADBResult):
"""Device selection/status result."""
device: dict[str, Any] | str | None = Field(
None, description="Selected device info"
)
message: str | None = Field(None, description="Status message")
available: list[str] | dict[str, Any] | None = Field(
None, description="Available devices"
)
cached_info: Any | None = Field(None, description="Cached device info (if any)")
class DeviceInfoResult(ADBResult):
"""Comprehensive device information."""
battery: dict[str, Any] | None = Field(
None, description="Battery state (level, status, plugged)"
)
ip_address: str | None = Field(None, description="Device IP address")
wifi_ssid: str | None = Field(None, description="Connected WiFi SSID")
model: str | None = Field(None, description="Device model")
manufacturer: str | None = Field(None, description="Device manufacturer")
device_name: str | None = Field(None, description="Device codename")
android_version: str | None = Field(None, description="Android version")
sdk_version: str | None = Field(None, description="SDK API level")
storage: dict[str, int] | None = Field(
None, description="Storage info (total_kb, used_kb, available_kb)"
)
class RebootResult(ActionResult):
"""Device reboot result."""
mode: str = Field(description="Reboot mode (normal, recovery, bootloader)")
cancelled: bool | None = Field(None, description="Whether cancelled")
message: str | None = Field(None, description="Status message")
class LogcatResult(ADBResult):
"""Logcat capture result."""
action: str | None = Field(None, description="Action (logcat_clear)")
lines_requested: int | None = Field(None, description="Lines requested")
filter: str | None = Field(None, description="Filter spec applied")
output: str | None = Field(None, description="Log output")
# ── Connectivity ─────────────────────────────────────────────────────
class ConnectResult(ADBResult):
"""ADB network connection result."""
address: str = Field(description="Device address (host:port)")
output: str | None = Field(None, description="ADB command output")
already_connected: bool | None = Field(
None, description="Whether already connected"
)
class TcpipResult(ADBResult):
"""TCP/IP mode switch result."""
port: int | None = Field(None, description="ADB TCP port")
device_ip: str | None = Field(None, description="Device IP address")
connect_address: str | None = Field(
None, description="Address for adb_connect (ip:port)"
)
message: str | None = Field(None, description="Status message")
class DevicePropertiesResult(ADBResult):
"""Batch device properties result."""
identity: dict[str, str] | None = Field(
None, description="Identity props (model, manufacturer, serial)"
)
software: dict[str, str] | None = Field(
None, description="Software props (android version, sdk, build)"
)
hardware: dict[str, str] | None = Field(
None, description="Hardware props (chipset, ABI)"
)
system: dict[str, str] | None = Field(
None, description="System props (timezone, locale)"
)
# ── UI ───────────────────────────────────────────────────────────────
class UIDumpResult(ADBResult):
"""UI hierarchy dump result."""
xml: str | None = Field(None, description="Raw XML hierarchy")
clickable_elements: list[dict[str, Any]] = Field(
default_factory=list, description="Interactive elements"
)
element_count: int = Field(default=0, description="Number of interactive elements")
class UIFindResult(ADBResult):
"""UI element search result."""
matches: list[dict[str, Any]] = Field(
default_factory=list, description="Matching elements"
)
count: int = Field(default=0, description="Number of matches")
class WaitResult(ADBResult):
"""Wait/poll operation result."""
found: bool | None = Field(None, description="Whether text was found")
gone: bool | None = Field(None, description="Whether text disappeared")
element: dict[str, Any] | None = Field(None, description="Found element info")
wait_time: float | None = Field(None, description="Time waited in seconds")
attempts: int = Field(default=0, description="Poll attempts made")
class TapTextResult(ActionResult):
"""Tap-by-text result."""
text: str | None = Field(None, description="Text searched for")
coordinates: dict[str, int] | None = Field(None, description="Tapped coordinates")
element: dict[str, Any] | None = Field(None, description="Element that was tapped")
# ── Files ────────────────────────────────────────────────────────────
class FileTransferResult(ActionResult):
"""File push/pull result."""
local_path: str | None = Field(None, description="Host file path")
device_path: str | None = Field(None, description="Device file path")
output: str | None = Field(None, description="ADB output")
class FileListResult(ADBResult):
"""Directory listing result."""
path: str | None = Field(None, description="Listed directory path")
files: list[dict[str, Any]] = Field(
default_factory=list, description="File entries"
)
count: int = Field(default=0, description="Number of files")
class FileDeleteResult(ActionResult):
"""File deletion result."""
path: str | None = Field(None, description="Deleted file path")
cancelled: bool | None = Field(None, description="Whether cancelled")
message: str | None = Field(None, description="Status message")
class FileExistsResult(ADBResult):
"""File existence check result."""
path: str | None = Field(None, description="Checked path")
exists: bool = Field(description="Whether the file exists")
# ── Settings ─────────────────────────────────────────────────────────
class SettingGetResult(ADBResult):
"""Settings read result."""
namespace: str | None = Field(None, description="Settings namespace")
key: str | None = Field(None, description="Setting key")
value: str | None = Field(None, description="Setting value")
exists: bool | None = Field(None, description="Whether key exists")
class SettingPutResult(ADBResult):
"""Settings write result."""
namespace: str | None = Field(None, description="Settings namespace")
key: str | None = Field(None, description="Setting key")
value: str | None = Field(None, description="Value written")
readback: str | None = Field(None, description="Read-back verification")
verified: bool | None = Field(None, description="Whether verified")
cancelled: bool | None = Field(None, description="Whether cancelled")
message: str | None = Field(None, description="Status message")
class ToggleResult(ActionResult):
"""Radio/toggle result (wifi, bluetooth, airplane)."""
verified: bool | None = Field(None, description="Whether state verified")
wifi_on: str | None = Field(None, description="WiFi state after toggle")
airplane_mode: bool | None = Field(None, description="Airplane mode state")
cancelled: bool | None = Field(None, description="Whether cancelled")
message: str | None = Field(None, description="Status message")
class BrightnessResult(ADBResult):
"""Screen brightness result."""
brightness: int | None = Field(None, description="Brightness level 0-255")
auto_brightness: bool | None = Field(None, description="Auto-brightness state")
class TimeoutResult(ADBResult):
"""Screen timeout result."""
timeout_seconds: int | None = Field(None, description="Timeout in seconds")
timeout_ms: int | None = Field(None, description="Timeout in milliseconds")
class NotificationListResult(ADBResult):
"""Notification list result."""
notifications: list[dict[str, str | None]] = Field(
default_factory=list, description="Notification entries"
)
count: int = Field(default=0, description="Number of notifications")
class ClipboardGetResult(ADBResult):
"""Clipboard read result."""
text: str | None = Field(None, description="Clipboard text content")
method: str | None = Field(None, description="Method used to read")
class MediaControlResult(ActionResult):
"""Media control result."""
keycode: str | None = Field(None, description="Android keycode sent")
# ── Config ───────────────────────────────────────────────────────────
class ConfigStatusResult(BaseModel):
"""Server configuration status."""
developer_mode: bool = Field(description="Developer mode enabled")
auto_select_single_device: bool = Field(description="Auto-select when one device")
default_screenshot_dir: str | None = Field(
None, description="Screenshot output directory"
)
current_device: str | None = Field(None, description="Currently selected device")
class ConfigResult(ADBResult):
"""Configuration change result."""
developer_mode: bool | None = Field(None, description="Developer mode state")
screenshot_dir: str | None = Field(None, description="Screenshot directory")
message: str | None = Field(None, description="Status message")

View File

@ -29,6 +29,7 @@ from .mixins import (
SettingsMixin, SettingsMixin,
UIMixin, UIMixin,
) )
from .models import ConfigResult, ConfigStatusResult
class ADBServer( class ADBServer(
@ -59,7 +60,7 @@ class ADBServer(
# === Configuration Tools === # === Configuration Tools ===
@mcp_tool() @mcp_tool()
async def config_status(self) -> dict[str, Any]: async def config_status(self) -> ConfigStatusResult:
"""Get current server configuration. """Get current server configuration.
Shows developer mode status and other settings. Shows developer mode status and other settings.
@ -68,15 +69,15 @@ class ADBServer(
Current configuration values Current configuration values
""" """
config = get_config() config = get_config()
return { return ConfigStatusResult(
"developer_mode": config.developer_mode, developer_mode=config.developer_mode,
"auto_select_single_device": config.auto_select_single_device, auto_select_single_device=config.auto_select_single_device,
"default_screenshot_dir": config.default_screenshot_dir, default_screenshot_dir=config.default_screenshot_dir,
"current_device": self.get_current_device(), current_device=self.get_current_device(),
} )
@mcp_tool() @mcp_tool()
async def config_set_developer_mode(self, enabled: bool) -> dict[str, Any]: async def config_set_developer_mode(self, enabled: bool) -> ConfigResult:
"""Enable or disable developer mode. """Enable or disable developer mode.
Developer mode unlocks advanced tools: Developer mode unlocks advanced tools:
@ -97,18 +98,18 @@ class ADBServer(
config = get_config() config = get_config()
config.developer_mode = enabled config.developer_mode = enabled
return { return ConfigResult(
"success": True, success=True,
"developer_mode": enabled, developer_mode=enabled,
"message": ( message=(
"Developer mode enabled. Advanced tools are now available." "Developer mode enabled. Advanced tools are now available."
if enabled if enabled
else "Developer mode disabled. Using standard tools only." else "Developer mode disabled. Using standard tools only."
), ),
} )
@mcp_tool() @mcp_tool()
async def config_set_screenshot_dir(self, directory: str | None) -> dict[str, Any]: async def config_set_screenshot_dir(self, directory: str | None) -> ConfigResult:
"""Set default directory for screenshots. """Set default directory for screenshots.
Screenshots will be saved to this directory by default. Screenshots will be saved to this directory by default.
@ -123,10 +124,10 @@ class ADBServer(
config = get_config() config = get_config()
config.default_screenshot_dir = directory config.default_screenshot_dir = directory
return { return ConfigResult(
"success": True, success=True,
"screenshot_dir": directory, screenshot_dir=directory,
} )
# === Help / Discovery === # === Help / Discovery ===

View File

@ -9,8 +9,8 @@ class TestAppLaunch:
async def test_launch(self, server): async def test_launch(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.app_launch("com.android.chrome") result = await server.app_launch("com.android.chrome")
assert result["success"] is True assert result.success is True
assert result["package"] == "com.android.chrome" assert result.package == "com.android.chrome"
args = server.run_shell_args.call_args[0][0] args = server.run_shell_args.call_args[0][0]
assert "monkey" in args assert "monkey" in args
assert "com.android.chrome" in args assert "com.android.chrome" in args
@ -18,15 +18,15 @@ class TestAppLaunch:
async def test_failure(self, server): async def test_failure(self, server):
server.run_shell_args.return_value = fail("not found") server.run_shell_args.return_value = fail("not found")
result = await server.app_launch("com.missing.app") result = await server.app_launch("com.missing.app")
assert result["success"] is False assert result.success is False
class TestAppOpenUrl: class TestAppOpenUrl:
async def test_open(self, server): async def test_open(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.app_open_url("https://example.com") result = await server.app_open_url("https://example.com")
assert result["success"] is True assert result.success is True
assert result["url"] == "https://example.com" assert result.url == "https://example.com"
args = server.run_shell_args.call_args[0][0] args = server.run_shell_args.call_args[0][0]
assert "am" in args assert "am" in args
assert "android.intent.action.VIEW" in args assert "android.intent.action.VIEW" in args
@ -36,8 +36,8 @@ class TestAppClose:
async def test_close(self, server): async def test_close(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.app_close("com.example.app") result = await server.app_close("com.example.app")
assert result["success"] is True assert result.success is True
assert result["package"] == "com.example.app" assert result.package == "com.example.app"
args = server.run_shell_args.call_args[0][0] args = server.run_shell_args.call_args[0][0]
assert "am" in args assert "am" in args
assert "force-stop" in args assert "force-stop" in args
@ -51,22 +51,22 @@ class TestAppCurrent:
) )
server.run_shell_args.return_value = ok(stdout=focused) server.run_shell_args.return_value = ok(stdout=focused)
result = await server.app_current() result = await server.app_current()
assert result["success"] is True assert result.success is True
assert result["package"] == "com.android.chrome" assert result.package == "com.android.chrome"
async def test_focused_app_format(self, server): async def test_focused_app_format(self, server):
server.run_shell_args.return_value = ok( server.run_shell_args.return_value = ok(
stdout=" mFocusedApp=ActivityRecord{abc com.example/.MainActivity t123}" stdout=" mFocusedApp=ActivityRecord{abc com.example/.MainActivity t123}"
) )
result = await server.app_current() result = await server.app_current()
assert result["success"] is True assert result.success is True
assert result["package"] == "com.example" assert result.package == "com.example"
async def test_no_focus(self, server): async def test_no_focus(self, server):
server.run_shell_args.return_value = ok(stdout="no focus info") server.run_shell_args.return_value = ok(stdout="no focus info")
result = await server.app_current() result = await server.app_current()
assert result["success"] is True assert result.success is True
assert result["package"] is None assert result.package is None
class TestAppListPackages: class TestAppListPackages:
@ -76,9 +76,9 @@ class TestAppListPackages:
stdout="package:com.android.chrome\npackage:com.example.app\n" stdout="package:com.android.chrome\npackage:com.example.app\n"
) )
result = await server.app_list_packages() result = await server.app_list_packages()
assert result["success"] is True assert result.success is True
assert result["count"] == 2 assert result.count == 2
assert "com.android.chrome" in result["packages"] assert "com.android.chrome" in result.packages
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_filter(self, server): async def test_filter(self, server):
@ -86,8 +86,8 @@ class TestAppListPackages:
stdout="package:com.android.chrome\npackage:com.example.app\n" stdout="package:com.android.chrome\npackage:com.example.app\n"
) )
result = await server.app_list_packages(filter_text="chrome") result = await server.app_list_packages(filter_text="chrome")
assert result["count"] == 1 assert result.count == 1
assert "com.android.chrome" in result["packages"] assert "com.android.chrome" in result.packages
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_third_party(self, server): async def test_third_party(self, server):
@ -99,7 +99,7 @@ class TestAppListPackages:
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.app_list_packages() result = await server.app_list_packages()
assert result["success"] is False assert result.success is False
class TestAppInstall: class TestAppInstall:
@ -107,7 +107,7 @@ class TestAppInstall:
async def test_install(self, server): async def test_install(self, server):
server.run_adb.return_value = ok(stdout="Success") server.run_adb.return_value = ok(stdout="Success")
result = await server.app_install("/tmp/app.apk") result = await server.app_install("/tmp/app.apk")
assert result["success"] is True assert result.success is True
args = server.run_adb.call_args[0][0] args = server.run_adb.call_args[0][0]
assert "install" in args assert "install" in args
assert "-r" in args assert "-r" in args
@ -115,7 +115,7 @@ class TestAppInstall:
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.app_install("/tmp/app.apk") result = await server.app_install("/tmp/app.apk")
assert result["success"] is False assert result.success is False
class TestAppUninstall: class TestAppUninstall:
@ -124,15 +124,15 @@ class TestAppUninstall:
ctx.set_elicit("accept", "Yes, uninstall") ctx.set_elicit("accept", "Yes, uninstall")
server.run_adb.return_value = ok() server.run_adb.return_value = ok()
result = await server.app_uninstall(ctx, "com.example.app") result = await server.app_uninstall(ctx, "com.example.app")
assert result["success"] is True assert result.success is True
assert result["package"] == "com.example.app" assert result.package == "com.example.app"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_keep_data(self, server, ctx): async def test_keep_data(self, server, ctx):
ctx.set_elicit("accept", "Yes, uninstall") ctx.set_elicit("accept", "Yes, uninstall")
server.run_adb.return_value = ok() server.run_adb.return_value = ok()
result = await server.app_uninstall(ctx, "com.example.app", keep_data=True) result = await server.app_uninstall(ctx, "com.example.app", keep_data=True)
assert result["kept_data"] is True assert result.kept_data is True
args = server.run_adb.call_args[0][0] args = server.run_adb.call_args[0][0]
assert "-k" in args assert "-k" in args
@ -140,8 +140,8 @@ class TestAppUninstall:
async def test_cancelled(self, server, ctx): async def test_cancelled(self, server, ctx):
ctx.set_elicit("accept", "Cancel") ctx.set_elicit("accept", "Cancel")
result = await server.app_uninstall(ctx, "com.example.app") result = await server.app_uninstall(ctx, "com.example.app")
assert result["success"] is False assert result.success is False
assert result.get("cancelled") is True assert result.cancelled is True
class TestAppClearData: class TestAppClearData:
@ -150,13 +150,13 @@ class TestAppClearData:
ctx.set_elicit("accept", "Yes, clear all data") ctx.set_elicit("accept", "Yes, clear all data")
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.app_clear_data(ctx, "com.example.app") result = await server.app_clear_data(ctx, "com.example.app")
assert result["success"] is True assert result.success is True
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_cancelled(self, server, ctx): async def test_cancelled(self, server, ctx):
ctx.set_elicit("accept", "Cancel") ctx.set_elicit("accept", "Cancel")
result = await server.app_clear_data(ctx, "com.example.app") result = await server.app_clear_data(ctx, "com.example.app")
assert result.get("cancelled") is True assert result.cancelled is True
class TestActivityStart: class TestActivityStart:
@ -164,8 +164,8 @@ class TestActivityStart:
async def test_basic(self, server): async def test_basic(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.activity_start("com.example/.MainActivity") result = await server.activity_start("com.example/.MainActivity")
assert result["success"] is True assert result.success is True
assert result["component"] == "com.example/.MainActivity" assert result.component == "com.example/.MainActivity"
args = server.run_shell_args.call_args[0][0] args = server.run_shell_args.call_args[0][0]
assert "am" in args assert "am" in args
assert "start" in args assert "start" in args
@ -208,7 +208,7 @@ class TestActivityStart:
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.activity_start("com.example/.Act") result = await server.activity_start("com.example/.Act")
assert result["success"] is False assert result.success is False
class TestBroadcastSend: class TestBroadcastSend:
@ -216,8 +216,8 @@ class TestBroadcastSend:
async def test_basic(self, server): async def test_basic(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.broadcast_send("com.example.ACTION") result = await server.broadcast_send("com.example.ACTION")
assert result["success"] is True assert result.success is True
assert result["broadcast_action"] == "com.example.ACTION" assert result.broadcast_action == "com.example.ACTION"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_with_package(self, server): async def test_with_package(self, server):
@ -230,4 +230,4 @@ class TestBroadcastSend:
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.broadcast_send("ACTION") result = await server.broadcast_send("ACTION")
assert result["success"] is False assert result.success is False

View File

@ -9,38 +9,38 @@ class TestAdbConnect:
async def test_success(self, server): async def test_success(self, server):
server.run_adb.return_value = ok(stdout="connected to 10.0.0.1:5555") server.run_adb.return_value = ok(stdout="connected to 10.0.0.1:5555")
result = await server.adb_connect("10.0.0.1") result = await server.adb_connect("10.0.0.1")
assert result["success"] is True assert result.success is True
assert result["address"] == "10.0.0.1:5555" assert result.address == "10.0.0.1:5555"
server.run_adb.assert_called_once_with(["connect", "10.0.0.1:5555"]) server.run_adb.assert_called_once_with(["connect", "10.0.0.1:5555"])
async def test_custom_port(self, server): async def test_custom_port(self, server):
server.run_adb.return_value = ok(stdout="connected to 10.0.0.1:5556") server.run_adb.return_value = ok(stdout="connected to 10.0.0.1:5556")
result = await server.adb_connect("10.0.0.1", port=5556) result = await server.adb_connect("10.0.0.1", port=5556)
assert result["address"] == "10.0.0.1:5556" assert result.address == "10.0.0.1:5556"
async def test_already_connected(self, server): async def test_already_connected(self, server):
server.run_adb.return_value = ok(stdout="already connected to 10.0.0.1:5555") server.run_adb.return_value = ok(stdout="already connected to 10.0.0.1:5555")
result = await server.adb_connect("10.0.0.1") result = await server.adb_connect("10.0.0.1")
assert result["success"] is True assert result.success is True
assert result["already_connected"] is True assert result.already_connected is True
async def test_failure(self, server): async def test_failure(self, server):
server.run_adb.return_value = ok(stdout="failed to connect") server.run_adb.return_value = ok(stdout="failed to connect")
result = await server.adb_connect("10.0.0.1") result = await server.adb_connect("10.0.0.1")
assert result["success"] is False assert result.success is False
class TestAdbDisconnect: class TestAdbDisconnect:
async def test_success(self, server): async def test_success(self, server):
server.run_adb.return_value = ok(stdout="disconnected 10.0.0.1:5555") server.run_adb.return_value = ok(stdout="disconnected 10.0.0.1:5555")
result = await server.adb_disconnect("10.0.0.1") result = await server.adb_disconnect("10.0.0.1")
assert result["success"] is True assert result.success is True
assert result["address"] == "10.0.0.1:5555" assert result.address == "10.0.0.1:5555"
async def test_failure(self, server): async def test_failure(self, server):
server.run_adb.return_value = ok(stdout="error: no such device") server.run_adb.return_value = ok(stdout="error: no such device")
result = await server.adb_disconnect("10.0.0.1") result = await server.adb_disconnect("10.0.0.1")
assert result["success"] is False assert result.success is False
class TestAdbTcpip: class TestAdbTcpip:
@ -51,23 +51,23 @@ class TestAdbTcpip:
) )
server.run_adb.return_value = ok(stdout="restarting in TCP mode port: 5555") server.run_adb.return_value = ok(stdout="restarting in TCP mode port: 5555")
result = await server.adb_tcpip(ctx) result = await server.adb_tcpip(ctx)
assert result["success"] is True assert result.success is True
assert result["device_ip"] == "192.168.1.100" assert result.device_ip == "192.168.1.100"
assert result["connect_address"] == "192.168.1.100:5555" assert result.connect_address == "192.168.1.100:5555"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_rejects_network_device(self, server, ctx): async def test_rejects_network_device(self, server, ctx):
server.set_current_device("10.20.0.25:5555") server.set_current_device("10.20.0.25:5555")
result = await server.adb_tcpip(ctx) result = await server.adb_tcpip(ctx)
assert result["success"] is False assert result.success is False
assert "already a network device" in result["error"] assert "already a network device" in result.error
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_no_wifi_ip(self, server, ctx): async def test_no_wifi_ip(self, server, ctx):
server.run_shell_args.return_value = ok(stdout="wlan0: no ip") server.run_shell_args.return_value = ok(stdout="wlan0: no ip")
result = await server.adb_tcpip(ctx) result = await server.adb_tcpip(ctx)
assert result["success"] is False assert result.success is False
assert "WiFi" in result["error"] assert "WiFi" in result.error
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_custom_port(self, server, ctx): async def test_custom_port(self, server, ctx):
@ -76,27 +76,27 @@ class TestAdbTcpip:
) )
server.run_adb.return_value = ok() server.run_adb.return_value = ok()
result = await server.adb_tcpip(ctx, port=5556) result = await server.adb_tcpip(ctx, port=5556)
assert result["port"] == 5556 assert result.port == 5556
assert result["connect_address"] == "192.168.1.50:5556" assert result.connect_address == "192.168.1.50:5556"
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.adb_tcpip(ctx) result = await server.adb_tcpip(ctx)
assert result["success"] is False assert result.success is False
assert "developer mode" in result["error"].lower() assert "developer mode" in result.error.lower()
class TestAdbPair: class TestAdbPair:
async def test_success(self, server): async def test_success(self, server):
server.run_adb.return_value = ok(stdout="Successfully paired to 10.0.0.1:37000") server.run_adb.return_value = ok(stdout="Successfully paired to 10.0.0.1:37000")
result = await server.adb_pair("10.0.0.1", 37000, "123456") result = await server.adb_pair("10.0.0.1", 37000, "123456")
assert result["success"] is True assert result.success is True
server.run_adb.assert_called_once_with(["pair", "10.0.0.1:37000", "123456"]) server.run_adb.assert_called_once_with(["pair", "10.0.0.1:37000", "123456"])
async def test_failure(self, server): async def test_failure(self, server):
server.run_adb.return_value = fail("Failed: wrong code") server.run_adb.return_value = fail("Failed: wrong code")
result = await server.adb_pair("10.0.0.1", 37000, "000000") result = await server.adb_pair("10.0.0.1", 37000, "000000")
assert result["success"] is False assert result.success is False
class TestDeviceProperties: class TestDeviceProperties:
@ -110,13 +110,13 @@ class TestDeviceProperties:
} }
server.get_device_property.side_effect = lambda p, d=None: props.get(p) server.get_device_property.side_effect = lambda p, d=None: props.get(p)
result = await server.device_properties() result = await server.device_properties()
assert result["success"] is True assert result.success is True
assert result["identity"]["model"] == "Pixel 6" assert result.identity["model"] == "Pixel 6"
assert result["software"]["android_version"] == "14" assert result.software["android_version"] == "14"
assert result["hardware"]["chipset"] == "gs101" assert result.hardware["chipset"] == "gs101"
async def test_no_properties(self, server): async def test_no_properties(self, server):
server.get_device_property.return_value = None server.get_device_property.return_value = None
result = await server.device_properties() result = await server.device_properties()
assert result["success"] is False assert result.success is False
assert "No properties" in result["error"] assert "No properties" in result.error

View File

@ -37,7 +37,7 @@ class TestDevicesUse:
stdout="List of devices attached\nABC123\tdevice\n" stdout="List of devices attached\nABC123\tdevice\n"
) )
result = await server.devices_use("ABC123") result = await server.devices_use("ABC123")
assert result["success"] is True assert result.success is True
assert server.get_current_device() == "ABC123" assert server.get_current_device() == "ABC123"
async def test_not_found(self, server): async def test_not_found(self, server):
@ -45,30 +45,30 @@ class TestDevicesUse:
stdout="List of devices attached\nOTHER\tdevice\n" stdout="List of devices attached\nOTHER\tdevice\n"
) )
result = await server.devices_use("MISSING") result = await server.devices_use("MISSING")
assert result["success"] is False assert result.success is False
assert "not found" in result["error"] assert "not found" in result.error
async def test_offline_device(self, server): async def test_offline_device(self, server):
server.run_adb.return_value = ok( server.run_adb.return_value = ok(
stdout="List of devices attached\nABC123\toffline\n" stdout="List of devices attached\nABC123\toffline\n"
) )
result = await server.devices_use("ABC123") result = await server.devices_use("ABC123")
assert result["success"] is False assert result.success is False
assert "offline" in result["error"] assert "offline" in result.error
class TestDevicesCurrent: class TestDevicesCurrent:
async def test_no_device_set(self, server): async def test_no_device_set(self, server):
server.run_adb.return_value = ok(stdout="List of devices attached\n") server.run_adb.return_value = ok(stdout="List of devices attached\n")
result = await server.devices_current() result = await server.devices_current()
assert result["device"] is None assert result.device is None
async def test_auto_detect_single(self, server): async def test_auto_detect_single(self, server):
server.run_adb.return_value = ok( server.run_adb.return_value = ok(
stdout="List of devices attached\nABC123\tdevice\n" stdout="List of devices attached\nABC123\tdevice\n"
) )
result = await server.devices_current() result = await server.devices_current()
assert result.get("available") is not None assert result.available is not None
async def test_device_set(self, server): async def test_device_set(self, server):
# Pre-populate cache and set device # Pre-populate cache and set device
@ -78,7 +78,8 @@ class TestDevicesCurrent:
await server.devices_list() await server.devices_list()
server.set_current_device("ABC123") server.set_current_device("ABC123")
result = await server.devices_current() result = await server.devices_current()
assert result["device"]["device_id"] == "ABC123" # device is a dict from model_dump()
assert result.device["device_id"] == "ABC123"
class TestDeviceInfo: class TestDeviceInfo:
@ -105,16 +106,16 @@ class TestDeviceInfo:
}.get(p) }.get(p)
result = await server.device_info() result = await server.device_info()
assert result["success"] is True assert result.success is True
assert result["battery"]["level"] == 85 assert result.battery["level"] == 85
assert result["ip_address"] == "192.168.1.100" assert result.ip_address == "192.168.1.100"
assert result["wifi_ssid"] == "MyNetwork" assert result.wifi_ssid == "MyNetwork"
assert result["model"] == "Pixel 6" assert result.model == "Pixel 6"
async def test_device_offline(self, server): async def test_device_offline(self, server):
server.run_shell_args.return_value = fail("device offline") server.run_shell_args.return_value = fail("device offline")
result = await server.device_info() result = await server.device_info()
assert result["success"] is False assert result.success is False
class TestDeviceReboot: class TestDeviceReboot:
@ -123,27 +124,27 @@ class TestDeviceReboot:
ctx.set_elicit("accept", "Yes, reboot now") ctx.set_elicit("accept", "Yes, reboot now")
server.run_adb.return_value = ok() server.run_adb.return_value = ok()
result = await server.device_reboot(ctx) result = await server.device_reboot(ctx)
assert result["success"] is True assert result.success is True
assert result["mode"] == "normal" assert result.mode == "normal"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_reboot_recovery(self, server, ctx): async def test_reboot_recovery(self, server, ctx):
ctx.set_elicit("accept", "Yes, reboot now") ctx.set_elicit("accept", "Yes, reboot now")
server.run_adb.return_value = ok() server.run_adb.return_value = ok()
result = await server.device_reboot(ctx, mode="recovery") result = await server.device_reboot(ctx, mode="recovery")
assert result["mode"] == "recovery" assert result.mode == "recovery"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_cancelled(self, server, ctx): async def test_cancelled(self, server, ctx):
ctx.set_elicit("accept", "Cancel") ctx.set_elicit("accept", "Cancel")
result = await server.device_reboot(ctx) result = await server.device_reboot(ctx)
assert result["success"] is False assert result.success is False
assert result.get("cancelled") is True assert result.cancelled is True
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.device_reboot(ctx) result = await server.device_reboot(ctx)
assert result["success"] is False assert result.success is False
class TestLogcat: class TestLogcat:
@ -152,30 +153,30 @@ class TestLogcat:
logline = "01-01 00:00:00.000 I/TAG: message" logline = "01-01 00:00:00.000 I/TAG: message"
server.run_shell_args.return_value = ok(stdout=logline) server.run_shell_args.return_value = ok(stdout=logline)
result = await server.logcat_capture() result = await server.logcat_capture()
assert result["success"] is True assert result.success is True
assert result["output"].startswith("01-01") assert result.output.startswith("01-01")
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_with_filter(self, server): async def test_with_filter(self, server):
server.run_shell_args.return_value = ok(stdout="filtered output") server.run_shell_args.return_value = ok(stdout="filtered output")
result = await server.logcat_capture(filter_spec="MyApp:D *:S") result = await server.logcat_capture(filter_spec="MyApp:D *:S")
assert result["filter"] == "MyApp:D *:S" assert result.filter == "MyApp:D *:S"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_clear_first(self, server): async def test_clear_first(self, server):
server.run_shell_args.side_effect = [ok(), ok(stdout="fresh logs")] server.run_shell_args.side_effect = [ok(), ok(stdout="fresh logs")]
result = await server.logcat_capture(clear_first=True) result = await server.logcat_capture(clear_first=True)
assert result["success"] is True assert result.success is True
assert server.run_shell_args.call_count == 2 assert server.run_shell_args.call_count == 2
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.logcat_capture() result = await server.logcat_capture()
assert result["success"] is False assert result.success is False
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_logcat_clear(self, server): async def test_logcat_clear(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.logcat_clear() result = await server.logcat_clear()
assert result["success"] is True assert result.success is True
assert result["action"] == "logcat_clear" assert result.action == "logcat_clear"

View File

@ -12,19 +12,19 @@ class TestFilePush:
local_file.write_text("content") local_file.write_text("content")
server.run_adb.return_value = ok(stdout="1 file pushed") server.run_adb.return_value = ok(stdout="1 file pushed")
result = await server.file_push(ctx, str(local_file), "/sdcard/test.txt") result = await server.file_push(ctx, str(local_file), "/sdcard/test.txt")
assert result["success"] is True assert result.success is True
assert result["device_path"] == "/sdcard/test.txt" assert result.device_path == "/sdcard/test.txt"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_local_not_found(self, server, ctx): async def test_local_not_found(self, server, ctx):
result = await server.file_push(ctx, "/nonexistent/file.txt", "/sdcard/") result = await server.file_push(ctx, "/nonexistent/file.txt", "/sdcard/")
assert result["success"] is False assert result.success is False
assert "not found" in result["error"] assert "not found" in result.error
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.file_push(ctx, "/tmp/f", "/sdcard/f") result = await server.file_push(ctx, "/tmp/f", "/sdcard/f")
assert result["success"] is False assert result.success is False
class TestFilePull: class TestFilePull:
@ -34,18 +34,18 @@ class TestFilePull:
result = await server.file_pull( result = await server.file_pull(
ctx, "/sdcard/test.txt", str(tmp_path / "out.txt") ctx, "/sdcard/test.txt", str(tmp_path / "out.txt")
) )
assert result["success"] is True assert result.success is True
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_default_local_path(self, server, ctx): async def test_default_local_path(self, server, ctx):
server.run_adb.return_value = ok() server.run_adb.return_value = ok()
result = await server.file_pull(ctx, "/sdcard/data.db") result = await server.file_pull(ctx, "/sdcard/data.db")
assert "data.db" in result["local_path"] assert "data.db" in result.local_path
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.file_pull(ctx, "/sdcard/f") result = await server.file_pull(ctx, "/sdcard/f")
assert result["success"] is False assert result.success is False
class TestFileList: class TestFileList:
@ -59,23 +59,23 @@ class TestFileList:
) )
) )
result = await server.file_list("/sdcard/") result = await server.file_list("/sdcard/")
assert result["success"] is True assert result.success is True
assert result["count"] == 2 assert result.count == 2
assert result["files"][0]["name"] == "Documents" assert result.files[0]["name"] == "Documents"
assert result["files"][0]["is_directory"] is True assert result.files[0]["is_directory"] is True
assert result["files"][1]["name"] == "test.txt" assert result.files[1]["name"] == "test.txt"
assert result["files"][1]["is_directory"] is False assert result.files[1]["is_directory"] is False
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_failure(self, server): async def test_failure(self, server):
server.run_shell_args.return_value = fail("No such file") server.run_shell_args.return_value = fail("No such file")
result = await server.file_list("/nonexistent/") result = await server.file_list("/nonexistent/")
assert result["success"] is False assert result.success is False
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.file_list() result = await server.file_list()
assert result["success"] is False assert result.success is False
class TestFileDelete: class TestFileDelete:
@ -84,20 +84,20 @@ class TestFileDelete:
ctx.set_elicit("accept", "Yes, delete") ctx.set_elicit("accept", "Yes, delete")
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.file_delete(ctx, "/sdcard/old.txt") result = await server.file_delete(ctx, "/sdcard/old.txt")
assert result["success"] is True assert result.success is True
assert result["path"] == "/sdcard/old.txt" assert result.path == "/sdcard/old.txt"
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_cancelled(self, server, ctx): async def test_cancelled(self, server, ctx):
ctx.set_elicit("accept", "Cancel") ctx.set_elicit("accept", "Cancel")
result = await server.file_delete(ctx, "/sdcard/keep.txt") result = await server.file_delete(ctx, "/sdcard/keep.txt")
assert result["success"] is False assert result.success is False
assert result.get("cancelled") is True assert result.cancelled is True
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.file_delete(ctx, "/sdcard/f") result = await server.file_delete(ctx, "/sdcard/f")
assert result["success"] is False assert result.success is False
class TestFileExists: class TestFileExists:
@ -105,16 +105,16 @@ class TestFileExists:
async def test_exists(self, server): async def test_exists(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.file_exists("/sdcard/file.txt") result = await server.file_exists("/sdcard/file.txt")
assert result["success"] is True assert result.success is True
assert result["exists"] is True assert result.exists is True
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_not_exists(self, server): async def test_not_exists(self, server):
server.run_shell_args.return_value = fail() server.run_shell_args.return_value = fail()
result = await server.file_exists("/sdcard/missing.txt") result = await server.file_exists("/sdcard/missing.txt")
assert result["exists"] is False assert result.exists is False
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.file_exists("/sdcard/f") result = await server.file_exists("/sdcard/f")
assert result["success"] is False assert result.success is False

View File

@ -9,8 +9,8 @@ class TestInputTap:
async def test_tap(self, server): async def test_tap(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_tap(100, 200) result = await server.input_tap(100, 200)
assert result["success"] is True assert result.success is True
assert result["coordinates"] == {"x": 100, "y": 200} assert result.coordinates == {"x": 100, "y": 200}
server.run_shell_args.assert_called_once_with( server.run_shell_args.assert_called_once_with(
["input", "tap", "100", "200"], None ["input", "tap", "100", "200"], None
) )
@ -25,23 +25,23 @@ class TestInputTap:
async def test_tap_failure(self, server): async def test_tap_failure(self, server):
server.run_shell_args.return_value = fail("no device") server.run_shell_args.return_value = fail("no device")
result = await server.input_tap(0, 0) result = await server.input_tap(0, 0)
assert result["success"] is False assert result.success is False
assert result["error"] == "no device" assert result.error == "no device"
class TestInputSwipe: class TestInputSwipe:
async def test_swipe(self, server): async def test_swipe(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_swipe(0, 100, 0, 500, duration_ms=500) result = await server.input_swipe(0, 100, 0, 500, duration_ms=500)
assert result["success"] is True assert result.success is True
assert result["from"] == {"x": 0, "y": 100} assert result.start == {"x": 0, "y": 100}
assert result["to"] == {"x": 0, "y": 500} assert result.end == {"x": 0, "y": 500}
assert result["duration_ms"] == 500 assert result.duration_ms == 500
async def test_swipe_default_duration(self, server): async def test_swipe_default_duration(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_swipe(0, 0, 100, 100) result = await server.input_swipe(0, 0, 100, 100)
assert result["duration_ms"] == 300 assert result.duration_ms == 300
class TestInputScroll: class TestInputScroll:
@ -52,8 +52,8 @@ class TestInputScroll:
ok(), ok(),
] ]
result = await server.input_scroll_down() result = await server.input_scroll_down()
assert result["success"] is True assert result.success is True
assert result["action"] == "scroll_down" assert result.action == "scroll_down"
# Verify swipe args: center x, 65% down to 25% down # Verify swipe args: center x, 65% down to 25% down
swipe_call = server.run_shell_args.call_args_list[1] swipe_call = server.run_shell_args.call_args_list[1]
@ -70,8 +70,8 @@ class TestInputScroll:
ok(), ok(),
] ]
result = await server.input_scroll_up() result = await server.input_scroll_up()
assert result["success"] is True assert result.success is True
assert result["action"] == "scroll_up" assert result.action == "scroll_up"
async def test_scroll_fallback_dimensions(self, server): async def test_scroll_fallback_dimensions(self, server):
# wm size fails, should fall back to 1080x1920 # wm size fails, should fall back to 1080x1920
@ -80,14 +80,14 @@ class TestInputScroll:
ok(), ok(),
] ]
result = await server.input_scroll_down() result = await server.input_scroll_down()
assert result["success"] is True assert result.success is True
class TestInputKeys: class TestInputKeys:
async def test_back(self, server): async def test_back(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_back() result = await server.input_back()
assert result["action"] == "back" assert result.action == "back"
server.run_shell_args.assert_called_once_with( server.run_shell_args.assert_called_once_with(
["input", "keyevent", "KEYCODE_BACK"], None ["input", "keyevent", "KEYCODE_BACK"], None
) )
@ -95,46 +95,46 @@ class TestInputKeys:
async def test_home(self, server): async def test_home(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_home() result = await server.input_home()
assert result["action"] == "home" assert result.action == "home"
async def test_recent_apps(self, server): async def test_recent_apps(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_recent_apps() result = await server.input_recent_apps()
assert result["action"] == "recent_apps" assert result.action == "recent_apps"
class TestInputKey: class TestInputKey:
async def test_full_keycode(self, server): async def test_full_keycode(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_key("KEYCODE_ENTER") result = await server.input_key("KEYCODE_ENTER")
assert result["key_code"] == "KEYCODE_ENTER" assert result.key_code == "KEYCODE_ENTER"
async def test_auto_prefix(self, server): async def test_auto_prefix(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_key("ENTER") result = await server.input_key("ENTER")
assert result["key_code"] == "KEYCODE_ENTER" assert result.key_code == "KEYCODE_ENTER"
async def test_strips_dangerous_chars(self, server): async def test_strips_dangerous_chars(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_key("KEYCODE_ENTER; rm -rf /") result = await server.input_key("KEYCODE_ENTER; rm -rf /")
# Shell metacharacters stripped # Shell metacharacters stripped
assert ";" not in result["key_code"] assert ";" not in result.key_code
assert " " not in result["key_code"] assert " " not in result.key_code
assert "-" not in result["key_code"] assert "-" not in result.key_code
assert "/" not in result["key_code"] assert "/" not in result.key_code
async def test_lowercase_normalized(self, server): async def test_lowercase_normalized(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_key("enter") result = await server.input_key("enter")
assert result["key_code"] == "KEYCODE_ENTER" assert result.key_code == "KEYCODE_ENTER"
class TestInputText: class TestInputText:
async def test_simple_text(self, server): async def test_simple_text(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_text("hello") result = await server.input_text("hello")
assert result["success"] is True assert result.success is True
assert result["text"] == "hello" assert result.text == "hello"
server.run_shell_args.assert_called_once_with(["input", "text", "hello"], None) server.run_shell_args.assert_called_once_with(["input", "text", "hello"], None)
async def test_spaces_escaped(self, server): async def test_spaces_escaped(self, server):
@ -147,20 +147,20 @@ class TestInputText:
async def test_rejects_special_chars(self, server): async def test_rejects_special_chars(self, server):
for char in "'\"\\`$(){}[]|&;<>!~#%^*?": for char in "'\"\\`$(){}[]|&;<>!~#%^*?":
result = await server.input_text(f"text{char}here") result = await server.input_text(f"text{char}here")
assert result["success"] is False assert result.success is False
assert "clipboard_set" in result["error"] assert "clipboard_set" in result.error
async def test_rejects_semicolon_injection(self, server): async def test_rejects_semicolon_injection(self, server):
result = await server.input_text("hello; rm -rf /") result = await server.input_text("hello; rm -rf /")
assert result["success"] is False assert result.success is False
class TestClipboardSet: class TestClipboardSet:
async def test_cmd_clipboard(self, server): async def test_cmd_clipboard(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.clipboard_set("test text") result = await server.clipboard_set("test text")
assert result["success"] is True assert result.success is True
assert result["action"] == "clipboard_set" assert result.action == "clipboard_set"
async def test_cmd_clipboard_not_implemented_falls_back(self, server): async def test_cmd_clipboard_not_implemented_falls_back(self, server):
# First call: cmd clipboard returns "no shell command" # First call: cmd clipboard returns "no shell command"
@ -170,7 +170,7 @@ class TestClipboardSet:
ok(stdout="Broadcast completed: result=-1"), ok(stdout="Broadcast completed: result=-1"),
] ]
result = await server.clipboard_set("test") result = await server.clipboard_set("test")
assert result["success"] is True assert result.success is True
assert server.run_shell_args.call_count == 2 assert server.run_shell_args.call_count == 2
async def test_no_receiver_reports_failure(self, server): async def test_no_receiver_reports_failure(self, server):
@ -179,15 +179,15 @@ class TestClipboardSet:
ok(stdout="Broadcast completed: result=0"), # No receiver ok(stdout="Broadcast completed: result=0"), # No receiver
] ]
result = await server.clipboard_set("test") result = await server.clipboard_set("test")
assert result["success"] is False assert result.success is False
assert "no broadcast receiver" in result["error"].lower() assert "no broadcast receiver" in result.error.lower()
async def test_paste(self, server): async def test_paste(self, server):
# First call: cmd clipboard set, second call: paste keyevent # First call: cmd clipboard set, second call: paste keyevent
server.run_shell_args.side_effect = [ok(), ok()] server.run_shell_args.side_effect = [ok(), ok()]
result = await server.clipboard_set("text", paste=True) result = await server.clipboard_set("text", paste=True)
assert result["success"] is True assert result.success is True
assert result["pasted"] is True assert result.pasted is True
# Verify KEYCODE_PASTE was sent # Verify KEYCODE_PASTE was sent
paste_call = server.run_shell_args.call_args_list[1] paste_call = server.run_shell_args.call_args_list[1]
assert "KEYCODE_PASTE" in paste_call[0][0] assert "KEYCODE_PASTE" in paste_call[0][0]
@ -196,8 +196,8 @@ class TestClipboardSet:
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
long_text = "x" * 200 long_text = "x" * 200
result = await server.clipboard_set(long_text) result = await server.clipboard_set(long_text)
assert len(result["text"]) < 200 assert len(result.text) < 200
assert result["text"].endswith("...") assert result.text.endswith("...")
class TestInputLongPress: class TestInputLongPress:
@ -205,9 +205,9 @@ class TestInputLongPress:
async def test_long_press(self, server): async def test_long_press(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.input_long_press(100, 200, duration_ms=2000) result = await server.input_long_press(100, 200, duration_ms=2000)
assert result["success"] is True assert result.success is True
assert result["action"] == "long_press" assert result.action == "long_press"
assert result["duration_ms"] == 2000 assert result.duration_ms == 2000
# Long press = swipe from same point to same point # Long press = swipe from same point to same point
args = server.run_shell_args.call_args[0][0] args = server.run_shell_args.call_args[0][0]
assert args[2] == args[4] # x1 == x2 assert args[2] == args[4] # x1 == x2
@ -216,8 +216,8 @@ class TestInputLongPress:
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.input_long_press(0, 0) result = await server.input_long_press(0, 0)
assert result["success"] is False assert result.success is False
assert "developer mode" in result["error"].lower() assert "developer mode" in result.error.lower()
class TestShellCommand: class TestShellCommand:
@ -225,12 +225,12 @@ class TestShellCommand:
async def test_executes(self, server): async def test_executes(self, server):
server.run_shell.return_value = ok(stdout="output") server.run_shell.return_value = ok(stdout="output")
result = await server.shell_command("ls /sdcard") result = await server.shell_command("ls /sdcard")
assert result["success"] is True assert result.success is True
assert result["stdout"] == "output" assert result.stdout == "output"
assert result["command"] == "ls /sdcard" assert result.command == "ls /sdcard"
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.shell_command("ls") result = await server.shell_command("ls")
assert result["success"] is False assert result.success is False
assert "developer mode" in result["error"].lower() assert "developer mode" in result.error.lower()

View File

@ -34,49 +34,49 @@ class TestScreenSize:
async def test_physical(self, server): async def test_physical(self, server):
server.run_shell_args.return_value = ok(stdout="Physical size: 1080x1920") server.run_shell_args.return_value = ok(stdout="Physical size: 1080x1920")
result = await server.screen_size() result = await server.screen_size()
assert result["success"] is True assert result.success is True
assert result["width"] == 1080 assert result.width == 1080
assert result["height"] == 1920 assert result.height == 1920
async def test_override(self, server): async def test_override(self, server):
server.run_shell_args.return_value = ok( server.run_shell_args.return_value = ok(
stdout="Physical size: 1080x1920\nOverride size: 720x1280" stdout="Physical size: 1080x1920\nOverride size: 720x1280"
) )
result = await server.screen_size() result = await server.screen_size()
assert result["success"] is True assert result.success is True
# Should parse the first match # Should parse the first match
assert result["width"] == 1080 assert result.width == 1080
async def test_failure(self, server): async def test_failure(self, server):
server.run_shell_args.return_value = fail("error") server.run_shell_args.return_value = fail("error")
result = await server.screen_size() result = await server.screen_size()
assert result["success"] is False assert result.success is False
class TestScreenDensity: class TestScreenDensity:
async def test_density(self, server): async def test_density(self, server):
server.run_shell_args.return_value = ok(stdout="Physical density: 420") server.run_shell_args.return_value = ok(stdout="Physical density: 420")
result = await server.screen_density() result = await server.screen_density()
assert result["success"] is True assert result.success is True
assert result["dpi"] == 420 assert result.dpi == 420
async def test_failure(self, server): async def test_failure(self, server):
server.run_shell_args.return_value = fail("error") server.run_shell_args.return_value = fail("error")
result = await server.screen_density() result = await server.screen_density()
assert result["success"] is False assert result.success is False
class TestScreenOnOff: class TestScreenOnOff:
async def test_screen_on(self, server): async def test_screen_on(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.screen_on() result = await server.screen_on()
assert result["success"] is True assert result.success is True
assert result["action"] == "screen_on" assert result.action == "screen_on"
async def test_screen_off(self, server): async def test_screen_off(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.screen_off() result = await server.screen_off()
assert result["action"] == "screen_off" assert result.action == "screen_off"
class TestScreenRecord: class TestScreenRecord:
@ -92,8 +92,8 @@ class TestScreenRecord:
filename="test.mp4", filename="test.mp4",
duration_seconds=5, duration_seconds=5,
) )
assert result["success"] is True assert result.success is True
assert result["duration_seconds"] == 5 assert result.duration_seconds == 5
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_duration_capped(self, server, ctx, tmp_path): async def test_duration_capped(self, server, ctx, tmp_path):
@ -103,12 +103,12 @@ class TestScreenRecord:
server.run_shell_args.side_effect = [ok(), ok()] server.run_shell_args.side_effect = [ok(), ok()]
server.run_adb.return_value = ok() server.run_adb.return_value = ok()
result = await server.screen_record(ctx, duration_seconds=999) result = await server.screen_record(ctx, duration_seconds=999)
assert result["duration_seconds"] == 180 # Capped at 180 assert result.duration_seconds == 180 # Capped at 180
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.screen_record(ctx) result = await server.screen_record(ctx)
assert result["success"] is False assert result.success is False
class TestScreenSetSize: class TestScreenSetSize:
@ -116,17 +116,17 @@ class TestScreenSetSize:
async def test_set(self, server): async def test_set(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.screen_set_size(720, 1280) result = await server.screen_set_size(720, 1280)
assert result["success"] is True assert result.success is True
assert result["width"] == 720 assert result.width == 720
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_reset(self, server): async def test_reset(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.screen_reset_size() result = await server.screen_reset_size()
assert result["success"] is True assert result.success is True
assert result["action"] == "reset_size" assert result.action == "reset_size"
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.screen_set_size(720, 1280) result = await server.screen_set_size(720, 1280)
assert result["success"] is False assert result.success is False

View File

@ -4,33 +4,33 @@
class TestConfigStatus: class TestConfigStatus:
async def test_status(self, server): async def test_status(self, server):
result = await server.config_status() result = await server.config_status()
assert "developer_mode" in result assert hasattr(result, "developer_mode")
assert "auto_select_single_device" in result assert hasattr(result, "auto_select_single_device")
assert "current_device" in result assert hasattr(result, "current_device")
async def test_reflects_current_device(self, server): async def test_reflects_current_device(self, server):
server.set_current_device("ABC123") server.set_current_device("ABC123")
result = await server.config_status() result = await server.config_status()
assert result["current_device"] == "ABC123" assert result.current_device == "ABC123"
class TestConfigSetDeveloperMode: class TestConfigSetDeveloperMode:
async def test_enable(self, server): async def test_enable(self, server):
result = await server.config_set_developer_mode(True) result = await server.config_set_developer_mode(True)
assert result["success"] is True assert result.success is True
assert result["developer_mode"] is True assert result.developer_mode is True
async def test_disable(self, server): async def test_disable(self, server):
result = await server.config_set_developer_mode(False) result = await server.config_set_developer_mode(False)
assert result["developer_mode"] is False assert result.developer_mode is False
class TestConfigSetScreenshotDir: class TestConfigSetScreenshotDir:
async def test_set(self, server): async def test_set(self, server):
result = await server.config_set_screenshot_dir("/tmp/shots") result = await server.config_set_screenshot_dir("/tmp/shots")
assert result["success"] is True assert result.success is True
assert result["screenshot_dir"] == "/tmp/shots" assert result.screenshot_dir == "/tmp/shots"
async def test_clear(self, server): async def test_clear(self, server):
result = await server.config_set_screenshot_dir(None) result = await server.config_set_screenshot_dir(None)
assert result["screenshot_dir"] is None assert result.screenshot_dir is None

View File

@ -10,37 +10,37 @@ class TestSettingsGet:
async def test_valid(self, server): async def test_valid(self, server):
server.run_shell_args.return_value = ok(stdout="1") server.run_shell_args.return_value = ok(stdout="1")
result = await server.settings_get("global", "wifi_on") result = await server.settings_get("global", "wifi_on")
assert result["success"] is True assert result.success is True
assert result["value"] == "1" assert result.value == "1"
assert result["exists"] is True assert result.exists is True
async def test_null_value(self, server): async def test_null_value(self, server):
server.run_shell_args.return_value = ok(stdout="null") server.run_shell_args.return_value = ok(stdout="null")
result = await server.settings_get("global", "missing_key") result = await server.settings_get("global", "missing_key")
assert result["success"] is True assert result.success is True
assert result["value"] is None assert result.value is None
assert result["exists"] is False assert result.exists is False
async def test_invalid_namespace(self, server): async def test_invalid_namespace(self, server):
result = await server.settings_get("invalid", "key") result = await server.settings_get("invalid", "key")
assert result["success"] is False assert result.success is False
assert "Invalid namespace" in result["error"] assert "Invalid namespace" in result.error
async def test_invalid_key(self, server): async def test_invalid_key(self, server):
result = await server.settings_get("global", "bad key!") result = await server.settings_get("global", "bad key!")
assert result["success"] is False assert result.success is False
assert "Invalid key" in result["error"] assert "Invalid key" in result.error
async def test_all_namespaces_valid(self, server): async def test_all_namespaces_valid(self, server):
server.run_shell_args.return_value = ok(stdout="value") server.run_shell_args.return_value = ok(stdout="value")
for ns in ("system", "global", "secure"): for ns in ("system", "global", "secure"):
result = await server.settings_get(ns, "test_key") result = await server.settings_get(ns, "test_key")
assert result["success"] is True assert result.success is True
async def test_key_with_dots(self, server): async def test_key_with_dots(self, server):
server.run_shell_args.return_value = ok(stdout="value") server.run_shell_args.return_value = ok(stdout="value")
result = await server.settings_get("global", "wifi.scan_always_enabled") result = await server.settings_get("global", "wifi.scan_always_enabled")
assert result["success"] is True assert result.success is True
class TestSettingsPut: class TestSettingsPut:
@ -48,26 +48,26 @@ class TestSettingsPut:
async def test_write_and_verify(self, server, ctx): async def test_write_and_verify(self, server, ctx):
server.run_shell_args.side_effect = [ok(), ok(stdout="128")] server.run_shell_args.side_effect = [ok(), ok(stdout="128")]
result = await server.settings_put(ctx, "system", "screen_brightness", "128") result = await server.settings_put(ctx, "system", "screen_brightness", "128")
assert result["success"] is True assert result.success is True
assert result["readback"] == "128" assert result.readback == "128"
assert result["verified"] is True assert result.verified is True
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_invalid_namespace(self, server, ctx): async def test_invalid_namespace(self, server, ctx):
result = await server.settings_put(ctx, "bad", "key", "val") result = await server.settings_put(ctx, "bad", "key", "val")
assert result["success"] is False assert result.success is False
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_invalid_key(self, server, ctx): async def test_invalid_key(self, server, ctx):
result = await server.settings_put(ctx, "global", "k;ey", "val") result = await server.settings_put(ctx, "global", "k;ey", "val")
assert result["success"] is False assert result.success is False
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_secure_namespace_elicits(self, server, ctx): async def test_secure_namespace_elicits(self, server, ctx):
ctx.set_elicit("accept", "Yes, write setting") ctx.set_elicit("accept", "Yes, write setting")
server.run_shell_args.side_effect = [ok(), ok(stdout="val")] server.run_shell_args.side_effect = [ok(), ok(stdout="val")]
result = await server.settings_put(ctx, "secure", "key", "val") result = await server.settings_put(ctx, "secure", "key", "val")
assert result["success"] is True assert result.success is True
# Verify elicitation happened # Verify elicitation happened
assert any("secure" in msg for _, msg in ctx.messages) assert any("secure" in msg for _, msg in ctx.messages)
@ -75,13 +75,13 @@ class TestSettingsPut:
async def test_secure_cancelled(self, server, ctx): async def test_secure_cancelled(self, server, ctx):
ctx.set_elicit("accept", "Cancel") ctx.set_elicit("accept", "Cancel")
result = await server.settings_put(ctx, "secure", "key", "val") result = await server.settings_put(ctx, "secure", "key", "val")
assert result["success"] is False assert result.success is False
assert result.get("cancelled") is True assert result.cancelled is True
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.settings_put(ctx, "system", "k", "v") result = await server.settings_put(ctx, "system", "k", "v")
assert result["success"] is False assert result.success is False
class TestWifiToggle: class TestWifiToggle:
@ -89,21 +89,21 @@ class TestWifiToggle:
async def test_enable(self, server): async def test_enable(self, server):
server.run_shell_args.side_effect = [ok(), ok(stdout="1")] server.run_shell_args.side_effect = [ok(), ok(stdout="1")]
result = await server.wifi_toggle(True) result = await server.wifi_toggle(True)
assert result["success"] is True assert result.success is True
assert result["action"] == "enable" assert result.action == "enable"
assert result["verified"] is True assert result.verified is True
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_disable(self, server): async def test_disable(self, server):
server.run_shell_args.side_effect = [ok(), ok(stdout="0")] server.run_shell_args.side_effect = [ok(), ok(stdout="0")]
result = await server.wifi_toggle(False) result = await server.wifi_toggle(False)
assert result["action"] == "disable" assert result.action == "disable"
assert result["verified"] is True assert result.verified is True
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.wifi_toggle(True) result = await server.wifi_toggle(True)
assert result["success"] is False assert result.success is False
class TestBluetoothToggle: class TestBluetoothToggle:
@ -111,13 +111,13 @@ class TestBluetoothToggle:
async def test_enable(self, server): async def test_enable(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.bluetooth_toggle(True) result = await server.bluetooth_toggle(True)
assert result["success"] is True assert result.success is True
assert result["action"] == "enable" assert result.action == "enable"
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.bluetooth_toggle(False) result = await server.bluetooth_toggle(False)
assert result["success"] is False assert result.success is False
class TestAirplaneModeToggle: class TestAirplaneModeToggle:
@ -126,8 +126,8 @@ class TestAirplaneModeToggle:
ctx.set_elicit("accept", "Yes, enable airplane mode") ctx.set_elicit("accept", "Yes, enable airplane mode")
server.run_shell_args.side_effect = [ok(), ok()] server.run_shell_args.side_effect = [ok(), ok()]
result = await server.airplane_mode_toggle(ctx, True) result = await server.airplane_mode_toggle(ctx, True)
assert result["success"] is True assert result.success is True
assert result["airplane_mode"] is True assert result.airplane_mode is True
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_enable_network_device_warns(self, server, ctx): async def test_enable_network_device_warns(self, server, ctx):
@ -135,7 +135,7 @@ class TestAirplaneModeToggle:
ctx.set_elicit("accept", "Yes, enable airplane mode") ctx.set_elicit("accept", "Yes, enable airplane mode")
server.run_shell_args.side_effect = [ok(), ok()] server.run_shell_args.side_effect = [ok(), ok()]
result = await server.airplane_mode_toggle(ctx, True) result = await server.airplane_mode_toggle(ctx, True)
assert result["success"] is True assert result.success is True
# Should have warned about network disconnection # Should have warned about network disconnection
warns = [msg for level, msg in ctx.messages if "sever" in msg.lower()] warns = [msg for level, msg in ctx.messages if "sever" in msg.lower()]
assert len(warns) > 0 assert len(warns) > 0
@ -144,14 +144,14 @@ class TestAirplaneModeToggle:
async def test_cancelled(self, server, ctx): async def test_cancelled(self, server, ctx):
ctx.set_elicit("accept", "Cancel") ctx.set_elicit("accept", "Cancel")
result = await server.airplane_mode_toggle(ctx, True) result = await server.airplane_mode_toggle(ctx, True)
assert result["success"] is False assert result.success is False
assert result.get("cancelled") is True assert result.cancelled is True
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_disable_no_elicitation(self, server, ctx): async def test_disable_no_elicitation(self, server, ctx):
server.run_shell_args.side_effect = [ok(), ok()] server.run_shell_args.side_effect = [ok(), ok()]
result = await server.airplane_mode_toggle(ctx, False) result = await server.airplane_mode_toggle(ctx, False)
assert result["success"] is True assert result.success is True
# No elicitation for disable # No elicitation for disable
elicits = [m for level, m in ctx.messages if level == "elicit"] elicits = [m for level, m in ctx.messages if level == "elicit"]
assert len(elicits) == 0 assert len(elicits) == 0
@ -159,7 +159,7 @@ class TestAirplaneModeToggle:
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server, ctx): async def test_requires_dev_mode(self, server, ctx):
result = await server.airplane_mode_toggle(ctx, True) result = await server.airplane_mode_toggle(ctx, True)
assert result["success"] is False assert result.success is False
class TestScreenBrightness: class TestScreenBrightness:
@ -167,25 +167,25 @@ class TestScreenBrightness:
async def test_set(self, server): async def test_set(self, server):
server.run_shell_args.side_effect = [ok(), ok()] server.run_shell_args.side_effect = [ok(), ok()]
result = await server.screen_brightness(128) result = await server.screen_brightness(128)
assert result["success"] is True assert result.success is True
assert result["brightness"] == 128 assert result.brightness == 128
assert result["auto_brightness"] is False assert result.auto_brightness is False
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_out_of_range(self, server): async def test_out_of_range(self, server):
result = await server.screen_brightness(300) result = await server.screen_brightness(300)
assert result["success"] is False assert result.success is False
assert "0-255" in result["error"] assert "0-255" in result.error
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_negative(self, server): async def test_negative(self, server):
result = await server.screen_brightness(-1) result = await server.screen_brightness(-1)
assert result["success"] is False assert result.success is False
@pytest.mark.usefixtures("_no_dev_mode") @pytest.mark.usefixtures("_no_dev_mode")
async def test_requires_dev_mode(self, server): async def test_requires_dev_mode(self, server):
result = await server.screen_brightness(128) result = await server.screen_brightness(128)
assert result["success"] is False assert result.success is False
class TestScreenTimeout: class TestScreenTimeout:
@ -193,20 +193,20 @@ class TestScreenTimeout:
async def test_set(self, server): async def test_set(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.screen_timeout(30) result = await server.screen_timeout(30)
assert result["success"] is True assert result.success is True
assert result["timeout_seconds"] == 30 assert result.timeout_seconds == 30
assert result["timeout_ms"] == 30000 assert result.timeout_ms == 30000
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_too_large(self, server): async def test_too_large(self, server):
result = await server.screen_timeout(9999) result = await server.screen_timeout(9999)
assert result["success"] is False assert result.success is False
assert "1-1800" in result["error"] assert "1-1800" in result.error
@pytest.mark.usefixtures("_dev_mode") @pytest.mark.usefixtures("_dev_mode")
async def test_zero(self, server): async def test_zero(self, server):
result = await server.screen_timeout(0) result = await server.screen_timeout(0)
assert result["success"] is False assert result.success is False
class TestNotificationList: class TestNotificationList:
@ -227,11 +227,11 @@ class TestNotificationList:
""" """
server.run_shell_args.return_value = ok(stdout=dumpsys_output) server.run_shell_args.return_value = ok(stdout=dumpsys_output)
result = await server.notification_list() result = await server.notification_list()
assert result["success"] is True assert result.success is True
assert result["count"] == 2 assert result.count == 2
assert result["notifications"][0]["package"] == "com.example.app" assert result.notifications[0]["package"] == "com.example.app"
assert result["notifications"][0]["title"] == "Test Title" assert result.notifications[0]["title"] == "Test Title"
assert result["notifications"][0]["text"] == "Test message body" assert result.notifications[0]["text"] == "Test message body"
async def test_limit(self, server): async def test_limit(self, server):
# Build output with many notifications # Build output with many notifications
@ -241,13 +241,13 @@ class TestNotificationList:
lines.append(f" android.title=Title {i}") lines.append(f" android.title=Title {i}")
server.run_shell_args.return_value = ok(stdout="\n".join(lines)) server.run_shell_args.return_value = ok(stdout="\n".join(lines))
result = await server.notification_list(limit=3) result = await server.notification_list(limit=3)
assert result["count"] <= 3 assert result.count <= 3
async def test_empty(self, server): async def test_empty(self, server):
server.run_shell_args.return_value = ok(stdout="") server.run_shell_args.return_value = ok(stdout="")
result = await server.notification_list() result = await server.notification_list()
assert result["success"] is True assert result.success is True
assert result["count"] == 0 assert result.count == 0
class TestClipboardGet: class TestClipboardGet:
@ -255,8 +255,8 @@ class TestClipboardGet:
# Build parcel programmatically with correct encoding # Build parcel programmatically with correct encoding
server.run_shell_args.return_value = ok(stdout=_build_parcel("hello world")) server.run_shell_args.return_value = ok(stdout=_build_parcel("hello world"))
result = await server.clipboard_get() result = await server.clipboard_get()
assert result["success"] is True assert result.success is True
assert result["text"] == "hello world" assert result.text == "hello world"
async def test_empty_clipboard(self, server): async def test_empty_clipboard(self, server):
server.run_shell_args.return_value = ok( server.run_shell_args.return_value = ok(
@ -264,12 +264,12 @@ class TestClipboardGet:
) )
result = await server.clipboard_get() result = await server.clipboard_get()
# No text/plain marker = not parseable # No text/plain marker = not parseable
assert result["success"] is False assert result.success is False
async def test_failure(self, server): async def test_failure(self, server):
server.run_shell_args.return_value = fail("error") server.run_shell_args.return_value = fail("error")
result = await server.clipboard_get() result = await server.clipboard_get()
assert result["success"] is False assert result.success is False
def _build_parcel(text: str) -> str: def _build_parcel(text: str) -> str:
@ -361,31 +361,31 @@ class TestMediaControl:
async def test_play(self, server): async def test_play(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.media_control("play") result = await server.media_control("play")
assert result["success"] is True assert result.success is True
assert result["action"] == "play" assert result.action == "play"
assert result["keycode"] == "KEYCODE_MEDIA_PLAY" assert result.keycode == "KEYCODE_MEDIA_PLAY"
async def test_all_actions(self, server): async def test_all_actions(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
for action, keycode in _MEDIA_KEYCODES.items(): for action, keycode in _MEDIA_KEYCODES.items():
result = await server.media_control(action) result = await server.media_control(action)
assert result["success"] is True assert result.success is True
assert result["keycode"] == keycode assert result.keycode == keycode
async def test_case_insensitive(self, server): async def test_case_insensitive(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.media_control("PLAY") result = await server.media_control("PLAY")
assert result["success"] is True assert result.success is True
assert result["action"] == "play" assert result.action == "play"
async def test_unknown_action(self, server): async def test_unknown_action(self, server):
result = await server.media_control("rewind") result = await server.media_control("rewind")
assert result["success"] is False assert result.success is False
assert "Unknown action" in result["error"] assert "Unknown action" in result.error
assert "play" in result["error"] # Lists available actions assert "play" in result.error # Lists available actions
async def test_whitespace_stripped(self, server): async def test_whitespace_stripped(self, server):
server.run_shell_args.return_value = ok() server.run_shell_args.return_value = ok()
result = await server.media_control(" pause ") result = await server.media_control(" pause ")
assert result["success"] is True assert result.success is True
assert result["action"] == "pause" assert result.action == "pause"

View File

@ -28,14 +28,14 @@ class TestUiDump:
ok(), # rm cleanup ok(), # rm cleanup
] ]
result = await server.ui_dump(ctx) result = await server.ui_dump(ctx)
assert result["success"] is True assert result.success is True
assert result["element_count"] >= 2 # Settings + Wi-Fi at minimum assert result.element_count >= 2 # Settings + Wi-Fi at minimum
assert "xml" in result assert result.xml is not None
async def test_dump_failure(self, server, ctx): async def test_dump_failure(self, server, ctx):
server.run_shell_args.return_value = fail("error") server.run_shell_args.return_value = fail("error")
result = await server.ui_dump(ctx) result = await server.ui_dump(ctx)
assert result["success"] is False assert result.success is False
class TestParseUiElements: class TestParseUiElements:
@ -64,30 +64,30 @@ class TestUiFindElement:
async def test_find_by_text(self, server): async def test_find_by_text(self, server):
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
result = await server.ui_find_element(text="Settings") result = await server.ui_find_element(text="Settings")
assert result["success"] is True assert result.success is True
assert result["count"] == 1 assert result.count == 1
assert result["matches"][0]["text"] == "Settings" assert result.matches[0]["text"] == "Settings"
async def test_find_by_resource_id(self, server): async def test_find_by_resource_id(self, server):
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
result = await server.ui_find_element(resource_id="title") result = await server.ui_find_element(resource_id="title")
# Settings and Wi-Fi both have "title" in their resource-id # Settings and Wi-Fi both have "title" in their resource-id
assert result["count"] >= 2 assert result.count >= 2
async def test_not_found(self, server): async def test_not_found(self, server):
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
result = await server.ui_find_element(text="Missing") result = await server.ui_find_element(text="Missing")
assert result["success"] is True assert result.success is True
assert result["count"] == 0 assert result.count == 0
class TestWaitForText: class TestWaitForText:
async def test_found_immediately(self, server): async def test_found_immediately(self, server):
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()] server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
result = await server.wait_for_text("Settings", timeout_seconds=1) result = await server.wait_for_text("Settings", timeout_seconds=1)
assert result["success"] is True assert result.success is True
assert result["found"] is True assert result.found is True
assert result["attempts"] == 1 assert result.attempts == 1
async def test_timeout(self, server): async def test_timeout(self, server):
server.run_shell_args.side_effect = [ server.run_shell_args.side_effect = [
@ -98,8 +98,8 @@ class TestWaitForText:
result = await server.wait_for_text( result = await server.wait_for_text(
"Missing", timeout_seconds=0.1, poll_interval=0.05 "Missing", timeout_seconds=0.1, poll_interval=0.05
) )
assert result["success"] is False assert result.success is False
assert result["found"] is False assert result.found is False
class TestWaitForTextGone: class TestWaitForTextGone:
@ -110,8 +110,8 @@ class TestWaitForTextGone:
ok(), ok(),
] ]
result = await server.wait_for_text_gone("Missing", timeout_seconds=1) result = await server.wait_for_text_gone("Missing", timeout_seconds=1)
assert result["success"] is True assert result.success is True
assert result["gone"] is True assert result.gone is True
class TestTapText: class TestTapText:
@ -124,8 +124,8 @@ class TestTapText:
ok(), # tap ok(), # tap
] ]
result = await server.tap_text("Settings") result = await server.tap_text("Settings")
assert result["success"] is True assert result.success is True
assert result["coordinates"] == {"x": 100, "y": 125} assert result.coordinates == {"x": 100, "y": 125}
async def test_not_found(self, server): async def test_not_found(self, server):
server.run_shell_args.side_effect = [ server.run_shell_args.side_effect = [
@ -137,5 +137,5 @@ class TestTapText:
ok(), # fallback search by content_desc ok(), # fallback search by content_desc
] ]
result = await server.tap_text("NonExistent") result = await server.tap_text("NonExistent")
assert result["success"] is False assert result.success is False
assert "No element found" in result["error"] assert "No element found" in result.error