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.
7 changes: 7 additions & 0 deletions adb_vision/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[project]
name = "adb-vision"
version = "0.1.0"
dependencies = [
"av>=16.1.0",
"pillow>=10.0.0",
]
164 changes: 164 additions & 0 deletions adb_vision/screenshot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import asyncio
import struct
import base64
import io
import os
Copy link

Choose a reason for hiding this comment

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

🔥 The Roast: Importing struct like it's going to a party, but it never shows up to work. The invite is right there on line 5, but the function body is ghosting it harder than my ex.

🩹 The Fix:

Suggested change
import os
import asyncio
import base64
import io
import re
from pathlib import Path
from typing import Protocol

📏 Severity: nitpick

Comment on lines +2 to +5
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.

There are unused imports here (e.g., struct and os), which adds noise and can confuse readers about what the implementation relies on. Please remove unused imports (or start using them if they’re intended for upcoming functionality).

Suggested change
import struct
import base64
import io
import os
import base64
import io

Copilot uses AI. Check for mistakes.
import re
Copy link

Choose a reason for hiding this comment

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

🔥 The Roast: os is the plus-one that didn't even make it into the venue. Imported but never referenced — it's the os in 'gloss over unused imports.'

🩹 The Fix: Remove this import.

📏 Severity: nitpick

from pathlib import Path
from typing import Protocol, Optional
Copy link

Choose a reason for hiding this comment

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

🔥 The Roast: Optional is sitting on the bench with its helmet on, ready to play, but the coach never calls its name. Classic premature optimization for a type hint that never materialized.

🩹 The Fix: Remove Optional from the import.

📏 Severity: nitpick


# Type definition for the ADB run function
class AdbRunFn(Protocol):
async def __call__(self, *args: str, timeout: float) -> bytes: ...

async def _capture_scrcpy(*, adb_run: AdbRunFn, serial: str, adb_exe: str) -> str:
"""Return base64-encoded PNG screenshot via scrcpy single-frame capture.

This implementation uses scrcpy-server v1.20 which is available in the repository.
It pushes the server to the device, starts it, captures one frame, and cleans up.
"""
try:
import av
from av.codec import CodecContext
except ImportError:
raise ImportError("PyAV (av) is required for scrcpy capture. Please install it.")

from PIL import Image

# 1. Locate and push scrcpy-server.jar
# The file is expected at ALAS/alas_wrapped/bin/scrcpy/scrcpy-server-v1.20.jar
# This script is at ALAS/adb_vision/screenshot.py
base_dir = Path(__file__).resolve().parents[1]
local_jar = base_dir / "alas_wrapped" / "bin" / "scrcpy" / "scrcpy-server-v1.20.jar"

if not local_jar.exists():
# Fallback to current working directory relative path
local_jar = Path("alas_wrapped/bin/scrcpy/scrcpy-server-v1.20.jar")
if not local_jar.exists():
raise FileNotFoundError(f"scrcpy-server-v1.20.jar not found. Checked: {local_jar.absolute()}")
Comment on lines +32 to +38
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 FileNotFoundError message only reports the fallback path (alas_wrapped/bin/scrcpy/...) even though the function first checks a path relative to __file__. This makes debugging harder when the repo layout is unexpected. Consider including both attempted locations (or the originally computed path) in the error message.

Suggested change
local_jar = base_dir / "alas_wrapped" / "bin" / "scrcpy" / "scrcpy-server-v1.20.jar"
if not local_jar.exists():
# Fallback to current working directory relative path
local_jar = Path("alas_wrapped/bin/scrcpy/scrcpy-server-v1.20.jar")
if not local_jar.exists():
raise FileNotFoundError(f"scrcpy-server-v1.20.jar not found. Checked: {local_jar.absolute()}")
primary_jar = base_dir / "alas_wrapped" / "bin" / "scrcpy" / "scrcpy-server-v1.20.jar"
if primary_jar.exists():
local_jar = primary_jar
else:
# Fallback to current working directory relative path
fallback_jar = Path("alas_wrapped/bin/scrcpy/scrcpy-server-v1.20.jar")
if fallback_jar.exists():
local_jar = fallback_jar
else:
raise FileNotFoundError(
"scrcpy-server-v1.20.jar not found. "
f"Checked: {primary_jar.resolve()} and {fallback_jar.resolve()}"
)

Copilot uses AI. Check for mistakes.

device_jar = "/data/local/tmp/scrcpy-server.jar"
await adb_run("-s", serial, "push", str(local_jar), device_jar, timeout=15.0)

# 2. Start scrcpy server
# Position arguments for scrcpy-server v1.20:
# 1: version (1.20)
# 2: log_level (info)
# 3: max_size (0 for no limit)
# 4: bit_rate (20000000)
# 5: max_fps (1 for single frame capture)
# 6: lock_video_orientation (-1: unlocked)
# 7: tunnel_forward (true: server listens on localabstract:scrcpy)
# 8: crop (-)
# 9: send_frame_meta (false)
# 10: control (true)
# 11: display_id (0)
# 12: show_touches (false)
# 13: stay_awake (false)
# 14: codec_options (i-frame-interval=0 to get I-frame immediately)
# 15: encoder_name (-)
# 16: power_off_on_close (false)

codec_options = "i-frame-interval=0"

cmd = [
"shell", f"CLASSPATH={device_jar}", "app_process", "/", "com.genymobile.scrcpy.Server",
"1.20", "info", "0", "20000000", "1", "-1", "true", "-", "false", "true", "0", "false", "false", codec_options, "-", "false"
]

server_process = await asyncio.create_subprocess_exec(
adb_exe, "-s", serial, *cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
Comment on lines +71 to +72
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.

asyncio.create_subprocess_exec() is started with stdout/stderr set to PIPE, but the code never drains server_process.stdout (and only reads stderr on early exit). If scrcpy writes enough logs, the OS pipe buffer can fill and block the scrcpy process, potentially hanging screenshot capture. Consider redirecting output to DEVNULL, lowering log level, or continuously draining the pipes in background tasks while the server is running.

Suggested change
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,

Copilot uses AI. Check for mistakes.
)

local_port = None
try:
# 3. Setup port forwarding
forward_res = await adb_run("-s", serial, "forward", "tcp:0", "localabstract:scrcpy", timeout=5.0)
forward_out = forward_res.decode().strip()

# Parse assigned port (can be "21504" or "tcp:21504")
m = re.search(r"(\d+)", forward_out)
if m:
local_port = int(m.group(1))
else:
raise RuntimeError(f"Failed to parse port from adb forward: {forward_out}")

# 4. Connect to the server socket
reader, writer = None, None
for i in range(20):
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection("127.0.0.1", local_port),
timeout=2.0
)
break
except (ConnectionRefusedError, asyncio.TimeoutError, OSError):
if server_process.returncode is not None:
stderr_data = await server_process.stderr.read()
raise RuntimeError(f"scrcpy-server exited: {stderr_data.decode(errors='replace')}")
await asyncio.sleep(0.5)

if not reader or not writer:
raise RuntimeError("Failed to connect to scrcpy-server socket")

try:
# 5. Read header (69 bytes)
# Dummy byte(1), Device name(64), Resolution(4)
await reader.readexactly(69)

Choose a reason for hiding this comment

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

P1 Badge Guard scrcpy socket reads with timeouts

This read path has no timeout (readexactly(69) here and later reader.read(...) in the decode loop), so if scrcpy accepts the TCP connection but stalls before emitting bytes, _capture_scrcpy() can hang forever and never reach the cleanup block. In practice that can wedge screenshot-dependent workflows until an external watchdog kills the task; wrapping these awaits in asyncio.wait_for(...) (or equivalent bounded read logic) avoids indefinite hangs on partial server failures.

Useful? React with 👍 / 👎.


# 6. Read and decode video stream
codec = CodecContext.create("h264", "r")

img_b64 = None
# Read and parse chunks until a frame is decoded
for _ in range(100):
chunk = await reader.read(16384)
if not chunk:
break

packets = codec.parse(chunk)
for packet in packets:
frames = codec.decode(packet)
for frame in frames:
# Success! Convert to PNG
img = frame.to_image()
buf = io.BytesIO()
img.save(buf, format="PNG")
img_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
break
if img_b64: break
if img_b64: break

if not img_b64:
raise RuntimeError("Failed to decode a frame from scrcpy stream")

return img_b64

finally:
if writer:
writer.close()
try:
await writer.wait_closed()
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 except Exception: pass is the debugging equivalent of a 'nothing to see here' sign after a car crash. Sure, wait_closed() might fail on a half-dead connection, but swallowing ALL exceptions means legitimate bugs go to die silently. I've debugged issues that took hours because someone thought 'eh, what could go wrong?'

🩹 The Fix:

Suggested change
await writer.wait_closed()
try:
await writer.wait_closed()
except (ConnectionResetError, BrokenPipeError):
pass # Expected on already-closed connections

📏 Severity: warning

except Exception:
pass

finally:
Copy link

Choose a reason for hiding this comment

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

🔥 The Roast: A triple-nested try-except that ends in pass. This cleanup code is like a Russian nesting doll of exception suppression — by the time you get to the center, you've forgotten what error you were trying to handle. If kill() fails, you're truly out of options, but at least log it so we know something went sideways.

🩹 The Fix:

Suggested change
finally:
finally:
# 7. Cleanup
if server_process:
try:
server_process.terminate()
await asyncio.wait_for(server_process.wait(), timeout=2.0)
except ProcessLookupError:
pass # Already gone
except asyncio.TimeoutError:
try:
server_process.kill()
await server_process.wait()
except ProcessLookupError:
pass # Already gone

📏 Severity: warning

# 7. Cleanup
if server_process:
try:
server_process.terminate()
await asyncio.wait_for(server_process.wait(), timeout=2.0)
except Exception:
try:
server_process.kill()
await server_process.wait()
except Exception:
pass

if local_port:
try:
Copy link

Choose a reason for hiding this comment

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

🔥 The Roast: Another except Exception: pass? This is like leaving a port forward dangling on the device because we couldn't be bothered to handle the error. If adb forward --remove fails, the port stays allocated until someone manually cleans it up or the device reboots. Hope you don't hit this 20 times in a row.

🩹 The Fix:

Suggested change
try:
if local_port:
try:
await adb_run("-s", serial, "forward", "--remove", f"tcp:{local_port}", timeout=5.0)
except Exception as e:
import warnings
warnings.warn(f"Failed to remove ADB forward for port {local_port}: {e}")

📏 Severity: warning

await adb_run("-s", serial, "forward", "--remove", f"tcp:{local_port}", timeout=5.0)
except Exception:
pass
130 changes: 130 additions & 0 deletions adb_vision/test_scrcpy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import pytest
import asyncio
import base64
import io
from unittest.mock import MagicMock, patch, AsyncMock, ANY
from adb_vision.screenshot import _capture_scrcpy
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.

These tests will not run under the repo’s default pytest configuration: pytest.ini restricts testpaths to agent_orchestrator/, so adb_vision/test_scrcpy.py is not collected in CI by default. If this package is meant to be tested automatically, either update the root pytest configuration / CI command, or relocate these tests under the collected test suite.

Copilot uses AI. Check for mistakes.

@pytest.mark.asyncio
async def test_capture_scrcpy_happy_path():
# Mock adb_run
adb_run = AsyncMock()
adb_run.side_effect = [
b"pushed", # push jar
b"21504", # forward tcp:0
b"removed" # forward --remove
]

# Mock asyncio.create_subprocess_exec
mock_process = AsyncMock()
mock_process.returncode = None
mock_process.terminate = MagicMock()
mock_process.wait = AsyncMock()

# Mock asyncio.open_connection
mock_reader = AsyncMock()
mock_writer = AsyncMock()
mock_writer.close = MagicMock()
mock_writer.wait_closed = AsyncMock()

# Header: 69 bytes
mock_reader.readexactly.return_value = b"\x00" + b"x" * 64 + b"\x05\x00\x02\xd0"

# Mock frame.to_image() to return a real PIL image so img.save works
from PIL import Image
real_img = Image.new("RGB", (1280, 720), color="red")

with patch("asyncio.create_subprocess_exec", return_value=mock_process), \
patch("asyncio.open_connection", return_value=(mock_reader, mock_writer)), \
patch("av.codec.CodecContext") as mock_codec_context_class:

mock_codec = MagicMock()
mock_codec_context_class.create.return_value = mock_codec

mock_packet = MagicMock()
mock_codec.parse.return_value = [mock_packet]

mock_frame = MagicMock()
mock_codec.decode.return_value = [mock_frame]
mock_frame.to_image.return_value = real_img

mock_reader.read.side_effect = [b"some h264 data", b""]

serial = "127.0.0.1:5555"
result = await _capture_scrcpy(
adb_run=adb_run,
serial=serial,
adb_exe="adb"
)

assert result is not None
assert len(result) > 0
# Verify it's valid base64
base64.b64decode(result)

# Verify ADB calls
assert adb_run.call_count >= 3
Copy link

Choose a reason for hiding this comment

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

🔥 The Roast: assert adb_run.call_count >= 3 is the unit test equivalent of 'I think the code ran, probably, maybe.' This assertion is so loose it would pass if the function called ADB 47 times. The subsequent assert_any_call is the only thing saving this test from being completely useless.

🩹 The Fix:

Suggested change
assert adb_run.call_count >= 3
assert adb_run.call_count == 3
adb_run.assert_any_call("-s", serial, "push", ANY, "/data/local/tmp/scrcpy-server.jar", timeout=15.0)
adb_run.assert_any_call("-s", serial, "forward", "tcp:0", "localabstract:scrcpy", timeout=5.0)
adb_run.assert_any_call("-s", serial, "forward", "--remove", "tcp:21504", timeout=5.0)

📏 Severity: suggestion

adb_run.assert_any_call("-s", serial, "push", ANY, "/data/local/tmp/scrcpy-server.jar", timeout=15.0)
adb_run.assert_any_call("-s", serial, "forward", "tcp:0", "localabstract:scrcpy", timeout=5.0)
adb_run.assert_any_call("-s", serial, "forward", "--remove", "tcp:21504", timeout=5.0)

@pytest.mark.asyncio
async def test_capture_scrcpy_server_fail():
adb_run = AsyncMock()
adb_run.side_effect = [
b"pushed", # push jar
b"21504", # forward tcp:0
b"removed" # forward --remove
]

mock_process = AsyncMock()
mock_process.returncode = 1
mock_process.stderr.read.return_value = b"some error"
mock_process.terminate = MagicMock()
mock_process.wait = AsyncMock()

with patch("asyncio.create_subprocess_exec", return_value=mock_process), \
patch("asyncio.open_connection", side_effect=ConnectionRefusedError()):

with pytest.raises(RuntimeError, match="scrcpy-server exited: some error"):
await _capture_scrcpy(
adb_run=adb_run,
serial="127.0.0.1:5555",
adb_exe="adb"
)

@pytest.mark.asyncio
async def test_capture_scrcpy_decode_fail():
adb_run = AsyncMock()
adb_run.side_effect = [
b"pushed", # push jar
b"21504", # forward tcp:0
b"removed" # forward --remove
]

mock_process = AsyncMock()
mock_process.returncode = None
mock_process.terminate = MagicMock()
mock_process.wait = AsyncMock()

mock_reader = AsyncMock()
mock_writer = AsyncMock()
mock_writer.close = MagicMock()
mock_writer.wait_closed = AsyncMock()
mock_reader.readexactly.return_value = b"\x00" + b"x" * 64 + b"\x05\x00\x02\xd0"
mock_reader.read.return_value = b"" # No data

with patch("asyncio.create_subprocess_exec", return_value=mock_process), \
patch("asyncio.open_connection", return_value=(mock_reader, mock_writer)), \
patch("av.codec.CodecContext") as mock_codec_context_class:

mock_codec = MagicMock()
mock_codec_context_class.create.return_value = mock_codec
mock_codec.parse.return_value = []

with pytest.raises(RuntimeError, match="Failed to decode a frame from scrcpy stream"):
await _capture_scrcpy(
adb_run=adb_run,
serial="127.0.0.1:5555",
adb_exe="adb"
)