Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added adb_vision/__init__.py
Empty file.
71 changes: 71 additions & 0 deletions adb_vision/screenshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import asyncio
import base64
import requests
from typing import Callable, Coroutine

# Define AdbRunFn type for type hinting
# async def _adb_run(*args, timeout) -> bytes
AdbRunFn = Callable[..., Coroutine[None, None, bytes]]

async def _capture_droidcast(*, adb_run: AdbRunFn, serial: str, adb_exe: str) -> str:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: This function signature is like bringing a passport to a domestic flight — technically you're prepared for anything, but you're not actually going anywhere. serial and adb_exe are just sitting there, unused, taking up space in the parameter list like that one camping chair nobody sits in.

🩹 The Fix: Either use them (pass to adb_run if needed) or remove them. If adb_run is already bound to a serial, document that.

📏 Severity: suggestion

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serial and adb_exe are part of the signature but are unused in the implementation. If they’re intentionally unused (because adb_run already closes over them), consider prefixing them with _ or updating the type contract so static analysis/linting doesn’t flag this.

Suggested change
async def _capture_droidcast(*, adb_run: AdbRunFn, serial: str, adb_exe: str) -> str:
async def _capture_droidcast(*, adb_run: AdbRunFn, _serial: str, _adb_exe: str) -> str:

Copilot uses AI. Check for mistakes.
"""Return base64-encoded PNG screenshot via DroidCast HTTP stream."""
url = "http://localhost:53516/screenshot?format=png"

async def fetch_screenshot(raise_for_status=False):
try:
# Using asyncio.to_thread for the synchronous requests call
response = await asyncio.to_thread(requests.get, url, timeout=5.0)
if response.status_code == 200:
Comment on lines +11 to +18
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTTP URL uses /screenshot?format=png, but the bundled DroidCast implementation in alas_wrapped/module/device/method/droidcast.py documents PNG screenshots coming from the /preview endpoint (and /screenshot returning a raw bitmap for DroidCast_raw). Using /screenshot?format=png is likely to 404 with the APK that exists in this repo; align the endpoint with the APK/version you ship.

Copilot uses AI. Check for mistakes.
return base64.b64encode(response.content).decode("ascii")
if raise_for_status:
response.raise_for_status()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
if raise_for_status:
Comment on lines +14 to +23
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function uses requests.get(...) directly, which will honor HTTP_PROXY/HTTPS_PROXY env vars by default; that can break localhost calls in proxied environments. Consider using a requests.Session() with trust_env = False (as done in alas_wrapped/module/device/method/droidcast.py) or otherwise explicitly disabling proxy use for this local request.

Copilot uses AI. Check for mistakes.
raise
except requests.exceptions.HTTPError:
if raise_for_status:
raise
return None

# Try fetching first (idempotency)
result = await fetch_screenshot()
if result:
return result

# Setup DroidCast if not running or not responding correctly
apk_path = "alas_wrapped/bin/DroidCast/DroidCast-debug-1.2.3.apk"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: The APK file you're looking for is like that friend who says they'll be there at 8 but shows up at 9 with a different name. The repo has DroidCast_raw-release-1.0.apk, but you're hunting for DroidCast-debug-1.2.3.apk. This isn't a scavenger hunt — this is going to blow up at runtime with a very confused error message.

🩹 The Fix:

Suggested change
apk_path = "alas_wrapped/bin/DroidCast/DroidCast-debug-1.2.3.apk"
apk_path = "alas_wrapped/bin/DroidCast/DroidCast_raw-release-1.0.apk"

📏 Severity: critical

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apk_path points to alas_wrapped/bin/DroidCast/DroidCast-debug-1.2.3.apk, but the repo only contains alas_wrapped/bin/DroidCast/DroidCast_raw-release-1.0.apk. As written, the install step will fail at runtime; update the path (and any related logic) to reference an APK that actually exists in-repo (or resolve it dynamically).

Suggested change
apk_path = "alas_wrapped/bin/DroidCast/DroidCast-debug-1.2.3.apk"
apk_path = "alas_wrapped/bin/DroidCast/DroidCast_raw-release-1.0.apk"

Copilot uses AI. Check for mistakes.

# 1. Install APK
await adb_run("install", "-r", apk_path, timeout=30.0)

# 2. Start service
try:
await adb_run(
"shell", "am", "start-foreground-service",
"-n", "com.rayworks.droidcast/.Main",
"-a", "android.intent.action.MAIN",
timeout=10.0
)
except Exception:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: except Exception: is the Python equivalent of "whatever, man." You're catching KeyboardInterrupt, SystemExit, and that one weird exception your coworker's library throws when Mercury is in retrograde. The fallback might hide real problems like a rug hides... well, things you sweep under rugs.

🩹 The Fix: Be specific — catch RuntimeError or whatever adb_run actually raises. Your future debugging self will send you a thank-you card.

📏 Severity: warning

# Fallback to app_process method
await adb_run(
"shell", "CLASSPATH=/data/local/tmp/DroidCast.dex",
"app_process", "/", "com.rayworks.droidcast.Main",
timeout=5.0
)
Comment on lines +49 to +55
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback app_process launch references CLASSPATH=/data/local/tmp/DroidCast.dex, but this code never pushes that file to the device, and there is no DroidCast.dex artifact in the repo. The existing ALAS droidcast launcher uses the remote APK path as the CLASSPATH; this fallback path/class should be updated so it can actually work on a fresh device.

Copilot uses AI. Check for mistakes.

# 3. Forward port
await adb_run("forward", "tcp:53516", "tcp:53516", timeout=5.0)

# 4. Fetch again
for i in range(3):
try:
# On the last attempt, we want to know why it failed
result = await fetch_screenshot(raise_for_status=(i == 2))
if result:
return result
except Exception as e:
raise RuntimeError(f"DroidCast HTTP failure: {e}")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: You're wrapping an exception in RuntimeError like a bad Christmas gift, but you kept the receipt... just not the original wrapping paper. The original traceback gets lost in translation, so when this fails in production, you'll be playing detective with half the clues.

🩹 The Fix: Use raise RuntimeError(f"DroidCast HTTP failure: {e}") from e to preserve the chain. Or just let it propagate if the message is clear enough.

📏 Severity: suggestion

await asyncio.sleep(1.0)

raise RuntimeError("Failed to capture screenshot via DroidCast: service did not respond with 200 OK")
124 changes: 124 additions & 0 deletions adb_vision/test_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import pytest
import unittest.mock as mock
import base64
import io
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: import io is sitting here like that one person in a group project who doesn't contribute but still gets their name on the presentation. It's not used anywhere.

🩹 The Fix: ```suggestion
import pytest
import unittest.mock as mock
import base64
import requests


📏 **Severity**: nitpick

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

io is imported but never used in this test module; please remove it to keep the test file clean.

Suggested change
import io

Copilot uses AI. Check for mistakes.
import requests
from adb_vision.screenshot import _capture_droidcast
Comment on lines +1 to +6
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root pytest.ini restricts collection to testpaths = agent_orchestrator, so this new adb_vision/test_server.py won’t run under the default pytest invocation. To ensure the new DroidCast backend stays covered in CI, either move these tests under agent_orchestrator/ (or tests/) or update the test configuration accordingly.

Copilot uses AI. Check for mistakes.

def _fake_png() -> bytes:
"""Return a minimal valid PNG (1x1 transparent) for use in tests."""
return (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00"
b"\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00"
b"\x01\x00\x00\x05\x00\x01\r\n\x2d\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
)

@pytest.mark.asyncio
async def test_capture_droidcast_happy_path():
"""Happy path: first fetch fails, then install/start/forward/fetch succeeds."""
adb_run = mock.AsyncMock()
fake_png_data = _fake_png()
fake_png_b64 = base64.b64encode(fake_png_data).decode("ascii")

# Mock requests.get to fail once, then succeed
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.content = fake_png_data

with mock.patch("requests.get", side_effect=[
requests.exceptions.ConnectionError(), # Initial check for idempotency
mock_response # After setup
]) as m_get:
result = await _capture_droidcast(
adb_run=adb_run,
serial="emulator-5554",
adb_exe="adb"
)

assert result == fake_png_b64
assert m_get.call_count == 2

# Verify ADB calls
assert adb_run.call_count >= 3
adb_run.assert_any_call("install", "-r", "alas_wrapped/bin/DroidCast/DroidCast-debug-1.2.3.apk", timeout=30.0)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: This test is asserting that a file exists at a path that... doesn't exist. It's like testing that your house keys work on a door that's not your house. The test will pass because you're mocking adb_run, but it's validating a hardcoded path that's wrong — the classic "the tests pass but the code doesn't work" scenario.

🩹 The Fix: Update to match the actual APK filename: alas_wrapped/bin/DroidCast/DroidCast_raw-release-1.0.apk

📏 Severity: warning

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test asserts an install call using alas_wrapped/bin/DroidCast/DroidCast-debug-1.2.3.apk, but that APK isn’t present in the repo (only DroidCast_raw-release-1.0.apk exists). Once the implementation is corrected to use a real APK path, update this assertion to match.

Suggested change
adb_run.assert_any_call("install", "-r", "alas_wrapped/bin/DroidCast/DroidCast-debug-1.2.3.apk", timeout=30.0)
adb_run.assert_any_call("install", "-r", "alas_wrapped/bin/DroidCast/DroidCast_raw-release-1.0.apk", timeout=30.0)

Copilot uses AI. Check for mistakes.
adb_run.assert_any_call("forward", "tcp:53516", "tcp:53516", timeout=5.0)

@pytest.mark.asyncio
async def test_capture_droidcast_idempotent():
"""Idempotency: first fetch succeeds, skipping all ADB setup calls."""
adb_run = mock.AsyncMock()
fake_png_data = _fake_png()
fake_png_b64 = base64.b64encode(fake_png_data).decode("ascii")

mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.content = fake_png_data

with mock.patch("requests.get", return_value=mock_response) as m_get:
result = await _capture_droidcast(
adb_run=adb_run,
serial="emulator-5554",
adb_exe="adb"
)

assert result == fake_png_b64
assert m_get.call_count == 1
assert adb_run.call_count == 0

@pytest.mark.asyncio
async def test_capture_droidcast_http_failure():
"""Failure: HTTP returns 404 after setup -> raises RuntimeError."""
adb_run = mock.AsyncMock()

mock_response_404 = mock.Mock()
mock_response_404.status_code = 404
# mock_response_404.raise_for_status will be called on last retry
mock_response_404.raise_for_status.side_effect = requests.exceptions.HTTPError("404 Client Error")

with mock.patch("requests.get", side_effect=[
requests.exceptions.ConnectionError(), # Initial check
mock_response_404, # After setup
mock_response_404,
mock_response_404
]) as m_get:
with pytest.raises(RuntimeError, match="DroidCast HTTP failure: 404 Client Error"):
await _capture_droidcast(
adb_run=adb_run,
serial="emulator-5554",
adb_exe="adb"
)

assert m_get.call_count == 4 # 1 init + 3 retries

@pytest.mark.asyncio
async def test_capture_droidcast_fallback_start():
"""Verify fallback to app_process if am start-foreground-service fails."""
adb_run = mock.AsyncMock(side_effect=[
None, # install
RuntimeError("am failed"), # am start
None, # app_process
None # forward
])
fake_png_data = _fake_png()
fake_png_b64 = base64.b64encode(fake_png_data).decode("ascii")

mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.content = fake_png_data

with mock.patch("requests.get", side_effect=[
requests.exceptions.ConnectionError(),
mock_response
]):
result = await _capture_droidcast(
adb_run=adb_run,
serial="emulator-5554",
adb_exe="adb"
)

assert result == fake_png_b64
adb_run.assert_any_call(
"shell", "CLASSPATH=/data/local/tmp/DroidCast.dex",
"app_process", "/", "com.rayworks.droidcast.Main",
timeout=5.0
)