Compare commits
3 Commits
e0c05dc72a
...
321b6073da
| Author | SHA1 | Date | |
|---|---|---|---|
| 321b6073da | |||
| 3614ba8f8f | |||
| fb297f7937 |
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcadb"
|
name = "mcadb"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
description = "Android ADB MCP Server for device automation via Model Context Protocol"
|
description = "Android ADB MCP Server for device automation via Model Context Protocol"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Ryan Malloy", email = "ryan@supported.systems"}
|
{name = "Ryan Malloy", email = "ryan@supported.systems"}
|
||||||
@ -52,6 +52,9 @@ line-length = 88
|
|||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.11"
|
python_version = "3.11"
|
||||||
strict = true
|
strict = true
|
||||||
|
|||||||
@ -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] = {}
|
||||||
@ -513,7 +528,7 @@ class SettingsMixin(ADBBaseMixin):
|
|||||||
break
|
break
|
||||||
current = {}
|
current = {}
|
||||||
# Extract package from NotificationRecord line
|
# Extract package from NotificationRecord line
|
||||||
pkg_match = re.search(r"pkg=(\S+)", stripped)
|
pkg_match = re.search(r"pkg=([\w.]+)", stripped)
|
||||||
if pkg_match:
|
if pkg_match:
|
||||||
current["package"] = pkg_match.group(1)
|
current["package"] = pkg_match.group(1)
|
||||||
|
|
||||||
@ -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,
|
||||||
}
|
)
|
||||||
|
|||||||
159
src/mixins/ui.py
159
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."""
|
||||||
@ -102,7 +103,7 @@ class UIMixin(ADBBaseMixin):
|
|||||||
|
|
||||||
# Regex to find node elements with their attributes
|
# Regex to find node elements with their attributes
|
||||||
node_pattern = re.compile(r"<node\s+([^>]+?)(?:/>|>)", re.DOTALL)
|
node_pattern = re.compile(r"<node\s+([^>]+?)(?:/>|>)", re.DOTALL)
|
||||||
attr_pattern = re.compile(r'(\w+)="([^"]*)"')
|
attr_pattern = re.compile(r'([\w-]+)="([^"]*)"')
|
||||||
|
|
||||||
for match in node_pattern.finditer(xml_content):
|
for match in node_pattern.finditer(xml_content):
|
||||||
attrs_str = match.group(1)
|
attrs_str = match.group(1)
|
||||||
@ -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 ===
|
||||||
|
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
124
tests/conftest.py
Normal file
124
tests/conftest.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""Shared test fixtures for mcadb tests."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.models import CommandResult
|
||||||
|
from src.server import ADBServer
|
||||||
|
|
||||||
|
# --- Helpers ---
|
||||||
|
|
||||||
|
|
||||||
|
def ok(stdout: str = "", stderr: str = "") -> CommandResult:
|
||||||
|
"""Create a successful CommandResult."""
|
||||||
|
return CommandResult(success=True, stdout=stdout, stderr=stderr, returncode=0)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(stderr: str = "error", stdout: str = "") -> CommandResult:
|
||||||
|
"""Create a failed CommandResult."""
|
||||||
|
return CommandResult(success=False, stdout=stdout, stderr=stderr, returncode=1)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mock Context ---
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ElicitResult:
|
||||||
|
"""Minimal stand-in for FastMCP's ElicitationResult."""
|
||||||
|
|
||||||
|
action: str = "accept"
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MockContext:
|
||||||
|
"""Mock MCP Context that records calls for assertion."""
|
||||||
|
|
||||||
|
messages: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
_elicit_response: ElicitResult = field(default_factory=ElicitResult)
|
||||||
|
|
||||||
|
async def info(self, msg: str) -> None:
|
||||||
|
self.messages.append(("info", msg))
|
||||||
|
|
||||||
|
async def warning(self, msg: str) -> None:
|
||||||
|
self.messages.append(("warning", msg))
|
||||||
|
|
||||||
|
async def error(self, msg: str) -> None:
|
||||||
|
self.messages.append(("error", msg))
|
||||||
|
|
||||||
|
async def elicit(self, msg: str, options: list[str] | None = None) -> ElicitResult:
|
||||||
|
self.messages.append(("elicit", msg))
|
||||||
|
return self._elicit_response
|
||||||
|
|
||||||
|
def set_elicit(self, action: str = "accept", content: str = "") -> None:
|
||||||
|
"""Configure the next elicit response."""
|
||||||
|
self._elicit_response = ElicitResult(action=action, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_config(monkeypatch: pytest.MonkeyPatch, config_dir: Any) -> None:
|
||||||
|
"""Reset the Config singleton and point it at a temp directory.
|
||||||
|
|
||||||
|
CONFIG_DIR and CONFIG_FILE are module-level variables computed at
|
||||||
|
import time, so setting the env var isn't enough — we must patch
|
||||||
|
the variables directly.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
config_path = Path(config_dir)
|
||||||
|
monkeypatch.setattr("src.config.Config._instance", None)
|
||||||
|
monkeypatch.setattr("src.config.CONFIG_DIR", config_path)
|
||||||
|
monkeypatch.setattr("src.config.CONFIG_FILE", config_path / "config.json")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server() -> ADBServer:
|
||||||
|
"""Create an ADBServer with mocked ADB execution.
|
||||||
|
|
||||||
|
Both run_adb and run_shell_args are replaced with AsyncMock,
|
||||||
|
so no real subprocess calls are made. Configure return values
|
||||||
|
per-test with server.run_adb.return_value = ok("...").
|
||||||
|
"""
|
||||||
|
s = ADBServer()
|
||||||
|
s.run_adb = AsyncMock(return_value=ok()) # type: ignore[method-assign]
|
||||||
|
s.run_shell_args = AsyncMock(return_value=ok()) # type: ignore[method-assign]
|
||||||
|
s.run_shell = AsyncMock(return_value=ok()) # type: ignore[method-assign]
|
||||||
|
s.get_device_property = AsyncMock(return_value=None) # type: ignore[method-assign]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ctx() -> MockContext:
|
||||||
|
"""Create a mock MCP Context."""
|
||||||
|
return MockContext()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _dev_mode(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
|
||||||
|
"""Enable developer mode for the test."""
|
||||||
|
_reset_config(monkeypatch, tmp_path / "dev-config")
|
||||||
|
from src.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
config._settings["developer_mode"] = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _no_dev_mode(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
|
||||||
|
"""Disable developer mode for the test."""
|
||||||
|
_reset_config(monkeypatch, tmp_path / "nodev-config")
|
||||||
|
from src.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
config._settings["developer_mode"] = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
|
||||||
|
"""Isolate config to a temp directory so tests don't touch real config."""
|
||||||
|
_reset_config(monkeypatch, tmp_path / "config")
|
||||||
233
tests/test_apps.py
Normal file
233
tests/test_apps.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
"""Tests for apps mixin (launch, close, current, install, intents)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppLaunch:
|
||||||
|
async def test_launch(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.app_launch("com.android.chrome")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.package == "com.android.chrome"
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "monkey" in args
|
||||||
|
assert "com.android.chrome" in args
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_shell_args.return_value = fail("not found")
|
||||||
|
result = await server.app_launch("com.missing.app")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppOpenUrl:
|
||||||
|
async def test_open(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.app_open_url("https://example.com")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.url == "https://example.com"
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "am" in args
|
||||||
|
assert "android.intent.action.VIEW" in args
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppClose:
|
||||||
|
async def test_close(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.app_close("com.example.app")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.package == "com.example.app"
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "am" in args
|
||||||
|
assert "force-stop" in args
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppCurrent:
|
||||||
|
async def test_parse_focused(self, server):
|
||||||
|
focused = (
|
||||||
|
" mCurrentFocus=Window{abc com.android.chrome"
|
||||||
|
"/org.chromium.chrome.browser.ChromeTabbedActivity}"
|
||||||
|
)
|
||||||
|
server.run_shell_args.return_value = ok(stdout=focused)
|
||||||
|
result = await server.app_current()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.package == "com.android.chrome"
|
||||||
|
|
||||||
|
async def test_focused_app_format(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout=" mFocusedApp=ActivityRecord{abc com.example/.MainActivity t123}"
|
||||||
|
)
|
||||||
|
result = await server.app_current()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.package == "com.example"
|
||||||
|
|
||||||
|
async def test_no_focus(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="no focus info")
|
||||||
|
result = await server.app_current()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.package is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppListPackages:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_list(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout="package:com.android.chrome\npackage:com.example.app\n"
|
||||||
|
)
|
||||||
|
result = await server.app_list_packages()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.count == 2
|
||||||
|
assert "com.android.chrome" in result.packages
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_filter(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout="package:com.android.chrome\npackage:com.example.app\n"
|
||||||
|
)
|
||||||
|
result = await server.app_list_packages(filter_text="chrome")
|
||||||
|
assert result.count == 1
|
||||||
|
assert "com.android.chrome" in result.packages
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_third_party(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="package:com.user.app\n")
|
||||||
|
await server.app_list_packages(third_party_only=True)
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "-3" in args
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.app_list_packages()
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppInstall:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_install(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="Success")
|
||||||
|
result = await server.app_install("/tmp/app.apk")
|
||||||
|
assert result.success is True
|
||||||
|
args = server.run_adb.call_args[0][0]
|
||||||
|
assert "install" in args
|
||||||
|
assert "-r" in args
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.app_install("/tmp/app.apk")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppUninstall:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_uninstall(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, uninstall")
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.app_uninstall(ctx, "com.example.app")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.package == "com.example.app"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_keep_data(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, uninstall")
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.app_uninstall(ctx, "com.example.app", keep_data=True)
|
||||||
|
assert result.kept_data is True
|
||||||
|
args = server.run_adb.call_args[0][0]
|
||||||
|
assert "-k" in args
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_cancelled(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Cancel")
|
||||||
|
result = await server.app_uninstall(ctx, "com.example.app")
|
||||||
|
assert result.success is False
|
||||||
|
assert result.cancelled is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppClearData:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_clear(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, clear all data")
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.app_clear_data(ctx, "com.example.app")
|
||||||
|
assert result.success is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_cancelled(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Cancel")
|
||||||
|
result = await server.app_clear_data(ctx, "com.example.app")
|
||||||
|
assert result.cancelled is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestActivityStart:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_basic(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.activity_start("com.example/.MainActivity")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.component == "com.example/.MainActivity"
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "am" in args
|
||||||
|
assert "start" in args
|
||||||
|
assert "-n" in args
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_with_action_and_data(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
await server.activity_start(
|
||||||
|
"com.example/.DeepLink",
|
||||||
|
action="android.intent.action.VIEW",
|
||||||
|
data_uri="myapp://product/123",
|
||||||
|
)
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "-a" in args
|
||||||
|
assert "-d" in args
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_with_extras(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
await server.activity_start(
|
||||||
|
"com.example/.Act",
|
||||||
|
extras={"key": "value", "flag": "true", "count": "42"},
|
||||||
|
)
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "--es" in args # string extra
|
||||||
|
assert "--ez" in args # boolean extra
|
||||||
|
assert "--ei" in args # integer extra
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_with_flags(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
await server.activity_start(
|
||||||
|
"com.example/.Act",
|
||||||
|
flags=["FLAG_ACTIVITY_NEW_TASK"],
|
||||||
|
)
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "-f" in args
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.activity_start("com.example/.Act")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestBroadcastSend:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_basic(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.broadcast_send("com.example.ACTION")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.broadcast_action == "com.example.ACTION"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_with_package(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
await server.broadcast_send("ACTION", package="com.target")
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert "-p" in args
|
||||||
|
assert "com.target" in args
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.broadcast_send("ACTION")
|
||||||
|
assert result.success is False
|
||||||
234
tests/test_base.py
Normal file
234
tests/test_base.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""Tests for base ADB execution mixin."""
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.mixins.base import ADBBaseMixin
|
||||||
|
from src.models import CommandResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunAdb:
|
||||||
|
@pytest.fixture
|
||||||
|
def base(self):
|
||||||
|
return ADBBaseMixin()
|
||||||
|
|
||||||
|
async def test_basic_command(self, base):
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"output\n", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
|
||||||
|
patcher = patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
)
|
||||||
|
with patcher as mock_exec:
|
||||||
|
result = await base.run_adb(["devices"])
|
||||||
|
|
||||||
|
mock_exec.assert_called_once_with(
|
||||||
|
"adb",
|
||||||
|
"devices",
|
||||||
|
stdout=-1,
|
||||||
|
stderr=-1,
|
||||||
|
)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.stdout == "output"
|
||||||
|
|
||||||
|
async def test_device_targeting(self, base):
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"ok", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
|
||||||
|
patcher = patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
)
|
||||||
|
with patcher as mock_exec:
|
||||||
|
await base.run_adb(["shell", "ls"], device_id="ABC123")
|
||||||
|
|
||||||
|
# Should insert -s ABC123 before the command
|
||||||
|
mock_exec.assert_called_once_with(
|
||||||
|
"adb",
|
||||||
|
"-s",
|
||||||
|
"ABC123",
|
||||||
|
"shell",
|
||||||
|
"ls",
|
||||||
|
stdout=-1,
|
||||||
|
stderr=-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_current_device_fallback(self, base):
|
||||||
|
base.set_current_device("DEF456")
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"ok", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
|
||||||
|
patcher = patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
)
|
||||||
|
with patcher as mock_exec:
|
||||||
|
await base.run_adb(["devices"])
|
||||||
|
|
||||||
|
args = mock_exec.call_args[0]
|
||||||
|
assert "-s" in args
|
||||||
|
assert "DEF456" in args
|
||||||
|
|
||||||
|
async def test_device_id_overrides_current(self, base):
|
||||||
|
base.set_current_device("OLD")
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"ok", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
|
||||||
|
patcher = patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
)
|
||||||
|
with patcher as mock_exec:
|
||||||
|
await base.run_adb(["shell", "ls"], device_id="NEW")
|
||||||
|
|
||||||
|
args = mock_exec.call_args[0]
|
||||||
|
assert "NEW" in args
|
||||||
|
assert "OLD" not in args
|
||||||
|
|
||||||
|
async def test_failure(self, base):
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"", b"not found")
|
||||||
|
mock_proc.returncode = 1
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
||||||
|
result = await base.run_adb(["shell", "missing"])
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.stderr == "not found"
|
||||||
|
assert result.returncode == 1
|
||||||
|
|
||||||
|
async def test_timeout(self, base):
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.side_effect = TimeoutError()
|
||||||
|
mock_proc.kill = MagicMock()
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
||||||
|
result = await base.run_adb(["shell", "hang"], timeout=1)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "timed out" in result.stderr
|
||||||
|
assert result.returncode == -1
|
||||||
|
|
||||||
|
async def test_exception(self, base):
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
side_effect=FileNotFoundError("adb not found"),
|
||||||
|
):
|
||||||
|
result = await base.run_adb(["devices"])
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "adb not found" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunShellArgs:
|
||||||
|
@pytest.fixture
|
||||||
|
def base(self):
|
||||||
|
return ADBBaseMixin()
|
||||||
|
|
||||||
|
async def test_quotes_arguments(self, base):
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"ok", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
|
||||||
|
patcher = patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
)
|
||||||
|
with patcher as mock_exec:
|
||||||
|
await base.run_shell_args(["input", "text", "hello world"])
|
||||||
|
|
||||||
|
args = mock_exec.call_args[0]
|
||||||
|
# "shell" should be in the args
|
||||||
|
assert "shell" in args
|
||||||
|
# Arguments should be shlex-quoted
|
||||||
|
quoted_hello = shlex.quote("hello world")
|
||||||
|
assert quoted_hello in args
|
||||||
|
|
||||||
|
async def test_injection_safety(self, base):
|
||||||
|
"""Verify dangerous characters get quoted."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"ok", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
|
||||||
|
patcher = patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
)
|
||||||
|
with patcher as mock_exec:
|
||||||
|
await base.run_shell_args(["echo", "; rm -rf /"])
|
||||||
|
|
||||||
|
args = mock_exec.call_args[0]
|
||||||
|
# The dangerous string should be quoted, not bare
|
||||||
|
assert "; rm -rf /" not in args
|
||||||
|
quoted = shlex.quote("; rm -rf /")
|
||||||
|
assert quoted in args
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunShell:
|
||||||
|
@pytest.fixture
|
||||||
|
def base(self):
|
||||||
|
return ADBBaseMixin()
|
||||||
|
|
||||||
|
async def test_splits_command(self, base):
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"ok", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
|
||||||
|
patcher = patch(
|
||||||
|
"asyncio.create_subprocess_exec",
|
||||||
|
return_value=mock_proc,
|
||||||
|
)
|
||||||
|
with patcher as mock_exec:
|
||||||
|
await base.run_shell("ls -la /sdcard")
|
||||||
|
|
||||||
|
args = mock_exec.call_args[0]
|
||||||
|
assert "shell" in args
|
||||||
|
assert "ls" in args
|
||||||
|
assert "-la" in args
|
||||||
|
assert "/sdcard" in args
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDeviceProperty:
|
||||||
|
@pytest.fixture
|
||||||
|
def base(self):
|
||||||
|
b = ADBBaseMixin()
|
||||||
|
b.run_shell_args = AsyncMock() # type: ignore[method-assign]
|
||||||
|
return b
|
||||||
|
|
||||||
|
async def test_returns_value(self, base):
|
||||||
|
base.run_shell_args.return_value = CommandResult(
|
||||||
|
success=True, stdout="Pixel 6", stderr="", returncode=0
|
||||||
|
)
|
||||||
|
result = await base.get_device_property("ro.product.model")
|
||||||
|
assert result == "Pixel 6"
|
||||||
|
|
||||||
|
async def test_returns_none_on_empty(self, base):
|
||||||
|
base.run_shell_args.return_value = CommandResult(
|
||||||
|
success=True, stdout="", stderr="", returncode=0
|
||||||
|
)
|
||||||
|
result = await base.get_device_property("ro.missing")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_on_failure(self, base):
|
||||||
|
base.run_shell_args.return_value = CommandResult(
|
||||||
|
success=False, stdout="", stderr="err", returncode=1
|
||||||
|
)
|
||||||
|
result = await base.get_device_property("ro.missing")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceState:
|
||||||
|
def test_set_get_device(self):
|
||||||
|
base = ADBBaseMixin()
|
||||||
|
assert base.get_current_device() is None
|
||||||
|
base.set_current_device("ABC")
|
||||||
|
assert base.get_current_device() == "ABC"
|
||||||
|
base.set_current_device(None)
|
||||||
|
assert base.get_current_device() is None
|
||||||
78
tests/test_config.py
Normal file
78
tests/test_config.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for configuration management."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.config import get_config, is_developer_mode
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_config(monkeypatch, config_dir):
|
||||||
|
"""Reset singleton and point Config at a specific directory."""
|
||||||
|
config_path = Path(config_dir)
|
||||||
|
monkeypatch.setattr("src.config.Config._instance", None)
|
||||||
|
monkeypatch.setattr("src.config.CONFIG_DIR", config_path)
|
||||||
|
monkeypatch.setattr("src.config.CONFIG_FILE", config_path / "config.json")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
def test_defaults(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
config = get_config()
|
||||||
|
assert config.developer_mode is False
|
||||||
|
assert config.auto_select_single_device is True
|
||||||
|
assert config.default_screenshot_dir is None
|
||||||
|
|
||||||
|
def test_developer_mode_toggle(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
config = get_config()
|
||||||
|
assert config.developer_mode is False
|
||||||
|
config.developer_mode = True
|
||||||
|
assert config.developer_mode is True
|
||||||
|
|
||||||
|
def test_persistence(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
config = get_config()
|
||||||
|
config.developer_mode = True
|
||||||
|
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
assert config_file.exists()
|
||||||
|
data = json.loads(config_file.read_text())
|
||||||
|
assert data["developer_mode"] is True
|
||||||
|
|
||||||
|
def test_screenshot_dir(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
config = get_config()
|
||||||
|
config.default_screenshot_dir = "/tmp/shots"
|
||||||
|
assert config.default_screenshot_dir == "/tmp/shots"
|
||||||
|
|
||||||
|
def test_get_set(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
config = get_config()
|
||||||
|
config.set("custom_key", "custom_value")
|
||||||
|
assert config.get("custom_key") == "custom_value"
|
||||||
|
|
||||||
|
def test_to_dict(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
config = get_config()
|
||||||
|
d = config.to_dict()
|
||||||
|
assert "developer_mode" in d
|
||||||
|
assert "auto_select_single_device" in d
|
||||||
|
|
||||||
|
def test_load_corrupt_file(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
(tmp_path / "config.json").write_text("{invalid json")
|
||||||
|
# Need a fresh singleton to trigger _load with corrupt file
|
||||||
|
monkeypatch.setattr("src.config.Config._instance", None)
|
||||||
|
config = get_config()
|
||||||
|
assert config.developer_mode is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsDeveloperMode:
|
||||||
|
def test_off_by_default(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
assert is_developer_mode() is False
|
||||||
|
|
||||||
|
def test_on_when_enabled(self, tmp_path, monkeypatch):
|
||||||
|
_fresh_config(monkeypatch, tmp_path)
|
||||||
|
get_config().developer_mode = True
|
||||||
|
assert is_developer_mode() is True
|
||||||
122
tests/test_connectivity.py
Normal file
122
tests/test_connectivity.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"""Tests for connectivity mixin (connect, disconnect, tcpip, pair, properties)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbConnect:
|
||||||
|
async def test_success(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="connected to 10.0.0.1:5555")
|
||||||
|
result = await server.adb_connect("10.0.0.1")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.address == "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):
|
||||||
|
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)
|
||||||
|
assert result.address == "10.0.0.1:5556"
|
||||||
|
|
||||||
|
async def test_already_connected(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="already connected to 10.0.0.1:5555")
|
||||||
|
result = await server.adb_connect("10.0.0.1")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.already_connected is True
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="failed to connect")
|
||||||
|
result = await server.adb_connect("10.0.0.1")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbDisconnect:
|
||||||
|
async def test_success(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="disconnected 10.0.0.1:5555")
|
||||||
|
result = await server.adb_disconnect("10.0.0.1")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.address == "10.0.0.1:5555"
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="error: no such device")
|
||||||
|
result = await server.adb_disconnect("10.0.0.1")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbTcpip:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_success(self, server, ctx):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout="10: wlan0 inet 192.168.1.100/24"
|
||||||
|
)
|
||||||
|
server.run_adb.return_value = ok(stdout="restarting in TCP mode port: 5555")
|
||||||
|
result = await server.adb_tcpip(ctx)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.device_ip == "192.168.1.100"
|
||||||
|
assert result.connect_address == "192.168.1.100:5555"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_rejects_network_device(self, server, ctx):
|
||||||
|
server.set_current_device("10.20.0.25:5555")
|
||||||
|
result = await server.adb_tcpip(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
assert "already a network device" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_no_wifi_ip(self, server, ctx):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="wlan0: no ip")
|
||||||
|
result = await server.adb_tcpip(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
assert "WiFi" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_custom_port(self, server, ctx):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout="10: wlan0 inet 192.168.1.50/24"
|
||||||
|
)
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.adb_tcpip(ctx, port=5556)
|
||||||
|
assert result.port == 5556
|
||||||
|
assert result.connect_address == "192.168.1.50:5556"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.adb_tcpip(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
assert "developer mode" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbPair:
|
||||||
|
async def test_success(self, server):
|
||||||
|
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")
|
||||||
|
assert result.success is True
|
||||||
|
server.run_adb.assert_called_once_with(["pair", "10.0.0.1:37000", "123456"])
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_adb.return_value = fail("Failed: wrong code")
|
||||||
|
result = await server.adb_pair("10.0.0.1", 37000, "000000")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceProperties:
|
||||||
|
async def test_returns_properties(self, server):
|
||||||
|
props = {
|
||||||
|
"ro.product.model": "Pixel 6",
|
||||||
|
"ro.product.manufacturer": "Google",
|
||||||
|
"ro.build.version.release": "14",
|
||||||
|
"ro.build.version.sdk": "34",
|
||||||
|
"ro.board.platform": "gs101",
|
||||||
|
}
|
||||||
|
server.get_device_property.side_effect = lambda p, d=None: props.get(p)
|
||||||
|
result = await server.device_properties()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.identity["model"] == "Pixel 6"
|
||||||
|
assert result.software["android_version"] == "14"
|
||||||
|
assert result.hardware["chipset"] == "gs101"
|
||||||
|
|
||||||
|
async def test_no_properties(self, server):
|
||||||
|
server.get_device_property.return_value = None
|
||||||
|
result = await server.device_properties()
|
||||||
|
assert result.success is False
|
||||||
|
assert "No properties" in result.error
|
||||||
182
tests/test_devices.py
Normal file
182
tests/test_devices.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
"""Tests for devices mixin (list, use, current, info, reboot, logcat)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestDevicesList:
|
||||||
|
async def test_parse_devices(self, server):
|
||||||
|
server.run_adb.return_value = ok(
|
||||||
|
stdout=(
|
||||||
|
"List of devices attached\n"
|
||||||
|
"ABC123\tdevice\tmodel:Pixel_6 product:oriole\n"
|
||||||
|
"10.20.0.25:5555\tdevice\tmodel:K2401 product:K2401\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
devices = await server.devices_list()
|
||||||
|
assert len(devices) == 2
|
||||||
|
assert devices[0].device_id == "ABC123"
|
||||||
|
assert devices[0].model == "Pixel_6"
|
||||||
|
assert devices[1].device_id == "10.20.0.25:5555"
|
||||||
|
|
||||||
|
async def test_empty(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="List of devices attached\n")
|
||||||
|
devices = await server.devices_list()
|
||||||
|
assert len(devices) == 0
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_adb.return_value = fail("adb not found")
|
||||||
|
devices = await server.devices_list()
|
||||||
|
assert len(devices) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDevicesUse:
|
||||||
|
async def test_select_device(self, server):
|
||||||
|
server.run_adb.return_value = ok(
|
||||||
|
stdout="List of devices attached\nABC123\tdevice\n"
|
||||||
|
)
|
||||||
|
result = await server.devices_use("ABC123")
|
||||||
|
assert result.success is True
|
||||||
|
assert server.get_current_device() == "ABC123"
|
||||||
|
|
||||||
|
async def test_not_found(self, server):
|
||||||
|
server.run_adb.return_value = ok(
|
||||||
|
stdout="List of devices attached\nOTHER\tdevice\n"
|
||||||
|
)
|
||||||
|
result = await server.devices_use("MISSING")
|
||||||
|
assert result.success is False
|
||||||
|
assert "not found" in result.error
|
||||||
|
|
||||||
|
async def test_offline_device(self, server):
|
||||||
|
server.run_adb.return_value = ok(
|
||||||
|
stdout="List of devices attached\nABC123\toffline\n"
|
||||||
|
)
|
||||||
|
result = await server.devices_use("ABC123")
|
||||||
|
assert result.success is False
|
||||||
|
assert "offline" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
class TestDevicesCurrent:
|
||||||
|
async def test_no_device_set(self, server):
|
||||||
|
server.run_adb.return_value = ok(stdout="List of devices attached\n")
|
||||||
|
result = await server.devices_current()
|
||||||
|
assert result.device is None
|
||||||
|
|
||||||
|
async def test_auto_detect_single(self, server):
|
||||||
|
server.run_adb.return_value = ok(
|
||||||
|
stdout="List of devices attached\nABC123\tdevice\n"
|
||||||
|
)
|
||||||
|
result = await server.devices_current()
|
||||||
|
assert result.available is not None
|
||||||
|
|
||||||
|
async def test_device_set(self, server):
|
||||||
|
# Pre-populate cache and set device
|
||||||
|
server.run_adb.return_value = ok(
|
||||||
|
stdout="List of devices attached\nABC123\tdevice\tmodel:Pixel\n"
|
||||||
|
)
|
||||||
|
await server.devices_list()
|
||||||
|
server.set_current_device("ABC123")
|
||||||
|
result = await server.devices_current()
|
||||||
|
# device is a dict from model_dump()
|
||||||
|
assert result.device["device_id"] == "ABC123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceInfo:
|
||||||
|
async def test_full_info(self, server):
|
||||||
|
battery = (
|
||||||
|
"Current Battery Service state:\n level: 85\n status: 2\n plugged: 2"
|
||||||
|
)
|
||||||
|
df_out = (
|
||||||
|
"Filesystem 1K-blocks Used Available\n"
|
||||||
|
"/data 64000000 32000000 32000000"
|
||||||
|
)
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(stdout=battery),
|
||||||
|
ok(stdout="10: wlan0 inet 192.168.1.100/24"),
|
||||||
|
ok(stdout="mWifiInfo SSID: MyNetwork, BSSID: ..."),
|
||||||
|
ok(stdout=df_out),
|
||||||
|
]
|
||||||
|
server.get_device_property.side_effect = lambda p, d=None: {
|
||||||
|
"ro.build.version.release": "14",
|
||||||
|
"ro.build.version.sdk": "34",
|
||||||
|
"ro.product.model": "Pixel 6",
|
||||||
|
"ro.product.manufacturer": "Google",
|
||||||
|
"ro.product.device": "oriole",
|
||||||
|
}.get(p)
|
||||||
|
|
||||||
|
result = await server.device_info()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.battery["level"] == 85
|
||||||
|
assert result.ip_address == "192.168.1.100"
|
||||||
|
assert result.wifi_ssid == "MyNetwork"
|
||||||
|
assert result.model == "Pixel 6"
|
||||||
|
|
||||||
|
async def test_device_offline(self, server):
|
||||||
|
server.run_shell_args.return_value = fail("device offline")
|
||||||
|
result = await server.device_info()
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceReboot:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_reboot(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, reboot now")
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.device_reboot(ctx)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.mode == "normal"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_reboot_recovery(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, reboot now")
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.device_reboot(ctx, mode="recovery")
|
||||||
|
assert result.mode == "recovery"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_cancelled(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Cancel")
|
||||||
|
result = await server.device_reboot(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.cancelled is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.device_reboot(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogcat:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_capture(self, server):
|
||||||
|
logline = "01-01 00:00:00.000 I/TAG: message"
|
||||||
|
server.run_shell_args.return_value = ok(stdout=logline)
|
||||||
|
result = await server.logcat_capture()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output.startswith("01-01")
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_with_filter(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="filtered output")
|
||||||
|
result = await server.logcat_capture(filter_spec="MyApp:D *:S")
|
||||||
|
assert result.filter == "MyApp:D *:S"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_clear_first(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout="fresh logs")]
|
||||||
|
result = await server.logcat_capture(clear_first=True)
|
||||||
|
assert result.success is True
|
||||||
|
assert server.run_shell_args.call_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.logcat_capture()
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_logcat_clear(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.logcat_clear()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "logcat_clear"
|
||||||
120
tests/test_files.py
Normal file
120
tests/test_files.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Tests for files mixin (push, pull, list, delete, exists)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilePush:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_push(self, server, ctx, tmp_path):
|
||||||
|
local_file = tmp_path / "test.txt"
|
||||||
|
local_file.write_text("content")
|
||||||
|
server.run_adb.return_value = ok(stdout="1 file pushed")
|
||||||
|
result = await server.file_push(ctx, str(local_file), "/sdcard/test.txt")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.device_path == "/sdcard/test.txt"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_local_not_found(self, server, ctx):
|
||||||
|
result = await server.file_push(ctx, "/nonexistent/file.txt", "/sdcard/")
|
||||||
|
assert result.success is False
|
||||||
|
assert "not found" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.file_push(ctx, "/tmp/f", "/sdcard/f")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilePull:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_pull(self, server, ctx, tmp_path):
|
||||||
|
server.run_adb.return_value = ok(stdout="1 file pulled")
|
||||||
|
result = await server.file_pull(
|
||||||
|
ctx, "/sdcard/test.txt", str(tmp_path / "out.txt")
|
||||||
|
)
|
||||||
|
assert result.success is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_default_local_path(self, server, ctx):
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.file_pull(ctx, "/sdcard/data.db")
|
||||||
|
assert "data.db" in result.local_path
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.file_pull(ctx, "/sdcard/f")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileList:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_parse_ls(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout=(
|
||||||
|
"total 16\n"
|
||||||
|
"drwxr-xr-x 2 root root 4096 2024-01-15 10:30 Documents\n"
|
||||||
|
"-rw-r--r-- 1 root root 1234 2024-01-15 10:31 test.txt\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await server.file_list("/sdcard/")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.count == 2
|
||||||
|
assert result.files[0]["name"] == "Documents"
|
||||||
|
assert result.files[0]["is_directory"] is True
|
||||||
|
assert result.files[1]["name"] == "test.txt"
|
||||||
|
assert result.files[1]["is_directory"] is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_shell_args.return_value = fail("No such file")
|
||||||
|
result = await server.file_list("/nonexistent/")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.file_list()
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileDelete:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_delete(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, delete")
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.file_delete(ctx, "/sdcard/old.txt")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.path == "/sdcard/old.txt"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_cancelled(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Cancel")
|
||||||
|
result = await server.file_delete(ctx, "/sdcard/keep.txt")
|
||||||
|
assert result.success is False
|
||||||
|
assert result.cancelled is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.file_delete(ctx, "/sdcard/f")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileExists:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_exists(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.file_exists("/sdcard/file.txt")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.exists is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_not_exists(self, server):
|
||||||
|
server.run_shell_args.return_value = fail()
|
||||||
|
result = await server.file_exists("/sdcard/missing.txt")
|
||||||
|
assert result.exists is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.file_exists("/sdcard/f")
|
||||||
|
assert result.success is False
|
||||||
236
tests/test_input.py
Normal file
236
tests/test_input.py
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"""Tests for input mixin (tap, swipe, keys, text, clipboard)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputTap:
|
||||||
|
async def test_tap(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_tap(100, 200)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.coordinates == {"x": 100, "y": 200}
|
||||||
|
server.run_shell_args.assert_called_once_with(
|
||||||
|
["input", "tap", "100", "200"], None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_tap_with_device(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
await server.input_tap(10, 20, device_id="ABC")
|
||||||
|
server.run_shell_args.assert_called_once_with(
|
||||||
|
["input", "tap", "10", "20"], "ABC"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_tap_failure(self, server):
|
||||||
|
server.run_shell_args.return_value = fail("no device")
|
||||||
|
result = await server.input_tap(0, 0)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error == "no device"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputSwipe:
|
||||||
|
async def test_swipe(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_swipe(0, 100, 0, 500, duration_ms=500)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.start == {"x": 0, "y": 100}
|
||||||
|
assert result.end == {"x": 0, "y": 500}
|
||||||
|
assert result.duration_ms == 500
|
||||||
|
|
||||||
|
async def test_swipe_default_duration(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_swipe(0, 0, 100, 100)
|
||||||
|
assert result.duration_ms == 300
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputScroll:
|
||||||
|
async def test_scroll_down(self, server):
|
||||||
|
# First call: wm size, second call: the swipe
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok("Physical size: 1080x1920"),
|
||||||
|
ok(),
|
||||||
|
]
|
||||||
|
result = await server.input_scroll_down()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "scroll_down"
|
||||||
|
|
||||||
|
# Verify swipe args: center x, 65% down to 25% down
|
||||||
|
swipe_call = server.run_shell_args.call_args_list[1]
|
||||||
|
args = swipe_call[0][0]
|
||||||
|
assert args[0] == "input"
|
||||||
|
assert args[1] == "swipe"
|
||||||
|
assert args[2] == "540" # 1080 // 2
|
||||||
|
assert args[3] == "1248" # int(1920 * 0.65)
|
||||||
|
assert args[5] == "480" # int(1920 * 0.25)
|
||||||
|
|
||||||
|
async def test_scroll_up(self, server):
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok("Physical size: 1080x1920"),
|
||||||
|
ok(),
|
||||||
|
]
|
||||||
|
result = await server.input_scroll_up()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "scroll_up"
|
||||||
|
|
||||||
|
async def test_scroll_fallback_dimensions(self, server):
|
||||||
|
# wm size fails, should fall back to 1080x1920
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
fail("error"),
|
||||||
|
ok(),
|
||||||
|
]
|
||||||
|
result = await server.input_scroll_down()
|
||||||
|
assert result.success is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputKeys:
|
||||||
|
async def test_back(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_back()
|
||||||
|
assert result.action == "back"
|
||||||
|
server.run_shell_args.assert_called_once_with(
|
||||||
|
["input", "keyevent", "KEYCODE_BACK"], None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_home(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_home()
|
||||||
|
assert result.action == "home"
|
||||||
|
|
||||||
|
async def test_recent_apps(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_recent_apps()
|
||||||
|
assert result.action == "recent_apps"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputKey:
|
||||||
|
async def test_full_keycode(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_key("KEYCODE_ENTER")
|
||||||
|
assert result.key_code == "KEYCODE_ENTER"
|
||||||
|
|
||||||
|
async def test_auto_prefix(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_key("ENTER")
|
||||||
|
assert result.key_code == "KEYCODE_ENTER"
|
||||||
|
|
||||||
|
async def test_strips_dangerous_chars(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_key("KEYCODE_ENTER; rm -rf /")
|
||||||
|
# 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
|
||||||
|
|
||||||
|
async def test_lowercase_normalized(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_key("enter")
|
||||||
|
assert result.key_code == "KEYCODE_ENTER"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputText:
|
||||||
|
async def test_simple_text(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_text("hello")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.text == "hello"
|
||||||
|
server.run_shell_args.assert_called_once_with(["input", "text", "hello"], None)
|
||||||
|
|
||||||
|
async def test_spaces_escaped(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
await server.input_text("hello world")
|
||||||
|
server.run_shell_args.assert_called_once_with(
|
||||||
|
["input", "text", "hello%sworld"], None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_rejects_special_chars(self, server):
|
||||||
|
for char in "'\"\\`$(){}[]|&;<>!~#%^*?":
|
||||||
|
result = await server.input_text(f"text{char}here")
|
||||||
|
assert result.success is False
|
||||||
|
assert "clipboard_set" in result.error
|
||||||
|
|
||||||
|
async def test_rejects_semicolon_injection(self, server):
|
||||||
|
result = await server.input_text("hello; rm -rf /")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestClipboardSet:
|
||||||
|
async def test_cmd_clipboard(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.clipboard_set("test text")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "clipboard_set"
|
||||||
|
|
||||||
|
async def test_cmd_clipboard_not_implemented_falls_back(self, server):
|
||||||
|
# First call: cmd clipboard returns "no shell command"
|
||||||
|
# Second call: am broadcast succeeds with result=-1
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(stderr="No shell command implementation."),
|
||||||
|
ok(stdout="Broadcast completed: result=-1"),
|
||||||
|
]
|
||||||
|
result = await server.clipboard_set("test")
|
||||||
|
assert result.success is True
|
||||||
|
assert server.run_shell_args.call_count == 2
|
||||||
|
|
||||||
|
async def test_no_receiver_reports_failure(self, server):
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(stderr="No shell command implementation."),
|
||||||
|
ok(stdout="Broadcast completed: result=0"), # No receiver
|
||||||
|
]
|
||||||
|
result = await server.clipboard_set("test")
|
||||||
|
assert result.success is False
|
||||||
|
assert "no broadcast receiver" in result.error.lower()
|
||||||
|
|
||||||
|
async def test_paste(self, server):
|
||||||
|
# First call: cmd clipboard set, second call: paste keyevent
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok()]
|
||||||
|
result = await server.clipboard_set("text", paste=True)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.pasted is True
|
||||||
|
# Verify KEYCODE_PASTE was sent
|
||||||
|
paste_call = server.run_shell_args.call_args_list[1]
|
||||||
|
assert "KEYCODE_PASTE" in paste_call[0][0]
|
||||||
|
|
||||||
|
async def test_text_preview_truncated(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
long_text = "x" * 200
|
||||||
|
result = await server.clipboard_set(long_text)
|
||||||
|
assert len(result.text) < 200
|
||||||
|
assert result.text.endswith("...")
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputLongPress:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_long_press(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.input_long_press(100, 200, duration_ms=2000)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "long_press"
|
||||||
|
assert result.duration_ms == 2000
|
||||||
|
# Long press = swipe from same point to same point
|
||||||
|
args = server.run_shell_args.call_args[0][0]
|
||||||
|
assert args[2] == args[4] # x1 == x2
|
||||||
|
assert args[3] == args[5] # y1 == y2
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.input_long_press(0, 0)
|
||||||
|
assert result.success is False
|
||||||
|
assert "developer mode" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestShellCommand:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_executes(self, server):
|
||||||
|
server.run_shell.return_value = ok(stdout="output")
|
||||||
|
result = await server.shell_command("ls /sdcard")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.stdout == "output"
|
||||||
|
assert result.command == "ls /sdcard"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.shell_command("ls")
|
||||||
|
assert result.success is False
|
||||||
|
assert "developer mode" in result.error.lower()
|
||||||
56
tests/test_models.py
Normal file
56
tests/test_models.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""Tests for Pydantic models."""
|
||||||
|
|
||||||
|
from src.models import CommandResult, DeviceInfo, ScreenshotResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandResult:
|
||||||
|
def test_success(self):
|
||||||
|
r = CommandResult(success=True, stdout="ok", stderr="", returncode=0)
|
||||||
|
assert r.success is True
|
||||||
|
assert r.returncode == 0
|
||||||
|
|
||||||
|
def test_failure(self):
|
||||||
|
r = CommandResult(success=False, stdout="", stderr="err", returncode=1)
|
||||||
|
assert r.success is False
|
||||||
|
assert r.stderr == "err"
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
r = CommandResult(success=True, returncode=0)
|
||||||
|
assert r.stdout == ""
|
||||||
|
assert r.stderr == ""
|
||||||
|
|
||||||
|
def test_model_copy(self):
|
||||||
|
r = CommandResult(success=True, stdout="ok", stderr="", returncode=0)
|
||||||
|
r2 = r.model_copy(update={"success": False, "stderr": "changed"})
|
||||||
|
assert r2.success is False
|
||||||
|
assert r2.stderr == "changed"
|
||||||
|
assert r.success is True # original unchanged
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceInfo:
|
||||||
|
def test_basic(self):
|
||||||
|
d = DeviceInfo(device_id="ABC123", status="device")
|
||||||
|
assert d.device_id == "ABC123"
|
||||||
|
assert d.model is None
|
||||||
|
|
||||||
|
def test_full(self):
|
||||||
|
d = DeviceInfo(
|
||||||
|
device_id="ABC123",
|
||||||
|
status="device",
|
||||||
|
model="Pixel_6",
|
||||||
|
product="oriole",
|
||||||
|
)
|
||||||
|
assert d.model == "Pixel_6"
|
||||||
|
dump = d.model_dump()
|
||||||
|
assert dump["product"] == "oriole"
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenshotResult:
|
||||||
|
def test_success(self):
|
||||||
|
r = ScreenshotResult(success=True, local_path="/tmp/shot.png")
|
||||||
|
assert r.local_path == "/tmp/shot.png"
|
||||||
|
|
||||||
|
def test_failure(self):
|
||||||
|
r = ScreenshotResult(success=False, error="No device")
|
||||||
|
assert r.error == "No device"
|
||||||
|
assert r.local_path is None
|
||||||
132
tests/test_screenshot.py
Normal file
132
tests/test_screenshot.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""Tests for screenshot mixin (capture, screen size, density, record)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenshot:
|
||||||
|
async def test_capture(self, server, ctx, tmp_path, monkeypatch):
|
||||||
|
from src.config import get_config
|
||||||
|
|
||||||
|
get_config().default_screenshot_dir = str(tmp_path)
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.screenshot(ctx, filename="test.png")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.local_path is not None
|
||||||
|
assert "test.png" in result.local_path
|
||||||
|
|
||||||
|
async def test_capture_failure(self, server, ctx):
|
||||||
|
server.run_shell_args.return_value = fail("no screen")
|
||||||
|
result = await server.screenshot(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.error is not None
|
||||||
|
|
||||||
|
async def test_pull_failure(self, server, ctx):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
server.run_adb.return_value = fail("pull failed")
|
||||||
|
result = await server.screenshot(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenSize:
|
||||||
|
async def test_physical(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="Physical size: 1080x1920")
|
||||||
|
result = await server.screen_size()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.width == 1080
|
||||||
|
assert result.height == 1920
|
||||||
|
|
||||||
|
async def test_override(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout="Physical size: 1080x1920\nOverride size: 720x1280"
|
||||||
|
)
|
||||||
|
result = await server.screen_size()
|
||||||
|
assert result.success is True
|
||||||
|
# Should parse the first match
|
||||||
|
assert result.width == 1080
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_shell_args.return_value = fail("error")
|
||||||
|
result = await server.screen_size()
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenDensity:
|
||||||
|
async def test_density(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="Physical density: 420")
|
||||||
|
result = await server.screen_density()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.dpi == 420
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_shell_args.return_value = fail("error")
|
||||||
|
result = await server.screen_density()
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenOnOff:
|
||||||
|
async def test_screen_on(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.screen_on()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "screen_on"
|
||||||
|
|
||||||
|
async def test_screen_off(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.screen_off()
|
||||||
|
assert result.action == "screen_off"
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenRecord:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_record(self, server, ctx, tmp_path):
|
||||||
|
from src.config import get_config
|
||||||
|
|
||||||
|
get_config().default_screenshot_dir = str(tmp_path)
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok()] # record + rm
|
||||||
|
server.run_adb.return_value = ok() # pull
|
||||||
|
result = await server.screen_record(
|
||||||
|
ctx,
|
||||||
|
filename="test.mp4",
|
||||||
|
duration_seconds=5,
|
||||||
|
)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.duration_seconds == 5
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_duration_capped(self, server, ctx, tmp_path):
|
||||||
|
from src.config import get_config
|
||||||
|
|
||||||
|
get_config().default_screenshot_dir = str(tmp_path)
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok()]
|
||||||
|
server.run_adb.return_value = ok()
|
||||||
|
result = await server.screen_record(ctx, duration_seconds=999)
|
||||||
|
assert result.duration_seconds == 180 # Capped at 180
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.screen_record(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenSetSize:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_set(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.screen_set_size(720, 1280)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.width == 720
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_reset(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.screen_reset_size()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "reset_size"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.screen_set_size(720, 1280)
|
||||||
|
assert result.success is False
|
||||||
36
tests/test_server.py
Normal file
36
tests/test_server.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Tests for server-level tools (config, help resource)."""
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigStatus:
|
||||||
|
async def test_status(self, server):
|
||||||
|
result = await server.config_status()
|
||||||
|
assert hasattr(result, "developer_mode")
|
||||||
|
assert hasattr(result, "auto_select_single_device")
|
||||||
|
assert hasattr(result, "current_device")
|
||||||
|
|
||||||
|
async def test_reflects_current_device(self, server):
|
||||||
|
server.set_current_device("ABC123")
|
||||||
|
result = await server.config_status()
|
||||||
|
assert result.current_device == "ABC123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSetDeveloperMode:
|
||||||
|
async def test_enable(self, server):
|
||||||
|
result = await server.config_set_developer_mode(True)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.developer_mode is True
|
||||||
|
|
||||||
|
async def test_disable(self, server):
|
||||||
|
result = await server.config_set_developer_mode(False)
|
||||||
|
assert result.developer_mode is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSetScreenshotDir:
|
||||||
|
async def test_set(self, server):
|
||||||
|
result = await server.config_set_screenshot_dir("/tmp/shots")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.screenshot_dir == "/tmp/shots"
|
||||||
|
|
||||||
|
async def test_clear(self, server):
|
||||||
|
result = await server.config_set_screenshot_dir(None)
|
||||||
|
assert result.screenshot_dir is None
|
||||||
391
tests/test_settings.py
Normal file
391
tests/test_settings.py
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
"""Tests for settings mixin (settings, toggles, notifications, clipboard, media)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.mixins.settings import _MEDIA_KEYCODES, SettingsMixin
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsGet:
|
||||||
|
async def test_valid(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="1")
|
||||||
|
result = await server.settings_get("global", "wifi_on")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.value == "1"
|
||||||
|
assert result.exists is True
|
||||||
|
|
||||||
|
async def test_null_value(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="null")
|
||||||
|
result = await server.settings_get("global", "missing_key")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.value is None
|
||||||
|
assert result.exists is False
|
||||||
|
|
||||||
|
async def test_invalid_namespace(self, server):
|
||||||
|
result = await server.settings_get("invalid", "key")
|
||||||
|
assert result.success is False
|
||||||
|
assert "Invalid namespace" in result.error
|
||||||
|
|
||||||
|
async def test_invalid_key(self, server):
|
||||||
|
result = await server.settings_get("global", "bad key!")
|
||||||
|
assert result.success is False
|
||||||
|
assert "Invalid key" in result.error
|
||||||
|
|
||||||
|
async def test_all_namespaces_valid(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="value")
|
||||||
|
for ns in ("system", "global", "secure"):
|
||||||
|
result = await server.settings_get(ns, "test_key")
|
||||||
|
assert result.success is True
|
||||||
|
|
||||||
|
async def test_key_with_dots(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="value")
|
||||||
|
result = await server.settings_get("global", "wifi.scan_always_enabled")
|
||||||
|
assert result.success is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsPut:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_write_and_verify(self, server, ctx):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout="128")]
|
||||||
|
result = await server.settings_put(ctx, "system", "screen_brightness", "128")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.readback == "128"
|
||||||
|
assert result.verified is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_invalid_namespace(self, server, ctx):
|
||||||
|
result = await server.settings_put(ctx, "bad", "key", "val")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_invalid_key(self, server, ctx):
|
||||||
|
result = await server.settings_put(ctx, "global", "k;ey", "val")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_secure_namespace_elicits(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, write setting")
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout="val")]
|
||||||
|
result = await server.settings_put(ctx, "secure", "key", "val")
|
||||||
|
assert result.success is True
|
||||||
|
# Verify elicitation happened
|
||||||
|
assert any("secure" in msg for _, msg in ctx.messages)
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_secure_cancelled(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Cancel")
|
||||||
|
result = await server.settings_put(ctx, "secure", "key", "val")
|
||||||
|
assert result.success is False
|
||||||
|
assert result.cancelled is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.settings_put(ctx, "system", "k", "v")
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWifiToggle:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_enable(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout="1")]
|
||||||
|
result = await server.wifi_toggle(True)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "enable"
|
||||||
|
assert result.verified is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_disable(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout="0")]
|
||||||
|
result = await server.wifi_toggle(False)
|
||||||
|
assert result.action == "disable"
|
||||||
|
assert result.verified is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.wifi_toggle(True)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestBluetoothToggle:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_enable(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.bluetooth_toggle(True)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "enable"
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.bluetooth_toggle(False)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAirplaneModeToggle:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_enable_usb_device(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Yes, enable airplane mode")
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok()]
|
||||||
|
result = await server.airplane_mode_toggle(ctx, True)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.airplane_mode is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_enable_network_device_warns(self, server, ctx):
|
||||||
|
server.set_current_device("10.20.0.25:5555")
|
||||||
|
ctx.set_elicit("accept", "Yes, enable airplane mode")
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok()]
|
||||||
|
result = await server.airplane_mode_toggle(ctx, True)
|
||||||
|
assert result.success is True
|
||||||
|
# Should have warned about network disconnection
|
||||||
|
warns = [msg for level, msg in ctx.messages if "sever" in msg.lower()]
|
||||||
|
assert len(warns) > 0
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_cancelled(self, server, ctx):
|
||||||
|
ctx.set_elicit("accept", "Cancel")
|
||||||
|
result = await server.airplane_mode_toggle(ctx, True)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.cancelled is True
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_disable_no_elicitation(self, server, ctx):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok()]
|
||||||
|
result = await server.airplane_mode_toggle(ctx, False)
|
||||||
|
assert result.success is True
|
||||||
|
# No elicitation for disable
|
||||||
|
elicits = [m for level, m in ctx.messages if level == "elicit"]
|
||||||
|
assert len(elicits) == 0
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server, ctx):
|
||||||
|
result = await server.airplane_mode_toggle(ctx, True)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenBrightness:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_set(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok()]
|
||||||
|
result = await server.screen_brightness(128)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.brightness == 128
|
||||||
|
assert result.auto_brightness is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_out_of_range(self, server):
|
||||||
|
result = await server.screen_brightness(300)
|
||||||
|
assert result.success is False
|
||||||
|
assert "0-255" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_negative(self, server):
|
||||||
|
result = await server.screen_brightness(-1)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_no_dev_mode")
|
||||||
|
async def test_requires_dev_mode(self, server):
|
||||||
|
result = await server.screen_brightness(128)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestScreenTimeout:
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_set(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.screen_timeout(30)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.timeout_seconds == 30
|
||||||
|
assert result.timeout_ms == 30000
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_too_large(self, server):
|
||||||
|
result = await server.screen_timeout(9999)
|
||||||
|
assert result.success is False
|
||||||
|
assert "1-1800" in result.error
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_dev_mode")
|
||||||
|
async def test_zero(self, server):
|
||||||
|
result = await server.screen_timeout(0)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotificationList:
|
||||||
|
async def test_parse_notifications(self, server):
|
||||||
|
dumpsys_output = """
|
||||||
|
NotificationRecord(0x1234 pkg=com.example.app)
|
||||||
|
extras {
|
||||||
|
android.title=Test Title
|
||||||
|
android.text=Test message body
|
||||||
|
}
|
||||||
|
postTime=1700000000000
|
||||||
|
NotificationRecord(0x5678 pkg=com.other.app)
|
||||||
|
extras {
|
||||||
|
android.title=Second
|
||||||
|
android.text=Another notification
|
||||||
|
}
|
||||||
|
postTime=1700000001000
|
||||||
|
"""
|
||||||
|
server.run_shell_args.return_value = ok(stdout=dumpsys_output)
|
||||||
|
result = await server.notification_list()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.count == 2
|
||||||
|
assert result.notifications[0]["package"] == "com.example.app"
|
||||||
|
assert result.notifications[0]["title"] == "Test Title"
|
||||||
|
assert result.notifications[0]["text"] == "Test message body"
|
||||||
|
|
||||||
|
async def test_limit(self, server):
|
||||||
|
# Build output with many notifications
|
||||||
|
lines = []
|
||||||
|
for i in range(10):
|
||||||
|
lines.append(f" NotificationRecord(0x{i:04x} pkg=com.app{i})")
|
||||||
|
lines.append(f" android.title=Title {i}")
|
||||||
|
server.run_shell_args.return_value = ok(stdout="\n".join(lines))
|
||||||
|
result = await server.notification_list(limit=3)
|
||||||
|
assert result.count <= 3
|
||||||
|
|
||||||
|
async def test_empty(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(stdout="")
|
||||||
|
result = await server.notification_list()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestClipboardGet:
|
||||||
|
async def test_parses_parcel(self, server):
|
||||||
|
# Build parcel programmatically with correct encoding
|
||||||
|
server.run_shell_args.return_value = ok(stdout=_build_parcel("hello world"))
|
||||||
|
result = await server.clipboard_get()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.text == "hello world"
|
||||||
|
|
||||||
|
async def test_empty_clipboard(self, server):
|
||||||
|
server.run_shell_args.return_value = ok(
|
||||||
|
stdout="Result: Parcel(00000000 00000000 '........')"
|
||||||
|
)
|
||||||
|
result = await server.clipboard_get()
|
||||||
|
# No text/plain marker = not parseable
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
async def test_failure(self, server):
|
||||||
|
server.run_shell_args.return_value = fail("error")
|
||||||
|
result = await server.clipboard_get()
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parcel(text: str) -> str:
|
||||||
|
"""Build a fake service call parcel output containing clipboard text.
|
||||||
|
|
||||||
|
Mimics the format of `service call clipboard 4` output with a
|
||||||
|
ClipData Parcel containing text/plain MIME type and UTF-8 text.
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
# Status word (0 = success)
|
||||||
|
parts.append(struct.pack("<I", 0))
|
||||||
|
# Non-null marker
|
||||||
|
parts.append(struct.pack("<I", 1))
|
||||||
|
# ClipDescription: label length = 0 (no label)
|
||||||
|
parts.append(struct.pack("<I", 0))
|
||||||
|
# MIME type count = 1
|
||||||
|
parts.append(struct.pack("<I", 1))
|
||||||
|
# MIME type "text/plain" in UTF-16LE with length prefix
|
||||||
|
mime = "text/plain"
|
||||||
|
mime_utf16 = mime.encode("utf-16-le")
|
||||||
|
parts.append(struct.pack("<I", len(mime)))
|
||||||
|
parts.append(mime_utf16)
|
||||||
|
# Pad to 4-byte boundary
|
||||||
|
pad = (4 - len(mime_utf16) % 4) % 4
|
||||||
|
parts.append(b"\x00" * pad)
|
||||||
|
# Extras (none), timestamps, flags
|
||||||
|
parts.append(struct.pack("<I", 0xFFFFFFFF)) # no extras
|
||||||
|
parts.append(struct.pack("<I", 0xFFFFFFFF)) # no extras
|
||||||
|
parts.append(struct.pack("<I", 0)) # flags
|
||||||
|
# Item count = 1
|
||||||
|
parts.append(struct.pack("<I", 1))
|
||||||
|
# CharSequence type marker
|
||||||
|
parts.append(struct.pack("<I", 0))
|
||||||
|
# Text as length-prefixed UTF-8
|
||||||
|
text_bytes = text.encode("utf-8")
|
||||||
|
parts.append(struct.pack("<I", len(text_bytes)))
|
||||||
|
parts.append(text_bytes)
|
||||||
|
pad = (4 - len(text_bytes) % 4) % 4
|
||||||
|
parts.append(b"\x00" * pad)
|
||||||
|
|
||||||
|
data = b"".join(parts)
|
||||||
|
|
||||||
|
# Format as hex words like real parcel output
|
||||||
|
hex_lines = []
|
||||||
|
for i in range(0, len(data), 16):
|
||||||
|
chunk = data[i : i + 16]
|
||||||
|
words = []
|
||||||
|
for j in range(0, len(chunk), 4):
|
||||||
|
word = chunk[j : j + 4].ljust(4, b"\x00")
|
||||||
|
words.append(int.from_bytes(word, "little"))
|
||||||
|
hex_str = " ".join(f"{w:08x}" for w in words)
|
||||||
|
hex_lines.append(f" 0x{i:08x}: {hex_str} '...'")
|
||||||
|
|
||||||
|
return "Result: Parcel(\n" + "\n".join(hex_lines) + "\n)"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseClipboardParcel:
|
||||||
|
"""Direct tests for the static parcel parser."""
|
||||||
|
|
||||||
|
def test_valid_parcel(self):
|
||||||
|
raw = _build_parcel("test data here")
|
||||||
|
result = SettingsMixin._parse_clipboard_parcel(raw)
|
||||||
|
assert result == "test data here"
|
||||||
|
|
||||||
|
def test_nonzero_status(self):
|
||||||
|
raw = "Result: Parcel(00000001 '....')"
|
||||||
|
result = SettingsMixin._parse_clipboard_parcel(raw)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_hex_words(self):
|
||||||
|
result = SettingsMixin._parse_clipboard_parcel("no hex here")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_mime_marker(self):
|
||||||
|
raw = "Result: Parcel(00000000 00000001 '........')"
|
||||||
|
result = SettingsMixin._parse_clipboard_parcel(raw)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_long_text(self):
|
||||||
|
long_text = "The quick brown fox jumps over the lazy dog. " * 10
|
||||||
|
raw = _build_parcel(long_text)
|
||||||
|
result = SettingsMixin._parse_clipboard_parcel(raw)
|
||||||
|
assert result == long_text
|
||||||
|
|
||||||
|
|
||||||
|
class TestMediaControl:
|
||||||
|
async def test_play(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.media_control("play")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "play"
|
||||||
|
assert result.keycode == "KEYCODE_MEDIA_PLAY"
|
||||||
|
|
||||||
|
async def test_all_actions(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
for action, keycode in _MEDIA_KEYCODES.items():
|
||||||
|
result = await server.media_control(action)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.keycode == keycode
|
||||||
|
|
||||||
|
async def test_case_insensitive(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.media_control("PLAY")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "play"
|
||||||
|
|
||||||
|
async def test_unknown_action(self, server):
|
||||||
|
result = await server.media_control("rewind")
|
||||||
|
assert result.success is False
|
||||||
|
assert "Unknown action" in result.error
|
||||||
|
assert "play" in result.error # Lists available actions
|
||||||
|
|
||||||
|
async def test_whitespace_stripped(self, server):
|
||||||
|
server.run_shell_args.return_value = ok()
|
||||||
|
result = await server.media_control(" pause ")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.action == "pause"
|
||||||
141
tests/test_ui.py
Normal file
141
tests/test_ui.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"""Tests for UI inspection mixin (dump, find, wait, tap_text)."""
|
||||||
|
|
||||||
|
from tests.conftest import fail, ok
|
||||||
|
|
||||||
|
SAMPLE_UI_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<hierarchy>
|
||||||
|
<node text="Settings" class="android.widget.TextView"
|
||||||
|
resource-id="com.android.settings:id/title"
|
||||||
|
bounds="[0,100][200,150]" clickable="true" focusable="true"
|
||||||
|
content-desc="" />
|
||||||
|
<node text="" class="android.widget.ImageView"
|
||||||
|
resource-id="com.android.settings:id/icon"
|
||||||
|
bounds="[0,50][48,98]" clickable="false" focusable="false"
|
||||||
|
content-desc="Settings icon" />
|
||||||
|
<node text="Wi-Fi" class="android.widget.TextView"
|
||||||
|
resource-id="com.android.settings:id/title"
|
||||||
|
bounds="[200,100][400,150]" clickable="true" focusable="false"
|
||||||
|
content-desc="" />
|
||||||
|
</hierarchy>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestUiDump:
|
||||||
|
async def test_dump(self, server, ctx):
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(), # uiautomator dump
|
||||||
|
ok(stdout=SAMPLE_UI_XML), # cat
|
||||||
|
ok(), # rm cleanup
|
||||||
|
]
|
||||||
|
result = await server.ui_dump(ctx)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.element_count >= 2 # Settings + Wi-Fi at minimum
|
||||||
|
assert result.xml is not None
|
||||||
|
|
||||||
|
async def test_dump_failure(self, server, ctx):
|
||||||
|
server.run_shell_args.return_value = fail("error")
|
||||||
|
result = await server.ui_dump(ctx)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseUiElements:
|
||||||
|
def test_parse_clickable(self, server):
|
||||||
|
elements = server._parse_ui_elements(SAMPLE_UI_XML)
|
||||||
|
texts = [e["text"] for e in elements]
|
||||||
|
assert "Settings" in texts
|
||||||
|
assert "Wi-Fi" in texts
|
||||||
|
|
||||||
|
def test_center_coordinates(self, server):
|
||||||
|
elements = server._parse_ui_elements(SAMPLE_UI_XML)
|
||||||
|
settings = [e for e in elements if e["text"] == "Settings"][0]
|
||||||
|
assert settings["center"] == {"x": 100, "y": 125}
|
||||||
|
|
||||||
|
def test_content_desc_included(self, server):
|
||||||
|
elements = server._parse_ui_elements(SAMPLE_UI_XML)
|
||||||
|
icon = [e for e in elements if e["content_desc"] == "Settings icon"]
|
||||||
|
assert len(icon) == 1
|
||||||
|
|
||||||
|
def test_empty_xml(self, server):
|
||||||
|
elements = server._parse_ui_elements("")
|
||||||
|
assert elements == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestUiFindElement:
|
||||||
|
async def test_find_by_text(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
|
||||||
|
result = await server.ui_find_element(text="Settings")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.count == 1
|
||||||
|
assert result.matches[0]["text"] == "Settings"
|
||||||
|
|
||||||
|
async def test_find_by_resource_id(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
|
||||||
|
result = await server.ui_find_element(resource_id="title")
|
||||||
|
# Settings and Wi-Fi both have "title" in their resource-id
|
||||||
|
assert result.count >= 2
|
||||||
|
|
||||||
|
async def test_not_found(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
|
||||||
|
result = await server.ui_find_element(text="Missing")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitForText:
|
||||||
|
async def test_found_immediately(self, server):
|
||||||
|
server.run_shell_args.side_effect = [ok(), ok(stdout=SAMPLE_UI_XML), ok()]
|
||||||
|
result = await server.wait_for_text("Settings", timeout_seconds=1)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.found is True
|
||||||
|
assert result.attempts == 1
|
||||||
|
|
||||||
|
async def test_timeout(self, server):
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(),
|
||||||
|
ok(stdout="<hierarchy></hierarchy>"),
|
||||||
|
ok(),
|
||||||
|
] * 10
|
||||||
|
result = await server.wait_for_text(
|
||||||
|
"Missing", timeout_seconds=0.1, poll_interval=0.05
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
assert result.found is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWaitForTextGone:
|
||||||
|
async def test_already_gone(self, server):
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(),
|
||||||
|
ok(stdout="<hierarchy></hierarchy>"),
|
||||||
|
ok(),
|
||||||
|
]
|
||||||
|
result = await server.wait_for_text_gone("Missing", timeout_seconds=1)
|
||||||
|
assert result.success is True
|
||||||
|
assert result.gone is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestTapText:
|
||||||
|
async def test_tap_found(self, server):
|
||||||
|
# find_element calls ui_dump which has 3 calls, then tap has 1
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(),
|
||||||
|
ok(stdout=SAMPLE_UI_XML),
|
||||||
|
ok(), # ui_dump for find
|
||||||
|
ok(), # tap
|
||||||
|
]
|
||||||
|
result = await server.tap_text("Settings")
|
||||||
|
assert result.success is True
|
||||||
|
assert result.coordinates == {"x": 100, "y": 125}
|
||||||
|
|
||||||
|
async def test_not_found(self, server):
|
||||||
|
server.run_shell_args.side_effect = [
|
||||||
|
ok(),
|
||||||
|
ok(stdout=SAMPLE_UI_XML),
|
||||||
|
ok(), # first search by text
|
||||||
|
ok(),
|
||||||
|
ok(stdout=SAMPLE_UI_XML),
|
||||||
|
ok(), # fallback search by content_desc
|
||||||
|
]
|
||||||
|
result = await server.tap_text("NonExistent")
|
||||||
|
assert result.success is False
|
||||||
|
assert "No element found" in result.error
|
||||||
Loading…
x
Reference in New Issue
Block a user