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.
9.9 KiB
Plan: Android on-device webcam capture
Status: implemented on branch
feature/android-webcam-capture. Last updated 2026-06-02.
Context
LedGrab captures webcams on desktop through OpenCV (cv2.VideoCapture) in
server/src/ledgrab/core/capture_engines/camera_engine.py. On the experimental Android-TV
build, opencv-python-headless has no Chaquopy cp311 wheel, so the camera engine never
loads and cameras are unusable on-device.
Android doesn't need OpenCV to capture a camera: the platform exposes Camera2
(android.hardware.camera2), and the codebase already has the bridge shape to plug a Kotlin
capture source into a push-based Python engine. This feature adds an on-device camera engine
so a USB/integrated camera can drive ambient lighting, at parity with how the desktop OpenCV
camera engine feeds the pipeline.
The design mirrors the working screen-capture bridge
(mediaprojection_engine.py ↔ ScreenCapture.kt) and the just-shipped audio engine
(android_audio_engine.py ↔ AudioCapture.kt). No new Python dependencies (numpy already
bundled) and no new Gradle dependencies (Camera2 is in-platform) → no Chaquopy /
build.gradle.kts changes.
Approach
A new push-based capture engine registered in the existing EngineRegistry, plus a Kotlin
CameraBridge that opens the camera on demand:
[capture source acquired] → AndroidCameraCaptureStream.initialize()
→ android_camera_engine.start_camera(index, w, h) [guarded jclass]
→ CameraBridge.startCamera(index, w, h) [Camera2 open + session]
→ onImageAvailable → YUV_420_888→RGB (stride-aware) → push_frame(rgbBytes, w, h)
→ android_camera_engine [module-level queue] → AndroidCameraCaptureStream.capture_frame()
→ ScreenCaptureLiveStream → processing pipeline [unchanged]
[capture source released] → AndroidCameraCaptureStream.cleanup()
→ android_camera_engine.stop_camera() → CameraBridge.stopCamera() [releases the camera]
The camera is only open while a camera source is active — the camera-in-use indicator and
battery cost are bounded to actual use, unlike always-on screen/audio capture. This on-demand
control reuses the synchronous Python→Kotlin singleton pattern of BleBridge/UsbSerialBridge.
Selection path (why nothing downstream changes)
Webcams on desktop are a ScreenCapturePictureSource (stream_type="raw") bound to a capture
template whose engine_type="camera" + a display_index. live_stream_manager
_create_screen_capture_live_stream reads engine_type from the template and calls
EngineRegistry.create_stream(engine_type, display_index, config). Android adds
engine_type="android_camera" — the same path. The frontend
(static/js/features/streams-capture-templates.ts) is fully data-driven: the engine list,
the resolution config dropdown (keyed by field name), and the camera picker
(/config/displays?engine_type=android_camera, since HAS_OWN_DISPLAYS=True) all work with
no frontend changes.
Part A — Python (core/capture_engines/android_camera_engine.py)
Mirrors mediaprojection_engine.py (module-level queue.Queue + push_frame + _last_frame
fallback + drop-oldest) and the desktop CameraEngine shape (cameras as displays,
resolution config).
_camera_bridge()— lazy,is_android()-guardedfrom java import jclass; jclass("com.ledgrab.android.CameraBridge").INSTANCE. Never imported at module load (this module imports on desktop CI). Mirrorscore/devices/android_ble_transport.py.list_cameras()→ parsesCameraBridge.listCameras()JSON into[{"index","name","facing"}];_enumerate_cameras()caches it (30 s TTL).push_frame(rgb_bytes, w, h)→np.frombuffer(...uint8)reshape(h, w, 3)(RGB, 3 B/px — NOT the RGBA(h,w,4)of the screen engine) →.copy()→ drop-oldest enqueue. A short/malformed buffer is dropped, never reshape-crashes.start_camera(index, w, h) -> bool/stop_camera(index)→ guarded bridge calls.AndroidCameraEngine:ENGINE_TYPE="android_camera",ENGINE_PRIORITY=0(never auto-selected over MediaProjection=100 — explicitengine_typeonly),HAS_OWN_DISPLAYS=True,is_available()=is_android() and ≥1 enumerated camera,get_config_choices()exposesresolution(same presets as desktop).AndroidCameraCaptureStream:initialize()parsesresolution→start_camera(...)(raises if it returns False), drains stale frames;capture_frame()pops queue / returns_last_frame;cleanup()→stop_camera(...).
Registered in capture_engines/__init__.py behind a guarded import (mirrors the
mediaprojection block).
Part B — Android (CameraBridge.kt)
object CameraBridge (mirrors BleBridge):
init(context)— fromLedGrabApp.onCreate(context only, no camera opened).listCameras(): String— JSON array fromCameraManager.cameraIdList+LENS_FACING(front/back/external). No CAMERA permission needed.startCamera(index, width, height): Boolean— checks CAMERA permission; resolves cameraId; picks the supported YUV size closest to the request (balanced default ≤1280×720 for "auto"); opens device + capture session on a privateHandlerThread, blocking until configured (runBlocking { withTimeout { ... } }oversuspendCancellableCoroutine-wrapped Camera2 callbacks); sets a repeating preview request. Returns false (no throw across JNI) on permission/range/configure failure. Closes any prior camera first.onImageAvailable→ paced (≈20 fps) → stride-aware YUV_420_888→RGB (BT.601 fixed-point, reused plane + RGB buffers) → push to the cachedandroid_camera_enginemodule handle.stopCamera()— stops repeating, closes session/device/reader, idempotent.
Part C — Wiring + permission + manifest
LedGrabApp.kt—CameraBridge.init(this)next toBleBridge.init.MainActivity.kt—ensureCameraPermission()(mirrorensureAudioPermission): requestCAMERAiffhasSystemFeature(FEATURE_CAMERA_ANY); called from bothstartCaptureService(MediaProjection path) andstartRootCaptureService(root path). Fire-and-forget.AndroidManifest.xml—<uses-permission CAMERA>+<uses-feature camera.any required=false><uses-permission FOREGROUND_SERVICE_CAMERA>, andcameraadded to theCaptureServiceforegroundServiceTypeunion (mediaProjection|specialUse|camera).
CaptureService.onStartCommand— on API 34+, ORFOREGROUND_SERVICE_TYPE_CAMERAinto the promotion type only when CAMERA is already granted. Unlike audio playback capture (which rides the MediaProjection token under the mediaProjection type), the camera has no such coupling, so without its own FGS type Android 14+ revokes camera access once the app is backgrounded. The conditional guard avoids a failedstartForeground(which would kill the whole service) on a camera-less / not-yet-granted box. If CAMERA is granted later, the camera type takes effect on the next Start.- No
proguard-rules.prochange — the blanket-keep class com.ledgrab.android.** { *; }already coversCameraBridge, and R8/minify is disabled.
What does NOT change
- Frontend / API — data-driven engine list, config, and display picker.
build.gradle.kts/ Chaquopy pip block — no new Python or Gradle packages.- Processing pipeline —
ScreenCaptureLiveStream, filters, color-strip sources unchanged.
Files
Create
server/src/ledgrab/core/capture_engines/android_camera_engine.pyandroid/app/src/main/java/com/ledgrab/android/CameraBridge.ktserver/tests/core/test_android_camera_engine.py
Modify
server/src/ledgrab/core/capture_engines/__init__.py— guarded import + registration.android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt—CameraBridge.init.android/app/src/main/java/com/ledgrab/android/MainActivity.kt—ensureCameraPermission.android/app/src/main/AndroidManifest.xml—CAMERA+camera.any.
Tests (Python — desktop CI, no device)
server/tests/core/test_android_camera_engine.py: push→capture round-trips RGB (h,w,3);
drop-oldest when full; _last_frame fallback on empty; short-buffer never crashes;
initialize() opens with parsed/auto resolution and raises on open-failure / off-Android;
cleanup() closes once (idempotent); is_available() gating (android + cameras); display
enumeration; priority 0 never beats MediaProjection; create-via-registry yields a pushed frame.
Verification
- Python:
py -3.13 -m pytest tests/core/test_android_camera_engine.py --no-cov -q, then the full suite (1880 passed, 2 skipped; 15 new). - Lint:
ruff check src/ tests/ --fix— clean. - Android build:
./gradlew :app:assembleDebug— BUILD SUCCESSFUL. - On device (manual): install APK → Start capture → grant CAMERA → create a capture
template with engine
android_camera+ a camera display + a ScreenCapture source bound to a strip → confirm LEDs react to the camera feed and the camera indicator only lights while the source is active.
Risks / notes
- MVP scope: webcam works while LedGrab capture is running (the Python server only runs
inside
CaptureService; there is no camera-only start path on Android). - One camera at a time:
startCameracloses any previously-open camera first. "auto"resolution picks a balanced output size (~720p), not the sensor max, to keep the per-frame YUV→RGB conversion cheap on low-end TV boxes.- USB-UVC webcams appear only if the device exposes them through Camera2 (
LENS_FACING_EXTERNAL), which varies by box; an explicit UVC library would be a separate, larger effort. - No frame-rotation correction — sensor orientation is not applied (ambient color sampling is largely orientation-tolerant); could be added later.
- CAMERA denied → the engine reports no usable camera and capture proceeds without it.