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:
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Android TV launcher banner: 320x180 landscape.
|
||||
Shown on the leanback home row. The previous build reused the square
|
||||
launcher icon, which letterboxed badly. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:fillColor="#0d1117"
|
||||
android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
|
||||
<!-- Subtle teal glow top-left -->
|
||||
<path
|
||||
android:fillColor="#1A64ffda"
|
||||
android:pathData="M0,0 L160,0 L160,90 L0,90 Z" />
|
||||
<!-- Subtle purple glow bottom-right -->
|
||||
<path
|
||||
android:fillColor="#15bb86fc"
|
||||
android:pathData="M160,90 L320,90 L320,180 L160,180 Z" />
|
||||
<!-- TV body, centered -->
|
||||
<path
|
||||
android:fillColor="#1c2333"
|
||||
android:pathData="M88,56 L196,56 Q204,56 204,64 L204,116 Q204,124 196,124 L88,124 Q80,124 80,116 L80,64 Q80,56 88,56 Z" />
|
||||
<!-- TV screen -->
|
||||
<path
|
||||
android:fillColor="#161b22"
|
||||
android:pathData="M92,60 L192,60 Q196,60 196,64 L196,116 Q196,120 192,120 L92,120 Q88,120 88,116 L88,64 Q88,60 92,60 Z" />
|
||||
<!-- LED glow strips -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:fillAlpha="0.8"
|
||||
android:pathData="M94,50 L190,50 L190,54 L94,54 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M72,62 L76,62 L76,118 L72,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M208,62 L212,62 L212,118 L208,118 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
android:fillAlpha="0.7"
|
||||
android:pathData="M94,126 L190,126 L190,130 L94,130 Z" />
|
||||
<!-- Wordmark "LedGrab" — drawn as paths so we don't depend on the
|
||||
system font cache being warm at TV launch. -->
|
||||
<!-- L -->
|
||||
<path android:fillColor="#64ffda"
|
||||
android:pathData="M222,72 L228,72 L228,100 L240,100 L240,106 L222,106 Z" />
|
||||
<!-- e -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M244,82 L260,82 Q264,82 264,86 L264,94 L250,94 L250,100 L262,100 L262,106 L246,106 Q244,106 244,104 Z M250,86 L250,90 L258,90 L258,86 Z" />
|
||||
<!-- d -->
|
||||
<path android:fillColor="#e6edf3"
|
||||
android:pathData="M266,72 L272,72 L272,82 L284,82 Q286,82 286,84 L286,106 L268,106 Q266,106 266,104 Z M272,88 L272,100 L280,100 L280,88 Z" />
|
||||
</vector>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static fallback for the status dot. The animated version
|
||||
(animated_status_dot.xml) is used at runtime; this is what
|
||||
XML rendering tools show in the editor. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/green_status" />
|
||||
<size android:width="18dp" android:height="18dp" />
|
||||
</shape>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome status-bar icon. Android requires white-on-transparent for
|
||||
notification icons since API 21 - reusing the colored launcher would
|
||||
render as a gray blob. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<!-- TV body -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M5,7 L19,7 Q20,7 20,8 L20,16 Q20,17 19,17 L5,17 Q4,17 4,16 L4,8 Q4,7 5,7 Z M5.5,8.5 L5.5,15.5 L18.5,15.5 L18.5,8.5 Z" />
|
||||
<!-- TV stand -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M10,17 L10,18.5 L14,18.5 L14,17 Z M9,19 L15,19 L15,20 L9,20 Z" />
|
||||
<!-- LED glow strips around the TV (bright dots) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M6,5.5 L18,5.5 L18,6.5 L6,6.5 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Splash screen icon (API 31+ uses a 1:1 vector inside a 240dp circle).
|
||||
The SplashScreen API masks this with a circle automatically. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="240dp"
|
||||
android:height="240dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- 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 strips, brighter on splash for impact -->
|
||||
<path
|
||||
android:fillColor="#64ffda"
|
||||
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
|
||||
<path
|
||||
android:fillColor="#bb86fc"
|
||||
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ff6b6b"
|
||||
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
|
||||
<path
|
||||
android:fillColor="#ffd93d"
|
||||
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>
|
||||
Reference in New Issue
Block a user