# 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 |
```