ef1f9eade2
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.
125 lines
4.5 KiB
XML
125 lines
4.5 KiB
XML
<?xml version="1.0" encoding="utf-8"?>
|
|
<!-- RUNNING STATE -->
|
|
<LinearLayout
|
|
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:orientation="horizontal"
|
|
android:gravity="center_vertical"
|
|
android:paddingStart="120dp"
|
|
android:paddingEnd="120dp"
|
|
android:paddingTop="80dp"
|
|
android:paddingBottom="80dp">
|
|
|
|
<!-- 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:id="@+id/status_dot"
|
|
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"
|
|
android:nextFocusUp="@id/stop_button_running"
|
|
android:nextFocusDown="@id/stop_button_running" />
|
|
</LinearLayout>
|
|
|
|
<!-- Right: QR code + fallback hint -->
|
|
<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" />
|
|
|
|
<TextView
|
|
android:layout_width="280dp"
|
|
android:layout_height="wrap_content"
|
|
android:text="@string/scan_fallback_hint"
|
|
android:textColor="@color/text_hint"
|
|
android:textSize="14sp"
|
|
android:gravity="center"
|
|
android:layout_marginTop="6dp" />
|
|
</LinearLayout>
|
|
</LinearLayout>
|