# 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: -------------------------------------------------------------------------------- ``` 1 | 3.11 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | 12 | # Project specific files 13 | *.png 14 | window_dump.xml 15 | output.mov 16 | config.yaml 17 | test.py 18 | .coverage 19 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Android MCP Server 2 | 3 | An MCP (Model Context Protocol) server that provides programmatic control over 4 | Android devices through ADB (Android Debug Bridge). This server exposes 5 | various Android device management capabilities that can be accessed by MCP 6 | clients like [Claude desktop](https://modelcontextprotocol.io/quickstart/user) 7 | and Code editors 8 | (e.g. [Cursor](https://docs.cursor.com/context/model-context-protocol)) 9 | 10 | ## Features 11 | 12 | - 🔧 ADB Command Execution 13 | - 📸 Device Screenshot Capture 14 | - 🎯 UI Layout Analysis 15 | - 📱 Device Package Management 16 | 17 | ## Prerequisites 18 | 19 | - Python 3.x 20 | - ADB (Android Debug Bridge) installed and configured 21 | - Android device or emulator (not tested) 22 | 23 | ## Installation 24 | 25 | 1. Clone the repository: 26 | 27 | ```bash 28 | git clone https://github.com/minhalvp/android-mcp-server.git 29 | cd android-mcp-server 30 | ``` 31 | 32 | 2. Install dependencies: 33 | This project uses [uv](https://github.com/astral-sh/uv) for project 34 | management via various methods of 35 | [installation](https://docs.astral.sh/uv/getting-started/installation/). 36 | 37 | ```bash 38 | uv python install 3.11 39 | uv sync 40 | ``` 41 | 42 | ## Configuration 43 | 44 | The server supports flexible device configuration with multiple usage scenarios. 45 | 46 | ### Device Selection Modes 47 | 48 | **1. Automatic Selection (Recommended for single device)** 49 | 50 | - No configuration file needed 51 | - Automatically connects to the only connected device 52 | - Perfect for development with a single test device 53 | 54 | **2. Manual Device Selection** 55 | 56 | - Use when you have multiple devices connected 57 | - Specify exact device in configuration file 58 | 59 | ### Configuration File (Optional) 60 | 61 | The configuration file (`config.yaml`) is **optional**. If not present, the server will automatically select the device if only one is connected. 62 | 63 | #### For Automatic Selection 64 | 65 | Simply ensure only one device is connected and run the server - no configuration needed! 66 | 67 | #### For Manual Selection 68 | 69 | 1. Create a configuration file: 70 | 71 | ```bash 72 | cp config.yaml.example config.yaml 73 | ``` 74 | 75 | 2. Edit `config.yaml` and specify your device: 76 | 77 | ```yaml 78 | device: 79 | name: "your-device-serial-here" # Device identifier from 'adb devices' 80 | ``` 81 | 82 | **For auto-selection**, you can use any of these methods: 83 | 84 | ```yaml 85 | device: 86 | name: null # Explicit null (recommended) 87 | # name: "" # Empty string 88 | # name: # Or leave empty/comment out 89 | ``` 90 | 91 | ### Finding Your Device Serial 92 | 93 | To find your device identifier, run: 94 | 95 | ```bash 96 | adb devices 97 | ``` 98 | 99 | Example output: 100 | 101 | ``` 102 | List of devices attached 103 | 13b22d7f device 104 | emulator-5554 device 105 | ``` 106 | 107 | Use the first column value (e.g., `13b22d7f` or `emulator-5554`) as the device name. 108 | 109 | ### Usage Scenarios 110 | 111 | | Scenario | Configuration Required | Behavior | 112 | |----------|----------------------|----------| 113 | | Single device connected | None | ✅ Auto-connects to the device | 114 | | Multiple devices, want specific one | `config.yaml` with `device.name` | ✅ Connects to specified device | 115 | | Multiple devices, no config | None | ❌ Shows error with available devices | 116 | | No devices connected | N/A | ❌ Shows "no devices" error | 117 | 118 | **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. 119 | 120 | ## Usage 121 | 122 | An MCP client is needed to use this server. The Claude Desktop app is an example 123 | of an MCP client. To use this server with Claude Desktop: 124 | 125 | 1. Locate your Claude Desktop configuration file: 126 | 127 | - Windows: `%APPDATA%\Claude\claude_desktop_config.json` 128 | - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 129 | 130 | 2. Add the Android MCP server configuration to the `mcpServers` section: 131 | 132 | ```json 133 | { 134 | "mcpServers": { 135 | "android": { 136 | "command": "path/to/uv", 137 | "args": ["--directory", "path/to/android-mcp-server", "run", "server.py"] 138 | } 139 | } 140 | } 141 | ``` 142 | 143 | Replace: 144 | 145 | - `path/to/uv` with the actual path to your `uv` executable 146 | - `path/to/android-mcp-server` with the absolute path to where you cloned this 147 | repository 148 | 149 | <https://github.com/user-attachments/assets/c45bbc17-f698-43e7-85b4-f1b39b8326a8> 150 | 151 | ### Available Tools 152 | 153 | The server exposes the following tools: 154 | 155 | ```python 156 | def get_packages() -> str: 157 | """ 158 | Get all installed packages on the device. 159 | Returns: 160 | str: A list of all installed packages on the device as a string 161 | """ 162 | ``` 163 | 164 | ```python 165 | def execute_adb_command(command: str) -> str: 166 | """ 167 | Executes an ADB command and returns the output. 168 | Args: 169 | command (str): The ADB command to execute 170 | Returns: 171 | str: The output of the ADB command 172 | """ 173 | ``` 174 | 175 | ```python 176 | def get_uilayout() -> str: 177 | """ 178 | Retrieves information about clickable elements in the current UI. 179 | Returns a formatted string containing details about each clickable element, 180 | including their text, content description, bounds, and center coordinates. 181 | 182 | Returns: 183 | str: A formatted list of clickable elements with their properties 184 | """ 185 | ``` 186 | 187 | ```python 188 | def get_screenshot() -> Image: 189 | """ 190 | Takes a screenshot of the device and returns it. 191 | Returns: 192 | Image: the screenshot 193 | """ 194 | ``` 195 | 196 | ```python 197 | def get_package_action_intents(package_name: str) -> list[str]: 198 | """ 199 | Get all non-data actions from Activity Resolver Table for a package 200 | Args: 201 | package_name (str): The name of the package to get actions for 202 | Returns: 203 | list[str]: A list of all non-data actions from the Activity Resolver 204 | Table for the package 205 | """ 206 | ``` 207 | 208 | ## Contributing 209 | 210 | Contributions are welcome! 211 | 212 | ## Acknowledgments 213 | 214 | - Built with 215 | [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) 216 | ``` -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Android MCP Server Tests 3 | 4 | This package contains unit and integration tests for the Android MCP Server. 5 | """ 6 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "android-mcp" 7 | version = "0.1.0" 8 | description = "An MCP server for android automation" 9 | readme = "README.md" 10 | requires-python = ">=3.11" 11 | dependencies = [ 12 | "mcp>=1.0.0", 13 | "pure-python-adb>=0.3.0.dev0", 14 | "PyYAML>=6.0", 15 | "Pillow>=10.0.0", 16 | ] 17 | 18 | [project.optional-dependencies] 19 | test = ["pytest>=8.0.0", "pytest-mock>=3.12.0", "pytest-cov>=4.0.0"] 20 | 21 | [tool.setuptools] 22 | py-modules = ["server", "adbdevicemanager"] 23 | 24 | [tool.pytest.ini_options] 25 | testpaths = ["tests"] 26 | python_files = ["test_*.py"] 27 | python_classes = ["Test*"] 28 | python_functions = ["test_*"] 29 | addopts = ["--strict-markers", "--disable-warnings", "-v"] 30 | ``` -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Test runner script for Android MCP Server 4 | 5 | This script installs test dependencies and runs the complete test suite. 6 | """ 7 | 8 | import os 9 | import subprocess 10 | import sys 11 | 12 | 13 | def run_command(command, description): 14 | """Run a command and handle errors""" 15 | print(f"\n{'='*60}") 16 | print(f"Running: {description}") 17 | print(f"Command: {command}") 18 | print(f"{'='*60}") 19 | 20 | try: 21 | result = subprocess.run(command, shell=True, 22 | check=True, capture_output=True, text=True) 23 | if result.stdout: 24 | print(result.stdout) 25 | return True 26 | except subprocess.CalledProcessError as e: 27 | print(f"Error running command: {e}") 28 | if e.stdout: 29 | print(f"STDOUT: {e.stdout}") 30 | if e.stderr: 31 | print(f"STDERR: {e.stderr}") 32 | return False 33 | 34 | 35 | def main(): 36 | """Main test runner function""" 37 | print("Android MCP Server Test Runner") 38 | print("=" * 60) 39 | 40 | # Change to the script directory 41 | script_dir = os.path.dirname(os.path.abspath(__file__)) 42 | os.chdir(script_dir) 43 | print(f"Working directory: {script_dir}") 44 | 45 | # Install test dependencies 46 | if not run_command("pip install -e .[test]", "Installing test dependencies"): 47 | print("Failed to install test dependencies") 48 | return 1 49 | 50 | # Run tests with coverage 51 | if not run_command("pytest tests/ -v --cov=. --cov-report=term-missing", "Running tests with coverage"): 52 | print("Tests failed") 53 | return 1 54 | 55 | print("\n" + "="*60) 56 | print("All tests passed successfully!") 57 | print("="*60) 58 | return 0 59 | 60 | 61 | if __name__ == "__main__": 62 | sys.exit(main()) 63 | ``` -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import sys 3 | 4 | import yaml 5 | from mcp.server.fastmcp import FastMCP, Image 6 | 7 | from adbdevicemanager import AdbDeviceManager 8 | 9 | CONFIG_FILE = "config.yaml" 10 | CONFIG_FILE_EXAMPLE = "config.yaml.example" 11 | 12 | # Load config (make config file optional) 13 | config = {} 14 | device_name = None 15 | 16 | if os.path.exists(CONFIG_FILE): 17 | try: 18 | with open(CONFIG_FILE) as f: 19 | config = yaml.safe_load(f.read()) or {} 20 | device_config = config.get("device", {}) 21 | configured_device_name = device_config.get( 22 | "name") if device_config else None 23 | 24 | # Support multiple ways to specify auto-selection: 25 | # 1. name: null (None in Python) 26 | # 2. name: "" (empty string) 27 | # 3. name field completely missing 28 | if configured_device_name and configured_device_name.strip(): 29 | device_name = configured_device_name.strip() 30 | print(f"Loaded config from {CONFIG_FILE}") 31 | print(f"Configured device: {device_name}") 32 | else: 33 | print(f"Loaded config from {CONFIG_FILE}") 34 | print( 35 | "No device specified in config, will auto-select if only one device connected") 36 | except Exception as e: 37 | print(f"Error loading config file {CONFIG_FILE}: {e}", file=sys.stderr) 38 | print( 39 | f"Please check the format of your config file or recreate it from {CONFIG_FILE_EXAMPLE}", file=sys.stderr) 40 | sys.exit(1) 41 | else: 42 | print( 43 | f"Config file {CONFIG_FILE} not found, using auto-selection for device") 44 | 45 | # Initialize MCP and device manager 46 | # AdbDeviceManager will handle auto-selection if device_name is None 47 | mcp = FastMCP("android") 48 | deviceManager = AdbDeviceManager(device_name) 49 | 50 | 51 | @mcp.tool() 52 | def get_packages() -> str: 53 | """ 54 | Get all installed packages on the device 55 | Returns: 56 | str: A list of all installed packages on the device as a string 57 | """ 58 | result = deviceManager.get_packages() 59 | return result 60 | 61 | 62 | @mcp.tool() 63 | def execute_adb_shell_command(command: str) -> str: 64 | """Executes an ADB command and returns the output or an error. 65 | Args: 66 | command (str): The ADB shell command to execute 67 | Returns: 68 | str: The output of the ADB command 69 | """ 70 | result = deviceManager.execute_adb_shell_command(command) 71 | return result 72 | 73 | 74 | @mcp.tool() 75 | def get_uilayout() -> str: 76 | """ 77 | Retrieves information about clickable elements in the current UI. 78 | Returns a formatted string containing details about each clickable element, 79 | including its text, content description, bounds, and center coordinates. 80 | 81 | Returns: 82 | str: A formatted list of clickable elements with their properties 83 | """ 84 | result = deviceManager.get_uilayout() 85 | return result 86 | 87 | 88 | @mcp.tool() 89 | def get_screenshot() -> Image: 90 | """Takes a screenshot of the device and returns it. 91 | Returns: 92 | Image: the screenshot 93 | """ 94 | deviceManager.take_screenshot() 95 | return Image(path="compressed_screenshot.png") 96 | 97 | 98 | @mcp.tool() 99 | def get_package_action_intents(package_name: str) -> list[str]: 100 | """ 101 | Get all non-data actions from Activity Resolver Table for a package 102 | Args: 103 | package_name (str): The name of the package to get actions for 104 | Returns: 105 | list[str]: A list of all non-data actions from the Activity Resolver Table for the package 106 | """ 107 | result = deviceManager.get_package_action_intents(package_name) 108 | return result 109 | 110 | 111 | if __name__ == "__main__": 112 | mcp.run(transport="stdio") 113 | ``` -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for configuration loading logic 3 | """ 4 | 5 | import os 6 | import tempfile 7 | from unittest.mock import mock_open, patch 8 | 9 | import pytest 10 | import yaml 11 | 12 | 13 | class TestConfigLoading: 14 | """Test configuration loading scenarios""" 15 | 16 | def setup_method(self): 17 | """Setup for each test method""" 18 | self.temp_dir = tempfile.mkdtemp() 19 | self.config_file = os.path.join(self.temp_dir, "config.yaml") 20 | 21 | def _load_config_logic(self, config_file_path): 22 | """ 23 | Simulate the config loading logic from server.py 24 | Returns: (device_name, messages) 25 | """ 26 | messages = [] 27 | device_name = None 28 | 29 | if os.path.exists(config_file_path): 30 | try: 31 | with open(config_file_path) as f: 32 | config = yaml.safe_load(f.read()) or {} 33 | device_config = config.get("device", {}) 34 | configured_device_name = device_config.get( 35 | "name") if device_config else None 36 | 37 | if configured_device_name and configured_device_name.strip(): 38 | device_name = configured_device_name.strip() 39 | messages.append(f"Loaded config from {config_file_path}") 40 | messages.append(f"Configured device: {device_name}") 41 | else: 42 | messages.append(f"Loaded config from {config_file_path}") 43 | messages.append( 44 | "No device specified in config, will auto-select if only one device connected") 45 | except Exception as e: 46 | messages.append( 47 | f"Error loading config file {config_file_path}: {e}") 48 | raise 49 | else: 50 | messages.append( 51 | f"Config file {config_file_path} not found, using auto-selection for device") 52 | 53 | return device_name, messages 54 | 55 | def test_no_config_file(self): 56 | """Test behavior when config file doesn't exist""" 57 | non_existent_file = os.path.join(self.temp_dir, "non_existent.yaml") 58 | 59 | device_name, messages = self._load_config_logic(non_existent_file) 60 | 61 | assert device_name is None 62 | assert any("not found" in msg for msg in messages) 63 | assert any("auto-selection" in msg for msg in messages) 64 | 65 | def test_config_with_null_name(self): 66 | """Test config with name: null""" 67 | config_content = """ 68 | device: 69 | name: null 70 | """ 71 | with open(self.config_file, 'w') as f: 72 | f.write(config_content) 73 | 74 | device_name, messages = self._load_config_logic(self.config_file) 75 | 76 | assert device_name is None 77 | assert any("Loaded config" in msg for msg in messages) 78 | assert any("auto-select" in msg for msg in messages) 79 | 80 | def test_config_with_empty_string_name(self): 81 | """Test config with name: ''""" 82 | config_content = """ 83 | device: 84 | name: "" 85 | """ 86 | with open(self.config_file, 'w') as f: 87 | f.write(config_content) 88 | 89 | device_name, messages = self._load_config_logic(self.config_file) 90 | 91 | assert device_name is None 92 | assert any("auto-select" in msg for msg in messages) 93 | 94 | def test_config_with_whitespace_name(self): 95 | """Test config with name containing only whitespace""" 96 | config_content = """ 97 | device: 98 | name: " \t \n " 99 | """ 100 | with open(self.config_file, 'w') as f: 101 | f.write(config_content) 102 | 103 | device_name, messages = self._load_config_logic(self.config_file) 104 | 105 | assert device_name is None 106 | assert any("auto-select" in msg for msg in messages) 107 | 108 | def test_config_without_name_field(self): 109 | """Test config with device section but no name field""" 110 | config_content = """ 111 | device: 112 | # name field is missing 113 | other_setting: value 114 | """ 115 | with open(self.config_file, 'w') as f: 116 | f.write(config_content) 117 | 118 | device_name, messages = self._load_config_logic(self.config_file) 119 | 120 | assert device_name is None 121 | assert any("auto-select" in msg for msg in messages) 122 | 123 | def test_config_without_device_section(self): 124 | """Test config without device section""" 125 | config_content = """ 126 | # No device section 127 | other_config: value 128 | """ 129 | with open(self.config_file, 'w') as f: 130 | f.write(config_content) 131 | 132 | device_name, messages = self._load_config_logic(self.config_file) 133 | 134 | assert device_name is None 135 | assert any("auto-select" in msg for msg in messages) 136 | 137 | def test_config_with_valid_device_name(self): 138 | """Test config with valid device name""" 139 | config_content = """ 140 | device: 141 | name: "test-device-123" 142 | """ 143 | with open(self.config_file, 'w') as f: 144 | f.write(config_content) 145 | 146 | device_name, messages = self._load_config_logic(self.config_file) 147 | 148 | assert device_name == "test-device-123" 149 | assert any( 150 | "Configured device: test-device-123" in msg for msg in messages) 151 | 152 | def test_config_with_device_name_with_whitespace(self): 153 | """Test config with device name that has surrounding whitespace""" 154 | config_content = """ 155 | device: 156 | name: " test-device-123 " 157 | """ 158 | with open(self.config_file, 'w') as f: 159 | f.write(config_content) 160 | 161 | device_name, messages = self._load_config_logic(self.config_file) 162 | 163 | assert device_name == "test-device-123" # Should be trimmed 164 | assert any( 165 | "Configured device: test-device-123" in msg for msg in messages) 166 | 167 | def test_invalid_yaml_config(self): 168 | """Test behavior with invalid YAML""" 169 | config_content = """ 170 | device: 171 | name: "test-device 172 | # Missing closing quote - invalid YAML 173 | """ 174 | with open(self.config_file, 'w') as f: 175 | f.write(config_content) 176 | 177 | with pytest.raises(Exception): 178 | self._load_config_logic(self.config_file) 179 | 180 | def test_empty_config_file(self): 181 | """Test behavior with empty config file""" 182 | with open(self.config_file, 'w') as f: 183 | f.write("") 184 | 185 | device_name, messages = self._load_config_logic(self.config_file) 186 | 187 | assert device_name is None 188 | assert any("auto-select" in msg for msg in messages) 189 | 190 | def teardown_method(self): 191 | """Cleanup after each test method""" 192 | if os.path.exists(self.config_file): 193 | os.remove(self.config_file) 194 | os.rmdir(self.temp_dir) 195 | ``` -------------------------------------------------------------------------------- /tests/test_adb_device_manager.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Tests for AdbDeviceManager 3 | """ 4 | 5 | import os 6 | import sys 7 | from unittest.mock import MagicMock, patch 8 | 9 | import pytest 10 | 11 | from adbdevicemanager import AdbDeviceManager 12 | 13 | # Add the parent directory to the path so we can import our modules 14 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 15 | 16 | 17 | class TestAdbDeviceManager: 18 | """Test AdbDeviceManager functionality""" 19 | 20 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 21 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 22 | @patch('adbdevicemanager.AdbClient') 23 | def test_single_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb): 24 | """Test auto-selection when only one device is connected""" 25 | # Setup mocks 26 | mock_check_adb.return_value = True 27 | mock_get_devices.return_value = ["device123"] 28 | mock_device = MagicMock() 29 | mock_adb_client.return_value.device.return_value = mock_device 30 | 31 | # Test with device_name=None (auto-selection) 32 | with patch('builtins.print') as mock_print: 33 | manager = AdbDeviceManager(device_name=None, exit_on_error=False) 34 | 35 | # Verify the correct device was selected 36 | mock_adb_client.return_value.device.assert_called_once_with( 37 | "device123") 38 | assert manager.device == mock_device 39 | 40 | # Verify auto-selection message was printed 41 | mock_print.assert_called_with( 42 | "No device specified, automatically selected: device123") 43 | 44 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 45 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 46 | def test_multiple_devices_no_selection_error(self, mock_get_devices, mock_check_adb): 47 | """Test error when multiple devices are connected but none specified""" 48 | # Setup mocks 49 | mock_check_adb.return_value = True 50 | mock_get_devices.return_value = ["device123", "device456"] 51 | 52 | # Test with device_name=None and multiple devices 53 | with pytest.raises(RuntimeError) as exc_info: 54 | AdbDeviceManager(device_name=None, exit_on_error=False) 55 | 56 | assert "Multiple devices connected" in str(exc_info.value) 57 | assert "device123" in str(exc_info.value) 58 | assert "device456" in str(exc_info.value) 59 | 60 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 61 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 62 | @patch('adbdevicemanager.AdbClient') 63 | def test_specific_device_selection(self, mock_adb_client, mock_get_devices, mock_check_adb): 64 | """Test selecting a specific device""" 65 | # Setup mocks 66 | mock_check_adb.return_value = True 67 | mock_get_devices.return_value = ["device123", "device456"] 68 | mock_device = MagicMock() 69 | mock_adb_client.return_value.device.return_value = mock_device 70 | 71 | # Test with specific device name 72 | manager = AdbDeviceManager( 73 | device_name="device456", exit_on_error=False) 74 | 75 | # Verify the correct device was selected 76 | mock_adb_client.return_value.device.assert_called_once_with( 77 | "device456") 78 | assert manager.device == mock_device 79 | 80 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 81 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 82 | def test_device_not_found_error(self, mock_get_devices, mock_check_adb): 83 | """Test error when specified device is not found""" 84 | # Setup mocks 85 | mock_check_adb.return_value = True 86 | mock_get_devices.return_value = ["device123", "device456"] 87 | 88 | # Test with non-existent device 89 | with pytest.raises(RuntimeError) as exc_info: 90 | AdbDeviceManager(device_name="non-existent-device", 91 | exit_on_error=False) 92 | 93 | assert "Device non-existent-device not found" in str(exc_info.value) 94 | assert "Available devices" in str(exc_info.value) 95 | 96 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 97 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 98 | def test_no_devices_connected_error(self, mock_get_devices, mock_check_adb): 99 | """Test error when no devices are connected""" 100 | # Setup mocks 101 | mock_check_adb.return_value = True 102 | mock_get_devices.return_value = [] 103 | 104 | # Test with no devices 105 | with pytest.raises(RuntimeError) as exc_info: 106 | AdbDeviceManager(device_name=None, exit_on_error=False) 107 | 108 | assert "No devices connected" in str(exc_info.value) 109 | 110 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 111 | def test_adb_not_installed_error(self, mock_check_adb): 112 | """Test error when ADB is not installed""" 113 | # Setup mocks 114 | mock_check_adb.return_value = False 115 | 116 | # Test with ADB not installed 117 | with pytest.raises(RuntimeError) as exc_info: 118 | AdbDeviceManager(device_name=None, exit_on_error=False) 119 | 120 | assert "adb is not installed" in str(exc_info.value) 121 | 122 | @patch('subprocess.run') 123 | def test_check_adb_installed_success(self, mock_run): 124 | """Test successful ADB installation check""" 125 | mock_run.return_value = MagicMock() # Successful run 126 | 127 | result = AdbDeviceManager.check_adb_installed() 128 | 129 | assert result is True 130 | mock_run.assert_called_once() 131 | 132 | @patch('subprocess.run') 133 | def test_check_adb_installed_failure(self, mock_run): 134 | """Test failed ADB installation check""" 135 | mock_run.side_effect = FileNotFoundError() # ADB not found 136 | 137 | result = AdbDeviceManager.check_adb_installed() 138 | 139 | assert result is False 140 | 141 | @patch('adbdevicemanager.AdbClient') 142 | def test_get_available_devices(self, mock_adb_client): 143 | """Test getting available devices""" 144 | # Setup mock devices 145 | mock_device1 = MagicMock() 146 | mock_device1.serial = "device123" 147 | mock_device2 = MagicMock() 148 | mock_device2.serial = "device456" 149 | 150 | mock_adb_client.return_value.devices.return_value = [ 151 | mock_device1, mock_device2] 152 | 153 | devices = AdbDeviceManager.get_available_devices() 154 | 155 | assert devices == ["device123", "device456"] 156 | 157 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 158 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 159 | @patch('adbdevicemanager.AdbClient') 160 | def test_exit_on_error_true(self, mock_adb_client, mock_get_devices, mock_check_adb): 161 | """Test that exit_on_error=True calls sys.exit""" 162 | # Setup mocks to trigger error 163 | mock_check_adb.return_value = True 164 | mock_get_devices.return_value = [] # No devices 165 | 166 | # Test with exit_on_error=True (default) 167 | with patch('sys.exit') as mock_exit: 168 | with patch('builtins.print'): # Suppress error output 169 | AdbDeviceManager(device_name=None, exit_on_error=True) 170 | 171 | mock_exit.assert_called_once_with(1) 172 | ``` -------------------------------------------------------------------------------- /adbdevicemanager.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import subprocess 3 | import sys 4 | 5 | from PIL import Image as PILImage 6 | from ppadb.client import Client as AdbClient 7 | 8 | 9 | class AdbDeviceManager: 10 | def __init__(self, device_name: str | None = None, exit_on_error: bool = True) -> None: 11 | """ 12 | Initialize the ADB Device Manager 13 | 14 | Args: 15 | device_name: Optional name/serial of the device to manage. 16 | If None, attempts to auto-select if only one device is available. 17 | exit_on_error: Whether to exit the program if device initialization fails 18 | """ 19 | if not self.check_adb_installed(): 20 | error_msg = "adb is not installed or not in PATH. Please install adb and ensure it is in your PATH." 21 | if exit_on_error: 22 | print(error_msg, file=sys.stderr) 23 | sys.exit(1) 24 | else: 25 | raise RuntimeError(error_msg) 26 | 27 | available_devices = self.get_available_devices() 28 | if not available_devices: 29 | error_msg = "No devices connected. Please connect a device and try again." 30 | if exit_on_error: 31 | print(error_msg, file=sys.stderr) 32 | sys.exit(1) 33 | else: 34 | raise RuntimeError(error_msg) 35 | 36 | selected_device_name: str | None = None 37 | 38 | if device_name: 39 | if device_name not in available_devices: 40 | error_msg = f"Device {device_name} not found. Available devices: {available_devices}" 41 | if exit_on_error: 42 | print(error_msg, file=sys.stderr) 43 | sys.exit(1) 44 | else: 45 | raise RuntimeError(error_msg) 46 | selected_device_name = device_name 47 | else: # No device_name provided, try auto-selection 48 | if len(available_devices) == 1: 49 | selected_device_name = available_devices[0] 50 | print( 51 | f"No device specified, automatically selected: {selected_device_name}") 52 | elif len(available_devices) > 1: 53 | error_msg = f"Multiple devices connected: {available_devices}. Please specify a device in config.yaml or connect only one device." 54 | if exit_on_error: 55 | print(error_msg, file=sys.stderr) 56 | sys.exit(1) 57 | else: 58 | raise RuntimeError(error_msg) 59 | # If len(available_devices) == 0, it's already caught by the earlier check 60 | 61 | # At this point, selected_device_name should always be set due to the logic above 62 | # Initialize the device 63 | self.device = AdbClient().device(selected_device_name) 64 | 65 | @staticmethod 66 | def check_adb_installed() -> bool: 67 | """Check if ADB is installed on the system.""" 68 | try: 69 | subprocess.run(["adb", "version"], check=True, 70 | stdout=subprocess.PIPE) 71 | return True 72 | except (subprocess.CalledProcessError, FileNotFoundError): 73 | return False 74 | 75 | @staticmethod 76 | def get_available_devices() -> list[str]: 77 | """Get a list of available devices.""" 78 | return [device.serial for device in AdbClient().devices()] 79 | 80 | def get_packages(self) -> str: 81 | command = "pm list packages" 82 | packages = self.device.shell(command).strip().split("\n") 83 | result = [package[8:] for package in packages] 84 | output = "\n".join(result) 85 | return output 86 | 87 | def get_package_action_intents(self, package_name: str) -> list[str]: 88 | command = f"dumpsys package {package_name}" 89 | output = self.device.shell(command) 90 | 91 | resolver_table_start = output.find("Activity Resolver Table:") 92 | if resolver_table_start == -1: 93 | return [] 94 | resolver_section = output[resolver_table_start:] 95 | 96 | non_data_start = resolver_section.find("\n Non-Data Actions:") 97 | if non_data_start == -1: 98 | return [] 99 | 100 | section_end = resolver_section[non_data_start:].find("\n\n") 101 | if section_end == -1: 102 | non_data_section = resolver_section[non_data_start:] 103 | else: 104 | non_data_section = resolver_section[ 105 | non_data_start: non_data_start + section_end 106 | ] 107 | 108 | actions = [] 109 | for line in non_data_section.split("\n"): 110 | line = line.strip() 111 | if line.startswith("android.") or line.startswith("com."): 112 | actions.append(line) 113 | 114 | return actions 115 | 116 | def execute_adb_shell_command(self, command: str) -> str: 117 | """Executes an ADB command and returns the output.""" 118 | if command.startswith("adb shell "): 119 | command = command[10:] 120 | elif command.startswith("adb "): 121 | command = command[4:] 122 | result = self.device.shell(command) 123 | return result 124 | 125 | def take_screenshot(self) -> None: 126 | self.device.shell("screencap -p /sdcard/screenshot.png") 127 | self.device.pull("/sdcard/screenshot.png", "screenshot.png") 128 | self.device.shell("rm /sdcard/screenshot.png") 129 | 130 | # compressing the ss to avoid "maximum call stack exceeded" error on claude desktop 131 | with PILImage.open("screenshot.png") as img: 132 | width, height = img.size 133 | new_width = int(width * 0.3) 134 | new_height = int(height * 0.3) 135 | resized_img = img.resize( 136 | (new_width, new_height), PILImage.Resampling.LANCZOS 137 | ) 138 | 139 | resized_img.save( 140 | "compressed_screenshot.png", "PNG", quality=85, optimize=True 141 | ) 142 | 143 | def get_uilayout(self) -> str: 144 | self.device.shell("uiautomator dump") 145 | self.device.pull("/sdcard/window_dump.xml", "window_dump.xml") 146 | self.device.shell("rm /sdcard/window_dump.xml") 147 | 148 | import re 149 | import xml.etree.ElementTree as ET 150 | 151 | def calculate_center(bounds_str): 152 | matches = re.findall(r"\[(\d+),(\d+)\]", bounds_str) 153 | if len(matches) == 2: 154 | x1, y1 = map(int, matches[0]) 155 | x2, y2 = map(int, matches[1]) 156 | center_x = (x1 + x2) // 2 157 | center_y = (y1 + y2) // 2 158 | return center_x, center_y 159 | return None 160 | 161 | tree = ET.parse("window_dump.xml") 162 | root = tree.getroot() 163 | 164 | clickable_elements = [] 165 | for element in root.findall(".//node[@clickable='true']"): 166 | text = element.get("text", "") 167 | content_desc = element.get("content-desc", "") 168 | bounds = element.get("bounds", "") 169 | 170 | # Only include elements that have either text or content description 171 | if text or content_desc: 172 | center = calculate_center(bounds) 173 | element_info = "Clickable element:" 174 | if text: 175 | element_info += f"\n Text: {text}" 176 | if content_desc: 177 | element_info += f"\n Description: {content_desc}" 178 | element_info += f"\n Bounds: {bounds}" 179 | if center: 180 | element_info += f"\n Center: ({center[0]}, {center[1]})" 181 | clickable_elements.append(element_info) 182 | 183 | if not clickable_elements: 184 | return "No clickable elements found with text or description" 185 | else: 186 | result = "\n\n".join(clickable_elements) 187 | return result 188 | ``` -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Integration tests for the complete server initialization flow 3 | """ 4 | 5 | import os 6 | import sys 7 | import tempfile 8 | from unittest.mock import MagicMock, patch 9 | 10 | from adbdevicemanager import AdbDeviceManager 11 | 12 | # Add the parent directory to the path so we can import our modules 13 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 14 | 15 | 16 | class TestServerIntegration: 17 | """Test complete server initialization scenarios""" 18 | 19 | def setup_method(self): 20 | """Setup for each test method""" 21 | self.temp_dir = tempfile.mkdtemp() 22 | self.config_file = os.path.join(self.temp_dir, "config.yaml") 23 | 24 | def _simulate_server_initialization(self, config_file_path): 25 | """ 26 | Simulate the complete server initialization process 27 | Returns: (device_manager, messages) 28 | """ 29 | import yaml 30 | 31 | messages = [] 32 | config = {} 33 | device_name = None 34 | 35 | if os.path.exists(config_file_path): 36 | try: 37 | with open(config_file_path) as f: 38 | config = yaml.safe_load(f.read()) or {} 39 | device_config = config.get("device", {}) 40 | configured_device_name = device_config.get( 41 | "name") if device_config else None 42 | 43 | if configured_device_name and configured_device_name.strip(): 44 | device_name = configured_device_name.strip() 45 | messages.append(f"Loaded config from {config_file_path}") 46 | messages.append(f"Configured device: {device_name}") 47 | else: 48 | messages.append(f"Loaded config from {config_file_path}") 49 | messages.append( 50 | "No device specified in config, will auto-select if only one device connected") 51 | except Exception as e: 52 | messages.append( 53 | f"Error loading config file {config_file_path}: {e}") 54 | raise 55 | else: 56 | messages.append( 57 | f"Config file {config_file_path} not found, using auto-selection for device") 58 | 59 | # Initialize device manager (with mocked dependencies) 60 | device_manager = AdbDeviceManager(device_name, exit_on_error=False) 61 | 62 | return device_manager, messages 63 | 64 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 65 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 66 | @patch('adbdevicemanager.AdbClient') 67 | def test_no_config_auto_selection_success(self, mock_adb_client, mock_get_devices, mock_check_adb): 68 | """Test successful server start with no config file and single device""" 69 | # Setup mocks 70 | mock_check_adb.return_value = True 71 | mock_get_devices.return_value = ["device123"] 72 | mock_device = MagicMock() 73 | mock_adb_client.return_value.device.return_value = mock_device 74 | 75 | # Use non-existent config file 76 | non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml") 77 | 78 | with patch('builtins.print') as mock_print: 79 | device_manager, messages = self._simulate_server_initialization( 80 | non_existent_config) 81 | 82 | # Verify results 83 | assert device_manager.device == mock_device 84 | assert any("not found" in msg for msg in messages) 85 | assert any("auto-selection" in msg for msg in messages) 86 | mock_print.assert_called_with( 87 | "No device specified, automatically selected: device123") 88 | 89 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 90 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 91 | @patch('adbdevicemanager.AdbClient') 92 | def test_config_with_null_device_auto_selection(self, mock_adb_client, mock_get_devices, mock_check_adb): 93 | """Test server start with config file containing name: null""" 94 | # Setup mocks 95 | mock_check_adb.return_value = True 96 | mock_get_devices.return_value = ["device456"] 97 | mock_device = MagicMock() 98 | mock_adb_client.return_value.device.return_value = mock_device 99 | 100 | # Create config with null device name 101 | config_content = """ 102 | device: 103 | name: null 104 | """ 105 | with open(self.config_file, 'w') as f: 106 | f.write(config_content) 107 | 108 | with patch('builtins.print') as mock_print: 109 | device_manager, messages = self._simulate_server_initialization( 110 | self.config_file) 111 | 112 | # Verify results 113 | assert device_manager.device == mock_device 114 | assert any("Loaded config" in msg for msg in messages) 115 | assert any("auto-select" in msg for msg in messages) 116 | mock_print.assert_called_with( 117 | "No device specified, automatically selected: device456") 118 | 119 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 120 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 121 | @patch('adbdevicemanager.AdbClient') 122 | def test_config_with_specific_device(self, mock_adb_client, mock_get_devices, mock_check_adb): 123 | """Test server start with config file specifying a device""" 124 | # Setup mocks 125 | mock_check_adb.return_value = True 126 | mock_get_devices.return_value = ["device123", "device456"] 127 | mock_device = MagicMock() 128 | mock_adb_client.return_value.device.return_value = mock_device 129 | 130 | # Create config with specific device name 131 | config_content = """ 132 | device: 133 | name: "device456" 134 | """ 135 | with open(self.config_file, 'w') as f: 136 | f.write(config_content) 137 | 138 | device_manager, messages = self._simulate_server_initialization( 139 | self.config_file) 140 | 141 | # Verify results 142 | assert device_manager.device == mock_device 143 | mock_adb_client.return_value.device.assert_called_once_with( 144 | "device456") 145 | assert any("Configured device: device456" in msg for msg in messages) 146 | 147 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 148 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 149 | def test_multiple_devices_no_config_error(self, mock_get_devices, mock_check_adb): 150 | """Test server initialization fails with multiple devices and no config""" 151 | # Setup mocks 152 | mock_check_adb.return_value = True 153 | mock_get_devices.return_value = ["device123", "device456"] 154 | 155 | # Use non-existent config file 156 | non_existent_config = os.path.join(self.temp_dir, "non_existent.yaml") 157 | 158 | try: 159 | device_manager, messages = self._simulate_server_initialization( 160 | non_existent_config) 161 | assert False, "Should have raised an exception" 162 | except RuntimeError as e: 163 | assert "Multiple devices connected" in str(e) 164 | assert "device123" in str(e) 165 | assert "device456" in str(e) 166 | 167 | @patch('adbdevicemanager.AdbDeviceManager.check_adb_installed') 168 | @patch('adbdevicemanager.AdbDeviceManager.get_available_devices') 169 | def test_device_not_found_error(self, mock_get_devices, mock_check_adb): 170 | """Test server initialization fails when specified device is not found""" 171 | # Setup mocks 172 | mock_check_adb.return_value = True 173 | mock_get_devices.return_value = ["device123"] 174 | 175 | # Create config with non-existent device name 176 | config_content = """ 177 | device: 178 | name: "non-existent-device" 179 | """ 180 | with open(self.config_file, 'w') as f: 181 | f.write(config_content) 182 | 183 | try: 184 | device_manager, messages = self._simulate_server_initialization( 185 | self.config_file) 186 | assert False, "Should have raised an exception" 187 | except RuntimeError as e: 188 | assert "Device non-existent-device not found" in str(e) 189 | assert "Available devices" in str(e) 190 | 191 | def teardown_method(self): 192 | """Cleanup after each test method""" 193 | if os.path.exists(self.config_file): 194 | os.remove(self.config_file) 195 | os.rmdir(self.temp_dir) 196 | ```