feat(android): on-device webcam capture via Camera2 (AndroidCameraEngine)

Add on-device webcam capture to the experimental Android-TV build. Desktop
captures webcams via OpenCV (no Chaquopy/Android wheel); this adds a push-based
AndroidCameraEngine that plugs into the same selection path desktop uses
(capture template engine_type="android_camera" + display_index, HAS_OWN_DISPLAYS).

A Kotlin CameraBridge (Camera2) enumerates cameras and opens them on demand —
only while a capture source is active, driven Python->Kotlin via a guarded jclass
singleton (BleBridge pattern) — converts each frame YUV_420_888->RGB, and pushes
RGB bytes into a module-level queue mirroring mediaprojection_engine.py. Cameras
surface as selectable displays like the desktop OpenCV engine; the data-driven
capture-template UI is unchanged. No new Python deps; no new Gradle deps
(Camera2 is in-platform).

Engine: ENGINE_PRIORITY=0 (never auto-selected over MediaProjection=100; explicit
engine_type only). Single-camera ownership is serialized with a lock + ref-count
(same-camera streams attach, different-camera refused, last release stops),
mirroring the desktop CameraEngine guard.

Permission: CAMERA requested at capture-start, gated on FEATURE_CAMERA_ANY so
camera-less TV boxes never prompt; graceful degradation when denied. The service
is promoted with the camera FGS type (+ FOREGROUND_SERVICE_CAMERA) only when
CAMERA is already granted, so backgrounded capture keeps working without risking
a failed startForeground on camera-less boxes (camera can't ride the
MediaProjection token the way audio playback capture does).

Reviewed via multi-agent adversarial pass (13 findings -> 4 fixed: device leak on
session-failure, multi-stream collision, camera FGS type, i18n key; 9 refuted).

Tests: 18 new desktop-CI tests (no device needed); full suite 1883 passed.
Verified: assembleDebug BUILD SUCCESSFUL, ruff clean.

Docs: ANDROID-REVIEW/android-webcam-capture-plan.md (design), updated
android-missing-functionality.md + README feature table + en/ru/zh locales.
This commit is contained in:
2026-06-02 13:36:23 +03:00
parent 34db5de8c3
commit 4bf3fe65db
14 changed files with 1480 additions and 17 deletions
@@ -0,0 +1,342 @@
"""Tests for the Android camera (webcam) capture engine.
These run on desktop CI (no Android device needed): ``is_android`` and the
Kotlin-bridge hooks (``list_cameras`` / ``start_camera`` / ``stop_camera``)
are monkeypatched, and RGB frames are pushed directly into the module-level
queue, exactly as the Kotlin ``CameraBridge`` would.
"""
import queue
import numpy as np
import pytest
# Importing the package triggers auto-registration of AndroidCameraEngine.
import ledgrab.core.capture_engines # noqa: F401
from ledgrab.core.capture_engines import android_camera_engine as eng
from ledgrab.core.capture_engines.factory import EngineRegistry
ENGINE_MOD = "ledgrab.core.capture_engines.android_camera_engine"
W = 16
H = 8
_FAKE_CAMERAS = [
{"index": 0, "name": "Back camera", "facing": "back"},
{"index": 1, "name": "Front camera", "facing": "front"},
]
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _drain() -> None:
while not eng._frame_queue.empty():
try:
eng._frame_queue.get_nowait()
except queue.Empty:
break
def _frame(marker: int = 0, w: int = W, h: int = H) -> bytes:
"""A tightly-packed RGB frame whose first pixel's R channel is ``marker``."""
arr = np.zeros((h, w, 3), dtype=np.uint8)
arr[0, 0, 0] = marker
return arr.tobytes()
@pytest.fixture
def reset_engine():
"""Reset module-global engine state; snapshot/restore the registry.
The engine keeps its queue + caches in module globals and the registry
is a class-level singleton — both must be restored so this test file
never disturbs the desktop engines other tests rely on.
"""
saved_engines = dict(EngineRegistry._engines)
eng.shutdown()
_drain()
eng._frames_received = 0
eng._active = False
eng._active_index = 0
eng._last_frame = None
eng._cam_cache = None
eng._cam_cache_time = 0.0
eng._owner_index = None
eng._owner_refs = 0
yield eng
eng.shutdown()
_drain()
eng._cam_cache = None
eng._cam_cache_time = 0.0
eng._owner_index = None
eng._owner_refs = 0
EngineRegistry._engines.clear()
EngineRegistry._engines.update(saved_engines)
@pytest.fixture
def on_android(monkeypatch, reset_engine):
"""Engine fixture with ``is_android`` True, demo mode off, fake cameras,
and the open/close hooks stubbed to succeed (recording calls)."""
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: True)
monkeypatch.setattr("ledgrab.core.capture_engines.factory.is_demo_mode", lambda: False)
monkeypatch.setattr(f"{ENGINE_MOD}.list_cameras", lambda: list(_FAKE_CAMERAS))
calls = {"start": [], "stop": []}
monkeypatch.setattr(
f"{ENGINE_MOD}.start_camera",
lambda index, w, h: calls["start"].append((index, w, h)) or True,
)
monkeypatch.setattr(
f"{ENGINE_MOD}.stop_camera",
lambda index: calls["stop"].append(index),
)
reset_engine.calls = calls
return reset_engine
# ---------------------------------------------------------------------------
# Queue / push contract
# ---------------------------------------------------------------------------
def test_push_frame_round_trips_rgb(on_android):
# Arrange
stream = eng.AndroidCameraEngine.create_stream(0, {})
stream.initialize()
# Act
eng.push_frame(_frame(marker=42), W, H)
got = stream.capture_frame()
# Assert
assert got is not None
assert got.image.shape == (H, W, 3)
assert got.image.dtype == np.uint8
assert int(got.image[0, 0, 0]) == 42
assert got.width == W and got.height == H
def test_queue_drops_oldest_when_full(reset_engine):
# Arrange
maxsize = eng._frame_queue.maxsize # 2
# Act — push more frames than the queue holds, each tagged 0..N-1
total = maxsize + 3
for i in range(total):
eng.push_frame(_frame(marker=i), W, H)
drained = []
while True:
try:
drained.append(eng._frame_queue.get_nowait())
except queue.Empty:
break
# Assert — only the newest `maxsize` frames survived, oldest dropped
assert len(drained) == maxsize
markers = [int(f.image[0, 0, 0]) for f in drained]
assert markers == list(range(total - maxsize, total))
def test_capture_frame_falls_back_to_last_frame_when_empty(on_android):
# Arrange
stream = eng.AndroidCameraEngine.create_stream(0, {})
stream.initialize()
eng.push_frame(_frame(marker=7), W, H)
# Act — first read drains the queue; second read finds it empty
first = stream.capture_frame()
second = stream.capture_frame()
# Assert — the static-frame fallback returns the cached last frame
assert first is not None
assert second is not None
assert int(second.image[0, 0, 0]) == 7
def test_push_frame_short_buffer_does_not_crash(reset_engine):
# A buffer shorter than width*height*3 must be dropped, not reshape-crash.
eng.push_frame(b"\x01\x02\x03", W, H) # far too short
assert eng._frame_queue.empty()
assert eng._last_frame is None
# ---------------------------------------------------------------------------
# On-demand open/close lifecycle
# ---------------------------------------------------------------------------
def test_initialize_opens_camera_with_parsed_resolution(on_android):
stream = eng.AndroidCameraEngine.create_stream(1, {"resolution": "1280x720"})
stream.initialize()
assert on_android.calls["start"] == [(1, 1280, 720)]
def test_initialize_auto_resolution_requests_zero(on_android):
stream = eng.AndroidCameraEngine.create_stream(0, {"resolution": "auto"})
stream.initialize()
assert on_android.calls["start"] == [(0, 0, 0)]
def test_cleanup_closes_camera_once(on_android):
stream = eng.AndroidCameraEngine.create_stream(0, {})
stream.initialize()
stream.cleanup()
assert on_android.calls["stop"] == [0]
# Idempotent — a second cleanup does not re-signal the bridge.
stream.cleanup()
assert on_android.calls["stop"] == [0]
def test_second_camera_index_is_refused(on_android):
# First stream owns camera 0.
s0 = eng.AndroidCameraEngine.create_stream(0, {})
s0.initialize()
# A stream on a DIFFERENT camera must be refused (one camera at a time),
# not silently steal camera 0's stream.
s1 = eng.AndroidCameraEngine.create_stream(1, {})
with pytest.raises(RuntimeError):
s1.initialize()
# Only the first open reached the bridge.
assert on_android.calls["start"] == [(0, 0, 0)]
def test_same_camera_attaches_and_refcounts(on_android):
# Two streams on the SAME camera share one physical open (ref-counted).
a = eng.AndroidCameraEngine.create_stream(0, {})
b = eng.AndroidCameraEngine.create_stream(0, {})
a.initialize()
b.initialize()
assert on_android.calls["start"] == [(0, 0, 0)] # opened once
# First release must NOT stop the camera (the other stream is still live).
a.cleanup()
assert on_android.calls["stop"] == []
# Last release stops it exactly once.
b.cleanup()
assert on_android.calls["stop"] == [0]
def test_camera_freed_after_release_allows_other_index(on_android):
# After fully releasing camera 0, a different camera can be opened.
s0 = eng.AndroidCameraEngine.create_stream(0, {})
s0.initialize()
s0.cleanup()
s1 = eng.AndroidCameraEngine.create_stream(1, {})
s1.initialize() # must not raise
assert on_android.calls["start"] == [(0, 0, 0), (1, 0, 0)]
def test_initialize_raises_when_open_fails(monkeypatch, reset_engine):
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: True)
monkeypatch.setattr(f"{ENGINE_MOD}.start_camera", lambda index, w, h: False)
stream = eng.AndroidCameraEngine.create_stream(0, {})
with pytest.raises(RuntimeError):
stream.initialize()
def test_initialize_raises_off_android(monkeypatch, reset_engine):
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: False)
stream = eng.AndroidCameraEngine.create_stream(0, {})
with pytest.raises(RuntimeError):
stream.initialize()
# ---------------------------------------------------------------------------
# Availability / enumeration (platform-gated)
# ---------------------------------------------------------------------------
def test_is_available_requires_android_and_cameras(monkeypatch, reset_engine):
# Off-Android → unavailable regardless of cameras.
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: False)
monkeypatch.setattr(f"{ENGINE_MOD}.list_cameras", lambda: list(_FAKE_CAMERAS))
assert eng.AndroidCameraEngine.is_available() is False
# On-Android but no cameras → unavailable.
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: True)
monkeypatch.setattr(f"{ENGINE_MOD}.list_cameras", lambda: [])
eng._cam_cache = None # bust the enumeration cache
assert eng.AndroidCameraEngine.is_available() is False
# On-Android with ≥1 camera → available.
monkeypatch.setattr(f"{ENGINE_MOD}.list_cameras", lambda: list(_FAKE_CAMERAS))
eng._cam_cache = None
assert eng.AndroidCameraEngine.is_available() is True
def test_get_available_displays_maps_cameras(on_android):
displays = eng.AndroidCameraEngine.get_available_displays()
assert len(displays) == 2
assert displays[0].index == 0 and displays[0].name == "Back camera"
assert displays[0].is_primary is True
assert displays[1].index == 1 and displays[1].name == "Front camera"
assert displays[1].is_primary is False
def test_config_choices_expose_resolution(reset_engine):
choices = eng.AndroidCameraEngine.get_config_choices()
assert "resolution" in choices
assert "auto" in choices["resolution"]
assert "1920x1080" in choices["resolution"]
# ---------------------------------------------------------------------------
# Registry integration
# ---------------------------------------------------------------------------
def test_engine_registers_with_expected_type_and_priority():
# Auto-registration ran on import; the engine is in the registry.
assert "android_camera" in EngineRegistry.get_all_engines()
assert eng.AndroidCameraEngine.ENGINE_PRIORITY == 0
assert eng.AndroidCameraEngine.HAS_OWN_DISPLAYS is True
def test_does_not_beat_mediaprojection_by_priority(monkeypatch, reset_engine):
"""Priority 0 must never let the camera win the best-engine race over
MediaProjection (100) on Android."""
from ledgrab.core.capture_engines import mediaprojection_engine as mp
monkeypatch.setattr(f"{ENGINE_MOD}.is_android", lambda: True)
monkeypatch.setattr(f"{ENGINE_MOD}.list_cameras", lambda: list(_FAKE_CAMERAS))
monkeypatch.setattr("ledgrab.core.capture_engines.factory.is_demo_mode", lambda: False)
eng._cam_cache = None
# Controlled registry: just the two engines whose priority race we assert.
EngineRegistry._engines.clear()
EngineRegistry.register(mp.MediaProjectionEngine)
EngineRegistry.register(eng.AndroidCameraEngine)
mp.configure(640, 480) # make MediaProjection available
try:
best = EngineRegistry.get_best_available_engine()
assert best == "mediaprojection"
assert best != "android_camera"
finally:
mp.shutdown()
while not mp._frame_queue.empty():
try:
mp._frame_queue.get_nowait()
except queue.Empty:
break
def test_stream_via_registry_yields_pushed_frame(on_android):
# Arrange — register cleanly (fixture restores afterward).
stream = EngineRegistry.create_stream("android_camera", 0, {})
stream.initialize()
# Act
eng.push_frame(_frame(marker=99), W, H)
got = stream.capture_frame()
# Assert
assert got is not None
assert int(got.image[0, 0, 0]) == 99
assert got.display_index == 0