diff --git a/.gitignore b/.gitignore
index 3599186..5616374 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,10 @@ parts/
sdist/
var/
wheels/
+# …but keep pre-built Android wheels (pydantic-core cross-compiled for
+# arm64-v8a / x86_64 / x86, required by the Chaquopy build)
+!android/wheels/
+!android/wheels/*
*.egg-info/
.installed.cfg
*.egg
diff --git a/CLAUDE.md b/CLAUDE.md
index 244ec8e..0f5a74c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,8 +28,21 @@ ast-index changed --base master # Show symbols changed in current bran
## Project Structure
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
+- `/android` — Android TV app (Kotlin shell + embedded Python via Chaquopy)
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
+## Android Dependency Sync (CRITICAL)
+
+The Android app (`android/app/build.gradle.kts`) installs the server package with `--no-deps` and lists Android-compatible dependencies **explicitly** in the Chaquopy `pip {}` block. This is because `server/pyproject.toml` includes desktop-only packages (mss, psutil, sounddevice, etc.) that have no Android wheels.
+
+**When adding a new dependency to `server/pyproject.toml`:**
+
+1. If the package is **pure Python or has Chaquopy wheels** (check [Chaquopy PyPI](https://chaquo.com/pypi-13.1/)), also add it to `android/app/build.gradle.kts` in the `pip { install(...) }` block
+2. If the package is **desktop-only** (native C/Rust extension without Android support), do NOT add it to `build.gradle.kts` — and guard its import with `try/except ImportError` in Python code
+3. If unsure, check Chaquopy's package index first
+
+**Incident context:** Chaquopy's pip runs on the build machine (Windows), not on Android. Platform markers like `sys_platform != 'linux'` evaluate against the BUILD host, not the target device. `pip install --exclude` does not exist. The only reliable way to exclude packages is to not list them.
+
## Context Files
| File | When to read |
diff --git a/TODO.md b/TODO.md
index c37aef9..a42dd1b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,34 +1,97 @@
-# Composite Nesting Support
+# LedGrab TODO
-## Phase 1: Store — Cycle & Depth Validation
+## Android — Restore Multi-ABI Wheels
-- [x] Add `get_transitive_dependencies()` to ColorStripStore
-- [x] Add `validate_nesting()` with cycle detection + depth limit (MAX_DEPTH=4)
+During emulator testing, we switched the build to **x86 only** (see `android/app/build.gradle.kts` `abiFilters`) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
-## Phase 2: API — Validation in Create/Update
+- [ ] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings:
+ - `arm64-v8a` (real TV boxes — the primary target)
+ - `x86_64` (modern emulators)
+ - `x86` (legacy emulators)
+- [ ] Verify wheels with `readelf -d`: SONAME must be `libpython3.11.so` in NEEDED
+- [ ] Restore `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts`
+- [ ] Re-test on real ARM64 Android TV hardware
-- [x] Call `validate_nesting()` in create handler for composite sources
-- [x] Call `validate_nesting()` in update handler for composite sources
+Build cache + scripts live in `android/build-scripts/` and `android/.build-cache/` (junction host + sysconfigdata for each ABI).
-## Phase 3: Runtime — Depth Guard in Stream
+## Android CI Pipeline
-- [x] Add `depth` parameter to CompositeColorStripStream
-- [x] Pass depth through ColorStripStreamManager.acquire()
-- [x] Cap depth at runtime to prevent runaway nesting
+Build the Android APK automatically on push/tag.
-## Phase 4: Frontend — Allow Composites in Layer Dropdown
+- [ ] Generate Gradle wrapper (`gradlew`) and commit it
+- [ ] Create CI workflow (`.gitea/workflows/build-android.yaml` or `.github/workflows/`)
+ - JDK 17 + Android SDK + NDK setup
+ - Python 3.11 for Chaquopy build
+ - Recreate the directory junction (`ln -s` on Linux, `mklink /J` on Windows)
+ - `./gradlew assembleRelease`
+ - Upload APK as artifact
+- [ ] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64)
+- [ ] APK signing for release builds (keystore setup)
+- [ ] Consider: publish APK to GitHub/Gitea releases on tag push
-- [x] Remove `source_type !== 'composite'` filter (keep self-exclusion)
-- [x] Update docstring in composite_stream.py
+## Android Root Capture (No Permission Dialog, No System Indicator)
-## Phase 5: Tests
+MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path would give much better UX.
-- [x] Cycle detection tests (A→B→A, self-reference)
-- [x] Depth limit tests (chain exceeding MAX_DEPTH)
-- [x] Valid nesting tests (A→B both composite, no cycle)
+- [ ] Detect root at runtime: check for `su` binary, `Superuser.apk`, etc.
+- [ ] Implement `SurfaceControlCaptureEngine` (new capture engine) using hidden `SurfaceControl.screenshot()` API via reflection
+ - No permission dialog
+ - No system capture indicator
+ - Direct bitmap output (no encoder/decoder roundtrip)
+- [ ] Engine priority: higher than MediaProjection when root detected
+- [ ] Fallback chain: `SurfaceControl` (root) → `MediaProjection` (stock) → `adb screencap` (last resort)
+- [ ] Handle Android version differences in `SurfaceControl` API surface (renamed/moved across API 29, 30, 33)
+- [ ] Alternative: shell out to `screenrecord --output-format=h264 -` as root (same H.264 decode as scrcpy_client_engine, but local instead of remote ADB)
-## Phase 6: Lint & Build
+Known projects using this approach for reference: scrcpy-hidden-api, shizuku, commercial scrcpy-derived apps.
-- [x] Ruff check passes
-- [x] TypeScript build passes
-- [x] All existing tests pass (715/715)
+## Android USB Serial Support
+
+Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
+
+- [ ] Add [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) dependency to `android/app/build.gradle.kts`
+- [ ] Create Kotlin `UsbSerialBridge` class that:
+ - Enumerates USB serial devices via Android USB Host API
+ - Requests user permission for USB device access
+ - Opens a serial connection (baud rate configurable)
+ - Exposes a write method callable from Python via Chaquopy
+- [ ] Create Python `AndroidSerialProvider` in `server/src/ledgrab/core/devices/` that:
+ - Replaces `pyserial` on Android (which can't access USB ports)
+ - Calls `UsbSerialBridge` via Chaquopy to send LED data
+ - Registers as an alternative serial transport when `is_android()` is True
+- [ ] Add USB device permission dialog to `MainActivity` (auto-triggered on device connect)
+- [ ] Test with common USB-to-serial chips: CH340, CP2102, FTDI
+- [ ] Document supported USB LED controllers in README
+
+## Android App — Known Issues
+
+Issues discovered during first end-to-end test on emulator (2026-04-14):
+
+- [ ] **Stop/Start capture produces no frames on second try.** First capture works, but after tapping Stop and Start again, no frames reach the Python pipeline. Likely causes:
+ - Global MediaProjection engine state (`_active`, `_frame_queue`) not reset on service restart
+ - ScreenCapture listener not properly detached from ImageReader on stop
+ - Python uvicorn port reuse issue when server restarts
+- [ ] **Web UI tabs show persistent spinners** on Dashboard, Automation, Sources, Integrations, Graph tabs (Targets tab works). All API calls return 200 OK, no console errors. Probably waiting on a specific WebSocket event that never fires when no output targets are configured/active.
+- [ ] **Dead keyboard/IME handling** — password inputs lack autocomplete attributes (minor accessibility warning in browser console)
+
+## Performance Metrics Abstraction
+
+The codebase has direct `psutil.*` calls scattered across `api/routes/system.py` and `core/processing/metrics_history.py`, with ad-hoc `if psutil is not None` guards sprinkled in to support Android. This couples Android platform handling to every call site.
+
+- [ ] Refactor: introduce `MetricsProvider` protocol in `utils/metrics.py` with methods like `cpu_percent()`, `memory_info()`, `process_info()`
+- [ ] Implement `PsutilMetricsProvider` (desktop) and `NullMetricsProvider` (fallback when psutil missing)
+- [ ] Later: `AndroidMetricsProvider` reading from `/proc` (see section below)
+- [ ] Replace all direct `psutil.*` calls with the provider; only one factory location knows about psutil availability
+
+## Android Performance Metrics
+
+Currently `psutil` (used for CPU/RAM monitoring in the web UI) is not available on Android via Chaquopy. Metrics calls are guarded with `if psutil is not None` so they return no data on Android.
+
+- [ ] Implement Android-native metrics collection:
+ - CPU usage via `/proc/self/stat` + `/proc/stat` parsing (no psutil needed)
+ - RAM usage via `/proc/meminfo` or `ActivityManager.getMemoryInfo()` through Chaquopy bridge
+ - App-specific memory via `Debug.getMemoryInfo()` (Kotlin → Python)
+- [ ] Create `AndroidMetricsProvider` in `server/src/ledgrab/utils/` that implements the same interface as the psutil-based provider
+- [ ] Wire into existing metrics endpoints (`/api/v1/system/metrics`) with platform detection
+- [ ] Consider: device battery/temperature readings for TV boxes (some have thermal throttling)
+- [ ] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..cda6433
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,17 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/app/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+
+# Chaquopy build cache
+.build-cache/
+
+# Python source junction (points at ../server/src/ledgrab — do not commit)
+/app/src/main/python/ledgrab
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..3217dbd
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,92 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.chaquo.python")
+}
+
+android {
+ namespace = "com.ledgrab.android"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.ledgrab.android"
+ minSdk = 24 // Android 7.0 — covers nearly all TV boxes
+ targetSdk = 34
+ versionCode = 1
+ versionName = "0.3.0"
+
+ ndk {
+ // Temporarily x86 only for emulator testing with new wheel
+ abiFilters += listOf("x86")
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+chaquopy {
+ defaultConfig {
+ version = "3.11"
+
+ pip {
+ // Pre-built wheels directory (for pydantic-core etc.)
+ // Must use absolute file:// URI with forward slashes
+ options("--find-links", "file:///${rootDir.absolutePath.replace("\\", "/")}/wheels/")
+
+ // ── Android-compatible dependencies ─────────────────
+ // Listed explicitly because pyproject.toml includes
+ // desktop-only packages with no Android wheels.
+ // See CLAUDE.md "Android Dependency Sync" for policy.
+ install("fastapi")
+ install("uvicorn") // without [standard] — no uvloop/httptools
+ install("httpx")
+ install("numpy")
+ install("pydantic") // needs pydantic-core wheel in wheels/
+ install("pydantic-settings")
+ install("PyYAML")
+ install("structlog")
+ install("python-json-logger")
+ install("python-dateutil")
+ install("python-multipart")
+ install("jinja2")
+ install("zeroconf")
+ install("aiomqtt")
+ install("openrgb-python")
+ // opencv-python-headless: no cp311 Android wheel on Chaquopy.
+ // LedGrab's cv2 usage is guarded with try/except ImportError
+ // and falls back to numpy/Pillow alternatives on Android.
+ install("Pillow")
+ install("websockets")
+ }
+ }
+}
+
+// LedGrab Python source is included via a directory junction:
+// android/app/src/main/python/ledgrab -> server/src/ledgrab
+// This is the standard Chaquopy way to include local Python packages.
+// Create the junction (run from repo root, no admin needed):
+// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
+
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.leanback:leanback:1.0.0")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.lifecycle:lifecycle-service:2.8.7")
+ // QR code generation for displaying server URL on TV
+ implementation("com.google.zxing:core:3.5.3")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..4e81310
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
new file mode 100644
index 0000000..cb3fe4b
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt
@@ -0,0 +1,161 @@
+package com.ledgrab.android
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.media.projection.MediaProjection
+import android.media.projection.MediaProjectionManager
+import android.os.Build
+import android.os.IBinder
+import android.util.DisplayMetrics
+import android.util.Log
+import android.view.WindowManager
+import androidx.core.app.NotificationCompat
+
+/**
+ * Foreground service that runs the Python LedGrab server and captures
+ * the screen via MediaProjection.
+ */
+class CaptureService : Service() {
+
+ companion object {
+ private const val TAG = "CaptureService"
+ private const val CHANNEL_ID = "ledgrab_capture"
+ private const val NOTIFICATION_ID = 1
+ private const val EXTRA_RESULT_CODE = "result_code"
+ private const val EXTRA_RESULT_DATA = "result_data"
+ private const val SERVER_PORT = 8080
+
+ fun createIntent(
+ context: Context,
+ resultCode: Int,
+ resultData: Intent,
+ ): Intent {
+ return Intent(context, CaptureService::class.java).apply {
+ putExtra(EXTRA_RESULT_CODE, resultCode)
+ putExtra(EXTRA_RESULT_DATA, resultData)
+ }
+ }
+ }
+
+ private var bridge: PythonBridge? = null
+ private var screenCapture: ScreenCapture? = null
+ private var mediaProjection: MediaProjection? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // CRITICAL: startForeground must be called IMMEDIATELY —
+ // before any other work, especially before getMediaProjection().
+ val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
+ val url = "http://$localIp:$SERVER_PORT"
+ startForeground(NOTIFICATION_ID, buildNotification(url))
+
+ if (intent == null) {
+ Log.w(TAG, "Service restarted without intent — stopping")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
+ @Suppress("DEPRECATION")
+ val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
+ } else {
+ intent.getParcelableExtra(EXTRA_RESULT_DATA)
+ }
+
+ if (resultData == null) {
+ Log.e(TAG, "No MediaProjection result data")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ try {
+ // Create MediaProjection from the consent token
+ val projectionManager =
+ getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ mediaProjection = projectionManager.getMediaProjection(resultCode, resultData)
+
+ if (mediaProjection == null) {
+ Log.e(TAG, "Failed to create MediaProjection")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ // Get screen metrics
+ val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
+ val metrics = DisplayMetrics()
+ @Suppress("DEPRECATION")
+ windowManager.defaultDisplay.getRealMetrics(metrics)
+
+ // Initialize Python bridge and configure capture engine
+ bridge = PythonBridge(this).also { b ->
+ b.configureCapture(480, 270) // Downscaled for LED use
+ b.startServer(SERVER_PORT)
+ }
+
+ // Start screen capture
+ screenCapture = ScreenCapture(
+ projection = mediaProjection!!,
+ metrics = metrics,
+ bridge = bridge!!,
+ targetWidth = 480,
+ targetHeight = 270,
+ targetFps = 30,
+ ).also { it.start() }
+
+ Log.i(TAG, "LedGrab service started — web UI at $url")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start capture", e)
+ stopSelf()
+ }
+
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ screenCapture?.stop()
+ screenCapture = null
+
+ bridge?.stopServer()
+ bridge = null
+
+ mediaProjection?.stop()
+ mediaProjection = null
+
+ Log.i(TAG, "LedGrab service destroyed")
+ super.onDestroy()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "LedGrab Screen Capture",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Shows while LedGrab is capturing the screen"
+ }
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun buildNotification(url: String): Notification {
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("LedGrab Running")
+ .setContentText("Web UI: $url")
+ .setSmallIcon(R.drawable.ic_launcher)
+ .setOngoing(true)
+ .build()
+ }
+}
diff --git a/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt b/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt
new file mode 100644
index 0000000..9288608
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/LedGrabApp.kt
@@ -0,0 +1,22 @@
+package com.ledgrab.android
+
+import android.app.Application
+import com.chaquo.python.Python
+import com.chaquo.python.android.AndroidPlatform
+
+/**
+ * Application class — initializes the Chaquopy Python runtime.
+ *
+ * `Python.start()` must be called once before any Python code runs.
+ * It loads libpython, extracts stdlib + pip packages from APK assets
+ * (first launch only), and sets up `sys.path`.
+ */
+class LedGrabApp : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ if (!Python.isStarted()) {
+ Python.start(AndroidPlatform(this))
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
new file mode 100644
index 0000000..ba7375d
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt
@@ -0,0 +1,129 @@
+package com.ledgrab.android
+
+import android.app.Activity
+import android.content.Intent
+import android.graphics.Bitmap
+import android.media.projection.MediaProjectionManager
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.qrcode.QRCodeWriter
+
+/**
+ * Main (and only) Activity for the Android TV app.
+ *
+ * Two-state UI: stopped (Start button) and running (URL + QR + Stop).
+ * Navigable with D-pad / USB controller.
+ */
+class MainActivity : Activity() {
+
+ companion object {
+ private const val TAG = "MainActivity"
+ private const val SERVER_PORT = 8080
+ private const val REQUEST_MEDIA_PROJECTION = 1001
+ }
+
+ private lateinit var stoppedPanel: View
+ private lateinit var runningPanel: View
+ private lateinit var statusText: TextView
+ private lateinit var urlText: TextView
+ private lateinit var qrImage: ImageView
+ private lateinit var toggleButton: Button
+ private lateinit var stopButtonRunning: Button
+ private lateinit var versionText: TextView
+ private var serviceRunning = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+
+ stoppedPanel = findViewById(R.id.stopped_panel)
+ runningPanel = findViewById(R.id.running_panel)
+ statusText = findViewById(R.id.status_text)
+ urlText = findViewById(R.id.url_text)
+ qrImage = findViewById(R.id.qr_image)
+ toggleButton = findViewById(R.id.toggle_button)
+ stopButtonRunning = findViewById(R.id.stop_button_running)
+ versionText = findViewById(R.id.version_text)
+
+ val versionName = packageManager
+ .getPackageInfo(packageName, 0).versionName
+ versionText.text = "v$versionName"
+
+ toggleButton.setOnClickListener { requestMediaProjection() }
+ stopButtonRunning.setOnClickListener { stopCaptureService() }
+
+ updateUI()
+ }
+
+ private fun requestMediaProjection() {
+ val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ @Suppress("DEPRECATION")
+ startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
+ }
+
+ @Deprecated("Using deprecated API for plain Activity compatibility")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == REQUEST_MEDIA_PROJECTION) {
+ if (resultCode == RESULT_OK && data != null) {
+ startCaptureService(resultCode, data)
+ } else {
+ statusText.text = "Permission denied — screen capture requires authorization"
+ Log.w(TAG, "MediaProjection permission denied")
+ }
+ }
+ }
+
+ private fun startCaptureService(resultCode: Int, resultData: Intent) {
+ val intent = CaptureService.createIntent(this, resultCode, resultData)
+ startForegroundService(intent)
+ serviceRunning = true
+ updateUI()
+ }
+
+ private fun stopCaptureService() {
+ stopService(Intent(this, CaptureService::class.java))
+ serviceRunning = false
+ updateUI()
+ }
+
+ private fun updateUI() {
+ if (serviceRunning) {
+ val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
+ val url = "http://$localIp:$SERVER_PORT"
+
+ urlText.text = url
+ qrImage.setImageBitmap(generateQrCode(url))
+
+ stoppedPanel.visibility = View.GONE
+ versionText.visibility = View.GONE
+ runningPanel.visibility = View.VISIBLE
+ stopButtonRunning.requestFocus()
+ } else {
+ urlText.text = ""
+ qrImage.setImageBitmap(null)
+
+ runningPanel.visibility = View.GONE
+ stoppedPanel.visibility = View.VISIBLE
+ versionText.visibility = View.VISIBLE
+ toggleButton.requestFocus()
+ }
+ }
+
+ private fun generateQrCode(text: String): Bitmap {
+ val size = 560
+ val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
+ for (x in 0 until size) {
+ for (y in 0 until size) {
+ bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt())
+ }
+ }
+ return bitmap
+ }
+}
diff --git a/android/app/src/main/java/com/ledgrab/android/NetworkUtils.kt b/android/app/src/main/java/com/ledgrab/android/NetworkUtils.kt
new file mode 100644
index 0000000..1a76222
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/NetworkUtils.kt
@@ -0,0 +1,28 @@
+package com.ledgrab.android
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.LinkProperties
+import java.net.Inet4Address
+
+/**
+ * Network utilities for discovering the device's local IP address.
+ */
+object NetworkUtils {
+
+ /**
+ * Return the device's local IPv4 address on the active network,
+ * or `null` if unavailable.
+ */
+ fun getLocalIpAddress(context: Context): String? {
+ val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = cm.activeNetwork ?: return null
+ val props: LinkProperties = cm.getLinkProperties(network) ?: return null
+
+ return props.linkAddresses
+ .map { it.address }
+ .filterIsInstance()
+ .firstOrNull { !it.isLoopbackAddress }
+ ?.hostAddress
+ }
+}
diff --git a/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt b/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt
new file mode 100644
index 0000000..0798fbd
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/PythonBridge.kt
@@ -0,0 +1,102 @@
+package com.ledgrab.android
+
+import android.content.Context
+import android.util.Log
+import com.chaquo.python.Python
+
+/**
+ * Bridge between Kotlin and the LedGrab Python server.
+ *
+ * All Python calls go through Chaquopy's `Python.getInstance()`.
+ * Frame data crosses the JNI boundary as a `ByteArray`.
+ */
+class PythonBridge(private val context: Context) {
+
+ companion object {
+ private const val TAG = "PythonBridge"
+ }
+
+ private var serverThread: Thread? = null
+ @Volatile private var running = false
+
+ /**
+ * Configure the MediaProjection engine with screen dimensions.
+ * Must be called before [startServer].
+ */
+ fun configureCapture(width: Int, height: Int) {
+ val py = Python.getInstance()
+ val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
+ engine.callAttr("configure", width, height)
+ Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
+ }
+
+ /**
+ * Start the LedGrab FastAPI server on a background thread.
+ *
+ * This blocks until [stopServer] is called, so it runs in its own thread.
+ */
+ fun startServer(port: Int = 8080) {
+ if (running) {
+ Log.w(TAG, "Server already running")
+ return
+ }
+
+ running = true
+ val dataDir = context.filesDir.absolutePath
+
+ serverThread = Thread({
+ try {
+ Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
+ val py = Python.getInstance()
+ val entry = py.getModule("ledgrab.android_entry")
+ entry.callAttr("start_server", dataDir, port)
+ } catch (e: Exception) {
+ Log.e(TAG, "Python server error", e)
+ } finally {
+ running = false
+ }
+ }, "ledgrab-python-server")
+ serverThread?.start()
+ }
+
+ /**
+ * Signal the Python server to shut down gracefully.
+ */
+ fun stopServer() {
+ if (!running) return
+
+ try {
+ val py = Python.getInstance()
+ val entry = py.getModule("ledgrab.android_entry")
+ entry.callAttr("stop_server")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping server", e)
+ }
+
+ serverThread?.join(10_000)
+ serverThread = null
+ running = false
+ Log.i(TAG, "Server stopped")
+ }
+
+ /**
+ * Push a captured RGBA frame to the Python MediaProjection engine.
+ *
+ * Called from [ScreenCapture] on the capture thread. The byte array
+ * crosses the JNI boundary — keep frames small (downscale to 480p
+ * before calling).
+ */
+ fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
+ if (!running) return
+
+ try {
+ val py = Python.getInstance()
+ val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
+ engine.callAttr("push_frame", rgbaBytes, width, height)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to push frame: ${e.message}")
+ }
+ }
+
+ val isRunning: Boolean get() = running
+}
diff --git a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
new file mode 100644
index 0000000..8c6023a
--- /dev/null
+++ b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt
@@ -0,0 +1,140 @@
+package com.ledgrab.android
+
+import android.graphics.Bitmap
+import android.graphics.PixelFormat
+import android.hardware.display.DisplayManager
+import android.hardware.display.VirtualDisplay
+import android.media.ImageReader
+import android.media.projection.MediaProjection
+import android.os.Handler
+import android.os.HandlerThread
+import android.util.DisplayMetrics
+import android.util.Log
+import java.nio.ByteBuffer
+
+/**
+ * Captures the Android screen via MediaProjection and feeds frames
+ * to [PythonBridge].
+ *
+ * Frames are downscaled to [targetWidth] x [targetHeight] before
+ * crossing the JNI boundary to minimize overhead. For LED ambient
+ * lighting, even 480x270 contains far more data than needed.
+ */
+class ScreenCapture(
+ private val projection: MediaProjection,
+ private val metrics: DisplayMetrics,
+ private val bridge: PythonBridge,
+ private val targetWidth: Int = 480,
+ private val targetHeight: Int = 270,
+ private val targetFps: Int = 30,
+) {
+ companion object {
+ private const val TAG = "ScreenCapture"
+ private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
+ }
+
+ private var virtualDisplay: VirtualDisplay? = null
+ private var imageReader: ImageReader? = null
+ private var captureThread: HandlerThread? = null
+ private var captureHandler: Handler? = null
+ @Volatile private var running = false
+ private var lastFrameTimeMs = 0L
+ private val frameIntervalMs = 1000L / targetFps
+
+ /**
+ * Start capturing the screen.
+ */
+ fun start() {
+ if (running) return
+ running = true
+
+ captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
+ captureHandler = Handler(captureThread!!.looper)
+
+ // Android 14+ requires registering a callback before createVirtualDisplay
+ projection.registerCallback(object : MediaProjection.Callback() {
+ override fun onStop() {
+ Log.i(TAG, "MediaProjection stopped")
+ stop()
+ }
+ }, captureHandler)
+
+ imageReader = ImageReader.newInstance(
+ targetWidth,
+ targetHeight,
+ PixelFormat.RGBA_8888,
+ 2, // maxImages — double buffer
+ )
+
+ imageReader?.setOnImageAvailableListener({ reader ->
+ if (!running) return@setOnImageAvailableListener
+
+ val now = System.currentTimeMillis()
+ if (now - lastFrameTimeMs < frameIntervalMs) {
+ // Skip frame to maintain target FPS
+ reader.acquireLatestImage()?.close()
+ return@setOnImageAvailableListener
+ }
+
+ val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
+ try {
+ val plane = image.planes[0]
+ val buffer = plane.buffer
+ val rowStride = plane.rowStride
+ val pixelStride = plane.pixelStride
+
+ // Handle row padding: rowStride may be > width * pixelStride
+ val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
+ // No padding — direct copy
+ val bytes = ByteArray(buffer.remaining())
+ buffer.get(bytes)
+ bytes
+ } else {
+ // Strip row padding
+ val rowBytes = targetWidth * pixelStride
+ val bytes = ByteArray(targetWidth * targetHeight * 4)
+ for (row in 0 until targetHeight) {
+ buffer.position(row * rowStride)
+ buffer.get(bytes, row * rowBytes, rowBytes)
+ }
+ bytes
+ }
+
+ bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
+ lastFrameTimeMs = now
+ } catch (e: Exception) {
+ Log.w(TAG, "Frame processing error: ${e.message}")
+ } finally {
+ image.close()
+ }
+ }, captureHandler)
+
+ virtualDisplay = projection.createVirtualDisplay(
+ VIRTUAL_DISPLAY_NAME,
+ targetWidth,
+ targetHeight,
+ metrics.densityDpi,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ imageReader?.surface,
+ null,
+ captureHandler,
+ )
+
+ Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
+ }
+
+ /**
+ * Stop capturing and release all resources.
+ */
+ fun stop() {
+ running = false
+ virtualDisplay?.release()
+ virtualDisplay = null
+ imageReader?.close()
+ imageReader = null
+ captureThread?.quitSafely()
+ captureThread = null
+ captureHandler = null
+ Log.i(TAG, "Screen capture stopped")
+ }
+}
diff --git a/android/app/src/main/res/color/btn_secondary_text.xml b/android/app/src/main/res/color/btn_secondary_text.xml
new file mode 100644
index 0000000..85c75e2
--- /dev/null
+++ b/android/app/src/main/res/color/btn_secondary_text.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_button_primary.xml b/android/app/src/main/res/drawable/bg_button_primary.xml
new file mode 100644
index 0000000..1b9e53e
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_button_primary.xml
@@ -0,0 +1,31 @@
+
+
+ -
+
+
-
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_button_secondary.xml b/android/app/src/main/res/drawable/bg_button_secondary.xml
new file mode 100644
index 0000000..2c0a3e1
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_button_secondary.xml
@@ -0,0 +1,33 @@
+
+
+ -
+
+
-
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_main.xml b/android/app/src/main/res/drawable/bg_main.xml
new file mode 100644
index 0000000..e64211e
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_main.xml
@@ -0,0 +1,28 @@
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_qr_container.xml b/android/app/src/main/res/drawable/bg_qr_container.xml
new file mode 100644
index 0000000..c963b9a
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_qr_container.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_status_dot.xml b/android/app/src/main/res/drawable/bg_status_dot.xml
new file mode 100644
index 0000000..7df4689
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_status_dot.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_url_chip.xml b/android/app/src/main/res/drawable/bg_url_chip.xml
new file mode 100644
index 0000000..0782e26
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_url_chip.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..300da25
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..0b9d19e
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..14948e0
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,20 @@
+
+
+ #0d1117
+ #111827
+ #161b22
+ #1c2333
+ #64ffda
+ #3399aa
+ #4D64ffda
+ #2664ffda
+ #bb86fc
+ #7c4dff
+ #4Dbb86fc
+ #26bb86fc
+ #4caf50
+ #1b5e20
+ #e6edf3
+ #8b949e
+ #484f58
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..2896672
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,11 @@
+
+
+ LedGrab
+ Ambient lighting for your TV
+ Start Capture
+ Stop
+ Running
+ Web UI address
+ Scan to configure
+ QR code for web UI
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..7cb4d52
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
diff --git a/android/build-scripts/build-pydantic-core.sh b/android/build-scripts/build-pydantic-core.sh
new file mode 100644
index 0000000..8382452
--- /dev/null
+++ b/android/build-scripts/build-pydantic-core.sh
@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+#
+# Cross-compile pydantic-core for Android ARM64.
+#
+# Prerequisites:
+# - Rust toolchain (rustup)
+# - Android NDK (set ANDROID_NDK_HOME or let this script find it)
+# - maturin (pip install maturin)
+# - Python 3.11 (matching Chaquopy's embedded version)
+#
+# Output: ../wheels/pydantic_core-*.whl
+#
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+WHEELS_DIR="$SCRIPT_DIR/../wheels"
+BUILD_DIR="$SCRIPT_DIR/../.build-cache"
+
+# ── Pydantic-core version (must match pydantic>=2.9.2 requirement) ──
+PYDANTIC_CORE_VERSION="2.27.2"
+
+# ── Find Android NDK ────────────────────────────────────────────────
+if [ -z "${ANDROID_NDK_HOME:-}" ]; then
+ # Try common locations
+ for candidate in \
+ "$HOME/Library/Android/sdk/ndk"/* \
+ "$HOME/Android/Sdk/ndk"/* \
+ "$LOCALAPPDATA/Android/Sdk/ndk"/* \
+ "/usr/local/lib/android/sdk/ndk"/*; do
+ if [ -d "$candidate" ]; then
+ ANDROID_NDK_HOME="$candidate"
+ break
+ fi
+ done
+fi
+
+if [ -z "${ANDROID_NDK_HOME:-}" ]; then
+ echo "ERROR: Android NDK not found. Set ANDROID_NDK_HOME or install via Android Studio."
+ exit 1
+fi
+echo "Using Android NDK: $ANDROID_NDK_HOME"
+
+# ── Determine NDK API level and toolchain ───────────────────────────
+API_LEVEL=24
+HOST_TAG=""
+case "$(uname -s)" in
+ Linux*) HOST_TAG="linux-x86_64" ;;
+ Darwin*) HOST_TAG="darwin-x86_64" ;;
+ MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;;
+ *) echo "Unsupported host OS"; exit 1 ;;
+esac
+
+TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG"
+CC="$TOOLCHAIN/bin/aarch64-linux-android${API_LEVEL}-clang"
+AR="$TOOLCHAIN/bin/llvm-ar"
+
+if [ ! -f "$CC" ] && [ ! -f "${CC}.cmd" ]; then
+ echo "ERROR: NDK compiler not found at $CC"
+ echo "Check your NDK installation."
+ exit 1
+fi
+
+# ── Install Rust Android target ─────────────────────────────────────
+echo "Adding Rust target aarch64-linux-android..."
+rustup target add aarch64-linux-android
+
+# ── Configure Cargo for cross-compilation ───────────────────────────
+mkdir -p "$BUILD_DIR"
+export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$CC"
+export CC_aarch64_linux_android="$CC"
+export AR_aarch64_linux_android="$AR"
+
+# ── Clone pydantic-core ─────────────────────────────────────────────
+REPO_DIR="$BUILD_DIR/pydantic-core"
+if [ -d "$REPO_DIR" ]; then
+ echo "Updating existing pydantic-core checkout..."
+ cd "$REPO_DIR"
+ git fetch --tags
+ git checkout "v$PYDANTIC_CORE_VERSION"
+else
+ echo "Cloning pydantic-core v$PYDANTIC_CORE_VERSION..."
+ git clone --depth 1 --branch "v$PYDANTIC_CORE_VERSION" \
+ https://github.com/pydantic/pydantic-core.git "$REPO_DIR"
+ cd "$REPO_DIR"
+fi
+
+# ── Build with maturin ──────────────────────────────────────────────
+echo "Building pydantic-core for aarch64-linux-android..."
+maturin build \
+ --release \
+ --target aarch64-linux-android \
+ --interpreter python3.11 \
+ --out "$WHEELS_DIR"
+
+echo ""
+echo "=== Build complete ==="
+echo "Wheels:"
+ls -la "$WHEELS_DIR"/pydantic_core-*.whl 2>/dev/null || echo "WARNING: No wheel found!"
diff --git a/android/build-scripts/setup-ndk.sh b/android/build-scripts/setup-ndk.sh
new file mode 100644
index 0000000..1e9152e
--- /dev/null
+++ b/android/build-scripts/setup-ndk.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env bash
+#
+# Set up the cross-compilation environment for building Python native
+# extensions targeting Android ARM64.
+#
+# This script:
+# 1. Verifies Android NDK is installed
+# 2. Installs the Rust aarch64-linux-android target
+# 3. Installs maturin (Python wheel builder for Rust extensions)
+# 4. Runs a quick test compile to verify the toolchain works
+#
+set -euo pipefail
+
+echo "=== LedGrab Android NDK Setup ==="
+
+# ── Check prerequisites ─────────────────────────────────────────────
+
+if ! command -v rustc &>/dev/null; then
+ echo "ERROR: Rust is not installed."
+ echo "Install from: https://rustup.rs/"
+ exit 1
+fi
+echo "Rust: $(rustc --version)"
+
+if ! command -v cargo &>/dev/null; then
+ echo "ERROR: Cargo is not installed."
+ exit 1
+fi
+echo "Cargo: $(cargo --version)"
+
+if ! command -v python3.11 &>/dev/null && ! command -v python3 &>/dev/null; then
+ echo "WARNING: Python 3.11 not found. Needed for maturin builds."
+fi
+
+# ── Install Rust target ─────────────────────────────────────────────
+
+echo ""
+echo "Installing Rust Android target..."
+rustup target add aarch64-linux-android
+echo "Installed targets:"
+rustup target list --installed | grep android
+
+# ── Install maturin ─────────────────────────────────────────────────
+
+echo ""
+echo "Installing maturin..."
+pip install maturin 2>/dev/null || pip3 install maturin 2>/dev/null || {
+ echo "WARNING: Could not install maturin. Install manually: pip install maturin"
+}
+
+if command -v maturin &>/dev/null; then
+ echo "maturin: $(maturin --version)"
+fi
+
+# ── Verify NDK ──────────────────────────────────────────────────────
+
+echo ""
+if [ -n "${ANDROID_NDK_HOME:-}" ]; then
+ echo "ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
+else
+ echo "ANDROID_NDK_HOME is not set."
+ echo "Set it to your NDK installation path, e.g.:"
+ echo " export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/26.1.10909125"
+ echo ""
+ echo "Or install NDK via Android Studio:"
+ echo " SDK Manager → SDK Tools → NDK (Side by side)"
+fi
+
+echo ""
+echo "=== Setup complete ==="
+echo ""
+echo "Next steps:"
+echo " 1. Ensure ANDROID_NDK_HOME is set"
+echo " 2. Run: ./build-pydantic-core.sh"
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..19396ed
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.9.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+ id("com.chaquo.python") version "17.0.0" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..1ae3cef
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,7 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
+org.gradle.parallel=false
+org.gradle.configuration-cache=false
+org.gradle.unsafe.isolated-projects=false
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..d22e677
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "LedGrab"
+include(":app")
\ No newline at end of file
diff --git a/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_arm64_v8a.whl b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_arm64_v8a.whl
new file mode 100644
index 0000000..03718e7
Binary files /dev/null and b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_arm64_v8a.whl differ
diff --git a/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86.whl b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86.whl
new file mode 100644
index 0000000..2882b23
Binary files /dev/null and b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86.whl differ
diff --git a/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86_64.whl b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86_64.whl
new file mode 100644
index 0000000..b19e265
Binary files /dev/null and b/android/wheels/pydantic_core-2.46.0-cp311-cp311-android_24_x86_64.whl differ
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 80d10a0..6c29335 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -66,6 +66,10 @@ camera = [
# opencv-python-headless is now a core dependency (used for image encoding)
# camera extra kept for backwards compatibility
]
+# High-performance Android capture via scrcpy H.264 streaming
+scrcpy = [
+ "scrcpy-client>=0.5.0",
+]
# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
notifications = [
"winrt-Windows.UI.Notifications>=3.0.0; sys_platform == 'win32'",
diff --git a/server/src/ledgrab/__init__.py b/server/src/ledgrab/__init__.py
index 248d466..fafc649 100644
--- a/server/src/ledgrab/__init__.py
+++ b/server/src/ledgrab/__init__.py
@@ -2,11 +2,15 @@
from importlib.metadata import version, PackageNotFoundError
+# Fallback version — kept in sync with pyproject.toml.
+# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
+# on Android, where the source is included directly via source sets).
+_FALLBACK_VERSION = "0.3.0"
+
try:
__version__ = version("ledgrab")
except PackageNotFoundError:
- # Running from source without pip install (e.g. dev, embedded Python)
- __version__ = "0.0.0-dev"
+ __version__ = _FALLBACK_VERSION
__author__ = "Alexei Dolgolyov"
__email__ = "dolgolyov.alexei@gmail.com"
diff --git a/server/src/ledgrab/android_entry.py b/server/src/ledgrab/android_entry.py
new file mode 100644
index 0000000..ce6d17c
--- /dev/null
+++ b/server/src/ledgrab/android_entry.py
@@ -0,0 +1,84 @@
+"""Android entry point for LedGrab.
+
+Called from Kotlin via Chaquopy to start/stop the FastAPI server
+inside an Android application. Sets up Android-specific paths
+(app-private storage) before importing the main application.
+"""
+
+import asyncio
+import os
+import threading
+from typing import Optional
+
+_server_thread: Optional[threading.Thread] = None
+_shutdown_event: Optional[asyncio.Event] = None
+_loop: Optional[asyncio.AbstractEventLoop] = None
+
+
+def start_server(data_dir: str, port: int = 8080) -> None:
+ """Start the LedGrab uvicorn server.
+
+ Called from Kotlin's ``PythonBridge.startServer()``. This function
+ blocks until ``stop_server()`` is called, so Kotlin should invoke it
+ on a background thread.
+
+ Args:
+ data_dir: Android app-private files directory
+ (e.g. ``/data/data/com.ledgrab.android/files``).
+ port: HTTP port for the web UI / API.
+ """
+ # ── Configure paths before any LedGrab imports ──────────────
+ os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
+ os.makedirs(os.path.join(data_dir, "data", "assets"), exist_ok=True)
+ os.makedirs(os.path.join(data_dir, "config"), exist_ok=True)
+
+ # Change working directory to app-private storage so relative
+ # paths (e.g. "config/default_config.yaml") don't hit system dirs
+ os.chdir(data_dir)
+
+ os.environ["LEDGRAB_STORAGE__DATABASE_FILE"] = os.path.join(data_dir, "data", "ledgrab.db")
+ os.environ["LEDGRAB_ASSETS__ASSETS_DIR"] = os.path.join(data_dir, "data", "assets")
+ os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
+ os.environ["LEDGRAB_SERVER__PORT"] = str(port)
+
+ # ── Now safe to import LedGrab ──────────────────────────────
+ import uvicorn # noqa: E402
+
+ from ledgrab.utils import setup_logging, get_logger # noqa: E402
+
+ setup_logging()
+ logger = get_logger(__name__)
+ logger.info("LedGrab Android: starting server on port %d", port)
+ logger.info("Data directory: %s", data_dir)
+
+ from ledgrab.config import get_config # noqa: E402
+
+ config = get_config()
+
+ uv_config = uvicorn.Config(
+ "ledgrab.main:app",
+ host=config.server.host,
+ port=port,
+ log_level=config.server.log_level.lower(),
+ # No uvloop/httptools on Android — use pure-Python asyncio
+ loop="asyncio",
+ )
+ server = uvicorn.Server(uv_config)
+
+ global _shutdown_event, _loop
+ _loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(_loop)
+ _shutdown_event = asyncio.Event()
+
+ logger.info("LedGrab Android: server starting")
+ _loop.run_until_complete(server.serve())
+ logger.info("LedGrab Android: server stopped")
+
+
+def stop_server() -> None:
+ """Signal the uvicorn server to shut down gracefully.
+
+ Called from Kotlin's ``PythonBridge.stopServer()``.
+ """
+ if _shutdown_event is not None and _loop is not None:
+ _loop.call_soon_threadsafe(_shutdown_event.set)
diff --git a/server/src/ledgrab/api/routes/color_strip_sources.py b/server/src/ledgrab/api/routes/color_strip_sources.py
index 8c67aa7..6171ea8 100644
--- a/server/src/ledgrab/api/routes/color_strip_sources.py
+++ b/server/src/ledgrab/api/routes/color_strip_sources.py
@@ -1643,8 +1643,7 @@ async def test_color_strip_ws(
try:
frame = _frame_live.get_latest_frame()
if frame is not None and frame.image is not None:
- from ledgrab.utils.image_codec import encode_jpeg
- import cv2 as _cv2
+ from ledgrab.utils.image_codec import encode_jpeg, resize_image
img = frame.image
# Ensure 3-channel RGB (some engines may produce BGRA)
@@ -1668,9 +1667,7 @@ async def test_color_strip_ws(
if scale < 1.0:
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
- img = _cv2.resize(
- img, (new_w, new_h), interpolation=_cv2.INTER_AREA
- )
+ img = resize_image(img, new_w, new_h)
# Wire format: [0xFD] [jpeg_bytes]
await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70))
except Exception as e:
diff --git a/server/src/ledgrab/api/routes/system.py b/server/src/ledgrab/api/routes/system.py
index 8d17536..1363241 100644
--- a/server/src/ledgrab/api/routes/system.py
+++ b/server/src/ledgrab/api/routes/system.py
@@ -12,7 +12,11 @@ from typing import Optional
import os
-import psutil
+try:
+ import psutil
+except ImportError:
+ psutil = None # type: ignore[assignment]
+
from fastapi import APIRouter, Depends, HTTPException, Query
from ledgrab import __version__, REPO_URL, DONATE_URL
@@ -54,9 +58,12 @@ from ledgrab.api.routes.system_settings import load_external_url # noqa: F401
logger = get_logger(__name__)
# Prime psutil CPU counters (first call always returns 0.0)
-psutil.cpu_percent(interval=None)
-_process = psutil.Process(os.getpid())
-_process.cpu_percent(interval=None) # prime process-level counter
+if psutil is not None:
+ psutil.cpu_percent(interval=None)
+ _process = psutil.Process(os.getpid())
+ _process.cpu_percent(interval=None) # prime process-level counter
+else:
+ _process = None # type: ignore[assignment]
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from ledgrab.utils.gpu import ( # noqa: E402
@@ -200,7 +207,18 @@ async def get_displays(
else:
display_dataclasses = await asyncio.to_thread(get_available_displays)
else:
- display_dataclasses = await asyncio.to_thread(get_available_displays)
+ # Try the mss-based detection first (desktop default).
+ # If mss is unavailable (e.g. on Android), fall back to the
+ # best registered engine that has its own display list.
+ try:
+ display_dataclasses = await asyncio.to_thread(get_available_displays)
+ except RuntimeError:
+ display_dataclasses = []
+ if not display_dataclasses:
+ best = EngineRegistry.get_best_available_engine()
+ if best:
+ engine_cls = EngineRegistry.get_engine(best)
+ display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
displays = [
@@ -264,6 +282,21 @@ def get_system_performance(_: AuthRequired):
and NVML calls are blocking and would stall the event loop if run
in an ``async def`` handler.
"""
+ if psutil is None or _process is None:
+ # psutil unavailable on this platform (e.g. Android)
+ from datetime import datetime, timezone
+
+ return PerformanceResponse(
+ timestamp=datetime.now(timezone.utc),
+ cpu_name=_cpu_name,
+ cpu_percent=0.0,
+ ram_used_mb=0.0,
+ ram_total_mb=0.0,
+ ram_percent=0.0,
+ app_cpu_percent=0.0,
+ app_ram_mb=0.0,
+ gpu=None,
+ )
mem = psutil.virtual_memory()
# App-level metrics
diff --git a/server/src/ledgrab/config.py b/server/src/ledgrab/config.py
index 81bf2e5..ac6782f 100644
--- a/server/src/ledgrab/config.py
+++ b/server/src/ledgrab/config.py
@@ -169,10 +169,13 @@ class Config(BaseSettings):
if demo_path.exists():
return cls.from_yaml(demo_path)
- # Try default location
- default_path = Path("config/default_config.yaml")
- if default_path.exists():
- return cls.from_yaml(default_path)
+ # Try default location (guard against permission errors on Android)
+ try:
+ default_path = Path("config/default_config.yaml")
+ if default_path.exists():
+ return cls.from_yaml(default_path)
+ except (PermissionError, OSError):
+ pass
# Use defaults
return cls()
diff --git a/server/src/ledgrab/core/audio/__init__.py b/server/src/ledgrab/core/audio/__init__.py
index 161da2e..b9f059e 100644
--- a/server/src/ledgrab/core/audio/__init__.py
+++ b/server/src/ledgrab/core/audio/__init__.py
@@ -1,4 +1,9 @@
-"""Audio capture engine abstraction layer."""
+"""Audio capture engine abstraction layer.
+
+Audio engines with native dependencies (WASAPI, PortAudio) are imported
+inside try/except blocks so the package loads cleanly on platforms where
+those libraries are unavailable (e.g. Android via Chaquopy).
+"""
from ledgrab.core.audio.base import (
AudioCaptureEngine,
@@ -13,13 +18,33 @@ from ledgrab.core.audio.analysis import (
DEFAULT_SAMPLE_RATE,
DEFAULT_CHUNK_SIZE,
)
-from ledgrab.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
-from ledgrab.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
+
+# ── Platform-specific audio engines ─────────────────────────────────
+
+try:
+ from ledgrab.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
+
+ _has_wasapi = True
+except ImportError:
+ _has_wasapi = False
+
+try:
+ from ledgrab.core.audio.sounddevice_engine import (
+ SounddeviceEngine,
+ SounddeviceCaptureStream,
+ )
+
+ _has_sounddevice = True
+except ImportError:
+ _has_sounddevice = False
+
from ledgrab.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
# Auto-register available engines
-AudioEngineRegistry.register(WasapiEngine)
-AudioEngineRegistry.register(SounddeviceEngine)
+if _has_wasapi:
+ AudioEngineRegistry.register(WasapiEngine)
+if _has_sounddevice:
+ AudioEngineRegistry.register(SounddeviceEngine)
AudioEngineRegistry.register(DemoAudioEngine)
__all__ = [
@@ -32,10 +57,11 @@ __all__ = [
"NUM_BANDS",
"DEFAULT_SAMPLE_RATE",
"DEFAULT_CHUNK_SIZE",
- "WasapiEngine",
- "WasapiCaptureStream",
- "SounddeviceEngine",
- "SounddeviceCaptureStream",
"DemoAudioEngine",
"DemoAudioCaptureStream",
]
+
+if _has_wasapi:
+ __all__ += ["WasapiEngine", "WasapiCaptureStream"]
+if _has_sounddevice:
+ __all__ += ["SounddeviceEngine", "SounddeviceCaptureStream"]
diff --git a/server/src/ledgrab/core/automations/platform_detector.py b/server/src/ledgrab/core/automations/platform_detector.py
index dbce74f..5fcfcc8 100644
--- a/server/src/ledgrab/core/automations/platform_detector.py
+++ b/server/src/ledgrab/core/automations/platform_detector.py
@@ -6,7 +6,6 @@ Non-Windows: graceful degradation (returns empty results).
import asyncio
import ctypes
-import ctypes.wintypes
import os
import sys
import threading
@@ -18,6 +17,9 @@ logger = get_logger(__name__)
_IS_WINDOWS = sys.platform == "win32"
+if _IS_WINDOWS:
+ import ctypes.wintypes
+
class PlatformDetector:
"""Detect running processes and the foreground window's process."""
diff --git a/server/src/ledgrab/core/capture/screen_capture.py b/server/src/ledgrab/core/capture/screen_capture.py
index d05f62f..323052e 100644
--- a/server/src/ledgrab/core/capture/screen_capture.py
+++ b/server/src/ledgrab/core/capture/screen_capture.py
@@ -3,9 +3,13 @@
from dataclasses import dataclass
from typing import List
-import mss
import numpy as np
+try:
+ import mss
+except ImportError:
+ mss = None # type: ignore[assignment]
+
from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
logger = get_logger(__name__)
@@ -54,6 +58,8 @@ def get_available_displays() -> List[DisplayInfo]:
Raises:
RuntimeError: If unable to detect displays
"""
+ if mss is None:
+ return []
try:
# Get friendly monitor names (Windows only, falls back to generic names)
monitor_names = get_monitor_names()
@@ -105,6 +111,8 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
ValueError: If display_index is invalid
RuntimeError: If screen capture fails
"""
+ if mss is None:
+ raise RuntimeError("mss library not available on this platform")
try:
with mss.mss() as sct:
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
diff --git a/server/src/ledgrab/core/capture_engines/__init__.py b/server/src/ledgrab/core/capture_engines/__init__.py
index d951243..1d1e841 100644
--- a/server/src/ledgrab/core/capture_engines/__init__.py
+++ b/server/src/ledgrab/core/capture_engines/__init__.py
@@ -1,4 +1,9 @@
-"""Screen capture engine abstraction layer."""
+"""Screen capture engine abstraction layer.
+
+Engines with native/platform-specific dependencies are imported inside
+try/except blocks so the package loads cleanly on any platform (including
+Android via Chaquopy where desktop capture libraries are unavailable).
+"""
from ledgrab.core.capture_engines.base import (
CaptureEngine,
@@ -7,14 +12,61 @@ from ledgrab.core.capture_engines.base import (
ScreenCapture,
)
from ledgrab.core.capture_engines.factory import EngineRegistry
-from ledgrab.core.capture_engines.mss_engine import MSSEngine, MSSCaptureStream
-from ledgrab.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream
-from ledgrab.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
-from ledgrab.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
-from ledgrab.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
-from ledgrab.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
-# Camera engine requires OpenCV — optional dependency
+# ── Desktop capture engines (platform-specific native deps) ──────────
+
+try:
+ from ledgrab.core.capture_engines.mss_engine import MSSEngine, MSSCaptureStream
+
+ _has_mss = True
+except ImportError:
+ _has_mss = False
+
+try:
+ from ledgrab.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream
+
+ _has_dxcam = True
+except ImportError:
+ _has_dxcam = False
+
+try:
+ from ledgrab.core.capture_engines.bettercam_engine import (
+ BetterCamEngine,
+ BetterCamCaptureStream,
+ )
+
+ _has_bettercam = True
+except ImportError:
+ _has_bettercam = False
+
+try:
+ from ledgrab.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
+
+ _has_wgc = True
+except ImportError:
+ _has_wgc = False
+
+# ── ADB-based Android capture ───────────────────────────────────────
+
+try:
+ from ledgrab.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
+
+ _has_scrcpy = True
+except ImportError:
+ _has_scrcpy = False
+
+try:
+ from ledgrab.core.capture_engines.scrcpy_client_engine import (
+ ScrcpyClientEngine,
+ ScrcpyClientCaptureStream,
+ )
+
+ _has_scrcpy_client = True
+except ImportError:
+ _has_scrcpy_client = False
+
+# ── Camera (OpenCV) ─────────────────────────────────────────────────
+
try:
from ledgrab.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
@@ -22,35 +74,67 @@ try:
except ImportError:
_has_camera = False
-# Auto-register available engines
-EngineRegistry.register(MSSEngine)
-EngineRegistry.register(DXcamEngine)
-EngineRegistry.register(BetterCamEngine)
-EngineRegistry.register(WGCEngine)
-EngineRegistry.register(ScrcpyEngine)
+# ── Android MediaProjection (Chaquopy bridge) ───────────────────────
+
+try:
+ from ledgrab.core.capture_engines.mediaprojection_engine import (
+ MediaProjectionEngine,
+ MediaProjectionCaptureStream,
+ )
+
+ _has_mediaprojection = True
+except ImportError:
+ _has_mediaprojection = False
+
+# ── Demo / always available ─────────────────────────────────────────
+
+from ledgrab.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
+
+# ── Auto-register available engines ─────────────────────────────────
+
+if _has_mss:
+ EngineRegistry.register(MSSEngine)
+if _has_dxcam:
+ EngineRegistry.register(DXcamEngine)
+if _has_bettercam:
+ EngineRegistry.register(BetterCamEngine)
+if _has_wgc:
+ EngineRegistry.register(WGCEngine)
+if _has_scrcpy:
+ EngineRegistry.register(ScrcpyEngine)
+if _has_scrcpy_client:
+ EngineRegistry.register(ScrcpyClientEngine)
if _has_camera:
EngineRegistry.register(CameraEngine)
+if _has_mediaprojection:
+ EngineRegistry.register(MediaProjectionEngine)
EngineRegistry.register(DemoCaptureEngine)
+# ── Public API ──────────────────────────────────────────────────────
+
__all__ = [
"CaptureEngine",
"CaptureStream",
"DisplayInfo",
"ScreenCapture",
"EngineRegistry",
- "MSSEngine",
- "MSSCaptureStream",
- "DXcamEngine",
- "DXcamCaptureStream",
- "BetterCamEngine",
- "BetterCamCaptureStream",
- "WGCEngine",
- "WGCCaptureStream",
- "ScrcpyEngine",
- "ScrcpyCaptureStream",
"DemoCaptureEngine",
"DemoCaptureStream",
]
+if _has_mss:
+ __all__ += ["MSSEngine", "MSSCaptureStream"]
+if _has_dxcam:
+ __all__ += ["DXcamEngine", "DXcamCaptureStream"]
+if _has_bettercam:
+ __all__ += ["BetterCamEngine", "BetterCamCaptureStream"]
+if _has_wgc:
+ __all__ += ["WGCEngine", "WGCCaptureStream"]
+if _has_scrcpy:
+ __all__ += ["ScrcpyEngine", "ScrcpyCaptureStream"]
+if _has_scrcpy_client:
+ __all__ += ["ScrcpyClientEngine", "ScrcpyClientCaptureStream"]
if _has_camera:
__all__ += ["CameraEngine", "CameraCaptureStream"]
+if _has_mediaprojection:
+ __all__ += ["MediaProjectionEngine", "MediaProjectionCaptureStream"]
diff --git a/server/src/ledgrab/core/capture_engines/mediaprojection_engine.py b/server/src/ledgrab/core/capture_engines/mediaprojection_engine.py
new file mode 100644
index 0000000..079312d
--- /dev/null
+++ b/server/src/ledgrab/core/capture_engines/mediaprojection_engine.py
@@ -0,0 +1,201 @@
+"""Android MediaProjection capture engine.
+
+Receives screen frames pushed from Kotlin (via Chaquopy) through a
+module-level frame queue. The Kotlin layer captures the screen using
+the ``MediaProjection`` API and calls :func:`push_frame` with raw RGBA
+bytes for each frame.
+
+This engine is only available when running inside an Android app that
+has set up the frame queue.
+"""
+
+import queue
+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
+
+logger = get_logger(__name__)
+
+# ---------------------------------------------------------------------------
+# Frame queue — the bridge between Kotlin and Python
+# ---------------------------------------------------------------------------
+
+_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
+_display_info: Optional[DisplayInfo] = None
+_active = False
+_frames_received = 0
+_frames_consumed = 0
+# MediaProjection only fires onImageAvailable when the screen changes.
+# Cache the last frame so preview stays usable on static screens.
+_last_frame: Optional["ScreenCapture"] = None
+
+
+def configure(width: int, height: int) -> None:
+ """Set display dimensions. Called from Kotlin before server start.
+
+ Drains any stale frames from a previous capture session so the
+ first frame after restart is actually current.
+ """
+ global _display_info, _active, _last_frame, _frames_received
+ # Drain the queue — frames from a previous capture session are stale
+ while not _frame_queue.empty():
+ try:
+ _frame_queue.get_nowait()
+ except queue.Empty:
+ break
+ _last_frame = None
+ _frames_received = 0
+ _display_info = DisplayInfo(
+ index=0,
+ name="Android TV Screen",
+ width=width,
+ height=height,
+ x=0,
+ y=0,
+ is_primary=True,
+ refresh_rate=60,
+ )
+ _active = True
+ logger.info("MediaProjection engine configured: %dx%d", width, height)
+
+
+def push_frame(rgba_bytes: bytes, width: int, height: int) -> None:
+ """Push a captured frame from Kotlin into the Python pipeline.
+
+ Called from Kotlin's ``PythonBridge.pushFrame()`` on the capture
+ thread. The RGBA byte buffer is converted to an RGB NumPy array
+ and placed on the queue. If the queue is full (Python consumer is
+ slow), the oldest frame is dropped.
+
+ Args:
+ rgba_bytes: Raw RGBA pixel data (width * height * 4 bytes).
+ width: Frame width in pixels.
+ height: Frame height in pixels.
+ """
+ global _frames_received
+ _frames_received += 1
+ if _frames_received == 1 or _frames_received % 100 == 0:
+ logger.info("MediaProjection: received %d frames", _frames_received)
+
+ # RGBA → RGB (drop alpha channel)
+ rgba = np.frombuffer(rgba_bytes, dtype=np.uint8).reshape((height, width, 4))
+ rgb = rgba[:, :, :3].copy()
+
+ frame = ScreenCapture(
+ image=rgb,
+ width=width,
+ height=height,
+ display_index=0,
+ )
+
+ global _last_frame
+ _last_frame = frame
+
+ # 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
+
+
+# ---------------------------------------------------------------------------
+# CaptureStream
+# ---------------------------------------------------------------------------
+
+
+class MediaProjectionCaptureStream(CaptureStream):
+ """Reads frames pushed by Kotlin from the module-level queue."""
+
+ def __init__(self, display_index: int, config: Dict[str, Any]):
+ super().__init__(display_index, config)
+
+ def initialize(self) -> None:
+ if self._initialized:
+ return
+ if not _active:
+ raise RuntimeError(
+ "MediaProjection engine not configured. "
+ "This engine is only available inside the Android app."
+ )
+ self._initialized = True
+ logger.info("MediaProjection capture stream initialized")
+
+ def capture_frame(self) -> Optional[ScreenCapture]:
+ if not self._initialized:
+ self.initialize()
+ # Prefer fresh frames from the queue; fall back to the last
+ # received frame when the screen is static (MediaProjection
+ # only emits frames on actual content changes).
+ try:
+ return _frame_queue.get(timeout=0.1)
+ except queue.Empty:
+ return _last_frame
+
+ def cleanup(self) -> None:
+ # Drain the queue
+ while not _frame_queue.empty():
+ try:
+ _frame_queue.get_nowait()
+ except queue.Empty:
+ break
+ self._initialized = False
+ logger.info("MediaProjection capture stream cleaned up")
+
+
+# ---------------------------------------------------------------------------
+# CaptureEngine
+# ---------------------------------------------------------------------------
+
+
+class MediaProjectionEngine(CaptureEngine):
+ """Android MediaProjection capture engine.
+
+ Only available when running inside the LedGrab Android app.
+ The Kotlin layer calls :func:`configure` at startup and
+ :func:`push_frame` for each captured frame.
+ """
+
+ ENGINE_TYPE = "mediaprojection"
+ ENGINE_PRIORITY = 100 # Highest priority on Android
+ HAS_OWN_DISPLAYS = True
+
+ @classmethod
+ def is_available(cls) -> bool:
+ return _active and _display_info is not None
+
+ @classmethod
+ def get_default_config(cls) -> Dict[str, Any]:
+ return {}
+
+ @classmethod
+ def get_available_displays(cls) -> List[DisplayInfo]:
+ if _display_info is not None:
+ return [_display_info]
+ return []
+
+ @classmethod
+ def create_stream(
+ cls, display_index: int, config: Dict[str, Any]
+ ) -> MediaProjectionCaptureStream:
+ return MediaProjectionCaptureStream(display_index, config)
diff --git a/server/src/ledgrab/core/capture_engines/scrcpy_client_engine.py b/server/src/ledgrab/core/capture_engines/scrcpy_client_engine.py
new file mode 100644
index 0000000..c6c7565
--- /dev/null
+++ b/server/src/ledgrab/core/capture_engines/scrcpy_client_engine.py
@@ -0,0 +1,279 @@
+"""High-performance Android screen capture via the scrcpy protocol.
+
+Uses the ``scrcpy-client`` library to stream H.264/H.265 video from an
+Android device over ADB. Frames are decoded by PyAV and delivered as
+NumPy arrays at up to 60 FPS — a major upgrade over the ``adb screencap``
+polling approach in :mod:`scrcpy_engine` (~1-2 FPS).
+
+Prerequisites (pip packages):
+ - scrcpy-client>=0.5.0 (pulls in ``av``, ``adbutils``, ``numpy``)
+
+The library pushes a small (~35 KB) ``scrcpy-server.jar`` to the device,
+launches it via ``app_process``, and opens an ADB socket for the encoded
+video stream. No APK installation, no root.
+"""
+
+import threading
+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
+
+logger = get_logger(__name__)
+
+try:
+ import scrcpy
+ from adbutils import adb as adb_client
+
+ _HAS_SCRCPY_CLIENT = True
+except ImportError:
+ _HAS_SCRCPY_CLIENT = False
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _list_devices() -> List[Dict[str, Any]]:
+ """Enumerate connected ADB devices via adbutils."""
+ if not _HAS_SCRCPY_CLIENT:
+ return []
+
+ devices = []
+ try:
+ for dev in adb_client.device_list():
+ serial = dev.serial
+ # Query model name
+ try:
+ model = dev.prop.model or serial
+ except Exception:
+ model = serial
+ # Query screen resolution
+ width, height = 1920, 1080
+ try:
+ wm_output = dev.shell("wm size")
+ for line in wm_output.strip().splitlines():
+ if "x" in line:
+ parts = line.split()[-1].split("x")
+ width, height = int(parts[0]), int(parts[1])
+ break
+ except Exception:
+ pass
+
+ devices.append(
+ {
+ "serial": serial,
+ "model": model,
+ "width": width,
+ "height": height,
+ }
+ )
+ except Exception as e:
+ logger.debug("Failed to enumerate ADB devices: %s", e)
+
+ return devices
+
+
+# ---------------------------------------------------------------------------
+# CaptureStream
+# ---------------------------------------------------------------------------
+
+
+class ScrcpyClientCaptureStream(CaptureStream):
+ """H.264/H.265 video stream from an Android device via scrcpy protocol.
+
+ The ``scrcpy.Client`` runs in a background thread. On each decoded
+ frame it invokes our callback, which stores the latest frame.
+ ``capture_frame()`` returns it without blocking.
+ """
+
+ def __init__(self, display_index: int, config: Dict[str, Any]):
+ super().__init__(display_index, config)
+ self._client: Optional["scrcpy.Client"] = None
+ self._latest_frame: Optional[ScreenCapture] = None
+ self._frame_lock = threading.Lock()
+ self._frame_event = threading.Event()
+ self._client_thread: Optional[threading.Thread] = None
+ self._device_serial: Optional[str] = None
+
+ def initialize(self) -> None:
+ if self._initialized:
+ return
+
+ devices = _list_devices()
+ if not devices:
+ raise RuntimeError(
+ "No ADB devices found. Connect a device via USB or " "'adb connect ' for WiFi."
+ )
+ if self.display_index >= len(devices):
+ raise RuntimeError(
+ f"Device index {self.display_index} out of range "
+ f"(found {len(devices)} device(s))"
+ )
+
+ device = devices[self.display_index]
+ self._device_serial = device["serial"]
+
+ max_fps = self.config.get("max_fps", 30)
+ max_size = self.config.get("max_size", 480)
+ bitrate = self.config.get("bitrate", 2_000_000)
+
+ logger.info(
+ "scrcpy-client: connecting to %s (%s, %dx%d) — " "max_fps=%d, max_size=%d, bitrate=%d",
+ self._device_serial,
+ device["model"],
+ device["width"],
+ device["height"],
+ max_fps,
+ max_size,
+ bitrate,
+ )
+
+ self._client = scrcpy.Client(
+ device=self._device_serial,
+ max_fps=max_fps,
+ max_size=max_size,
+ bitrate=bitrate,
+ )
+ self._client.add_listener(scrcpy.EVENT_FRAME, self._on_frame)
+
+ # scrcpy.Client.start() blocks, so run in a thread
+ self._client_thread = threading.Thread(
+ target=self._run_client, daemon=True, name="scrcpy-client"
+ )
+ self._client_thread.start()
+
+ # Wait for first frame (with timeout)
+ if not self._frame_event.wait(timeout=10.0):
+ logger.warning(
+ "scrcpy-client: no frame received within 10s from %s",
+ self._device_serial,
+ )
+
+ self._initialized = True
+ logger.info("scrcpy-client: stream initialized for %s", self._device_serial)
+
+ def _run_client(self) -> None:
+ """Start the scrcpy client (blocking)."""
+ try:
+ self._client.start(threaded=False)
+ except Exception as e:
+ logger.error("scrcpy-client: error for %s: %s", self._device_serial, e)
+
+ def _on_frame(self, frame: np.ndarray) -> None:
+ """Callback invoked by scrcpy-client for each decoded frame.
+
+ The frame arrives as a BGR numpy array; we convert to RGB to
+ match the ``ScreenCapture`` convention.
+ """
+ if frame is None:
+ return
+
+ # BGR → RGB
+ rgb = frame[:, :, ::-1].copy()
+ h, w = rgb.shape[:2]
+
+ with self._frame_lock:
+ self._latest_frame = ScreenCapture(
+ image=rgb,
+ width=w,
+ height=h,
+ display_index=self.display_index,
+ )
+ self._frame_event.set()
+
+ def capture_frame(self) -> Optional[ScreenCapture]:
+ if not self._initialized:
+ self.initialize()
+
+ if not self._frame_event.is_set():
+ return None
+
+ with self._frame_lock:
+ frame = self._latest_frame
+ self._frame_event.clear()
+
+ return frame
+
+ def cleanup(self) -> None:
+ if self._client is not None:
+ try:
+ self._client.stop()
+ except Exception as e:
+ logger.debug("scrcpy-client: stop error: %s", e)
+
+ if self._client_thread is not None and self._client_thread.is_alive():
+ self._client_thread.join(timeout=5)
+
+ self._client = None
+ self._client_thread = None
+ self._latest_frame = None
+ self._frame_event.clear()
+ self._initialized = False
+ logger.info("scrcpy-client: stream cleaned up (%s)", self._device_serial)
+
+
+# ---------------------------------------------------------------------------
+# CaptureEngine
+# ---------------------------------------------------------------------------
+
+
+class ScrcpyClientEngine(CaptureEngine):
+ """High-performance Android capture via scrcpy H.264 streaming.
+
+ Requires the ``scrcpy-client`` pip package (optional dependency).
+ When available, this engine is preferred over :class:`ScrcpyEngine`
+ (which falls back to ``adb screencap`` at ~1-2 FPS).
+
+ Prerequisites:
+ - ``pip install scrcpy-client`` (pulls in PyAV, adbutils)
+ - adb on PATH (for device connection)
+ - USB debugging enabled on Android device
+ """
+
+ ENGINE_TYPE = "scrcpy_client"
+ ENGINE_PRIORITY = 10 # Higher than ScrcpyEngine (5)
+ HAS_OWN_DISPLAYS = True
+
+ @classmethod
+ def is_available(cls) -> bool:
+ return _HAS_SCRCPY_CLIENT
+
+ @classmethod
+ def get_default_config(cls) -> Dict[str, Any]:
+ return {
+ "max_fps": 30,
+ "max_size": 480,
+ "bitrate": 2_000_000,
+ }
+
+ @classmethod
+ def get_available_displays(cls) -> List[DisplayInfo]:
+ devices = _list_devices()
+ displays = []
+ for idx, device in enumerate(devices):
+ displays.append(
+ DisplayInfo(
+ index=idx,
+ name=f"{device['model']} ({device['serial']})",
+ width=device["width"],
+ height=device["height"],
+ x=idx * 500,
+ y=0,
+ is_primary=(idx == 0),
+ refresh_rate=60,
+ )
+ )
+ logger.debug("scrcpy-client: detected %d Android device(s)", len(displays))
+ return displays
+
+ @classmethod
+ def create_stream(cls, display_index: int, config: Dict[str, Any]) -> ScrcpyClientCaptureStream:
+ return ScrcpyClientCaptureStream(display_index, config)
diff --git a/server/src/ledgrab/core/filters/downscaler.py b/server/src/ledgrab/core/filters/downscaler.py
index b483a18..9464075 100644
--- a/server/src/ledgrab/core/filters/downscaler.py
+++ b/server/src/ledgrab/core/filters/downscaler.py
@@ -2,10 +2,10 @@
from typing import List, Optional
-import cv2
import numpy as np
from ledgrab.core.filters.base import FilterOptionDef, PostprocessingFilter
+from ledgrab.utils.image_codec import resize_image
from ledgrab.core.filters.image_pool import ImagePool
from ledgrab.core.filters.registry import FilterRegistry
@@ -44,7 +44,7 @@ class DownscalerFilter(PostprocessingFilter):
if new_h == h and new_w == w:
return None
- downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
+ downscaled = resize_image(image, new_w, new_h)
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
np.copyto(result, downscaled)
diff --git a/server/src/ledgrab/core/filters/pixelate.py b/server/src/ledgrab/core/filters/pixelate.py
index ab896e1..6b1cad7 100644
--- a/server/src/ledgrab/core/filters/pixelate.py
+++ b/server/src/ledgrab/core/filters/pixelate.py
@@ -2,10 +2,10 @@
from typing import List, Optional
-import cv2
import numpy as np
from ledgrab.core.filters.base import FilterOptionDef, PostprocessingFilter
+from ledgrab.utils.image_codec import resize_image
from ledgrab.core.filters.image_pool import ImagePool
from ledgrab.core.filters.registry import FilterRegistry
@@ -42,8 +42,8 @@ class PixelateFilter(PostprocessingFilter):
# vectorized C++ instead of per-block Python loop
small_w = max(1, w // block_size)
small_h = max(1, h // block_size)
- small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA)
- pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
+ small = resize_image(image, small_w, small_h)
+ pixelated = resize_image(small, w, h)
np.copyto(image, pixelated)
return None
diff --git a/server/src/ledgrab/core/processing/kc_color_strip_stream.py b/server/src/ledgrab/core/processing/kc_color_strip_stream.py
index 56ca42b..64709cc 100644
--- a/server/src/ledgrab/core/processing/kc_color_strip_stream.py
+++ b/server/src/ledgrab/core/processing/kc_color_strip_stream.py
@@ -10,9 +10,10 @@ import threading
import time
from typing import TYPE_CHECKING, List, Optional
-import cv2
import numpy as np
+from ledgrab.utils.image_codec import resize_image
+
from ledgrab.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
@@ -150,7 +151,7 @@ class KeyColorsColorStripStream(ColorStripStream):
calc_fn = _CALC_FNS.get(src.interpolation_mode, calculate_average_color)
# Downsample
- small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
+ small = resize_image(capture.image, KC_WORK_SIZE[0], KC_WORK_SIZE[1])
# Extract colors per rectangle
n = len(self._rect_names)
diff --git a/server/src/ledgrab/core/processing/metrics_history.py b/server/src/ledgrab/core/processing/metrics_history.py
index 30f0e7b..28de300 100644
--- a/server/src/ledgrab/core/processing/metrics_history.py
+++ b/server/src/ledgrab/core/processing/metrics_history.py
@@ -6,7 +6,10 @@ from collections import deque
from datetime import datetime, timezone
from typing import Dict, Optional
-import psutil
+try:
+ import psutil
+except ImportError:
+ psutil = None # type: ignore[assignment]
from ledgrab.utils import get_logger
from ledgrab.utils.gpu import (
@@ -21,8 +24,11 @@ MAX_SAMPLES = 120 # ~2 minutes at 1-second interval
SAMPLE_INTERVAL = 1.0 # seconds
-_process = psutil.Process(os.getpid())
-_process.cpu_percent(interval=None) # prime process-level counter
+if psutil is not None:
+ _process = psutil.Process(os.getpid())
+ _process.cpu_percent(interval=None) # prime process-level counter
+else:
+ _process = None # type: ignore[assignment]
def _collect_system_snapshot() -> dict:
@@ -30,6 +36,21 @@ def _collect_system_snapshot() -> dict:
Returns a dict suitable for direct JSON serialization.
"""
+ if psutil is None or _process is None:
+ # psutil unavailable (e.g. Android) — return zeroed snapshot
+ return {
+ "t": datetime.now(timezone.utc).isoformat(),
+ "cpu": 0.0,
+ "ram_pct": 0.0,
+ "ram_used": 0.0,
+ "ram_total": 0.0,
+ "app_cpu": 0.0,
+ "app_ram": 0.0,
+ "gpu_util": None,
+ "gpu_temp": None,
+ "app_gpu_mem": None,
+ }
+
mem = psutil.virtual_memory()
proc_mem = _process.memory_info()
snapshot = {
diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts
index 0cbcac2..9baa28b 100644
--- a/server/src/ledgrab/static/js/app.ts
+++ b/server/src/ledgrab/static/js/app.ts
@@ -198,6 +198,7 @@ import {
// Layer 6: tabs, navigation, command palette, settings
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
+import { callTabLoader } from './core/tab-registry.ts';
import { navigateToCard } from './core/navigation.ts';
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
import {
@@ -761,6 +762,13 @@ document.addEventListener('DOMContentLoaded', async () => {
loadDisplays();
loadTargetsTab();
+ // Trigger the active tab's loader — initTabs() ran before authRequired
+ // was known, so its conditional loader call may have been skipped.
+ const activeTab = localStorage.getItem('activeTab') || 'dashboard';
+ if (activeTab !== 'targets') {
+ callTabLoader(activeTab);
+ }
+
// Start global events WebSocket and auto-refresh
startEventsWS();
startEntityEventListeners();
diff --git a/server/src/ledgrab/static/js/features/tabs.ts b/server/src/ledgrab/static/js/features/tabs.ts
index ae45421..e521ec9 100644
--- a/server/src/ledgrab/static/js/features/tabs.ts
+++ b/server/src/ledgrab/static/js/features/tabs.ts
@@ -2,7 +2,7 @@
* Tab switching — switchTab, initTabs, startAutoRefresh, hash routing.
*/
-import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
+import { apiKey, authRequired, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
import { getActiveSubTab, setActiveSubTab, callTabLoader, callSubTabSwitcher, getSubTabConfig, getTabConfig } from '../core/tab-registry.ts';
/** Parse location.hash into {tab, subTab}. */
@@ -48,9 +48,12 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
_setHash(name, getActiveSubTab(name));
}
+ // Authenticated when either auth is disabled or we have an apiKey
+ const isAuthed = !authRequired || !!apiKey;
+
if (name === 'dashboard') {
// Use window.* to avoid circular imports with feature modules
- if (!skipLoad && apiKey) callTabLoader(name);
+ if (!skipLoad && isAuthed) callTabLoader(name);
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
@@ -59,7 +62,7 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
if (typeof window.disconnectAllKCWebSockets === 'function') window.disconnectAllKCWebSockets();
if (typeof window.disconnectAllLedPreviewWS === 'function') window.disconnectAllLedPreviewWS();
}
- if (!apiKey || skipLoad) return;
+ if (!isAuthed || skipLoad) return;
callTabLoader(name);
}
}
@@ -97,7 +100,8 @@ export function startAutoRefresh(): void {
}
setRefreshInterval(setInterval(() => {
- if (!apiKey || document.hidden) return;
+ const isAuthed = !authRequired || !!apiKey;
+ if (!isAuthed || document.hidden) return;
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
const cfg = getTabConfig(activeTab);
if (!cfg?.autoRefresh) return;
diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json
index 6127e79..f035319 100644
--- a/server/src/ledgrab/static/locales/en.json
+++ b/server/src/ledgrab/static/locales/en.json
@@ -80,8 +80,11 @@
"templates.engine.dxcam.desc": "DirectX, low latency",
"templates.engine.bettercam.desc": "DirectX, high performance",
"templates.engine.camera.desc": "USB/IP camera capture",
- "templates.engine.scrcpy.desc": "Android screen mirror",
+ "templates.engine.scrcpy.desc": "Android screen mirror (adb screencap)",
+ "templates.engine.scrcpy_client.desc": "Android H.264 stream, high FPS",
"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.config": "Configuration",
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json
index 7c1e021..671171c 100644
--- a/server/src/ledgrab/static/locales/ru.json
+++ b/server/src/ledgrab/static/locales/ru.json
@@ -84,8 +84,11 @@
"templates.engine.dxcam.desc": "DirectX, низкая задержка",
"templates.engine.bettercam.desc": "DirectX, высокая производительность",
"templates.engine.camera.desc": "Захват USB/IP камеры",
- "templates.engine.scrcpy.desc": "Зеркалирование экрана Android",
+ "templates.engine.scrcpy.desc": "Зеркалирование экрана Android (adb screencap)",
+ "templates.engine.scrcpy_client.desc": "Android H.264 поток, высокая частота",
"templates.engine.wgc.desc": "Windows Graphics Capture",
+ "templates.engine.demo.desc": "Тестовый анимированный шаблон (демо)",
+ "templates.engine.mediaprojection.desc": "Нативный захват экрана Android",
"templates.config": "Конфигурация",
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json
index 63a5d83..cbca552 100644
--- a/server/src/ledgrab/static/locales/zh.json
+++ b/server/src/ledgrab/static/locales/zh.json
@@ -84,8 +84,11 @@
"templates.engine.dxcam.desc": "DirectX,低延迟",
"templates.engine.bettercam.desc": "DirectX,高性能",
"templates.engine.camera.desc": "USB/IP摄像头捕获",
- "templates.engine.scrcpy.desc": "Android屏幕镜像",
+ "templates.engine.scrcpy.desc": "Android屏幕镜像 (adb screencap)",
+ "templates.engine.scrcpy_client.desc": "Android H.264流,高帧率",
"templates.engine.wgc.desc": "Windows图形捕获",
+ "templates.engine.demo.desc": "动画测试图案(演示模式)",
+ "templates.engine.mediaprojection.desc": "原生Android屏幕捕获",
"templates.config": "配置",
"templates.config.show": "显示配置",
"templates.config.none": "无额外配置",
diff --git a/server/src/ledgrab/utils/image_codec.py b/server/src/ledgrab/utils/image_codec.py
index 90543d5..e67659c 100644
--- a/server/src/ledgrab/utils/image_codec.py
+++ b/server/src/ledgrab/utils/image_codec.py
@@ -1,24 +1,62 @@
-"""Image encoding/decoding/resizing utilities using OpenCV.
+"""Image encoding/decoding/resizing utilities.
+
+Uses OpenCV (cv2) when available for best performance, falls back to
+Pillow on platforms where cv2 is unavailable (e.g. Android via Chaquopy).
-Replaces PIL/Pillow for JPEG encoding, image loading, and resizing operations.
All functions work with numpy RGB arrays (H, W, 3) uint8.
"""
import base64
+import io
from pathlib import Path
from typing import Tuple, Union
-import cv2
import numpy as np
+try:
+ import cv2
+
+ _HAS_CV2 = True
+except ImportError:
+ _HAS_CV2 = False
+
+try:
+ from PIL import Image
+
+ _HAS_PIL = True
+except ImportError:
+ _HAS_PIL = False
+
+
+def _require_backend() -> None:
+ if not _HAS_CV2 and not _HAS_PIL:
+ raise ImportError(
+ "Neither opencv-python-headless nor Pillow is installed. "
+ "At least one is required for image operations."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Encode
+# ---------------------------------------------------------------------------
+
def encode_jpeg(image: np.ndarray, quality: int = 85) -> bytes:
"""Encode an RGB numpy array as JPEG bytes."""
- bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
- ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
- if not ok:
- raise RuntimeError("JPEG encoding failed")
- return buf.tobytes()
+ if _HAS_CV2:
+ bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
+ ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
+ if not ok:
+ raise RuntimeError("JPEG encoding failed")
+ return buf.tobytes()
+
+ if _HAS_PIL:
+ img = Image.fromarray(image, "RGB")
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG", quality=quality)
+ return buf.getvalue()
+
+ _require_backend()
def encode_jpeg_data_uri(image: np.ndarray, quality: int = 85) -> str:
@@ -28,62 +66,90 @@ def encode_jpeg_data_uri(image: np.ndarray, quality: int = 85) -> str:
return f"data:image/jpeg;base64,{b64}"
-def resize_image(image: np.ndarray, width: int, height: int) -> np.ndarray:
- """Resize an image to exact dimensions.
+# ---------------------------------------------------------------------------
+# Resize
+# ---------------------------------------------------------------------------
- Uses INTER_AREA for downscaling (better quality, faster) and
- INTER_LANCZOS4 for upscaling.
- """
- h, w = image.shape[:2]
- shrinking = (width * height) < (w * h)
- interp = cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4
- return cv2.resize(image, (width, height), interpolation=interp)
+
+def resize_image(image: np.ndarray, width: int, height: int) -> np.ndarray:
+ """Resize an image to exact dimensions."""
+ if _HAS_CV2:
+ h, w = image.shape[:2]
+ shrinking = (width * height) < (w * h)
+ interp = cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4
+ return cv2.resize(image, (width, height), interpolation=interp)
+
+ if _HAS_PIL:
+ img = Image.fromarray(image, "RGB")
+ resized = img.resize((width, height), Image.LANCZOS)
+ return np.asarray(resized)
+
+ _require_backend()
def thumbnail(image: np.ndarray, max_width: int) -> np.ndarray:
- """Create a thumbnail that fits within max_width, preserving aspect ratio.
-
- Uses INTER_AREA (optimal for downscaling).
- """
+ """Create a thumbnail that fits within max_width, preserving aspect ratio."""
h, w = image.shape[:2]
if w <= max_width:
return image.copy()
scale = max_width / w
new_w = max_width
new_h = max(1, int(h * scale))
- return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
+ return resize_image(image, new_w, new_h)
def resize_down(image: np.ndarray, max_width: int) -> np.ndarray:
- """Downscale if wider than max_width; return as-is otherwise.
-
- Uses INTER_AREA (optimal for downscaling).
- """
+ """Downscale if wider than max_width; return as-is otherwise."""
h, w = image.shape[:2]
if w <= max_width:
return image
scale = max_width / w
new_w = max_width
new_h = max(1, int(h * scale))
- return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
+ return resize_image(image, new_w, new_h)
+
+
+# ---------------------------------------------------------------------------
+# Load
+# ---------------------------------------------------------------------------
def load_image_file(path: Union[str, Path]) -> np.ndarray:
"""Load an image file and return as RGB numpy array."""
path = str(path)
- bgr = cv2.imread(path, cv2.IMREAD_COLOR)
- if bgr is None:
- raise FileNotFoundError(f"Cannot load image: {path}")
- return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
+
+ if _HAS_CV2:
+ bgr = cv2.imread(path, cv2.IMREAD_COLOR)
+ if bgr is None:
+ raise FileNotFoundError(f"Cannot load image: {path}")
+ return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
+
+ if _HAS_PIL:
+ img = Image.open(path).convert("RGB")
+ return np.asarray(img)
+
+ _require_backend()
def load_image_bytes(data: bytes) -> np.ndarray:
"""Decode image bytes (JPEG, PNG, etc.) and return as RGB numpy array."""
- arr = np.frombuffer(data, dtype=np.uint8)
- bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
- if bgr is None:
- raise ValueError("Cannot decode image data")
- return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
+ if _HAS_CV2:
+ arr = np.frombuffer(data, dtype=np.uint8)
+ bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
+ if bgr is None:
+ raise ValueError("Cannot decode image data")
+ return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
+
+ if _HAS_PIL:
+ img = Image.open(io.BytesIO(data)).convert("RGB")
+ return np.asarray(img)
+
+ _require_backend()
+
+
+# ---------------------------------------------------------------------------
+# Info
+# ---------------------------------------------------------------------------
def image_size(image: np.ndarray) -> Tuple[int, int]:
diff --git a/server/src/ledgrab/utils/platform.py b/server/src/ledgrab/utils/platform.py
new file mode 100644
index 0000000..549081e
--- /dev/null
+++ b/server/src/ledgrab/utils/platform.py
@@ -0,0 +1,37 @@
+"""Platform detection utilities.
+
+Centralizes platform checks so the rest of the codebase can use
+``is_android()``, ``is_windows()``, etc. instead of ad-hoc
+``sys.platform`` comparisons.
+
+Android reports ``sys.platform == "linux"``, so a dedicated check is
+needed to distinguish it from desktop Linux.
+"""
+
+import os
+import sys
+
+_is_android: bool | None = None
+
+
+def is_android() -> bool:
+ """Return True when running inside an Android environment (Chaquopy)."""
+ global _is_android
+ if _is_android is None:
+ _is_android = hasattr(sys, "getandroidapilevel") or bool(os.environ.get("ANDROID_ROOT"))
+ return _is_android
+
+
+def is_windows() -> bool:
+ """Return True on Windows."""
+ return sys.platform == "win32"
+
+
+def is_linux() -> bool:
+ """Return True on desktop Linux (excludes Android)."""
+ return sys.platform == "linux" and not is_android()
+
+
+def is_macos() -> bool:
+ """Return True on macOS."""
+ return sys.platform == "darwin"