From ef1f9eade270c08984d0481e5b5416ace9720bc6 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 26 May 2026 12:52:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(android):=20production-readiness=20pass=20?= =?UTF-8?q?=E2=80=94=20security,=20perf,=20compat,=20UI/UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 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":} 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. --- android/app/build.gradle.kts | 53 +++- android/app/src/main/AndroidManifest.xml | 34 +- .../java/com/ledgrab/android/ApiKeyManager.kt | 98 ++++++ .../com/ledgrab/android/CaptureService.kt | 92 ++++-- .../java/com/ledgrab/android/LedGrabApp.kt | 30 ++ .../java/com/ledgrab/android/MainActivity.kt | 290 ++++++++++++++---- .../java/com/ledgrab/android/NetworkUtils.kt | 52 +++- .../java/com/ledgrab/android/PythonBridge.kt | 21 +- .../src/main/java/com/ledgrab/android/Root.kt | 61 +++- .../com/ledgrab/android/RootScreenrecord.kt | 130 ++++++-- .../java/com/ledgrab/android/ScreenCapture.kt | 122 +++++--- .../com/ledgrab/android/UsbSerialBridge.kt | 33 +- .../app/src/main/res/drawable/banner_tv.xml | 58 ++++ .../src/main/res/drawable/bg_status_dot.xml | 4 + .../src/main/res/drawable/ic_notification.xml | 23 ++ .../app/src/main/res/drawable/ic_splash.xml | 37 +++ .../app/src/main/res/layout/activity_main.xml | 142 ++------- .../app/src/main/res/layout/panel_running.xml | 124 ++++++++ .../app/src/main/res/values-ru/strings.xml | 14 + .../app/src/main/res/values-zh/strings.xml | 14 + android/app/src/main/res/values/strings.xml | 14 + android/app/src/main/res/values/themes.xml | 10 + .../main/res/xml/network_security_config.xml | 26 +- android/build-scripts/build-pydantic-core.sh | 29 +- server/src/ledgrab/android_entry.py | 31 +- server/src/ledgrab/static/js/app.ts | 39 +++ 26 files changed, 1257 insertions(+), 324 deletions(-) create mode 100644 android/app/src/main/java/com/ledgrab/android/ApiKeyManager.kt create mode 100644 android/app/src/main/res/drawable/banner_tv.xml create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/app/src/main/res/drawable/ic_splash.xml create mode 100644 android/app/src/main/res/layout/panel_running.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dcbcf99..0a94911 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7eb3823..0adf48c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -26,9 +26,15 @@ - + + @@ -60,27 +66,41 @@ - + + android:exported="true" + android:theme="@style/Theme.LedGrab.Splash"> - + + android:foregroundServiceType="mediaProjection|specialUse" + android:exported="false"> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_status_dot.xml b/android/app/src/main/res/drawable/bg_status_dot.xml index 7df4689..351667b 100644 --- a/android/app/src/main/res/drawable/bg_status_dot.xml +++ b/android/app/src/main/res/drawable/bg_status_dot.xml @@ -1,5 +1,9 @@ + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..f843195 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_splash.xml b/android/app/src/main/res/drawable/ic_splash.xml new file mode 100644 index 0000000..fcb4adf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_splash.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 6f135e7..f164676 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -32,16 +32,28 @@ android:textStyle="bold" android:letterSpacing="0.08" android:layout_marginBottom="12dp" - android:fontFamily="sans-serif-light" /> + android:fontFamily="sans-serif" /> + android:layout_marginBottom="24dp" /> + + +