feat(android): production-readiness pass — security, perf, compat, UI/UX

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.
This commit is contained in:
2026-05-26 12:52:14 +03:00
parent 8bdcc17799
commit ef1f9eade2
26 changed files with 1257 additions and 324 deletions
+30 -1
View File
@@ -6,6 +6,7 @@ inside an Android application. Sets up Android-specific paths
"""
import asyncio
import json
import os
import threading
from typing import Any
@@ -15,7 +16,7 @@ _server: Any | None = None # uvicorn.Server
_loop: asyncio.AbstractEventLoop | None = None
def start_server(data_dir: str, port: int = 8080) -> None:
def start_server(data_dir: str, port: int = 8080, api_key: str | None = None) -> None:
"""Start the LedGrab uvicorn server.
Called from Kotlin's ``PythonBridge.startServer()``. This function
@@ -26,6 +27,11 @@ def start_server(data_dir: str, port: int = 8080) -> None:
data_dir: Android app-private files directory
(e.g. ``/data/data/com.ledgrab.android/files``).
port: HTTP port for the web UI / API.
api_key: Optional Bearer token to enable LAN auth. When set,
published as ``LEDGRAB_AUTH__API_KEYS={"android":<key>}``
so the server's auth gate accepts LAN requests carrying
``Authorization: Bearer <key>``. When None, the server
falls back to its default (loopback-only).
"""
# ── Configure paths before any LedGrab imports ──────────────
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
@@ -41,6 +47,14 @@ def start_server(data_dir: str, port: int = 8080) -> None:
os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
os.environ["LEDGRAB_SERVER__PORT"] = str(port)
# Provision LAN auth when the Kotlin launcher supplied a key. The
# config layer (pydantic-settings) parses ``LEDGRAB_AUTH__API_KEYS``
# as JSON when the value starts with `{`. We use a dict so the
# rest of the codebase sees a labelled key just like the YAML
# config form (api_keys: {android: ...}).
if api_key:
os.environ["LEDGRAB_AUTH__API_KEYS"] = json.dumps({"android": api_key})
# ── Now safe to import LedGrab ──────────────────────────────
import uvicorn # noqa: E402
@@ -50,10 +64,25 @@ def start_server(data_dir: str, port: int = 8080) -> None:
logger = get_logger(__name__)
logger.info("LedGrab Android: starting server on port %d", port)
logger.info("Data directory: %s", data_dir)
if api_key:
logger.info("LedGrab Android: API key auth enabled (label=android)")
else:
logger.warning("LedGrab Android: no API key — LAN requests will be rejected")
from ledgrab.config import get_config # noqa: E402
config = get_config()
# Defensive: confirm the env var actually landed in the parsed config.
# If pydantic-settings ever changes how it deserialises dict[str, str]
# from env, the LAN auth would silently break (server would 401 every
# phone scan). Logging the mismatch makes the failure mode obvious in
# adb logcat.
if api_key and config.auth.api_keys.get("android") != api_key:
logger.error(
"LedGrab Android: API key did NOT land in config — LAN auth will "
"reject all requests. Check pydantic-settings dict parsing for "
"LEDGRAB_AUTH__API_KEYS."
)
uv_config = uvicorn.Config(
"ledgrab.main:app",
+39
View File
@@ -244,6 +244,30 @@ import {
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
} from './features/donation.ts';
// ─── Out-of-band API key delivery (URL fragment) ───
/**
* Parse the URL fragment for an API key delivered out-of-band.
* Supported forms:
* #k=<token>
* #key=<token>
* #/some-route#k=<token> (key wins; route still applies)
*
* Returns null when no key is present or the value looks invalid
* (whitespace / suspicious characters). Tokens are constrained to a
* conservative URL-safe alphabet to avoid accepting arbitrary input
* — the Android launcher uses 64-char hex.
*/
function readApiKeyFromFragment(): string | null {
const hash = location.hash;
if (!hash) return null;
// Strip leading '#' once; search inside for either ?k= or &k= or
// a leading k=, plus the `key=` long form.
const body = hash.replace(/^#/, '');
const match = body.match(/(?:^|[?&#])(?:k|key)=([A-Za-z0-9_-]{16,512})/);
return match ? match[1] : null;
}
// ─── Register all HTML onclick / onchange / onfocus globals ───
Object.assign(window, {
@@ -724,6 +748,21 @@ window.addEventListener('beforeunload', () => {
document.addEventListener('DOMContentLoaded', async () => {
try {
// Bootstrap auth: first check the URL fragment for a key delivered
// out-of-band (e.g. the Android TV launcher embeds the per-install
// API key in the QR as ``#k=<token>``). Fragments are never sent in
// HTTP requests so this is safe to log. Persist to localStorage and
// strip the hash so a refresh doesn't keep showing it in the URL.
const tokenFromFragment = readApiKeyFromFragment();
if (tokenFromFragment) {
localStorage.setItem('ledgrab_api_key', tokenFromFragment);
try {
history.replaceState(null, '', location.pathname + location.search);
} catch {
// Older browsers without history API support — leave the
// fragment alone; it's already cached in localStorage.
}
}
// Load API key from localStorage before anything that triggers API calls
setApiKey(localStorage.getItem('ledgrab_api_key'));