feat: Android TV app embedding Python server via Chaquopy
Lint & Test / test (push) Successful in 2m10s

Adds a native Android TV application that runs the full LedGrab Python
server in-process via Chaquopy. Captures the TV box screen using the
MediaProjection API and exposes the existing web UI on the device's
local network — users configure via phone/tablet browser.

Android (new /android/ module):
- Kotlin shell: MainActivity, CaptureService (foreground service),
  ScreenCapture (MediaProjection + ImageReader), PythonBridge (Chaquopy).
- Polished Leanback-themed UI with QR code for easy web UI access.
- AGP 8.9 + Chaquopy 17 + Gradle 8.11 (avoids the AGP 8.7 thread-lock bug).
- Pre-built pydantic-core wheels for arm64-v8a, x86_64, x86 cross-compiled
  with maturin + Android NDK, linked against Chaquopy's libpython3.11.so.

Python server platform guards:
- New utils/platform.py with is_android()/is_windows()/is_linux() helpers.
- Guard every top-level import of desktop-only packages (mss, psutil,
  sounddevice, pyserial, PyAudioWPatch, etc.) with try/except ImportError.
- Android-incompatible calls gated with None-checks so the server runs on
  reduced capabilities on Android (no CPU/RAM metrics, no mss displays).
- utils/image_codec.py gains a Pillow fallback for resize + JPEG encode
  when cv2 is unavailable; all internal cv2.resize callers migrated.
- New android_entry.py start_server/stop_server invoked from Kotlin.
- get_displays API falls back to best available engine when mss fails.

New capture engines:
- MediaProjectionEngine: receives RGBA frames pushed from Kotlin through
  a thread-safe queue; caches last frame for static-screen previews.
- ScrcpyClientEngine: optional H.264 streaming via scrcpy-client library
  (priority 10, overrides the ADB-screencap engine when installed).

Frontend:
- Tab loaders previously required an apiKey; now correctly treat
  "auth disabled" as authenticated (Android has no auth by default).
- Re-trigger the active tab's loader after loadServerInfo resolves
  authRequired, since initTabs runs earlier.
- Add i18n keys for the demo / mediaprojection / scrcpy_client engines.

Docs:
- TODO.md: follow-ups for multi-ABI wheel rebuilds, CI pipeline, USB
  serial LED controllers, root-only capture, perf metrics abstraction.
- CLAUDE.md: Android dependency sync policy (pip --exclude doesn't exist).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 03:11:43 +03:00
parent a0b65e3fcb
commit 8574424fb7
56 changed files with 2443 additions and 126 deletions
+17
View File
@@ -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
+92
View File
@@ -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")
}
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- MediaProjection requires a foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Android TV declarations -->
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:name=".LedGrabApp"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
android:theme="@style/Theme.LedGrab">
<!-- TV launcher activity -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground service for screen capture + Python server -->
<service
android:name=".CaptureService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
</application>
</manifest>
@@ -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()
}
}
@@ -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))
}
}
}
@@ -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
}
}
@@ -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<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
}
}
@@ -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
}
@@ -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")
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:color="#ffffff" />
<item android:color="@color/purple_accent" />
</selector>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#4064ffda" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#64ffda" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#3dccb0" />
<corners android:radius="36dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/teal_accent" />
<corners android:radius="36dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#40bb86fc" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#7c4dff" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#9966d4" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#1Abb86fc" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
</selector>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/bg_navy" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="900dp"
android:centerX="0.5"
android:centerY="-0.2"
android:startColor="#1A64ffda"
android:endColor="#000d1117" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="600dp"
android:centerX="1.1"
android:centerY="1.2"
android:startColor="#12bb86fc"
android:endColor="#000d1117" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:left="-8dp" android:top="-8dp" android:right="-8dp" android:bottom="-8dp">
<shape android:shape="rectangle">
<solid android:color="#2064ffda" />
<corners android:radius="24dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#ffffff" />
<corners android:radius="16dp" />
<stroke android:width="3dp" android:color="@color/teal_accent" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/green_status" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_surface_elevated" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#2264ffda" />
</shape>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#0d1117"
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
<!-- Border ring -->
<path
android:strokeColor="#2264ffda"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:pathData="M54,54m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0" />
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow - top (teal) -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.7"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<!-- LED glow - left (purple) -->
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.6"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<!-- LED glow - right (red) -->
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.6"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<!-- LED glow - bottom (yellow) -->
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.6"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_main">
<!-- STOPPED STATE -->
<LinearLayout
android:id="@+id/stopped_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:paddingStart="160dp"
android:paddingEnd="160dp">
<ImageView
android:layout_width="72dp"
android:layout_height="72dp"
android:src="@drawable/ic_launcher"
android:contentDescription="@null"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@color/teal_accent"
android:textSize="64sp"
android:textStyle="bold"
android:letterSpacing="0.08"
android:layout_marginBottom="12dp"
android:fontFamily="sans-serif-light" />
<TextView
android:id="@+id/status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tagline"
android:textColor="@color/text_secondary"
android:textSize="28sp"
android:layout_marginBottom="64dp" />
<Button
android:id="@+id/toggle_button"
style="@style/Widget.LedGrab.Button.Primary"
android:layout_width="320dp"
android:layout_height="72dp"
android:text="@string/btn_start"
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Version at bottom -->
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="32dp"
android:textColor="@color/text_hint"
android:textSize="18sp"
tools:text="v0.1.0" />
<!-- RUNNING STATE -->
<LinearLayout
android:id="@+id/running_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="120dp"
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp"
android:visibility="gone">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Right: QR code -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg_navy">#0d1117</color>
<color name="bg_navy_mid">#111827</color>
<color name="bg_surface">#161b22</color>
<color name="bg_surface_elevated">#1c2333</color>
<color name="teal_accent">#64ffda</color>
<color name="teal_accent_dim">#3399aa</color>
<color name="teal_accent_alpha30">#4D64ffda</color>
<color name="teal_accent_alpha15">#2664ffda</color>
<color name="purple_accent">#bb86fc</color>
<color name="purple_accent_dim">#7c4dff</color>
<color name="purple_accent_alpha30">#4Dbb86fc</color>
<color name="purple_accent_alpha15">#26bb86fc</color>
<color name="green_status">#4caf50</color>
<color name="green_status_dim">#1b5e20</color>
<color name="text_primary">#e6edf3</color>
<color name="text_secondary">#8b949e</color>
<color name="text_hint">#484f58</color>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">Ambient lighting for your TV</string>
<string name="btn_start">Start Capture</string>
<string name="btn_stop">Stop</string>
<string name="status_running">Running</string>
<string name="label_web_ui">Web UI address</string>
<string name="scan_to_configure">Scan to configure</string>
<string name="qr_description">QR code for web UI</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LedGrab" parent="@style/Theme.Leanback">
<item name="android:windowBackground">@color/bg_navy</item>
<item name="android:colorBackground">@color/bg_navy</item>
<item name="android:windowNoTitle">true</item>
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
<item name="android:textColorHint">@color/text_hint</item>
<item name="android:colorAccent">@color/teal_accent</item>
<item name="android:colorControlHighlight">@color/teal_accent_dim</item>
<item name="android:colorControlActivated">@color/teal_accent</item>
</style>
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/bg_navy</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
<style name="Widget.LedGrab.Button.Secondary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:textColor">@color/btn_secondary_text</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
</resources>
@@ -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!"
+74
View File
@@ -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"
+5
View File
@@ -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
}
+7
View File
@@ -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
+7
View File
@@ -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
+18
View File
@@ -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")