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
+43 -10
View File
@@ -30,23 +30,39 @@ val ledgrabVersionCode: Int = run {
android {
namespace = "com.ledgrab.android"
compileSdk = 34
// SDK 35 (Android 15) — required for Play Store from Aug 2025 onward.
compileSdk = 35
defaultConfig {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
targetSdk = 35
// Derived from git commit count (or ANDROID_VERSION_CODE env var
// in CI). See ledgrabVersionCode above. Was stuck at 1 before —
// sideload updates silently refused to install.
versionCode = ledgrabVersionCode
versionName = "0.7.0"
// ABI selection. Detect armeabi-v7a wheel presence and opt the
// ABI in only when the matching pydantic-core wheel is on disk —
// otherwise Chaquopy would fail the build searching for it. The
// build script (build-scripts/build-pydantic-core.sh) is the
// source of truth for which ABIs we *can* ship.
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
val ledgrabAbis = buildList {
add("arm64-v8a")
add("x86_64")
add("x86")
if (v7Wheel) add("armeabi-v7a")
}
ndk {
// All three ABIs: arm64-v8a (real TV hardware), x86_64 (modern
// emulators), x86 (legacy emulators). Wheels in android/wheels/
// must be kept in sync — see build-scripts/build-pydantic-core.sh.
abiFilters += listOf("arm64-v8a", "x86_64", "x86")
// arm64-v8a is the primary target (real TV hardware).
// x86_64/x86 cover emulators.
// armeabi-v7a is opt-in: many pre-2018 Mecool/X96/H96 TV boxes
// still ship 32-bit ARMv7 — when a wheel exists in wheels/ we
// automatically include the ABI in builds.
abiFilters += ledgrabAbis
}
}
@@ -54,9 +70,12 @@ android {
// Each split contains only one native ABI's shared libraries + wheels.
splits {
abi {
val v7Wheel = file("$rootDir/wheels").listFiles().orEmpty()
.any { it.name.startsWith("pydantic_core-") && it.name.contains("armeabi_v7a") }
isEnable = true
reset()
include("arm64-v8a", "x86_64", "x86")
if (v7Wheel) include("armeabi-v7a")
isUniversalApk = true // also produce a fat APK for sideloading
}
}
@@ -96,10 +115,21 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = if (hasCiSigning) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
// Refuse to silently sign release APKs with the debug
// keystore — that's how a debug-signed release accidentally
// ships. CI must provide all four signing env vars. If a
// local "release" build is genuinely intended for testing,
// set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to opt out.
val allowDebugSigned =
System.getenv("ANDROID_ALLOW_DEBUG_SIGNED_RELEASE") == "1"
signingConfig = when {
hasCiSigning -> signingConfigs.getByName("release")
allowDebugSigned -> signingConfigs.getByName("debug")
else -> throw GradleException(
"Release builds require signing env vars " +
"(ANDROID_KEYSTORE_PATH/PASSWORD, ANDROID_KEY_ALIAS/PASSWORD). " +
"Set ANDROID_ALLOW_DEBUG_SIGNED_RELEASE=1 to force a debug-signed release."
)
}
}
}
@@ -175,6 +205,9 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// SplashScreen API — keeps a friendly logo on screen while Chaquopy
// unpacks the Python stdlib on first launch (can take 1-3s).
implementation("androidx.core:core-splashscreen:1.0.1")
// QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for