Multi-axis lift to ship-quality after a full review:
Security
- ApiKeyManager: per-install random API key, persisted via SharedPreferences
with synchronous first-write; threaded into uvicorn via the
LEDGRAB_AUTH__API_KEYS env var; embedded in QR as a URL fragment (#k=)
so it never appears in HTTP requests or server logs; frontend reads
location.hash on first visit and strips it via history.replaceState
- Root.runAsRoot(argv: Array<String>) overload with POSIX shell-quoting to
eliminate the shell-injection footgun (= excluded from unquoted-safe set)
- UsbSerialBridge: ContextCompat.RECEIVER_NOT_EXPORTED + intent.package
check in the broadcast receiver for defence-in-depth across API levels
- Release builds refuse to silently fall back to debug keystore; require
ANDROID_KEYSTORE_* env vars or explicit
ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1
- Crash log retention capped at 10 entries
- Fatal-error stack trace hidden behind a toggle on the error screen
Performance
- ScreenCapture / RootScreenrecord reuse a single RGBA ByteArray per
pipeline instead of allocating per frame — eliminates ~15 MB/s GC churn
at 30 fps on low-end TV boxes
- Frame pacer switched from System.currentTimeMillis() + integer division
(~30.3 fps drift) to SystemClock.elapsedRealtimeNanos with a catch-up
accumulator
- ScreenCapture computes capture dimensions from source aspect ratio so
non-16:9 displays don't get squashed
- RootScreenrecord input pump backs off 5 ms when MediaCodec is starved,
ending a tight spin that burned a CPU core on decoder stalls
- QR cached by URL — onResume from background no longer rebuilds the
560×560 bitmap each time
- ApiKey commit() pre-warmed off Main on app startup
Compatibility
- compileSdk / targetSdk bumped to 35 (Play Store requirement)
- armeabi-v7a build path added to build script + conditionally included
in gradle splits when the matching wheel is present in android/wheels/
- Foreground service type declared as mediaProjection|specialUse with
PROPERTY_SPECIAL_USE_FGS_SUBTYPE rationale; promotion via
ServiceCompat.startForeground with the correct type per mode
- NetworkUtils picks Ethernet > Wi-Fi > VPN > cellular instead of just
activeNetwork — fixes wrong-URL on TV boxes with both Ethernet + Wi-Fi
- enableOnBackInvokedCallback=true for Android 15 predictive-back
- Splash screen API via androidx.core:core-splashscreen — hides Chaquopy
stdlib unpack delay on cold first launch
UI / UX
- All previously hardcoded English strings (root prompt, permission
denial, fatal-error screen, notification text) now localised across
en/ru/zh
- Monochrome notification icon (was a colored launcher → gray blob in
status bar)
- 320×180 TV banner (was the square launcher → squashed on Leanback row)
- ViewStub-based running panel (deferred inflation)
- ObjectAnimator pulse on the Running status dot for liveness feedback
- "Starting…" button state while root is being probed
- Autostart checkbox hidden entirely on unrooted devices
- "No network" status when getLocalIpAddress returns null
- QR fallback hint text
- Animator cancelled in onStop to avoid leaking view hierarchy
Lifecycle hardening (from review)
- RootScreenrecord: processLock serialises EOF respawn vs concurrent
stop() to prevent orphaned screenrecord processes
- CaptureService.restartRootPipeline: publish-before-start under
@Synchronized to close the orphan window during watchdog restarts
- ScreenCapture.MediaProjection.Callback.onStop just flips
running=false instead of calling stop() (which self-joined
captureThread and hung 500 ms)
- updateUI early-returns when lateinit not initialised (fatal-error path)
- Watchdog give-up bound fixed (>= instead of >, was allowing 4 attempts)
server/android_entry.py accepts an optional api_key, sets
LEDGRAB_AUTH__API_KEYS={"android":<key>} as JSON before any LedGrab
import, logs a clear error if pydantic-settings parsing doesn't land
the value back in config (defensive guard against future settings
behaviour drift).
server/static/js/app.ts: bootstrap reads #k= from location.hash,
persists to localStorage, then strips via history.replaceState.
Two independent code-review passes; 147 relevant server tests still
pass; TypeScript and ruff clean.
Chaquopy's pip --find-links argument was built with a hard-coded
"file:///" prefix plus rootDir.absolutePath. That works on Windows
(paths start with "C:/...", so prefix + path produces three slashes
after "file:") but breaks on Linux (paths start with "/workspace/...",
so prefix + path produces four slashes — pip then parses "workspace"
as the URL's hostname and aborts with
"ValueError: non-local file URIs are not supported on this platform".
Pick the prefix based on whether the absolute path already starts with
"/", so we always end up with exactly three slashes between "file:" and
the drive letter or root.
Boot-time startup so LedGrab has display capture and control without user
interaction on rooted TV boxes. Also folds in a batch of review findings
from the Android package audit.
Autostart
- BootReceiver fires on BOOT_COMPLETED / LOCKED_BOOT_COMPLETED /
MY_PACKAGE_REPLACED, gated by AutostartPrefs and Root.looksRooted().
Dispatches CaptureService.createRootIntent via
ContextCompat.startForegroundService. Unrooted devices are a no-op
because MediaProjection consent cannot be bypassed silently.
- AutostartPrefs: thin SharedPreferences wrapper, defaults to enabled.
Exposed as a CheckBox on the stopped panel; greyed out when not rooted.
- Manifest: RECEIVE_BOOT_COMPLETED, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
WAKE_LOCK permissions + the new BootReceiver.
- MainActivity prompts for battery-optimization exemption on first opt-in
so Doze/App Standby doesn't kill the FG service on phones.
Service stability
- onStartCommand now flips isRunning only after startForeground succeeds
(was stuck=true forever if the FG transition threw) and resets on
exception. Returns START_REDELIVER_INTENT for root mode so the OS can
restart the service with the original intent (no consent token to
invalidate); MediaProjection mode keeps START_NOT_STICKY.
- Watchdog coroutine monitors RootScreenrecord.framesDelivered. Respawns
the pipeline on stall (reusing the existing Python bridge — no server
restart), caps at 3 consecutive restarts before giving up.
- RootScreenrecord.framesDelivered is now an AtomicInteger, exposed as a
public property for the watchdog.
- ScreenCapture takes an onProjectionStopped lambda; when the user taps
the system Cast/Screen-capture stop banner, the whole service is torn
down instead of leaving a stale FG notification.
- MainActivity's two startForegroundService calls switch to
ContextCompat.startForegroundService, clearing pre-existing NewApi lint
errors (minSdk=24 < API 26 native method).
Build
- versionCode derived from git rev-list --count HEAD (or the
ANDROID_VERSION_CODE env var for CI). Was pinned to 1 — sideload
upgrades were silently refusing to install.
- New i18n strings (autostart_label, autostart_unavailable, version_prefix)
in en/ru/zh; version_text now uses the resource instead of string
concat.
TODO.md: new "Android Autostart on Boot" section tracking done/pending
items; real-hardware verification on a Magisk'd TV box is the remaining
checkbox.
End-to-end BLE streaming: provider + client + per-protocol wire encoders
with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge
via Chaquopy) transports, discovery with protocol-family detection that
auto-fills the UI, throttled not-connected warning + 10 s reconnect
cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame,
and an explicit asyncio.wait_for wrapper around bleak connect() since
the WinRT backend doesn't always honor the timeout kwarg.
Also rewrites server/restart.ps1 to be parameterized (-Port / -Module /
-PythonVersion / timeouts / -Quiet), pick the right interpreter via the
py launcher, pre-flight the target module, poll port readiness on both
shutdown and startup, redirect child stdout/stderr so Start-Process
doesn't hang on inherited Git-Bash handles, and return proper exit codes.
Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh
resources, Chaquopy-safe value_stream psutil fallback, setup-required
modal, asset-store test coverage, and misc system/config touch-ups.
On rooted TV boxes, spawn `su -c screenrecord ... -` and feed the
H.264 stdout through MediaCodec into an ImageReader, surfacing RGBA
frames via PythonBridge. RootScreenrecordEngine (priority 110) is
picked automatically when root is available; falls back to
MediaProjection when Root.requestGrant() returns false.
Adds end-to-end support for driving USB-connected Adalight / AmbiLED
LED controllers from Android TV boxes. Android's security model blocks
direct USB access from Python, so writes route through a Kotlin
UsbSerialBridge singleton via Chaquopy.
Python side:
- New SerialTransport Protocol (serial_transport.py) with open / write /
flush / close. Desktop uses PySerialTransport (wraps pyserial),
Android uses AndroidSerialTransport (wraps the Kotlin bridge).
- list_serial_ports() factory returns desktop COM ports on desktop,
USB devices on Android — callers don't branch.
- URL scheme extended: existing COM3[:baud] and /dev/ttyUSB0[:baud]
unchanged; new usb:VID:PID[:serial][@baud] for Android (@ is the
baud separator since : is already used between VID and PID).
- AdalightClient and SerialDeviceProvider refactored to go through
the transport — no more direct pyserial imports in hot paths.
- 17 new unit tests cover URL parsing, PySerial transport, factory
selection, platform-branching discovery. Full suite 750 passing.
Kotlin side:
- UsbSerialBridge.kt singleton uses com.hoho.android.usbserial (mik3y)
which ships drivers for CH340, CP2102, FTDI, Prolific, and CDC-ACM
(Arduino). Exposes listDevices, open, write, close via @JvmStatic
for Chaquopy. First open() attempt without permission triggers the
system USB permission dialog; next call succeeds once user grants.
- usb-serial-for-android is distributed via JitPack — added that repo
in settings.gradle.kts and the dependency in app/build.gradle.kts.
- AndroidManifest declares uses-feature android.hardware.usb.host
(required=false so non-USB-host phones still install).
- LedGrabApp.onCreate calls UsbSerialBridge.init(this) so the bridge
resolves the UsbManager without needing an Activity ref.
Verified: ./gradlew compileDebugKotlin succeeds; off-Android import
of android_serial_transport works. Real-hardware smoke test on a
TV box with a CH340/CP2102/FTDI adapter still pending.
ESP-NOW (espnow_client / espnow_provider) still imports pyserial
directly because it needs bidirectional reads — separate refactor
to extend the transport with read() if that path ever needs Android
USB support.
Adds .gitea/workflows/build-android.yml — Linux runner installs JDK 17,
Python 3.11, Android SDK/NDK, symlinks server/src/ledgrab into the
Chaquopy python source dir, and runs assembleDebug on master pushes /
assembleRelease on v* tags. APK is uploaded as an artifact and attached
to the Gitea release on tag push. Conditional signing config in
build.gradle.kts reads keystore from env vars (CI secrets) and falls
back to debug signing locally. Gradle wrapper (gradlew/gradlew.bat/
gradle-wrapper.jar) committed so CI can drive the build.
Rebuilds pydantic-core wheels for arm64-v8a and x86_64 — both were
missing libpython3.11.so in NEEDED, which would have crashed at import
on real devices. build-pydantic-core.sh rewritten as a multi-ABI builder:
selects targets via args, sets RUSTFLAGS=-C link-arg=-Wl,--no-as-needed
-C link-arg=-lpython3.11 to force the symbol-resolution dependency,
uses the per-ABI sysconfigdata + libpython staged in
android/.build-cache/, prefers `py -3.11` on Windows (Git Bash's
python3.11 is an MSStore stub), uses the .cmd clang wrapper on Windows
(fixes os error 193), and verifies NEEDED via llvm-readelf after each
build. abiFilters restored to the full triple in build.gradle.kts;
multi-ABI debug APK builds cleanly (~99 MB).
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>