# Directory Structure ``` ├── .gitignore ├── .python-version ├── adbdevicemanager.py ├── config.yaml.example ├── LICENSE ├── pyproject.toml ├── README.md ├── run_tests.py ├── server.py ├── tests │ ├── __init__.py │ ├── test_adb_device_manager.py │ ├── test_config.py │ └── test_integration.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 3.11 ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Python-generated files __pycache__/ *.py[oc] build/ dist/ wheels/ *.egg-info # Virtual environments .venv # Project specific files *.png window_dump.xml output.mov config.yaml test.py .coverage ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Android MCP Server An MCP (Model Context Protocol) server that provides programmatic control over Android devices through ADB (Android Debug Bridge). This server exposes various Android device management capabilities that can be accessed by MCP clients like [Claude desktop](https://modelcontextprotocol.io/quickstart/user) and Code editors (e.g. [Cursor](https://docs.cursor.com/context/model-context-protocol)) ## Features - 🔧 ADB Command Execution - 📸 Device Screenshot Capture - 🎯 UI Layout Analysis - 📱 Device Package Management ## Prerequisites - Python 3.x - ADB (Android Debug Bridge) installed and configured - Android device or emulator (not tested) ## Installation 1. Clone the repository: ```bash git clone https://github.com/minhalvp/android-mcp-server.git cd android-mcp-server ``` 2. Install dependencies: This project uses [uv](https://github.com/astral-sh/uv) for project management via various methods of [installation](https://docs.astral.sh/uv/getting-started/installation/). ```bash uv python install 3.11 uv sync ``` ## Configuration The server supports flexible device configuration with multiple usage scenarios. ### Device Selection Modes **1. Automatic Selection (Recommended for single device)** - No configuration file needed - Automatically connects to the only connected device - Perfect for development with a single test device **2. Manual Device Selection** - Use when you have multiple devices connected - Specify exact device in configuration file ### Configuration File (Optional) The configuration file (`config.yaml`) is **optional**. If not present, the server will automatically select the device if only one is connected. #### For Automatic Selection Simply ensure only one device is connected and run the server - no configuration needed! #### For Manual Selection 1. Create a configuration file: ```bash cp config.yaml.example config.yaml ``` 2. Edit `config.yaml` and specify your device: ```yaml device: name: "your-device-serial-here" # Device identifier from 'adb devices' ``` **For auto-selection**, you can use any of these methods: ```yaml device: name: null # Explicit null (recommended) # name: "" # Empty string # name: # Or leave empty/comment out ``` ### Finding Your Device Serial To find your device identifier, run: ```bash adb devices ``` Example output: ``` List of devices attached 13b22d7f device emulator-5554 device ``` Use the first column value (e.g., `13b22d7f` or `emulator-5554`) as the device name. ### Usage Scenarios | Scenario | Configuration Required | Behavior | |----------|----------------------|----------| | Single device connected | None | ✅ Auto-connects to the device | | Multiple devices, want specific one | `config.yaml` with `device.name` | ✅ Connects to specified device | | Multiple devices, no config | None | ❌ Shows error with available devices | | No devices connected | N/A | ❌ Shows "no devices" error | **Note**: If you have multiple devices connected and don't specify which one to use, the server will show an error message listing all available devices. ## Usage An MCP client is needed to use this server. The Claude Desktop app is an example of an MCP client. To use this server with Claude Desktop: 1. Locate your Claude Desktop configuration file: - Windows: `%APPDATA%\Claude\claude_desktop_config.json` - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 2. Add the Android MCP server configuration to the `mcpServers` section: ```json { "mcpServers": { "android": { "command": "path/to/uv", "args": ["--directory", "path/to/android-mcp-server", "run", "server.py"] } } } ``` Replace: - `path/to/uv` with the actual path to your `uv` executable - `path/to/android-mcp-server` with the absolute path to where you cloned this repository <https://github.com/user-attachments/assets/c45bbc17-f698-43e7-85b4-f1b39b8326a8> ### Available Tools The server exposes the following tools: ```python def get_packages() -> str: """ Get all installed packages on the device. Returns: str: A list of all installed packages on the device as a string """ ``` ```python def execute_adb_command(command: str) -> str: """ Executes an ADB command and returns the output. Args: command (str): The ADB command to execute Returns: str: The output of the ADB command """ ``` ```python def get_uilayout() -> str: """ Retrieves information about clickable elements in the current UI. Returns a formatted string containing details about each clickable element, including their text, content description, bounds, and center coordinates. Returns: str: A formatted list of clickable elements with their properties """ ``` ```python def get_screenshot() -> Image: """ Takes a screenshot of the device and returns it. Returns: Image: the screenshot """ ``` ```python def get_package_action_intents(package_name: str) -> list[str]: """ Get all non-data actions from Activity Resolver Table for a package Args: package_name (str): The name of the package to get actions for Returns: list[str]: A list of all non-data actions from the Activity Resolver Table for the package """ ``` ## Contributing Contributions are welcome! ## Acknowledgments - Built with [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python """ Android MCP Server Tests This package contains unit and integration tests for the Android MCP Server. """ ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "android-mcp" version = "0.1.0" description = "An MCP server for android automation" readme = "README.md" requires-python = ">=3.11" dependencies = [ "mcp>=1.0.0", "pure-python-adb>=0.3.0.dev0", "PyYAML>=6.0", "Pillow>=10.0.0", ] [project.optional-dependencies] test = ["pytest>=8.0.0", "pytest-mock>=3.12.0", "pytest-cov>=4.0.0"] [tool.setuptools] py-modules = ["server", "adbdevicemanager"] [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = ["--strict-markers", "--disable-warnings", "-v"] ``` -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Test runner script for Android MCP Server This script installs test dependencies and runs the complete test suite. """ import os import subprocess import sys def run_command(command, description): """Run a command and handle errors""" print(f"\n{'='*60}") print(f"Running: {description}") print(f"Command: {command}") print(f"{'='*60}") try: result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True) if result.stdout: print(result.stdout) return True except subprocess.CalledProcessError as e: print(f"Error running command: {e}") if e.stdout: print(f"STDOUT: {e.stdout}") if e.stderr: print(f"STDERR: {e.stderr}") return False def main(): """Main test runner function""" print("Android MCP Server Test Runner") print("=" * 60) # Change to the script directory script_dir = os.path.dirname(os.path.abspath(__file__)) os.chdir(script_dir) print(f"Working directory: {script_dir}") # Install test dependencies if not run_command("pip install -e .[test]", "Installing test dependencies"): print("Failed to install test dependencies") return 1 # Run tests with coverage if not run_command("pytest tests/ -v --cov=. --cov-report=term-missing", "Running tests with coverage"): print("Tests failed") return 1 print("\n" + "="*60) print("All tests passed successfully!") print("="*60) return 0 if __name__ == "__main__": sys.exit(main()) ``` -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- ```python import os import sys import yaml from mcp.server.fastmcp import FastMCP, Image from adbdevicemanager import AdbDeviceManager CONFIG_FILE = "config.yaml" CONFIG_FILE_EXAMPLE = "config.yaml.example" # Load config (make config file optional) config = {} device_name = None if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE) as f: config = yaml.safe_load(f.read()) or {} device_config = config.get("device", {}) configured_device_name = device_config.get( "name") if device_config else None # Support multiple ways to specify auto-selection: # 1. name: null (None in Python) # 2. name: "" (empty string) # 3. name field completely missing if configured_device_name and configured_device_name.strip(): device_name = configured_device_name.strip() print(f"Loaded config from {CONFIG_FILE}") print(f"Configured device: {device_name}") else: print(f"Loaded config from {CONFIG_FILE}") print( "No device specified in config, will auto-select if only one device connected") except Exception as e: print(f"Error loading config file {CONFIG_FILE}: {e}", file=sys.stderr) print( f"Please check the format of your config file or recreate it from {CONFIG_FILE_EXAMPLE}", file=sys.stderr) sys.exit(1) else: print( f"Config file {CONFIG_FILE} not found, using auto-selection for device") # Initialize MCP and device manager # AdbDeviceManager will handle auto-selection if device_name is None mcp = FastMCP("android") deviceManager = AdbDeviceManager(device_name) @mcp.tool() def get_packages() -> str: """ Get all installed packages on the device Returns: str: A list of all installed packages on the device as a string """ result = deviceManager.get_packages() return result @mcp.tool() def execute_adb_shell_command(command: str) -> str: """Executes an ADB command and returns the output or an error. Args: command (str): The ADB shell command to execute Returns: str: The output of the ADB command """ result = deviceManager.execute_adb_shell_command(command) return result @mcp.tool() def get_uilayout() -> str: """ Retrieves information about clickable elements in the current UI. Returns a formatted string containing details about each clickable element, including its text, content description, bounds, and center coordinates. Returns: str: A formatted list of clickable elements with their properties """ result = deviceManager.get_uilayout() return result @mcp.tool() def get_screenshot() -> Image: """Takes a screenshot of the device and returns it. Returns: Image: the screenshot """ deviceManager.take_screenshot() return Image(path="compressed_screenshot.png") @mcp.tool() def get_package_action_intents(package_name: str) -> list[str]: """ Get all non-data actions from Activity Resolver Table for a package Args: package_name (str): The name of the package to get actions for Returns: list[str]: A list of all non-data actions from the Activity Resolver Table for the package """ result = deviceManager.get_package_action_intents(package_name) return result if __name__ == "__main__": mcp.run(transport="stdio") ``` -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- ```python """ Tests for configuration loading logic """ import os import tempfile from unittest.mock import mock_open, patch import pytest import yaml class TestConfigLoading: """Test configuration loading scenarios""" def setup_method(self): """Setup for each test method""" self.temp_dir = tempfile.mkdtemp() self.config_file = os.path.join(self.temp_dir, "config.yaml") def _load_config_logic(self, config_file_path): """ Simulate the config loading logic from server.py Returns: (device_name, messages) """ messages = [] device_name = None if os.path.exists(config_file_path): try: with open(config_file_path) as f: config = yaml.safe_load(f.read()) or {} device_config = config.get("device", {}) configured_device_name = device_config.get( "name") if device_config else None if configured_device_name and configured_device_name.strip(): device_name = configured_device_name.strip() messages.append(f"Loaded config from {config_file_path}") messages.append(f"Configured device: {device_name}") else: messages.append(f"Loaded config from {config_file_path}") messages.append( "No device specified in config, will auto-select if only one device connected") except Exception as e: messages.append( f"Error loading config file {config_file_path}: {e}") raise else: messages.append( f"Config file {config_file_path} not found, using auto-selection for device") return device_name, messages def test_no_config_file(self): """Test behavior when config file doesn't exist""" non_existent_file = os.path.join(self.temp_dir, "non_existent.yaml") device_name, messages = self._load_config_logic(non_existent_file) assert device_name is None assert any("not found" in msg for msg in messages) assert any("auto-selection" in msg for msg in messages) def test_config_with_null_name(self): """Test config with name: null""" config_content = """ device: name: null """ with open(self.config_file, 'w') as f: f.write(config_content) device_name, messages = self._load_config_logic(self.config_file) assert device_name is None assert any("Loaded config" in msg for msg in messages) assert any("auto-select" in msg for msg in messages) def test_config_with_empty_string_name(self): """Test config with name: ''""" config_content = """ device: name: "" """ with open(self.config_file, 'w') as f: f.write(config_content) device_name, messages = self._load_config_logic(self.config_file) assert device_name is None assert any("auto-select" in msg for msg in messages) def test_config_with_whitespace_name(self): """Test config with name containing only whitespace""" config_content = """ device: name: " \t \n " """ with open(self.config_file, 'w') as f: f.write(config_content) device_name, messages = self._load_config_logic(self.config_file) assert device_name is None assert any("auto-select" in msg for msg in messages) def test_config_without_name_field(self): """Test config with device section but no name field""" config_content = """ device: # name field is missing other_setting: value """ with open(self.config_file, 'w') as f: f.write(config_content) device_name, messages = self._load_config_logic(self.config_file) assert device_name is None assert any("auto-select" in msg for msg in messages) def test_config_without_device_section(self): """Test config without device section""" config_content = """ # No device section other_config: value """ with open(self.config_file, 'w') as f: f.write(config_content) device_name, messages = self._load_config_logic(self.config_file) assert device_name is None assert any("auto-select" in msg for msg in messages) def test_config_with_valid_device_name(self): """Test config with valid device name""" config_content = """ device: name: "test-device-123" """ with open(self.config_file, 'w') as f: f.write(config_content) device_name, messages = self._load_config_logic(self.config_file) assert device_name == "test-device-123" assert any( "Configured device: test-device-123" in msg for msg in messages) def test_config_with_device_name_with_whitespace(self): """Test config with device name that has surrounding whitespace""" config_content = """ device: name: " test-device-123 " """ with open(self.config_file, 'w') as f: f.write(config_content) device_name, messages = self._load_config_logic(self.config_file) assert device_name == "test-device-123" # Should be trimmed assert any( "Configured device: test-device-123" in msg for msg in messages) def test_invalid_yaml_config(self): """Test behavior with invalid YAML""" config_content = """ device: name: "test-device # Missing closing quote - invalid YAML """ with open(self.config_file, 'w') as f: f.write(config_content) with pytest.raises(Exception): self._load_config_logic(self.config_file) def test_empty_config_file(self): """Test behavior with empty config file""" with open(self.config_file, 'w') as f: f.write("") device_name, messages = self._load_config_logic(self.config_file) assert device_name is None assert any("auto-select" in msg for msg in messages) def teardown_method(self): """Cleanup after each test method""" if os.path.exists(self.config_file): os.remove(self.config_file) os.rmdir(self.temp_dir) ``` -------------------------------------------------------------------------------- /tests/test_adb_device_manager.py: -------------------------------------------------------------------------------- ```python """ Tests for AdbDeviceManager """ import os import sys from unittest.mock import MagicMock, patch import pytest from adbdevicemanager import AdbDeviceManager # Add the parent directory to the path so we can import our modules sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) class TestAdbDeviceManager: """Test AdbDeviceManager functionality""" @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') @patch('adbdevicemanager.AdbClient') def test_single_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb): """Test auto-selection when only one device is connected""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123"] mock_device = MagicMock() mock_adb_client.return_value.device.return_value = mock_device # Test with device_name=None (auto-selection) with patch('builtins.print') as mock_print: manager = AdbDeviceManager(device_name=None, exit_on_error=False) # Verify the correct device was selected mock_adb_client.return_value.device.assert_called_once_with( "device123") assert manager.device == mock_device # Verify auto-selection message was printed mock_print.assert_called_with( "No device specified, automatically selected: device123") @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') def test_multiple_devices_no_selection_error(self, mock_get_devices, mock_check_adb): """Test error when multiple devices are connected but none specified""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123", "device456"] # Test with device_name=None and multiple devices with pytest.raises(RuntimeError) as exc_info: AdbDeviceManager(device_name=None, exit_on_error=False) assert "Multiple devices connected" in str(exc_info.value) assert "device123" in str(exc_info.value) assert "device456" in str(exc_info.value) @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') @patch('adbdevicemanager.AdbClient') def test_specific_device_selection(self, mock_adb_client, mock_get_devices, mock_check_adb): """Test selecting a specific device""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123", "device456"] mock_device = MagicMock() mock_adb_client.return_value.device.return_value = mock_device # Test with specific device name manager = AdbDeviceManager( device_name="device456", exit_on_error=False) # Verify the correct device was selected mock_adb_client.return_value.device.assert_called_once_with( "device456") assert manager.device == mock_device @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') def test_device_not_found_error(self, mock_get_devices, mock_check_adb): """Test error when specified device is not found""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123", "device456"] # Test with non-existent device with pytest.raises(RuntimeError) as exc_info: AdbDeviceManager(device_name="non-existent-device", exit_on_error=False) assert "Device non-existent-device not found" in str(exc_info.value) assert "Available devices" in str(exc_info.value) @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') def test_no_devices_connected_error(self, mock_get_devices, mock_check_adb): """Test error when no devices are connected""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = [] # Test with no devices with pytest.raises(RuntimeError) as exc_info: AdbDeviceManager(device_name=None, exit_on_error=False) assert "No devices connected" in str(exc_info.value) @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') def test_adb_not_installed_error(self, mock_check_adb): """Test error when ADB is not installed""" # Setup mocks mock_check_adb.return_value = False # Test with ADB not installed with pytest.raises(RuntimeError) as exc_info: AdbDeviceManager(device_name=None, exit_on_error=False) assert "adb is not installed" in str(exc_info.value) @patch('subprocess.run') def test_check_adb_installed_success(self, mock_run): """Test successful ADB installation check""" mock_run.return_value = MagicMock() # Successful run result = AdbDeviceManager.check_adb_installed() assert result is True mock_run.assert_called_once() @patch('subprocess.run') def test_check_adb_installed_failure(self, mock_run): """Test failed ADB installation check""" mock_run.side_effect = FileNotFoundError() # ADB not found result = AdbDeviceManager.check_adb_installed() assert result is False @patch('adbdevicemanager.AdbClient') def test_get_available_devices(self, mock_adb_client): """Test getting available devices""" # Setup mock devices mock_device1 = MagicMock() mock_device1.serial = "device123" mock_device2 = MagicMock() mock_device2.serial = "device456" mock_adb_client.return_value.devices.return_value = [ mock_device1, mock_device2] devices = AdbDeviceManager.get_available_devices() assert devices == ["device123", "device456"] @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') @patch('adbdevicemanager.AdbClient') def test_exit_on_error_true(self, mock_adb_client, mock_get_devices, mock_check_adb): """Test that exit_on_error=True calls sys.exit""" # Setup mocks to trigger error mock_check_adb.return_value = True mock_get_devices.return_value = [] # No devices # Test with exit_on_error=True (default) with patch('sys.exit') as mock_exit: with patch('builtins.print'): # Suppress error output AdbDeviceManager(device_name=None, exit_on_error=True) mock_exit.assert_called_once_with(1) ``` -------------------------------------------------------------------------------- /adbdevicemanager.py: -------------------------------------------------------------------------------- ```python import os import subprocess import sys from PIL import Image as PILImage from ppadb.client import Client as AdbClient class AdbDeviceManager: def __init__(self, device_name: str | None = None, exit_on_error: bool = True) -> None: """ Initialize the ADB Device Manager Args: device_name: Optional name/serial of the device to manage. If None, attempts to auto-select if only one device is available. exit_on_error: Whether to exit the program if device initialization fails """ if not self.check_adb_installed(): error_msg = "adb is not installed or not in PATH. Please install adb and ensure it is in your PATH." if exit_on_error: print(error_msg, file=sys.stderr) sys.exit(1) else: raise RuntimeError(error_msg) available_devices = self.get_available_devices() if not available_devices: error_msg = "No devices connected. Please connect a device and try again." if exit_on_error: print(error_msg, file=sys.stderr) sys.exit(1) else: raise RuntimeError(error_msg) selected_device_name: str | None = None if device_name: if device_name not in available_devices: error_msg = f"Device {device_name} not found. Available devices: {available_devices}" if exit_on_error: print(error_msg, file=sys.stderr) sys.exit(1) else: raise RuntimeError(error_msg) selected_device_name = device_name else: # No device_name provided, try auto-selection if len(available_devices) == 1: selected_device_name = available_devices[0] print( f"No device specified, automatically selected: {selected_device_name}") elif len(available_devices) > 1: error_msg = f"Multiple devices connected: {available_devices}. Please specify a device in config.yaml or connect only one device." if exit_on_error: print(error_msg, file=sys.stderr) sys.exit(1) else: raise RuntimeError(error_msg) # If len(available_devices) == 0, it's already caught by the earlier check # At this point, selected_device_name should always be set due to the logic above # Initialize the device self.device = AdbClient().device(selected_device_name) @staticmethod def check_adb_installed() -> bool: """Check if ADB is installed on the system.""" try: subprocess.run(["adb", "version"], check=True, stdout=subprocess.PIPE) return True except (subprocess.CalledProcessError, FileNotFoundError): return False @staticmethod def get_available_devices() -> list[str]: """Get a list of available devices.""" return [device.serial for device in AdbClient().devices()] def get_packages(self) -> str: command = "pm list packages" packages = self.device.shell(command).strip().split("\n") result = [package[8:] for package in packages] output = "\n".join(result) return output def get_package_action_intents(self, package_name: str) -> list[str]: command = f"dumpsys package {package_name}" output = self.device.shell(command) resolver_table_start = output.find("Activity Resolver Table:") if resolver_table_start == -1: return [] resolver_section = output[resolver_table_start:] non_data_start = resolver_section.find("\n Non-Data Actions:") if non_data_start == -1: return [] section_end = resolver_section[non_data_start:].find("\n\n") if section_end == -1: non_data_section = resolver_section[non_data_start:] else: non_data_section = resolver_section[ non_data_start: non_data_start + section_end ] actions = [] for line in non_data_section.split("\n"): line = line.strip() if line.startswith("android.") or line.startswith("com."): actions.append(line) return actions def execute_adb_shell_command(self, command: str) -> str: """Executes an ADB command and returns the output.""" if command.startswith("adb shell "): command = command[10:] elif command.startswith("adb "): command = command[4:] result = self.device.shell(command) return result def take_screenshot(self) -> None: self.device.shell("screencap -p /sdcard/screenshot.png") self.device.pull("/sdcard/screenshot.png", "screenshot.png") self.device.shell("rm /sdcard/screenshot.png") # compressing the ss to avoid "maximum call stack exceeded" error on claude desktop with PILImage.open("screenshot.png") as img: width, height = img.size new_width = int(width * 0.3) new_height = int(height * 0.3) resized_img = img.resize( (new_width, new_height), PILImage.Resampling.LANCZOS ) resized_img.save( "compressed_screenshot.png", "PNG", quality=85, optimize=True ) def get_uilayout(self) -> str: self.device.shell("uiautomator dump") self.device.pull("/sdcard/window_dump.xml", "window_dump.xml") self.device.shell("rm /sdcard/window_dump.xml") import re import xml.etree.ElementTree as ET def calculate_center(bounds_str): matches = re.findall(r"\[(\d+),(\d+)\]", bounds_str) if len(matches) == 2: x1, y1 = map(int, matches[0]) x2, y2 = map(int, matches[1]) center_x = (x1 + x2) // 2 center_y = (y1 + y2) // 2 return center_x, center_y return None tree = ET.parse("window_dump.xml") root = tree.getroot() clickable_elements = [] for element in root.findall(".//node[@clickable='true']"): text = element.get("text", "") content_desc = element.get("content-desc", "") bounds = element.get("bounds", "") # Only include elements that have either text or content description if text or content_desc: center = calculate_center(bounds) element_info = "Clickable element:" if text: element_info += f"\n Text: {text}" if content_desc: element_info += f"\n Description: {content_desc}" element_info += f"\n Bounds: {bounds}" if center: element_info += f"\n Center: ({center[0]}, {center[1]})" clickable_elements.append(element_info) if not clickable_elements: return "No clickable elements found with text or description" else: result = "\n\n".join(clickable_elements) return result ``` -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- ```python """ Integration tests for the complete server initialization flow """ import os import sys import tempfile from unittest.mock import MagicMock, patch from adbdevicemanager import AdbDeviceManager # Add the parent directory to the path so we can import our modules sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) class TestServerIntegration: """Test complete server initialization scenarios""" def setup_method(self): """Setup for each test method""" self.temp_dir = tempfile.mkdtemp() self.config_file = os.path.join(self.temp_dir, "config.yaml") def _simulate_server_initialization(self, config_file_path): """ Simulate the complete server initialization process Returns: (device_manager, messages) """ import yaml messages = [] config = {} device_name = None if os.path.exists(config_file_path): try: with open(config_file_path) as f: config = yaml.safe_load(f.read()) or {} device_config = config.get("device", {}) configured_device_name = device_config.get( "name") if device_config else None if configured_device_name and configured_device_name.strip(): device_name = configured_device_name.strip() messages.append(f"Loaded config from {config_file_path}") messages.append(f"Configured device: {device_name}") else: messages.append(f"Loaded config from {config_file_path}") messages.append( "No device specified in config, will auto-select if only one device connected") except Exception as e: messages.append( f"Error loading config file {config_file_path}: {e}") raise else: messages.append( f"Config file {config_file_path} not found, using auto-selection for device") # Initialize device manager (with mocked dependencies) device_manager = AdbDeviceManager(device_name, exit_on_error=False) return device_manager, messages @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') @patch('adbdevicemanager.AdbClient') def test_no_config_auto_selection_success(self, mock_adb_client, mock_get_devices, mock_check_adb): """Test successful server start with no config file and single device""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123"] mock_device = MagicMock() mock_adb_client.return_value.device.return_value = mock_device # Use non-existent config file non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml") with patch('builtins.print') as mock_print: device_manager, messages = self._simulate_server_initialization( non_existent_config) # Verify results assert device_manager.device == mock_device assert any("not found" in msg for msg in messages) assert any("auto-selection" in msg for msg in messages) mock_print.assert_called_with( "No device specified, automatically selected: device123") @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') @patch('adbdevicemanager.AdbClient') def test_config_with_null_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb): """Test server start with config file containing name: null""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device456"] mock_device = MagicMock() mock_adb_client.return_value.device.return_value = mock_device # Create config with null device name config_content = """ device: name: null """ with open(self.config_file, 'w') as f: f.write(config_content) with patch('builtins.print') as mock_print: device_manager, messages = self._simulate_server_initialization( self.config_file) # Verify results assert device_manager.device == mock_device assert any("Loaded config" in msg for msg in messages) assert any("auto-select" in msg for msg in messages) mock_print.assert_called_with( "No device specified, automatically selected: device456") @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') @patch('adbdevicemanager.AdbClient') def test_config_with_specific_device(self, mock_adb_client, mock_get_devices, mock_check_adb): """Test server start with config file specifying a device""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123", "device456"] mock_device = MagicMock() mock_adb_client.return_value.device.return_value = mock_device # Create config with specific device name config_content = """ device: name: "device456" """ with open(self.config_file, 'w') as f: f.write(config_content) device_manager, messages = self._simulate_server_initialization( self.config_file) # Verify results assert device_manager.device == mock_device mock_adb_client.return_value.device.assert_called_once_with( "device456") assert any("Configured device: device456" in msg for msg in messages) @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') def test_multiple_devices_no_config_error(self, mock_get_devices, mock_check_adb): """Test server initialization fails with multiple devices and no config""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123", "device456"] # Use non-existent config file non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml") try: device_manager, messages = self._simulate_server_initialization( non_existent_config) assert False, "Should have raised an exception" except RuntimeError as e: assert "Multiple devices connected" in str(e) assert "device123" in str(e) assert "device456" in str(e) @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') def test_device_not_found_error(self, mock_get_devices, mock_check_adb): """Test server initialization fails when specified device is not found""" # Setup mocks mock_check_adb.return_value = True mock_get_devices.return_value = ["device123"] # Create config with non-existent device name config_content = """ device: name: "non-existent-device" """ with open(self.config_file, 'w') as f: f.write(config_content) try: device_manager, messages = self._simulate_server_initialization( self.config_file) assert False, "Should have raised an exception" except RuntimeError as e: assert "Device non-existent-device not found" in str(e) assert "Available devices" in str(e) def teardown_method(self): """Cleanup after each test method""" if os.path.exists(self.config_file): os.remove(self.config_file) os.rmdir(self.temp_dir) ```