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.
11 KiB
Android (TV) — Missing Functionality Assessment
Status: review/feasibility document. No code changes. Last updated 2026-06-01.
Context
LedGrab ships an experimental on-device Android-TV build: a Kotlin shell that
embeds the Python FastAPI server via Chaquopy, with Kotlin↔Python bridges
(PythonBridge, BleBridge, UsbSerialBridge). Several desktop features are
unavailable on this build because their Python backends rely on native libraries
that have no Android/Chaquopy wheels (mss, dxcam, sounddevice/PortAudio,
opencv, nvidia-ml-py, winrt, dbus-next), or on OS facilities Android
sandboxes differently.
The README "Feature support by OS" table now carries an Android column reflecting this. This document assesses whether each missing feature can be added, how, and whether it's worth it.
The enabling pattern (why most of this is feasible)
Every desktop capability that's "missing" on Android is missing only because of a native dependency, not because the capability is impossible. Android exposes the same capability through a platform API, and the codebase already has the bridge shape to plug it in:
Bridge pattern: a Kotlin component captures an event/buffer → pushes it across the Chaquopy JNI boundary into a module-level receiver in a small Python engine → an existing engine/stream consumes it unchanged.
Reference implementation: server/src/ledgrab/core/capture_engines/mediaprojection_engine.py
(configure() + push_frame() + a bounded queue.Queue) ↔
android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt ↔
PythonBridge.pushFrame(). Screen capture already works on Android this exact way.
So for most missing features the work is: add a Kotlin capture source + a thin Python receiver engine mirroring that pattern.
Current Android capability matrix
| Feature | Desktop | Android (TV) today | Missing? |
|---|---|---|---|
| Screen capture | DXCam/WGC/MSS | ✅ MediaProjection + root screenrecord |
No |
| LED transports (network/USB-serial/BLE) | ✅ | ✅ (USB via Android driver, BLE via Android bridge) | No |
| System metrics | psutil | ✅ CPU/RAM/battery/thermal via /proc, /sys (AndroidMetricsProvider) |
No |
| Audio capture | WASAPI / Sounddevice | ❌ no PortAudio | Yes |
| Notification capture | WinRT / D-Bus | ✅ NotificationListenerService → push_notification() |
No (implemented) |
| Webcam capture | OpenCV | ✅ Camera2 + on-demand bridge (AndroidCameraEngine) |
No (implemented) |
| GPU monitoring | NVML | ❌ no NVIDIA GPU | Marginal |
| Capture from another Android phone | scrcpy/ADB | ❌ | Skip (redundant) |
| Automation: window/process conditions | Windows ctypes | ❌ sandboxed | Partial |
| Monitor names / multi-display | WMI / generic | Single built-in display | Low value |
Per-feature feasibility
🔊 Audio capture — FEASIBLE, HIGH VALUE ⭐ (detailed plan exists)
- Blocker: only
sounddevice/PortAudio is missing — not the capability. - Android path:
AudioPlaybackCapture(API 29+) captures system playback audio and takes aMediaProjectiontoken — which the app already obtains for screen capture. KotlinAudioRecord→ push PCM (float32) → a new push-basedAndroidAudioEnginemirroringmediaprojection_engine.py, registered incore/audio/__init__.py, feeding the existingAudioAnalyzerunchanged. Mic (AudioSource.MIC) is the fallback. - Effort: moderate. Value: high — music/sound-reactive lighting is a flagship use on a TV box. No new Python deps.
- ⚠️ DRM-protected apps (Netflix etc.) opt out of playback capture; works for non-DRM media and the device's own audio. Root mode (no MediaProjection) → mic-only.
- 📄 See
android-audio-capture-plan.mdfor the full implementation plan.
🔔 Notification capture — IMPLEMENTED ✅ (shipped)
- Android is the best platform for this:
NotificationListenerServiceis the native, event-push mechanism (no polling). - Path: a
NotificationListenerServiceresolves the posting app's display label and pushes it via a module-levelpush_notification()into the existingos_notification_listener.pypipeline (a new push-based_AndroidBackendalongside_WindowsBackend/_LinuxBackend). ExistingNotificationColorStripSourcefilters, per-app colors/sounds, and the history endpoint all work unchanged. No new Python deps. - Permission: user enables "Notification access" in Settings (
ACTION_NOTIFICATION_LISTENER_SETTINGS); no runtime-permission popup. - Effort: moderate. Value: high.
- ✅ Implemented on branch
feature/android-notification-capture: a push-based_AndroidBackend+ module-levelpush_notification()inos_notification_listener.py, a KotlinLedGrabNotificationListener(NLS), and prompt-once permission UX. App-name parity — only the resolved app label crosses the JNI boundary, never the notification title/body. ⚠️ App labels can differ across OSes (Windowsdisplay_name/ Linux D-Busapp_name/ AndroidgetApplicationLabel), so desktop-configured per-app colors/filters may need re-matching on Android.
📷 Webcam capture — IMPLEMENTED ✅ (shipped)
- Blocker was
opencv-python-headless(no Chaquopy cp311 wheel) — but capture doesn't need OpenCV. Implemented with Camera2 +ImageReaderin Kotlin pushing RGB frames through the same bridge as MediaProjection into a newAndroidCameraEngine. - Path: a Kotlin
CameraBridgesingleton (Camera2) enumerates cameras and opens the camera on demand (only while a capture source is active — driven Python→Kotlin via theBleBridge/UsbSerialBridgepattern), converts each frame YUV_420_888→RGB, and pushes it into a push-basedAndroidCameraEngine(core/capture_engines/android_camera_engine.py) that mirrorsmediaprojection_engine.py. Cameras surface as selectable "displays" exactly like the desktop OpenCVCameraEngine; the data-driven capture-template UI (engine list +resolutionconfig + display picker) needs no changes. No new Python deps; no new Gradle deps (Camera2 is in-platform). - Permission:
CAMERArequested at capture-start, gated onFEATURE_CAMERA_ANYso camera-less TV boxes never see the prompt; graceful degradation when denied. The service is promoted with thecameraFGS type (+FOREGROUND_SERVICE_CAMERA) only when CAMERA is already granted, so backgrounded capture keeps working without risking a failed service start on camera-less boxes. (Unlike audio playback capture, the camera can't ride the MediaProjection token, so it needs its own FGS type to survive backgrounding.) - Effort: moderate. Value: low (TVs rarely have cameras), but the implementation reuses
existing infrastructure end-to-end. Priority
0so it's never auto-selected over MediaProjection — chosen explicitly viaengine_type="android_camera". - ⚠️ MVP scope / limitations: webcam capture works while LedGrab capture is running
(no camera-only server path on Android); one camera active at a time;
"auto"picks a balanced output size (not the sensor max) to keep per-frame YUV→RGB cheap; USB-UVC webcams appear only if the device routes them through Camera2 (varies by box); no frame-rotation correction. - 📄 See
android-webcam-capture-plan.mdfor the full implementation notes.
🎮 GPU monitoring — MARGINAL, SKIP FOR NOW
- NVML is desktop-NVIDIA only. Android GPU load lives in vendor-specific sysfs
(Adreno
/sys/class/kgsl/kgsl-3d0/gpubusy, Mali/sys/class/devfreq/*.mali/...), inconsistent and often root-only. - CPU/RAM/battery/thermal are already covered by
AndroidMetricsProvider. A best-effort GPU-load reader could be added to that provider, but reliability is poor and value is low.
🪟 Automation: window/process conditions — PARTIAL
- Android forbids full window/process enumeration (
getRunningTasksrestricted since API 21+). - Obtainable: the current foreground app package via
UsageStatsManager(needs thePACKAGE_USAGE_STATSspecial access) or anAccessibilityService. - So "when is in the foreground → scene X" is feasible (mirrors
automations/platform_detector.py, which currently returns empty off-Windows); full window-title matching is not. Effort: moderate. Value: moderate (per-app scenes on a TV box).
📱 Capture from another Android phone (scrcpy/ADB) — SKIP
- Impractical and redundant: no
adbbinary in Chaquopy, TV boxes can't reliably host an adb server, and the device already captures its own screen via MediaProjection.
🖥️ Monitor names / multi-display — LOW VALUE
DisplayManagercan report a better display name and enumerate secondary (HDMI) displays, but MediaProjection captures the default display; capturing a secondary display is more involved and rarely useful on a single-screen box.
Prioritization
| Priority | Feature | Effort | Value | New Python deps | Status |
|---|---|---|---|---|---|
| 1 | Notification capture | Moderate | High | None | ✅ Implemented |
| 2 | Audio capture | Moderate | High | None | ✅ Implemented |
| 4 | Webcam capture (Camera2) | Moderate | Low | None | ✅ Implemented |
| 3 | Automation: foreground-app condition | Moderate | Moderate | None | Idea (only remaining) |
| — | GPU load (vendor sysfs) | Low–Med | Low | None | Not recommended |
| — | Capture from another phone | — | — | — | Won't do |
| — | Multi-display / monitor names | Low | Low | None | Not recommended |
Status: notifications, audio, and webcam are all shipped — each reuses existing
infrastructure (bridge pattern, the MediaProjection consent token / process-global
Python.getInstance(), the capture/audio/notification pipelines) and adds zero Python
dependencies, so none risks the Chaquopy --no-deps build constraint documented in
CLAUDE.md. The only remaining idea is the foreground-app automation condition (moderate
value); GPU load, another-phone capture, and multi-display remain not-recommended / won't-do.
Cross-cutting notes
- No
build.gradle.kts/ Chaquopy pip impact for notifications or audio — both use Android platform APIs (Kotlin) + stdlib/numpy(already bundled) on the Python side. - Per-instance
PythonBridge:PythonBridgeis created perCaptureServiceinstance, so system-bound services (e.g. aNotificationListenerService) call Python via the process-globalPython.getInstance()rather than borrowing that bridge. - Permissions are the recurring friction, not the capture: audio needs
RECORD_AUDIO+ (for playback capture) a MediaProjection token; notifications need the "Notification access" settings toggle; foreground-app automation needsPACKAGE_USAGE_STATS.