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:
parent
fb297f7937
commit
3614ba8f8f
@ -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()
|
||||||
|
|||||||
@ -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"),
|
||||||
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
)
|
||||||
|
|||||||
157
src/mixins/ui.py
157
src/mixins/ui.py
@ -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,
|
||||||
}
|
)
|
||||||
|
|||||||
400
src/models.py
400
src/models.py
@ -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")
|
||||||
|
|||||||
@ -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 ===
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user