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>
|
||||
@@ -32,16 +32,28 @@
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.08"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:fontFamily="sans-serif-light" />
|
||||
android:fontFamily="sans-serif" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:id="@+id/tagline_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/tagline"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginBottom="64dp" />
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<!-- Transient status (root probing / permission denial). Always
|
||||
present so the layout doesn't reflow when text appears. -->
|
||||
<TextView
|
||||
android:id="@+id/status_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="32dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="20sp"
|
||||
android:layout_marginBottom="32dp"
|
||||
tools:text="Checking root access…" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/toggle_button"
|
||||
@@ -51,7 +63,8 @@
|
||||
android:text="@string/btn_start"
|
||||
android:textSize="22sp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusDown="@+id/autostart_check" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/autostart_check"
|
||||
@@ -63,10 +76,11 @@
|
||||
android:textSize="20sp"
|
||||
android:buttonTint="@color/teal_accent"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true" />
|
||||
android:focusableInTouchMode="true"
|
||||
android:nextFocusUp="@id/toggle_button" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Version at bottom -->
|
||||
<!-- Version at bottom (always visible — looks polished on TV idle). -->
|
||||
<TextView
|
||||
android:id="@+id/version_text"
|
||||
android:layout_width="wrap_content"
|
||||
@@ -77,115 +91,13 @@
|
||||
android:textSize="18sp"
|
||||
tools:text="v0.1.0" />
|
||||
|
||||
<!-- RUNNING STATE -->
|
||||
<LinearLayout
|
||||
android:id="@+id/running_panel"
|
||||
<!-- RUNNING STATE — deferred-inflate via ViewStub so first paint is
|
||||
cheaper and the inflater doesn't measure two competing layouts. -->
|
||||
<ViewStub
|
||||
android:id="@+id/running_panel_stub"
|
||||
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"
|
||||
android:visibility="gone">
|
||||
|
||||
<!-- 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: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" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Right: QR code -->
|
||||
<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" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
android:inflatedId="@+id/running_panel"
|
||||
android:layout="@layout/panel_running"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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>
|
||||
@@ -3,12 +3,26 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Фоновая подсветка для телевизора</string>
|
||||
<string name="btn_start">Начать захват</string>
|
||||
<string name="btn_starting">Запуск…</string>
|
||||
<string name="btn_stop">Стоп</string>
|
||||
<string name="status_running">Работает</string>
|
||||
<string name="status_checking_root">Проверка root-доступа…</string>
|
||||
<string name="status_permission_denied">Доступ запрещён — для захвата экрана требуется разрешение</string>
|
||||
<string name="status_no_network">Нет сети — подключите Wi-Fi или Ethernet</string>
|
||||
<string name="label_web_ui">Адрес веб-интерфейса</string>
|
||||
<string name="scan_to_configure">Сканируйте для настройки</string>
|
||||
<string name="scan_fallback_hint">или откройте этот адрес с любого устройства в сети</string>
|
||||
<string name="qr_description">QR-код для веб-интерфейса</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Запускать при загрузке (только с root)</string>
|
||||
<string name="autostart_unavailable">Запуск при загрузке — недоступно (нужен root)</string>
|
||||
<string name="fatal_title">Не удалось запустить LedGrab</string>
|
||||
<string name="fatal_body_prefix">Ошибка инициализации Python:</string>
|
||||
<string name="fatal_copy_log">Скопировать журнал</string>
|
||||
<string name="fatal_show_details">Показать подробности</string>
|
||||
<string name="fatal_hide_details">Скрыть подробности</string>
|
||||
<string name="notification_channel_name">Захват LedGrab</string>
|
||||
<string name="notification_channel_description">Отображается, пока LedGrab захватывает экран.</string>
|
||||
<string name="notification_title">LedGrab работает</string>
|
||||
<string name="notification_text">Веб-интерфейс: %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">电视氛围灯光</string>
|
||||
<string name="btn_start">开始捕获</string>
|
||||
<string name="btn_starting">正在启动…</string>
|
||||
<string name="btn_stop">停止</string>
|
||||
<string name="status_running">运行中</string>
|
||||
<string name="status_checking_root">正在检查 root 权限…</string>
|
||||
<string name="status_permission_denied">权限被拒绝 — 屏幕捕获需要授权</string>
|
||||
<string name="status_no_network">无网络 — 请连接 Wi-Fi 或以太网</string>
|
||||
<string name="label_web_ui">Web界面地址</string>
|
||||
<string name="scan_to_configure">扫码配置</string>
|
||||
<string name="scan_fallback_hint">或在同一网络的任何设备上访问上方网址</string>
|
||||
<string name="qr_description">Web界面二维码</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">开机自启(仅限 root)</string>
|
||||
<string name="autostart_unavailable">开机自启 — 不可用(需要 root)</string>
|
||||
<string name="fatal_title">LedGrab 启动失败</string>
|
||||
<string name="fatal_body_prefix">Python 运行时初始化失败:</string>
|
||||
<string name="fatal_copy_log">复制日志</string>
|
||||
<string name="fatal_show_details">显示详情</string>
|
||||
<string name="fatal_hide_details">隐藏详情</string>
|
||||
<string name="notification_channel_name">LedGrab 屏幕捕获</string>
|
||||
<string name="notification_channel_description">LedGrab 捕获屏幕时显示。</string>
|
||||
<string name="notification_title">LedGrab 运行中</string>
|
||||
<string name="notification_text">Web界面:%1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -3,12 +3,26 @@
|
||||
<string name="app_name">LedGrab</string>
|
||||
<string name="tagline">Ambient lighting for your TV</string>
|
||||
<string name="btn_start">Start Capture</string>
|
||||
<string name="btn_starting">Starting…</string>
|
||||
<string name="btn_stop">Stop</string>
|
||||
<string name="status_running">Running</string>
|
||||
<string name="status_checking_root">Checking root access…</string>
|
||||
<string name="status_permission_denied">Permission denied — screen capture requires authorization</string>
|
||||
<string name="status_no_network">No network — connect Wi-Fi or Ethernet</string>
|
||||
<string name="label_web_ui">Web UI address</string>
|
||||
<string name="scan_to_configure">Scan to configure</string>
|
||||
<string name="scan_fallback_hint">or visit the URL above on any device on this network</string>
|
||||
<string name="qr_description">QR code for web UI</string>
|
||||
<string name="version_prefix">v%1$s</string>
|
||||
<string name="autostart_label">Start on boot (root only)</string>
|
||||
<string name="autostart_unavailable">Start on boot — unavailable (root required)</string>
|
||||
<string name="fatal_title">LedGrab failed to start</string>
|
||||
<string name="fatal_body_prefix">Python runtime initialization failed:</string>
|
||||
<string name="fatal_copy_log">Copy log</string>
|
||||
<string name="fatal_show_details">Show details</string>
|
||||
<string name="fatal_hide_details">Hide details</string>
|
||||
<string name="notification_channel_name">LedGrab capture</string>
|
||||
<string name="notification_channel_description">Shows while LedGrab is capturing the screen.</string>
|
||||
<string name="notification_title">LedGrab Running</string>
|
||||
<string name="notification_text">Web UI: %1$s</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
<item name="android:colorControlActivated">@color/teal_accent</item>
|
||||
</style>
|
||||
|
||||
<!-- Splash screen theme. Compatible across API levels via the
|
||||
androidx.core:core-splashscreen library. On API 31+ the system
|
||||
splash uses the foreground icon; on older versions the launch
|
||||
theme just paints the navy background, which is harmless. -->
|
||||
<style name="Theme.LedGrab.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/bg_navy</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.LedGrab</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
|
||||
<item name="android:background">@drawable/bg_button_primary</item>
|
||||
<item name="android:textColor">@color/bg_navy</item>
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
LedGrab communicates with WLED controllers, Home Assistant, and MQTT
|
||||
brokers on the local network via plain HTTP/UDP. Cleartext traffic
|
||||
must be allowed for these connections to work on Android 9+.
|
||||
LedGrab is a LAN-only app:
|
||||
- Inbound: web UI / API on the device (HTTP, port 8080)
|
||||
- Outbound: WLED HTTP/UDP, Home Assistant, MQTT brokers, mDNS
|
||||
|
||||
All of these are plaintext on the local network. Android's network
|
||||
security config doesn't support CIDR allowlists, so we cannot
|
||||
restrict cleartext to RFC1918 ranges declaratively — we have to
|
||||
permit cleartext base-wide.
|
||||
|
||||
Defence-in-depth that ACTUALLY mitigates this:
|
||||
1. Inbound: the FastAPI server in this app rejects non-loopback
|
||||
requests when no API key is configured (see ledgrab.api.auth).
|
||||
The Android launcher auto-generates an API key on first run
|
||||
(see ApiKeyManager.kt) and injects it via the
|
||||
LEDGRAB_AUTH__API_KEYS env var before uvicorn starts. The
|
||||
user's phone receives the key by scanning the QR, which
|
||||
embeds the key as a URL fragment (never logged server-side).
|
||||
2. Outbound: targets are validated by net_classify in the Python
|
||||
layer (LAN-only HTTP, SSRF-safe).
|
||||
|
||||
DO NOT remove the cleartext permission without first migrating
|
||||
every LAN peer to HTTPS — most WLED firmware, mDNS, and the LAN
|
||||
HTTP server itself rely on this flag.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
|
||||
Reference in New Issue
Block a user