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:
@@ -86,6 +86,18 @@ try:
|
||||
except ImportError:
|
||||
_has_mediaprojection = False
|
||||
|
||||
# ── Android camera/webcam (Camera2 via Chaquopy bridge) ─────────────
|
||||
|
||||
try:
|
||||
from ledgrab.core.capture_engines.android_camera_engine import (
|
||||
AndroidCameraEngine,
|
||||
AndroidCameraCaptureStream,
|
||||
)
|
||||
|
||||
_has_android_camera = True
|
||||
except ImportError:
|
||||
_has_android_camera = False
|
||||
|
||||
# ── Android root screenrecord (rooted Magisk devices) ───────────────
|
||||
|
||||
try:
|
||||
@@ -120,6 +132,8 @@ if _has_camera:
|
||||
EngineRegistry.register(CameraEngine)
|
||||
if _has_mediaprojection:
|
||||
EngineRegistry.register(MediaProjectionEngine)
|
||||
if _has_android_camera:
|
||||
EngineRegistry.register(AndroidCameraEngine)
|
||||
if _has_root_screenrecord:
|
||||
EngineRegistry.register(RootScreenrecordEngine)
|
||||
EngineRegistry.register(DemoCaptureEngine)
|
||||
@@ -152,5 +166,7 @@ if _has_camera:
|
||||
__all__ += ["CameraEngine", "CameraCaptureStream"]
|
||||
if _has_mediaprojection:
|
||||
__all__ += ["MediaProjectionEngine", "MediaProjectionCaptureStream"]
|
||||
if _has_android_camera:
|
||||
__all__ += ["AndroidCameraEngine", "AndroidCameraCaptureStream"]
|
||||
if _has_root_screenrecord:
|
||||
__all__ += ["RootScreenrecordEngine", "RootScreenrecordCaptureStream"]
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Android camera (webcam) capture engine.
|
||||
|
||||
Receives camera frames pushed from Kotlin (via Chaquopy) through a
|
||||
module-level frame queue. The Kotlin :class:`CameraBridge` opens a
|
||||
camera with the Camera2 API, converts each frame to RGB, and calls
|
||||
:func:`push_frame` with raw RGB bytes.
|
||||
|
||||
The physical camera is opened **on demand** — only while a capture
|
||||
stream is active. :meth:`AndroidCameraCaptureStream.initialize` calls
|
||||
:func:`start_camera` (which signals the Kotlin bridge to open the
|
||||
camera) and :meth:`cleanup` calls :func:`stop_camera`. This keeps the
|
||||
camera-in-use indicator and battery cost limited to actual use, unlike
|
||||
the always-on screen/audio capture.
|
||||
|
||||
Mirrors the screen-capture bridge
|
||||
(``core/capture_engines/mediaprojection_engine.py``): a module-level
|
||||
queue plus push/last-frame fallback/drop-oldest, consumed through the
|
||||
standard :class:`CaptureEngine` / :class:`CaptureStream` interface so
|
||||
the live-stream and processing pipelines work unchanged. Cameras are
|
||||
exposed as selectable "displays" exactly like the desktop OpenCV
|
||||
:class:`CameraEngine`.
|
||||
|
||||
This engine is only available when running inside the LedGrab Android
|
||||
app (``is_android()``) with at least one camera the Kotlin bridge can
|
||||
enumerate. All Java interop is lazy + guarded so this module imports
|
||||
cleanly on desktop CI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.capture_engines.base import (
|
||||
CaptureEngine,
|
||||
CaptureStream,
|
||||
DisplayInfo,
|
||||
ScreenCapture,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
from ledgrab.utils.platform import is_android
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame queue — the bridge between Kotlin and Python
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_frame_queue: "queue.Queue[ScreenCapture]" = queue.Queue(maxsize=2)
|
||||
_active = False
|
||||
_active_index = 0
|
||||
_frames_received = 0
|
||||
|
||||
# Single-camera ownership. The Kotlin bridge supports exactly one open camera
|
||||
# at a time (it closes any prior camera on a new open), and all streams share
|
||||
# the one module-level frame queue. So the engine serializes ownership the way
|
||||
# the desktop CameraEngine does with its _camera_lock/_active_cv2_indices: the
|
||||
# first stream to initialize() owns the camera; a second stream on the SAME
|
||||
# camera attaches (ref-counted); a second stream on a DIFFERENT camera is
|
||||
# refused. Only the last owner to clean up actually stops the camera. Without
|
||||
# this, two concurrent android_camera sources on different displays would make
|
||||
# the second open silently steal the first's frames, and either stream's
|
||||
# cleanup would drain the shared queue out from under the other.
|
||||
_state_lock = threading.Lock()
|
||||
_owner_index: int | None = None # display_index that currently owns the camera
|
||||
_owner_refs = 0 # number of streams attached to the active camera
|
||||
# Camera2 delivers frames continuously, but cache the last one so a
|
||||
# brief consumer stall still has something to read (mirrors
|
||||
# mediaprojection_engine's _last_frame).
|
||||
_last_frame: Optional["ScreenCapture"] = None
|
||||
|
||||
# Enumeration cache. is_available() is polled by the engine registry,
|
||||
# so the (cheap but non-free) Camera2 enumeration is cached briefly —
|
||||
# matching the desktop CameraEngine's 30 s TTL.
|
||||
_cam_cache: List[Dict[str, Any]] | None = None
|
||||
_cam_cache_time: float = 0.0
|
||||
_CAM_CACHE_TTL = 30.0 # seconds
|
||||
|
||||
# Resolution presets shown in the UI. Identical to the desktop
|
||||
# CameraEngine set so the data-driven capture-template config UI
|
||||
# (keyed by the "resolution" field name) renders the same dropdown.
|
||||
# "auto" lets the Kotlin bridge pick a balanced output size.
|
||||
_RESOLUTION_CHOICES: List[str] = [
|
||||
"auto",
|
||||
"640x480",
|
||||
"1280x720",
|
||||
"1920x1080",
|
||||
"2560x1440",
|
||||
"3840x2160",
|
||||
]
|
||||
|
||||
|
||||
def _parse_resolution(value: Any) -> tuple[int, int] | None:
|
||||
"""Parse a 'WxH' string into (width, height). None for 'auto'/invalid."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
s = value.strip().lower()
|
||||
if s in ("", "auto"):
|
||||
return None
|
||||
parts = s.replace("×", "x").split("x")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
try:
|
||||
w, h = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
return None
|
||||
if w <= 0 or h <= 0:
|
||||
return None
|
||||
return w, h
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kotlin CameraBridge interop — lazy + guarded (never at import time)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _camera_bridge():
|
||||
"""Return the Kotlin ``CameraBridge`` singleton, or None off-Android.
|
||||
|
||||
The ``from java import jclass`` import only resolves inside the
|
||||
Chaquopy runtime, so it must never run at module import time (this
|
||||
module is imported on desktop CI too). Mirrors
|
||||
``core/devices/android_ble_transport.py``.
|
||||
"""
|
||||
if not is_android():
|
||||
return None
|
||||
try:
|
||||
from java import jclass # type: ignore[import-not-found]
|
||||
except ImportError as exc:
|
||||
logger.debug("Chaquopy java interop not available: %s", exc)
|
||||
return None
|
||||
try:
|
||||
return jclass("com.ledgrab.android.CameraBridge").INSTANCE
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.debug("CameraBridge singleton unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def list_cameras() -> List[Dict[str, Any]]:
|
||||
"""Enumerate cameras via the Kotlin bridge.
|
||||
|
||||
Returns a list of ``{"index": int, "name": str, "facing": str}``
|
||||
dicts in stable enumeration order, or ``[]`` off-Android / on error
|
||||
/ when the device has no cameras or CAMERA enumeration fails.
|
||||
Monkeypatched in tests to inject a fake list without Android.
|
||||
"""
|
||||
bridge = _camera_bridge()
|
||||
if bridge is None:
|
||||
return []
|
||||
try:
|
||||
raw = bridge.listCameras() # JSON array string
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.warning("CameraBridge.listCameras failed: %s", exc)
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(str(raw))
|
||||
except (ValueError, TypeError) as exc: # pragma: no cover
|
||||
logger.warning("CameraBridge.listCameras returned invalid JSON: %s", exc)
|
||||
return []
|
||||
cameras: List[Dict[str, Any]] = []
|
||||
for i, entry in enumerate(parsed if isinstance(parsed, list) else []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
cameras.append(
|
||||
{
|
||||
"index": int(entry.get("index", i)),
|
||||
"name": str(entry.get("name") or f"Camera {i}"),
|
||||
"facing": str(entry.get("facing") or "unknown"),
|
||||
}
|
||||
)
|
||||
return cameras
|
||||
|
||||
|
||||
def _enumerate_cameras() -> List[Dict[str, Any]]:
|
||||
"""Cached camera enumeration (TTL ``_CAM_CACHE_TTL``)."""
|
||||
global _cam_cache, _cam_cache_time
|
||||
now = time.monotonic()
|
||||
if _cam_cache is not None and (now - _cam_cache_time) < _CAM_CACHE_TTL:
|
||||
return _cam_cache
|
||||
_cam_cache = list_cameras()
|
||||
_cam_cache_time = now
|
||||
return _cam_cache
|
||||
|
||||
|
||||
def start_camera(index: int, width: int, height: int) -> bool:
|
||||
"""Signal the Kotlin bridge to open camera ``index`` (on demand).
|
||||
|
||||
``width``/``height`` are the requested capture size (0 => let the
|
||||
bridge pick a balanced default). Returns True if the camera began
|
||||
streaming. False off-Android, when the bridge is unavailable, or
|
||||
when the open failed (e.g. CAMERA permission denied, camera in use).
|
||||
Monkeypatched in tests.
|
||||
"""
|
||||
bridge = _camera_bridge()
|
||||
if bridge is None:
|
||||
return False
|
||||
try:
|
||||
return bool(bridge.startCamera(index, width, height))
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.warning("CameraBridge.startCamera(%d) failed: %s", index, exc)
|
||||
return False
|
||||
|
||||
|
||||
def stop_camera(index: int) -> None:
|
||||
"""Signal the Kotlin bridge to close the active camera. No-op off-Android."""
|
||||
bridge = _camera_bridge()
|
||||
if bridge is None:
|
||||
return
|
||||
try:
|
||||
bridge.stopCamera()
|
||||
except Exception as exc: # pragma: no cover - Android-only path
|
||||
logger.debug("CameraBridge.stopCamera failed: %s", exc)
|
||||
|
||||
|
||||
def push_frame(rgb_bytes: bytes, width: int, height: int) -> None:
|
||||
"""Push one RGB frame from Kotlin into the capture pipeline.
|
||||
|
||||
Called from ``CameraBridge`` on its capture thread. The byte buffer
|
||||
is interpreted as tightly-packed RGB (``width * height * 3`` bytes,
|
||||
3 bytes/pixel — NOT RGBA). The buffer is copied out so Kotlin may
|
||||
reuse its backing array; the oldest queued frame is dropped if the
|
||||
consumer is slow.
|
||||
"""
|
||||
global _frames_received, _last_frame
|
||||
expected = width * height * 3
|
||||
if expected <= 0:
|
||||
return
|
||||
arr = np.frombuffer(rgb_bytes, dtype=np.uint8)
|
||||
if arr.size < expected:
|
||||
# Short/malformed buffer — drop rather than reshape-crash.
|
||||
return
|
||||
|
||||
# Copy out of the read-only frombuffer view (and off any reusable
|
||||
# Kotlin buffer) so the queued frame owns its memory. Mirrors
|
||||
# mediaprojection_engine.push_frame's .copy().
|
||||
rgb = arr[:expected].reshape((height, width, 3)).copy()
|
||||
|
||||
frame = ScreenCapture(
|
||||
image=rgb,
|
||||
width=width,
|
||||
height=height,
|
||||
display_index=_active_index,
|
||||
)
|
||||
_last_frame = frame
|
||||
|
||||
_frames_received += 1
|
||||
if _frames_received == 1 or _frames_received % 100 == 0:
|
||||
logger.info("Android camera: received %d frames", _frames_received)
|
||||
|
||||
# Drop oldest frame if queue is full (non-blocking).
|
||||
try:
|
||||
_frame_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
_frame_queue.put_nowait(frame)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Deactivate the engine. Called when the Android app stops."""
|
||||
global _active
|
||||
_active = False
|
||||
logger.info("Android camera engine shut down")
|
||||
|
||||
|
||||
def _drain_queue() -> None:
|
||||
"""Discard any queued frames (stale frames from a prior session)."""
|
||||
global _last_frame
|
||||
while not _frame_queue.empty():
|
||||
try:
|
||||
_frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
_last_frame = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureStream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AndroidCameraCaptureStream(CaptureStream):
|
||||
"""Reads camera frames pushed by Kotlin from the module-level queue.
|
||||
|
||||
Opening the physical camera is on demand: :meth:`initialize` asks
|
||||
the Kotlin bridge to open the camera bound to ``display_index`` and
|
||||
:meth:`cleanup` asks it to close.
|
||||
"""
|
||||
|
||||
def initialize(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
if not is_android():
|
||||
raise RuntimeError(
|
||||
"Android camera engine not available. "
|
||||
"This engine is only usable inside the Android app."
|
||||
)
|
||||
|
||||
parsed = _parse_resolution(self.config.get("resolution", "auto"))
|
||||
target_w, target_h = parsed if parsed is not None else (0, 0)
|
||||
|
||||
global _active, _active_index, _owner_index, _owner_refs
|
||||
with _state_lock:
|
||||
if _owner_index is not None and _owner_index != self.display_index:
|
||||
# Another camera is already streaming — the bridge can only
|
||||
# drive one at a time, so refuse rather than silently stealing
|
||||
# the active camera's frames (mirrors the desktop CameraEngine's
|
||||
# "already in use by another stream").
|
||||
raise RuntimeError(
|
||||
f"Android camera {_owner_index} is already in use by another "
|
||||
f"capture; only one camera can stream at a time"
|
||||
)
|
||||
if _owner_index == self.display_index:
|
||||
# Same camera already open — attach to it (ref-counted).
|
||||
_owner_refs += 1
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
"Android camera capture stream attached (camera=%d, refs=%d)",
|
||||
self.display_index,
|
||||
_owner_refs,
|
||||
)
|
||||
return
|
||||
|
||||
# No camera open — open this one. Drain stale frames first so the
|
||||
# first captured frame is actually current.
|
||||
_drain_queue()
|
||||
if not start_camera(self.display_index, target_w, target_h):
|
||||
raise RuntimeError(
|
||||
f"Failed to open Android camera {self.display_index} "
|
||||
f"(CAMERA permission denied, camera in use, or unavailable)"
|
||||
)
|
||||
_owner_index = self.display_index
|
||||
_owner_refs = 1
|
||||
_active = True
|
||||
_active_index = self.display_index
|
||||
self._initialized = True
|
||||
logger.info("Android camera capture stream initialized (camera=%d)", self.display_index)
|
||||
|
||||
def capture_frame(self) -> ScreenCapture | None:
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
# Prefer a fresh frame; fall back to the last one on a brief stall.
|
||||
try:
|
||||
return _frame_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
return _last_frame
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self._initialized:
|
||||
global _active, _owner_index, _owner_refs
|
||||
with _state_lock:
|
||||
_owner_refs -= 1
|
||||
if _owner_refs <= 0:
|
||||
# Last owner released — actually stop the camera.
|
||||
stop_camera(self.display_index)
|
||||
_owner_index = None
|
||||
_owner_refs = 0
|
||||
_active = False
|
||||
_drain_queue()
|
||||
self._initialized = False
|
||||
logger.info("Android camera capture stream cleaned up (camera=%d)", self.display_index)
|
||||
else:
|
||||
self._initialized = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CaptureEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AndroidCameraEngine(CaptureEngine):
|
||||
"""Android camera/webcam capture engine (Camera2 via Kotlin bridge).
|
||||
|
||||
Only available inside the LedGrab Android app with at least one
|
||||
enumerable camera. Each camera is exposed as a selectable
|
||||
"display", mirroring the desktop OpenCV :class:`CameraEngine`.
|
||||
Selected explicitly via ``engine_type="android_camera"`` in a
|
||||
capture template — never auto-selected (priority 0, below
|
||||
MediaProjection's 100).
|
||||
"""
|
||||
|
||||
ENGINE_TYPE = "android_camera"
|
||||
ENGINE_PRIORITY = 0 # never auto-selected over MediaProjection (100); explicit only
|
||||
HAS_OWN_DISPLAYS = True
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return is_android() and len(_enumerate_cameras()) > 0
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> Dict[str, Any]:
|
||||
return {"resolution": "auto"}
|
||||
|
||||
@classmethod
|
||||
def get_config_choices(cls) -> Dict[str, List[str]]:
|
||||
return {"resolution": list(_RESOLUTION_CHOICES)}
|
||||
|
||||
@classmethod
|
||||
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||
displays: List[DisplayInfo] = []
|
||||
for cam in _enumerate_cameras():
|
||||
idx = cam["index"]
|
||||
displays.append(
|
||||
DisplayInfo(
|
||||
index=idx,
|
||||
name=cam["name"],
|
||||
width=0,
|
||||
height=0,
|
||||
x=idx * 500,
|
||||
y=0,
|
||||
is_primary=(idx == 0),
|
||||
refresh_rate=30,
|
||||
)
|
||||
)
|
||||
return displays
|
||||
|
||||
@classmethod
|
||||
def create_stream(
|
||||
cls, display_index: int, config: Dict[str, Any]
|
||||
) -> AndroidCameraCaptureStream:
|
||||
merged = {**cls.get_default_config(), **config}
|
||||
return AndroidCameraCaptureStream(display_index, merged)
|
||||
@@ -103,6 +103,7 @@
|
||||
"templates.engine.wgc.desc": "Windows Graphics Capture",
|
||||
"templates.engine.demo.desc": "Animated test pattern (demo mode)",
|
||||
"templates.engine.mediaprojection.desc": "Native Android screen capture",
|
||||
"templates.engine.android_camera.desc": "On-device camera capture (Camera2)",
|
||||
"templates.config": "Configuration",
|
||||
"templates.config.show": "Show configuration",
|
||||
"templates.config.none": "No additional configuration",
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
"templates.engine.wgc.desc": "Windows Graphics Capture",
|
||||
"templates.engine.demo.desc": "Тестовый анимированный шаблон (демо)",
|
||||
"templates.engine.mediaprojection.desc": "Нативный захват экрана Android",
|
||||
"templates.engine.android_camera.desc": "Захват камеры устройства (Camera2)",
|
||||
"templates.config": "Конфигурация",
|
||||
"templates.config.show": "Показать конфигурацию",
|
||||
"templates.config.none": "Нет дополнительных настроек",
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
"templates.engine.wgc.desc": "Windows图形捕获",
|
||||
"templates.engine.demo.desc": "动画测试图案(演示模式)",
|
||||
"templates.engine.mediaprojection.desc": "原生Android屏幕捕获",
|
||||
"templates.engine.android_camera.desc": "设备摄像头捕获 (Camera2)",
|
||||
"templates.config": "配置",
|
||||
"templates.config.show": "显示配置",
|
||||
"templates.config.none": "无额外配置",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user