-
Notifications
You must be signed in to change notification settings - Fork 0
Implement uiautomator2 ATX HTTP Screenshot Backend #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,124 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import base64 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import requests | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import socket | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Callable, Coroutine, Any, Optional | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Type alias for the ADB run function | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| AdbRunFn = Callable[..., Coroutine[Any, Any, bytes]] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _get_free_port() -> int: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔥 The Roast: Ah yes, the classic "find a free port" race condition. You bind to port 0, get a free port, immediately close the socket, and hope nobody else takes it before you use it. This is like leaving a parking spot and hoping the car behind you doesn't steal it — in a busy mall parking lot. On a loaded CI runner or shared dev machine, this is practically begging for 🩹 The Fix: Either pass 📏 Severity: warning |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Find a free port on the host.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.bind(('', 0)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return s.getsockname()[1] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def _capture_u2(*, adb_run: AdbRunFn, serial: str, adb_exe: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔥 The Roast: This function signature promises to be a proper MCP tool, but it returns a plain 🩹 The Fix: Return the standard tool contract envelope: return {
"success": True,
"data": {"screenshot": base64.b64encode(content).decode("ascii")},
"error": None,
"observed_state": "screenshot_captured",
"expected_state": "screenshot_captured"
}📏 Severity: critical |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Return base64-encoded PNG screenshot via uiautomator2 ATX HTTP agent. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| This implementation talks to the ATX HTTP API directly, avoiding dependency | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| on the uiautomator2 Python library for the capture process. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 1. Forward port to device (use a dynamic port to avoid conflicts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| local_port = _get_free_port() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await adb_run("-s", serial, "forward", f"tcp:{local_port}", "tcp:7912", timeout=5.0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+4
to
+26
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import socket | |
| import os | |
| from typing import Callable, Coroutine, Any, Optional | |
| # Type alias for the ADB run function | |
| AdbRunFn = Callable[..., Coroutine[Any, Any, bytes]] | |
| def _get_free_port() -> int: | |
| """Find a free port on the host.""" | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
| s.bind(('', 0)) | |
| return s.getsockname()[1] | |
| async def _capture_u2(*, adb_run: AdbRunFn, serial: str, adb_exe: str) -> str: | |
| """Return base64-encoded PNG screenshot via uiautomator2 ATX HTTP agent. | |
| This implementation talks to the ATX HTTP API directly, avoiding dependency | |
| on the uiautomator2 Python library for the capture process. | |
| """ | |
| # 1. Forward port to device (use a dynamic port to avoid conflicts) | |
| local_port = _get_free_port() | |
| try: | |
| await adb_run("-s", serial, "forward", f"tcp:{local_port}", "tcp:7912", timeout=5.0) | |
| import os | |
| from typing import Callable, Coroutine, Any, Optional | |
| # Type alias for the ADB run function | |
| AdbRunFn = Callable[..., Coroutine[Any, Any, bytes]] | |
| async def _capture_u2(*, adb_run: AdbRunFn, serial: str, adb_exe: str) -> str: | |
| """Return base64-encoded PNG screenshot via uiautomator2 ATX HTTP agent. | |
| This implementation talks to the ATX HTTP API directly, avoiding dependency | |
| on the uiautomator2 Python library for the capture process. | |
| """ | |
| # 1. Forward port to device using an OS-assigned ephemeral port to avoid TOCTOU races | |
| try: | |
| forward_output = await adb_run( | |
| "-s", | |
| serial, | |
| "forward", | |
| "tcp:0", | |
| "tcp:7912", | |
| timeout=5.0, | |
| ) | |
| # adb prints the chosen local port number on stdout, e.g. "12345\n" | |
| local_port = int(forward_output.decode("utf-8", errors="ignore").strip()) |
Copilot
AI
Mar 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The inner except Exception at line 39 catches all exceptions (including the RuntimeError raised at line 38 when the forward-list loop's else branch fires), then unconditionally raises RuntimeError(f"Failed to forward port to {serial}: {e}"). This means the fallback loop on lines 32–38 that searches for an existing ADB forward is effectively a no-op: whether the inner for...else raises or successfully breaks, the outer except Exception on line 39 will always re-raise the original error e from the initial adb_run call. The control flow should be refactored to only raise from except Exception when no matching forward was found.
| try: | |
| forward_list = (await adb_run("forward", "--list", timeout=5.0)).decode("utf-8") | |
| for line in forward_list.splitlines(): | |
| parts = line.split() | |
| if len(parts) >= 3 and parts[0] == serial and parts[2] == "tcp:7912": | |
| local_port = int(parts[1].replace("tcp:", "")) | |
| break | |
| else: | |
| raise RuntimeError(f"Failed to forward port to {serial}: {e}") | |
| except Exception: | |
| found_forward = False | |
| try: | |
| forward_list = (await adb_run("forward", "--list", timeout=5.0)).decode("utf-8") | |
| for line in forward_list.splitlines(): | |
| parts = line.split() | |
| if len(parts) >= 3 and parts[0] == serial and parts[2] == "tcp:7912": | |
| local_port = int(parts[1].replace("tcp:", "")) | |
| found_forward = True | |
| break | |
| except Exception: | |
| # If we fail to list or parse forwards, we'll fall back to raising below. | |
| pass | |
| if not found_forward: |
Copilot
AI
Mar 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The variable data on line 50 is assigned the result of response.json() but is never used — the code immediately returns True without reading data. This is dead code that appears to be a leftover from a planned serial-number verification that was abandoned. It also triggers an unnecessary JSON deserialization on every health-check call, adding minor overhead.
| data = response.json() |
Copilot
AI
Mar 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The asset path on line 68 hardcodes Python 3.12:
"agent_orchestrator/.venv/lib/python3.12/site-packages/uiautomator2/assets"
The repo's pyproject.toml requires python >= 3.10, and the canonical setup instructions target Python 3.14+. This path will not exist on any Python version other than 3.12, making the fallback asset discovery silently fail on 3.10, 3.11, 3.13, or 3.14.
Additionally, alas_wrapped/bin/uiautomator2 (the first path checked, line 67) does not exist in this repository, so both fallback paths will fail in practice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔥 The Roast: else: pass — the Python equivalent of "¯_(ツ)_/¯". When we can't find the asset directory, we just... keep going? Without the atx-agent binary? The comment on line 91 says we "assume it's already there," but then we immediately try to start it on line 97 without checking. This is like assuming you have your house keys, not checking your pocket, and confidently walking into a locked house.
🩹 The Fix: Either raise an error here with clear instructions on where to get the assets, or check if the binary exists on the device before attempting to start it. Silent failures are debugging sessions waiting to happen.
📏 Severity: warning
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔥 The Roast: The comment on lines 87-91 admits "finding/pushing the right binary is complex," so we just... don't do it? We start a server that might not exist? This is like trying to start a car by hoping there's an engine under the hood instead of checking. The APKs get pushed and installed (yay!), but the actual atx-agent binary — you know, the thing that actually runs — is left as an exercise for the reader.
🩹 The Fix: Implement the binary push logic or at minimum check if /data/local/tmp/atx-agent exists before trying to start it. If it doesn't exist, provide a helpful error message telling the user where to get it.
📏 Severity: warning
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,143 @@ | ||||||||||
| import unittest | ||||||||||
| from unittest.mock import MagicMock, patch, AsyncMock, ANY | ||||||||||
| import asyncio | ||||||||||
| import base64 | ||||||||||
| import requests | ||||||||||
| import os | ||||||||||
|
Comment on lines
+5
to
+6
|
||||||||||
| import requests | |
| import os |
Copilot
AI
Mar 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The adb_vision/test_u2.py tests won't be automatically collected by pytest when run from the repo root because pytest.ini restricts testpaths to agent_orchestrator only. The adb_vision directory is not listed in testpaths, nor is there a conftest.py to add it to sys.path. This means the PR's claim of a "100% coverage" test suite is misleading — these tests are not wired into the project's test runner.
To fix this, either:
- Add
adb_visionto thetestpathslist inpytest.ini, or - Add a
conftest.pyinadb_vision/that sets up the path, and add the directory topytest.ini.
Copilot
AI
Mar 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In test_capture_u2_push_and_start, os.path.isdir and os.path.exists are patched at the global module level (@patch("os.path.isdir") and @patch("os.path.exists")). These patches affect every call to os.path.isdir and os.path.exists anywhere in the process for the duration of the test, not just in adb_vision.screenshot. The correct patch target should be "adb_vision.screenshot.os.path.isdir" and "adb_vision.screenshot.os.path.exists" to limit the scope of the mock to the module under test.
| @patch("os.path.isdir") | |
| @patch("os.path.exists") | |
| @patch("adb_vision.screenshot.os.path.isdir") | |
| @patch("adb_vision.screenshot.os.path.exists") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optionalis imported fromtypingon line 6 but is never used anywhere in the file. This is an unused import that should be removed.